[インデックス 16777] ファイルの概要
このコミットは、Goコンパイラの競合検出器(Race Detector)がsyscall.forkAndExecInChild
関数を計測しないように変更を加えるものです。具体的には、src/cmd/gc/racewalk.c
に新しい関数isforkfunc
を追加し、racewalk
関数内でこの関数がtrue
を返す場合に競合計測をスキップするように修正しています。また、src/pkg/syscall/exec_bsd.go
とsrc/pkg/syscall/exec_linux.go
には、この関数が競合計測されない理由を説明するコメントが追加されています。
コミット
commit 63e0ddc7bf0c7523d826331ff51a551c5040b50b
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Tue Jul 16 15:35:03 2013 +0400
cmd/gc: do not race instrument syscall.forkAndExecInChild
Race instrumentation can allocate, switch stacks, preempt, etc.
All that is not allowed in between fork and exec.
Fixes #4840.
R=golang-dev, daniel.morsing, dave
CC=golang-dev
https://golang.org/cl/11324044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/63e0ddc7bf0c7523d826331ff51a551c5040b50b
元コミット内容
cmd/gc: do not race instrument syscall.forkAndExecInChild
Race instrumentation can allocate, switch stacks, preempt, etc.
All that is not allowed in between fork and exec.
Fixes #4840.
R=golang-dev, daniel.morsing, dave
CC=golang-dev
https://golang.org/cl/11324044
変更の背景
この変更の背景には、Unix系のシステムコールであるfork
とexec
の間に存在する特殊な実行環境と、Go言語の競合検出器(Race Detector)の動作原理との間の非互換性があります。
fork
システムコールは、現在のプロセス(親プロセス)のほぼ正確なコピーである新しいプロセス(子プロセス)を作成します。この子プロセスは、fork
が呼び出された時点の親プロセスのメモリ空間、ファイルディスクリプタ、シグナルハンドラなどの状態を継承します。通常、fork
の直後にはexec
システムコールが呼び出され、子プロセスは新しいプログラムを実行するために自身のメモリ空間を上書きします。
fork
とexec
の間、特に子プロセス内では、非常に厳格な制約が存在します。この期間中、子プロセスは親プロセスから継承したリソース(特にロック)の状態に依存するため、新しいロックを取得したり、メモリを割り当てたり、スケジューラによってプリエンプトされたりすることは許されません。これらの操作は、親プロセスが保持していたロックの状態と矛盾する可能性があり、デッドロックやその他の予測不能な動作を引き起こす可能性があるためです。
Goの競合検出器は、データ競合を検出するために、コンパイル時にプログラムの特定の箇所に「計測(instrumentation)」コードを挿入します。この計測コードは、メモリへのアクセスを監視し、必要に応じてランタイム関数を呼び出します。これらのランタイム関数は、メモリの割り当て、スタックの切り替え、ゴルーチンのスケジューリングなど、fork
とexec
の間に許されない操作を実行する可能性があります。
syscall.forkAndExecInChild
関数は、Goプログラムが外部プロセスを実行するために内部的に使用する低レベルな関数であり、fork
とexec
の間のクリティカルなセクションで実行されます。もしこの関数が競合検出器によって計測されると、挿入された計測コードが前述の制約に違反し、プログラムのクラッシュや予期せぬ動作を引き起こす可能性がありました。
このコミットは、この問題を解決するために、syscall.forkAndExecInChild
関数が競合検出器の計測対象から除外されるようにGoコンパイラを修正しています。これにより、fork
とexec
の間の安全性が確保されます。コミットメッセージにあるFixes #4840
は、この問題がGoのIssueトラッカーで報告されていたことを示していますが、今回のWeb検索では直接的なIssueの内容は見つかりませんでした。しかし、コミットメッセージ自体が問題の核心を明確に説明しています。
前提知識の解説
Unix系OSにおけるfork()
とexec()
システムコール
Unix系オペレーティングシステムにおいて、新しいプロセスを作成し、別のプログラムを実行するための基本的なメカニズムは、fork()
とexec()
という2つのシステムコールによって実現されます。
-
fork()
:fork()
システムコールは、呼び出し元のプロセス(親プロセス)のほぼ正確なコピーである新しいプロセス(子プロセス)を作成します。子プロセスは、親プロセスのメモリ空間、開いているファイルディスクリプタ、シグナルハンドラ、環境変数などのほとんどの状態を継承します。fork()
は親プロセスでは子プロセスのPID(プロセスID)を返し、子プロセスでは0を返します。このシステムコールの重要な特性は、子プロセスが親プロセスと同じコードの実行を、fork()
の呼び出し直後から開始することです。 -
exec()
(厳密にはexecve()
など):exec()
ファミリーのシステムコール(例:execve()
,execl()
,execvp()
など)は、現在のプロセスイメージを、指定された新しいプログラムで置き換えます。つまり、exec()
が成功すると、現在のプロセスのコード、データ、スタック、ヒープはすべて新しいプログラムの内容で上書きされ、新しいプログラムが実行を開始します。PIDは変更されません。exec()
は通常、fork()
によって作成された子プロセス内で呼び出され、子プロセスが親プロセスとは異なるタスクを実行できるようにします。
fork()
とexec()
の間の特殊な環境
fork()
が呼び出されてからexec()
が呼び出されるまでの間(特に子プロセス内)は、非常に特殊で制約の厳しい実行環境となります。この期間は「fork-exec
の間のクリティカルセクション」とも呼ばれます。
- ロックの継承とデッドロックの危険性: 親プロセスが
fork()
を呼び出した時点で、もし親プロセスが何らかのロック(ミューテックスなど)を保持していた場合、そのロックの状態は子プロセスにも継承されます。子プロセスがexec()
を呼び出す前に、継承されたロックを解放せずに新しいロックを取得しようとすると、デッドロックが発生する可能性があります。これは、親プロセスが保持していたロックが子プロセスでは「ロックされたまま」の状態に見えるためです。 - メモリ割り当ての制限:
fork()
はメモリ空間をコピーするため、子プロセスが新しいメモリを割り当てようとすると、親プロセスが使用していたメモリ管理の内部状態と矛盾する可能性があります。特に、親プロセスがmalloc
などのメモリ割り当て関数によって内部的にロックを保持していた場合、子プロセスでのmalloc
呼び出しはデッドロックを引き起こす可能性があります。 - シグナルハンドラと非同期シグナルセーフティ:
fork()
とexec()
の間で実行されるコードは、非同期シグナルセーフ(async-signal-safe)である必要があります。これは、シグナルハンドラから安全に呼び出せる関数のみを使用できることを意味します。多くの標準ライブラリ関数(printf
やmalloc
など)は非同期シグナルセーフではありません。 - スケジューリングとプリエンプション: この期間中に子プロセスがスケジューラによってプリエンプトされ、別のゴルーチンやスレッドに切り替わることも、ロックの状態を複雑にし、デッドロックのリスクを高めます。
これらの理由から、fork()
とexec()
の間で実行されるコードは、可能な限りシンプルで、システムコール以外のライブラリ関数呼び出しを避け、メモリ割り当てやロック操作を行わないように設計される必要があります。
GoのRace Detector (競合検出器)
Go言語には、並行処理におけるデータ競合(data race)を検出するための組み込みツールである「Race Detector」があります。データ競合は、複数のゴルーチンが同時に同じメモリ位置にアクセスし、少なくとも1つのアクセスが書き込みであり、かつそれらのアクセスが同期メカニズムによって保護されていない場合に発生します。データ競合は、プログラムの予測不能な動作やクラッシュの原因となることがあり、デバッグが非常に困難です。
- 動作原理: Race Detectorは、Goプログラムをコンパイルする際に、メモリへのアクセス(読み書き)を監視するための追加のコード(計測コード)を挿入します。この計測コードは、各メモリアクセスが発生したゴルーチン、アクセスタイプ(読み込みか書き込みか)、およびアクセスが発生した時点のタイムスタンプを記録します。Race Detectorは、これらの情報を基に、同期されていない競合するアクセスパターンをリアルタイムで検出します。
- 計測(Instrumentation): Race Detectorによる計測は、プログラムの実行時にオーバーヘッドを発生させます。計測コードは、メモリへのアクセスごとにランタイム関数を呼び出す必要があり、これにはメモリ割り当て、スタックの切り替え、ゴルーチンのスケジューリングなどの操作が含まれる可能性があります。
- 有効化: Race Detectorは、
go run -race
、go build -race
、go test -race
などのコマンドで-race
フラグを指定することで有効にできます。
//go:norace
ディレクティブ
Goコンパイラには、特定の関数に対してRace Detectorによる計測を行わないように指示するための特別なディレクティブ(プラグマ)が存在します。それが//go:norace
です。このディレクティブは、関数の定義の直前にコメントとして記述されます。
//go:norace
func myCriticalFunction() {
// この関数内のコードはRace Detectorによって計測されない
}
//go:norace
は、fork()
とexec()
の間のクリティカルセクションのように、Race Detectorの計測コードが安全に実行できない、あるいはパフォーマンス上の極めて厳しい制約がある場合に利用されます。このディレクティブを使用することで、開発者は特定のコードパスがRace Detectorのオーバーヘッドや潜在的な副作用から免れることを保証できます。
技術的詳細
このコミットの技術的詳細は、Goコンパイラの内部、特に競合検出器の計測ロジックと、syscall
パッケージの低レベルな実装に深く関わっています。
syscall.forkAndExecInChild
の特殊性
syscall.forkAndExecInChild
関数は、Goランタイムが新しいプロセスを生成し、その中で別のプログラムを実行するために使用する内部的なヘルパー関数です。この関数は、fork
システムコールを呼び出した直後の子プロセス内で実行されるコードを含んでいます。前述の通り、この「fork
とexec
の間」の期間は、非常に制約の厳しい環境です。
- ロックの取得禁止: 親プロセスが保持していたロックの状態が子プロセスに継承されるため、子プロセスが新しいロックを取得しようとすると、デッドロックを引き起こす可能性があります。
- 再スケジューリング禁止: ゴルーチンのスケジューリングやコンテキストスイッチは、ランタイムの内部状態(ロックなど)に依存するため、この期間中に行われるべきではありません。
- メモリ割り当て(
malloc
)禁止:malloc
のようなメモリ割り当て関数は、内部的にロックを使用したり、ランタイムのメモリ管理状態を変更したりするため、fork
とexec
の間では安全ではありません。 - 新しいスタックセグメントの禁止: Goのゴルーチンは必要に応じてスタックを拡張しますが、この操作もランタイムの内部状態に依存し、
fork
とexec
の間では問題を引き起こす可能性があります。
Race Instrumentationの制約違反
Goの競合検出器が挿入する計測コードは、これらの制約に違反する可能性があります。
- メモリ割り当て: 計測コードは、内部的にデータ構造を保持するためにメモリを割り当てる可能性があります。
- スタック切り替え: 計測コードがランタイム関数を呼び出す際、ゴルーチンのスタックが切り替わったり、拡張されたりする可能性があります。
- プリエンプション: 計測コードの実行中に、ゴルーチンがプリエンプトされ、別のゴルーチンに制御が移る可能性があります。
これらの操作はすべて、fork
とexec
の間のクリティカルセクションで実行されると、デッドロック、クラッシュ、またはその他の未定義の動作を引き起こす可能性があります。
cmd/gc/racewalk.c
における変更
このコミットの主要な変更は、Goコンパイラのバックエンドの一部であるcmd/gc/racewalk.c
ファイルにあります。このファイルは、競合検出器の計測対象となる関数を決定し、実際に計測コードを挿入するロジックを含んでいます。
-
isforkfunc
関数の追加: 新しい静的関数isforkfunc(Node *fn)
が追加されました。この関数は、与えられた関数ノードfn
がsyscall.forkAndExecInChild
であるかどうかをチェックします。myimportpath != nil && strcmp(myimportpath, "syscall") == 0
: 現在処理しているパッケージがsyscall
パッケージであるかを確認します。strcmp(fn->nname->sym->name, "forkAndExecInChild") == 0
: 関数のシンボル名がforkAndExecInChild
であるかを確認します。 この関数がtrue
を返す場合、それはsyscall.forkAndExecInChild
関数であることを意味します。
-
racewalk
関数でのisforkfunc
の利用:racewalk
関数は、GoプログラムのAST(抽象構文木)を走査し、競合検出器の計測が必要な箇所を特定する役割を担っています。この関数内で、既存のispkgin(omit_pkgs, nelem(omit_pkgs))
(特定のパッケージを計測対象から除外するロジック)に加えて、isforkfunc(fn)
のチェックが追加されました。if(ispkgin(omit_pkgs, nelem(omit_pkgs)) || isforkfunc(fn))
この変更により、もし関数がsyscall.forkAndExecInChild
であるとisforkfunc
が判断した場合、racewalk
関数はその関数に対する競合計測をスキップするようになります。
src/pkg/syscall/exec_bsd.go
とsrc/pkg/syscall/exec_linux.go
におけるコメントの追加
これらのファイルは、それぞれBSD系OSとLinux系OSにおけるsyscall.forkAndExecInChild
関数のGo言語側の実装を含んでいます。このコミットでは、これらのファイルのforkAndExecInChild
関数のコメントに以下の行が追加されました。
// For the same reason compiler does not race instrument it.
これは、この関数がロックの取得、再スケジューリング、メモリ割り当て、新しいスタックセグメントの生成を許さないのと同じ理由で、コンパイラ(具体的には競合検出器)もこの関数を計測しないことを明示しています。このコメントは、コードの意図と、なぜこの関数が特殊な扱いを受けるのかを明確にするためのものです。
これらの変更により、syscall.forkAndExecInChild
関数は競合検出器の計測対象から確実に除外され、fork
とexec
の間のクリティカルセクションでの実行の安全性が保証されます。
コアとなるコードの変更箇所
src/cmd/gc/racewalk.c
--- a/src/cmd/gc/racewalk.c
+++ b/src/cmd/gc/racewalk.c
@@ -51,6 +51,18 @@ ispkgin(const char **pkgs, int n)\n \treturn 0;\n }\n \n+static int\n+isforkfunc(Node *fn)\n+{\n+\t// Special case for syscall.forkAndExecInChild.\n+\t// In the child, this function must not acquire any locks, because\n+\t// they might have been locked at the time of the fork. This means\n+\t// no rescheduling, no malloc calls, and no new stack segments.\n+\t// Race instrumentation does all of the above.\n+\treturn myimportpath != nil && strcmp(myimportpath, "syscall") == 0 &&\n+\t\tstrcmp(fn->nname->sym->name, "forkAndExecInChild") == 0;\n+}\n+\n void\n racewalk(Node *fn)\n {\n@@ -58,7 +70,7 @@ racewalk(Node *fn)\n \tNode *nodpc;\n \tchar s[1024];\n \n-\tif(ispkgin(omit_pkgs, nelem(omit_pkgs)))\n+\tif(ispkgin(omit_pkgs, nelem(omit_pkgs)) || isforkfunc(fn))\n \t\treturn;\n \n \tif(!ispkgin(noinst_pkgs, nelem(noinst_pkgs))) {
src/pkg/syscall/exec_bsd.go
--- a/src/pkg/syscall/exec_bsd.go
+++ b/src/pkg/syscall/exec_bsd.go
@@ -27,6 +27,7 @@ type SysProcAttr struct {\n // In the child, this function must not acquire any locks, because\n // they might have been locked at the time of the fork. This means\n // no rescheduling, no malloc calls, and no new stack segments.\n+// For the same reason compiler does not race instrument it.\n // The calls to RawSyscall are okay because they are assembly\n // functions that do not grow the stack.\n func forkAndExecInChild(argv0 *byte, argv, envv []*byte, chroot, dir *byte, attr *ProcAttr, sys *SysProcAttr, pipe int) (pid int, err Errno) {
src/pkg/syscall/exec_linux.go
--- a/src/pkg/syscall/exec_linux.go
+++ b/src/pkg/syscall/exec_linux.go
@@ -28,6 +28,7 @@ type SysProcAttr struct {\n // In the child, this function must not acquire any locks, because\n // they might have been locked at the time of the fork. This means\n // no rescheduling, no malloc calls, and no new stack segments.\n+// For the same reason compiler does not race instrument it.\n // The calls to RawSyscall are okay because they are assembly\n // functions that do not grow the stack.\n func forkAndExecInChild(argv0 *byte, argv, envv []*byte, chroot, dir *byte, attr *ProcAttr, sys *SysProcAttr, pipe int) (pid int, err Errno) {
コアとなるコードの解説
src/cmd/gc/racewalk.c
の変更
-
isforkfunc
関数の追加: この新しい関数は、Goコンパイラが処理している現在の関数がsyscall
パッケージのforkAndExecInChild
関数であるかどうかを判定します。myimportpath != nil && strcmp(myimportpath, "syscall") == 0
: 現在のファイルのインポートパスがsyscall
であるかを確認します。これにより、syscall
パッケージ内の関数のみを対象とします。strcmp(fn->nname->sym->name, "forkAndExecInChild") == 0
: 関数の名前がforkAndExecInChild
であるかを確認します。 この2つの条件が両方とも真である場合、この関数は1
(真)を返し、それ以外の場合は0
(偽)を返します。この関数は、fork
とexec
の間の特殊な環境における制約を考慮し、競合検出器の計測を避けるべき関数を正確に識別するために導入されました。
-
racewalk
関数の修正:racewalk
関数は、Goプログラムの抽象構文木(AST)を走査し、競合検出器による計測が必要な関数を特定します。修正前のコードでは、ispkgin(omit_pkgs, nelem(omit_pkgs))
という条件に基づいて、特定のパッケージ内の関数を計測対象から除外していました。 今回の変更では、この条件に|| isforkfunc(fn)
が追加されました。これは論理OR演算子であり、以下のいずれかの条件が満たされる場合に、現在の関数に対する競合計測をスキップすることを意味します。- 関数が
omit_pkgs
リストに含まれるパッケージ内にある場合。 - 関数が
syscall.forkAndExecInChild
であるとisforkfunc
が判定した場合。 この修正により、syscall.forkAndExecInChild
関数は、その特殊な性質のために、競合検出器による計測が明示的に回避されるようになりました。
- 関数が
src/pkg/syscall/exec_bsd.go
とsrc/pkg/syscall/exec_linux.go
の変更
これらのファイルは、Goのsyscall
パッケージの一部であり、それぞれBSD系OSとLinux系OSにおけるforkAndExecInChild
関数のGo言語側の実装を含んでいます。
追加された行は以下の通りです。
// For the same reason compiler does not race instrument it.
このコメントは、forkAndExecInChild
関数が「子プロセス内でロックを取得したり、再スケジューリングしたり、メモリを割り当てたり、新しいスタックセグメントを生成したりしてはならない」という既存のコメントに続くものです。この追加されたコメントは、コンパイラ(具体的にはGoの競合検出器)がこの関数を計測しない理由が、まさにこれらの制約と同じであるということを明確に示しています。つまり、競合計測がこれらの制約に違反する可能性があるため、コンパイラは意図的にこの関数を計測対象から外している、という設計上の決定を文書化しています。これは、開発者がこの関数の特殊な性質と、それがGoランタイムによってどのように扱われているかを理解するのに役立ちます。
関連リンク
- GitHubコミットページ: https://github.com/golang/go/commit/63e0ddc7bf0c7523d826331ff51a551c5040b50b
- Go Issue #4840: (Web検索では直接的なIssueの内容は見つかりませんでしたが、コミットメッセージで参照されています。)
参考にした情報源リンク
- Go Race Detectorに関する公式ドキュメントやブログ記事 (一般的な概念理解のため)
- Unix
fork()
とexec()
システムコールに関するドキュメント (一般的な概念理解のため) - Web検索結果: Go runtime handles race instrumentation around system calls (特に
fork
とexec
関連の制約について)- https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQGTR-pwsje_1eKu3A1NIAWL_1xcs-MV0obJPACdEyQj4TTYaqg-Yys3ytYUnMZ-BKvvRqXmKTHHXZ5ZvnhY0m0-pAUDFLI2860dK5n8NYGM5a_4l6C12hcWBTbHtcbGTZejPKig==
(このリンクは、Goランタイムが
fork
やexec
のようなシステムコール周辺で競合計測をどのように扱うかについての一般的な情報を提供しています。)
- https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQGTR-pwsje_1eKu3A1NIAWL_1xcs-MV0obJPACdEyQj4TTYaqg-Yys3ytYUnMZ-BKvvRqXmKTHHXZ5ZvnhY0m0-pAUDFLI2860dK5n8NYGM5a_4l6C12hcWBTbHtcbGTZejPKig==
(このリンクは、Goランタイムが