Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 18533] ファイルの概要

このコミットは、GoランタイムのPlan 9オペレーティングシステム向け386アーキテクチャ固有のアセンブリコードファイル src/pkg/runtime/sys_plan9_386.s に関連するものです。具体的には、rfork システムコール呼び出し時のスタック管理に関するバグ修正が含まれています。

コミット

commit 56872f02f0f69f32d4d919e4cacee9672e7a5a97
Author: David du Colombier <0intro@gmail.com>
Date:   Fri Feb 14 22:27:47 2014 +0100

    runtime: fix "invalid address in sys call" on Plan 9
    
    Rfork is not splitting the stack when creating a new thread,
    so the parent and child are executing on the same stack.
    However, if the parent returns and keeps executing before
    the child can read the arguments from the parent stack,
    the child will not see the right arguments. The solution
    is to load the needed pieces from the parent stack into
    register before INT $64.
    
    Thanks to Russ Cox for the explanation.
    
    LGTM=rsc
    R=rsc
    CC=ality, golang-codereviews
    https://golang.org/cl/64140043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/56872f02f0f69f32d4d919e4cacee9672e7a5a97

元コミット内容

runtime: fix "invalid address in sys call" on Plan 9

Rfork is not splitting the stack when creating a new thread,
so the parent and child are executing on the same stack.
However, if the parent returns and keeps executing before
the child can read the arguments from the parent stack,
the child will not see the right arguments. The solution
is to load the needed pieces from the parent stack into
register before INT $64.

Thanks to Russ Cox for the explanation.

変更の背景

このコミットは、Plan 9オペレーティングシステム上でGoプログラムが新しいスレッド(Goランタイムの文脈ではM/GモデルにおけるM、つまりOSスレッド)を作成する際に発生していた「invalid address in sys call」(システムコールにおける無効なアドレス)というエラーを修正するためのものです。

問題の根本原因は、Plan 9のrforkシステムコールが、新しいスレッドを作成する際に親スレッドと子スレッドの間でスタックを分離しないことにありました。これにより、親スレッドと子スレッドが同じ物理メモリ上のスタックを共有して実行される状態になります。

この共有スタックの状況下で、もし親スレッドがrfork呼び出しから戻り、子スレッドが親スタックから引数を読み取る前に親スレッドがスタックを上書きするような処理を続行した場合、子スレッドは誤った、あるいは無効な引数を読み取ってしまう可能性がありました。これが「invalid address in sys call」エラーとして現れていました。

この問題は、特にGoランタイムが新しいOSスレッドを起動し、そのスレッドに特定の引数(例えば、新しいM(マシン)構造体やG(ゴルーチン)構造体へのポインタ、新しいスタックのアドレス、実行開始関数など)を渡す必要がある場合に顕著でした。子スレッドがこれらの重要な引数を正しく取得できないと、ランタイムの初期化やゴルーチンのスケジューリングが失敗し、プログラムがクラッシュする原因となっていました。

前提知識の解説

このコミットを理解するためには、以下の概念について知っておく必要があります。

  1. Plan 9 (オペレーティングシステム): ベル研究所で開発された分散システム指向のオペレーティングシステムです。Unixの哲学をさらに推し進め、すべてのリソースをファイルとして表現し、それらをネットワーク越しに透過的にアクセスできることを特徴とします。Go言語は、その設計思想の一部をPlan 9から継承しており、初期のGoコンパイラやツールチェーンはPlan 9のツールをベースにしていました。

  2. rfork システムコール (Plan 9): Plan 9におけるプロセス作成の主要なシステムコールです。Unixのforkvforkに似ていますが、より柔軟なオプションを提供します。rforkは、新しいプロセス(またはスレッド)を作成する際に、アドレス空間、ファイルディスクリプタ、名前空間、スタックなどを親プロセスと共有するか、コピーするか、あるいは全く新しいものを作成するかを細かく制御できます。このコミットの文脈では、rforkがスタックを分離しない(共有する)挙動が問題となっていました。

  3. スタック (Stack): プログラムの実行中に、関数呼び出しの引数、ローカル変数、戻りアドレスなどを一時的に格納するために使用されるメモリ領域です。通常、新しいスレッドが作成されると、そのスレッド専用の独立したスタックが割り当てられます。しかし、Plan 9のrforkの特定のモードでは、親と子が同じスタックを共有することがあります。

  4. GoランタイムのM/Gモデル: Go言語の並行処理は、Goランタイムによって管理される「ゴルーチン (Goroutine)」と「OSスレッド (Machine, M)」の概念に基づいています。

    • G (Goroutine): Go言語の軽量な並行実行単位です。数KB程度の小さなスタックを持ち、必要に応じてスタックを拡張できます。Goランタイムのスケジューラによって管理されます。
    • M (Machine): オペレーティングシステムのスレッドに相当します。Goランタイムは、GをM上で実行します。MはOSによってスケジューリングされます。
    • P (Processor): 論理プロセッサの数を表し、MとGの間の仲介役となります。Pは実行可能なGのキューを保持し、MがGを実行する際にPを必要とします。 Goランタイムは、必要に応じて新しいM(OSスレッド)をrforkのようなシステムコールを使って作成します。この新しいMが起動する際に、初期化に必要な情報(例えば、どのゴルーチンを実行するか、どのスタックを使うかなど)を渡す必要があります。
  5. Goアセンブリ (Plan 9 Syntax): Go言語は、一部の低レベルな処理(システムコール、コンテキストスイッチなど)にアセンブリ言語を使用します。Goのアセンブリは、AT&T構文やIntel構文とは異なるPlan 9アセンブラの構文を使用します。

    • MOVL src, dst: 32ビットの値をsrcからdstへ移動します。
    • INT $64: ソフトウェア割り込み命令です。Plan 9では、システムコールを呼び出すために使用されます。AXレジスタにシステムコール番号を設定し、INT $64を実行することで、カーネルに処理を移します。
    • SP: スタックポインタレジスタ。現在のスタックのトップを指します。
    • SB: 静的ベースレジスタ。グローバルシンボルや外部シンボルへのオフセット計算に使用されます。
    • TEXT symbol(SB),flags,$framesize: 関数の定義を開始します。NOSPLITは、この関数がスタックを分割しないことを示します(つまり、スタックの拡張チェックを行わない)。

技術的詳細

このバグは、Plan 9のrforkシステムコールが新しいスレッドを作成する際に、親スレッドと子スレッドが同じスタックを共有するという特性に起因していました。Goランタイムは、新しいOSスレッド(M)を起動する際に、そのスレッドが実行を開始するために必要な引数(例えば、新しいスタックのアドレス、MとGの構造体へのポインタ、実行すべき関数へのポインタなど)を親スレッドのスタック上に配置していました。

問題は、rfork呼び出し後、親スレッドがrforkから戻り、スタックを使い続ける可能性があることでした。もし親スレッドが、子スレッドがまだスタック上の引数を読み取る前に、その引数が置かれているスタック領域を上書きしてしまった場合、子スレッドは古い、あるいは破損した引数を読み取ってしまい、結果として「invalid address in sys call」エラーが発生していました。これは、子スレッドが期待するM、G、スタック、関数ポインタなどの情報が正しく伝わらないため、システムコールが不正なアドレスを参照しようとしたり、ランタイムの初期化に失敗したりするためです。

この修正の核心は、rforkシステムコールを呼び出す直前に、子スレッドが必要とする重要な引数(stack, m, g, fn)を親スレッドのスタックからCPUのレジスタ(CX, BX, DX, SI)にロードすることです。レジスタはCPU内部の高速な記憶領域であり、スタックのように他のスレッドによって上書きされる心配がありません。

これにより、INT $64(システムコール呼び出し)が実行され、子スレッドが起動する際には、必要な引数が安全にレジスタに保持されているため、親スレッドがスタックを上書きしても子スレッドは正しい情報を参照できます。子スレッドは、レジスタからこれらの値を取得し、新しいスタックポインタを設定し、MとGの初期化を行うことができます。

修正前は、子スレッドが起動した後に改めてスタックからこれらの引数を読み取ろうとしていましたが、このタイミングでは既にスタックの内容が変更されている可能性がありました。修正後は、システムコール呼び出し前にレジスタに退避させることで、この競合状態を解消しています。

コアとなるコードの変更箇所

変更は src/pkg/runtime/sys_plan9_386.s ファイルの TEXT runtime·rfork(SB) 関数内で行われています。

--- a/src/pkg/runtime/sys_plan9_386.s
+++ b/src/pkg/runtime/sys_plan9_386.s
@@ -81,6 +81,10 @@ TEXT runtime·plan9_semrelease(SB),NOSPLIT,$0
 	
 TEXT runtime·rfork(SB),NOSPLIT,$0
 	MOVL    $19, AX // rfork
+\tMOVL\tstack+8(SP), CX
+\tMOVL\tmm+12(SP), BX\t// m
+\tMOVL\tgg+16(SP), DX\t// g
+\tMOVL\tfn+20(SP), SI\t// fn
 	INT     $64
 
 	// In parent, return.
@@ -88,13 +92,7 @@ TEXT runtime·rfork(SB),NOSPLIT,$0
 	JEQ\t2(PC)\n \tRET\n \n-\t// In child on old stack.\n-\tMOVL\tmm+12(SP), BX\t// m\n-\tMOVL\tgg+16(SP), DX\t// g\n-\tMOVL\tfn+20(SP), SI\t// fn\n-\n \t// set SP to be on the new child stack\n-\tMOVL\tstack+8(SP), CX\n \tMOVL\tCX, SP\n \n \t// Initialize m, g.

追加された行:

	MOVL	stack+8(SP), CX
	MOVL	mm+12(SP), BX	// m
	MOVL	gg+16(SP), DX	// g
	MOVL	fn+20(SP), SI	// fn

これらの行は、INT $64rforkシステムコール呼び出し)の直前に追加されています。

削除された行:

	// In child on old stack.
	MOVL	mm+12(SP), BX	// m
	MOVL	gg+16(SP), DX	// g
	MOVL	fn+20(SP), SI	// fn

	// set SP to be on the new child stack
	MOVL	stack+8(SP), CX

これらの行は、rforkシステムコール呼び出し後の子スレッドのパスから削除されています。

コアとなるコードの解説

このアセンブリコードは、GoランタイムがPlan 9上で新しいOSスレッドを作成する際のrforkシステムコールラッパーの一部です。

修正前:

修正前は、rforkシステムコール(INT $64)が呼び出された後、子スレッドの実行パスにおいて、親スレッドのスタックからmgfn、そして新しいスタックのアドレス(stack)を読み取っていました。

	INT     $64

	// In child on old stack.
	MOVL	mm+12(SP), BX	// m
	MOVL	gg+16(SP), DX	// g
	MOVL	fn+20(SP), SI	// fn

	// set SP to be on the new child stack
	MOVL	stack+8(SP), CX
	MOVL	CX, SP

このアプローチの問題点は、INT $64が実行され、子スレッドが起動するまでの間に、親スレッドがスタック上のこれらの引数を上書きしてしまう可能性があったことです。子スレッドがMOVL命令を実行する時点では、既にスタックの内容が不正になっている可能性があり、これが「invalid address in sys call」エラーの原因となっていました。

修正後:

修正後は、rforkシステムコールを呼び出す直前に、子スレッドが必要とする重要な引数を親スレッドのスタックからCPUのレジスタに退避させるように変更されました。

	MOVL    $19, AX // rfork
	MOVL	stack+8(SP), CX  // 新しいスタックのアドレスをCXレジスタにロード
	MOVL	mm+12(SP), BX	// m構造体へのポインタをBXレジスタにロード
	MOVL	gg+16(SP), DX	// g構造体へのポインタをDXレジスタにロード
	MOVL	fn+20(SP), SI	// 実行開始関数へのポインタをSIレジスタにロード
	INT     $64              // rforkシステムコールを呼び出す
  • MOVL $19, AX // rfork: rforkシステムコールの番号(19)をAXレジスタに設定します。
  • MOVL stack+8(SP), CX: 親スレッドのスタックポインタSPからオフセット+8の位置にあるstack引数(新しいスレッドのスタックアドレス)をCXレジスタにロードします。
  • MOVL mm+12(SP), BX // m: SPからオフセット+12の位置にあるmm引数(新しいM構造体へのポインタ)をBXレジスタにロードします。
  • MOVL gg+16(SP), DX // g: SPからオフセット+16の位置にあるgg引数(新しいG構造体へのポインタ)をDXレジスタにロードします。
  • MOVL fn+20(SP), SI // fn: SPからオフセット+20の位置にあるfn引数(新しいスレッドが実行を開始する関数へのポインタ)をSIレジスタにロードします。
  • INT $64: rforkシステムコールを実行します。この時点で、子スレッドが必要とするすべての重要な引数は、安全なレジスタに保持されています。

子スレッドのパスからは、これらの引数をスタックから読み取るための冗長なMOVL命令が削除されました。子スレッドは、レジスタに保持された値を使用して、新しいスタックポインタを設定し、MとGの初期化を続行します。

この変更により、親スレッドがスタックを上書きする可能性のある競合状態が解消され、子スレッドが常に正しい引数を受け取ることが保証されるようになりました。

関連リンク

参考にした情報源リンク