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

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

このコミットは、Goコンパイラの競合検出器(Race Detector)がsyscall.forkAndExecInChild関数を計測しないように変更を加えるものです。具体的には、src/cmd/gc/racewalk.cに新しい関数isforkfuncを追加し、racewalk関数内でこの関数がtrueを返す場合に競合計測をスキップするように修正しています。また、src/pkg/syscall/exec_bsd.gosrc/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系のシステムコールであるforkexecの間に存在する特殊な実行環境と、Go言語の競合検出器(Race Detector)の動作原理との間の非互換性があります。

forkシステムコールは、現在のプロセス(親プロセス)のほぼ正確なコピーである新しいプロセス(子プロセス)を作成します。この子プロセスは、forkが呼び出された時点の親プロセスのメモリ空間、ファイルディスクリプタ、シグナルハンドラなどの状態を継承します。通常、forkの直後にはexecシステムコールが呼び出され、子プロセスは新しいプログラムを実行するために自身のメモリ空間を上書きします。

forkexecの間、特に子プロセス内では、非常に厳格な制約が存在します。この期間中、子プロセスは親プロセスから継承したリソース(特にロック)の状態に依存するため、新しいロックを取得したり、メモリを割り当てたり、スケジューラによってプリエンプトされたりすることは許されません。これらの操作は、親プロセスが保持していたロックの状態と矛盾する可能性があり、デッドロックやその他の予測不能な動作を引き起こす可能性があるためです。

Goの競合検出器は、データ競合を検出するために、コンパイル時にプログラムの特定の箇所に「計測(instrumentation)」コードを挿入します。この計測コードは、メモリへのアクセスを監視し、必要に応じてランタイム関数を呼び出します。これらのランタイム関数は、メモリの割り当て、スタックの切り替え、ゴルーチンのスケジューリングなど、forkexecの間に許されない操作を実行する可能性があります。

syscall.forkAndExecInChild関数は、Goプログラムが外部プロセスを実行するために内部的に使用する低レベルな関数であり、forkexecの間のクリティカルなセクションで実行されます。もしこの関数が競合検出器によって計測されると、挿入された計測コードが前述の制約に違反し、プログラムのクラッシュや予期せぬ動作を引き起こす可能性がありました。

このコミットは、この問題を解決するために、syscall.forkAndExecInChild関数が競合検出器の計測対象から除外されるようにGoコンパイラを修正しています。これにより、forkexecの間の安全性が確保されます。コミットメッセージにある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)である必要があります。これは、シグナルハンドラから安全に呼び出せる関数のみを使用できることを意味します。多くの標準ライブラリ関数(printfmallocなど)は非同期シグナルセーフではありません。
  • スケジューリングとプリエンプション: この期間中に子プロセスがスケジューラによってプリエンプトされ、別のゴルーチンやスレッドに切り替わることも、ロックの状態を複雑にし、デッドロックのリスクを高めます。

これらの理由から、fork()exec()の間で実行されるコードは、可能な限りシンプルで、システムコール以外のライブラリ関数呼び出しを避け、メモリ割り当てやロック操作を行わないように設計される必要があります。

GoのRace Detector (競合検出器)

Go言語には、並行処理におけるデータ競合(data race)を検出するための組み込みツールである「Race Detector」があります。データ競合は、複数のゴルーチンが同時に同じメモリ位置にアクセスし、少なくとも1つのアクセスが書き込みであり、かつそれらのアクセスが同期メカニズムによって保護されていない場合に発生します。データ競合は、プログラムの予測不能な動作やクラッシュの原因となることがあり、デバッグが非常に困難です。

  • 動作原理: Race Detectorは、Goプログラムをコンパイルする際に、メモリへのアクセス(読み書き)を監視するための追加のコード(計測コード)を挿入します。この計測コードは、各メモリアクセスが発生したゴルーチン、アクセスタイプ(読み込みか書き込みか)、およびアクセスが発生した時点のタイムスタンプを記録します。Race Detectorは、これらの情報を基に、同期されていない競合するアクセスパターンをリアルタイムで検出します。
  • 計測(Instrumentation): Race Detectorによる計測は、プログラムの実行時にオーバーヘッドを発生させます。計測コードは、メモリへのアクセスごとにランタイム関数を呼び出す必要があり、これにはメモリ割り当て、スタックの切り替え、ゴルーチンのスケジューリングなどの操作が含まれる可能性があります。
  • 有効化: Race Detectorは、go run -racego build -racego 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システムコールを呼び出した直後の子プロセス内で実行されるコードを含んでいます。前述の通り、この「forkexecの間」の期間は、非常に制約の厳しい環境です。

  • ロックの取得禁止: 親プロセスが保持していたロックの状態が子プロセスに継承されるため、子プロセスが新しいロックを取得しようとすると、デッドロックを引き起こす可能性があります。
  • 再スケジューリング禁止: ゴルーチンのスケジューリングやコンテキストスイッチは、ランタイムの内部状態(ロックなど)に依存するため、この期間中に行われるべきではありません。
  • メモリ割り当て(malloc)禁止: mallocのようなメモリ割り当て関数は、内部的にロックを使用したり、ランタイムのメモリ管理状態を変更したりするため、forkexecの間では安全ではありません。
  • 新しいスタックセグメントの禁止: Goのゴルーチンは必要に応じてスタックを拡張しますが、この操作もランタイムの内部状態に依存し、forkexecの間では問題を引き起こす可能性があります。

Race Instrumentationの制約違反

Goの競合検出器が挿入する計測コードは、これらの制約に違反する可能性があります。

  • メモリ割り当て: 計測コードは、内部的にデータ構造を保持するためにメモリを割り当てる可能性があります。
  • スタック切り替え: 計測コードがランタイム関数を呼び出す際、ゴルーチンのスタックが切り替わったり、拡張されたりする可能性があります。
  • プリエンプション: 計測コードの実行中に、ゴルーチンがプリエンプトされ、別のゴルーチンに制御が移る可能性があります。

これらの操作はすべて、forkexecの間のクリティカルセクションで実行されると、デッドロック、クラッシュ、またはその他の未定義の動作を引き起こす可能性があります。

cmd/gc/racewalk.cにおける変更

このコミットの主要な変更は、Goコンパイラのバックエンドの一部であるcmd/gc/racewalk.cファイルにあります。このファイルは、競合検出器の計測対象となる関数を決定し、実際に計測コードを挿入するロジックを含んでいます。

  1. isforkfunc関数の追加: 新しい静的関数isforkfunc(Node *fn)が追加されました。この関数は、与えられた関数ノードfnsyscall.forkAndExecInChildであるかどうかをチェックします。

    • myimportpath != nil && strcmp(myimportpath, "syscall") == 0: 現在処理しているパッケージがsyscallパッケージであるかを確認します。
    • strcmp(fn->nname->sym->name, "forkAndExecInChild") == 0: 関数のシンボル名がforkAndExecInChildであるかを確認します。 この関数がtrueを返す場合、それはsyscall.forkAndExecInChild関数であることを意味します。
  2. 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.gosrc/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関数は競合検出器の計測対象から確実に除外され、forkexecの間のクリティカルセクションでの実行の安全性が保証されます。

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

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の変更

  1. isforkfunc関数の追加: この新しい関数は、Goコンパイラが処理している現在の関数がsyscallパッケージのforkAndExecInChild関数であるかどうかを判定します。

    • myimportpath != nil && strcmp(myimportpath, "syscall") == 0: 現在のファイルのインポートパスがsyscallであるかを確認します。これにより、syscallパッケージ内の関数のみを対象とします。
    • strcmp(fn->nname->sym->name, "forkAndExecInChild") == 0: 関数の名前がforkAndExecInChildであるかを確認します。 この2つの条件が両方とも真である場合、この関数は1(真)を返し、それ以外の場合は0(偽)を返します。この関数は、forkexecの間の特殊な環境における制約を考慮し、競合検出器の計測を避けるべき関数を正確に識別するために導入されました。
  2. 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.gosrc/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ランタイムによってどのように扱われているかを理解するのに役立ちます。

関連リンク

参考にした情報源リンク