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

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

このコミットは、Go言語のsyscallおよびosパッケージにおけるPlan 9オペレーティングシステム特有のfork-exec/waitにおける競合状態(race condition)を修正するものです。具体的には、Plan 9のプロセス管理モデルとGoのゴルーチン(goroutine)スケジューリングの間の不整合によって発生する問題に対処しています。

コミット

commit b62847000b3c9c3f7837b54dab0d1234944b0722
Author: Akshat Kumar <seed@mail.nanosouffle.net>
Date:   Fri Jan 18 16:43:25 2013 -0500

    syscall, os: fix a fork-exec/wait race in Plan 9.
    
    On Plan 9, only the parent of a given process can enter its wait
    queue. When a Go program tries to fork-exec a child process
    and subsequently waits for it to finish, the goroutines doing
    these two tasks do not necessarily tie themselves to the same
    (or any single) OS thread. In the case that the fork and the wait
    system calls happen on different OS threads (say, due to a
    goroutine being rescheduled somewhere along the way), the
    wait() will either return an error or end up waiting for a
    completely different child than was intended.
    
    This change forces the fork and wait syscalls to happen in the
    same goroutine and ties that goroutine to its OS thread until
    the child exits. The PID of the child is recorded upon fork and
    exit, and de-queued once the child's wait message has been read.
    The Wait API, then, is translated into a synthetic implementation
    that simply waits for the requested PID to show up in the queue
    and then reads the associated stats.
    
    R=rsc, rminnich, npe, mirtchovski, ality
    CC=golang-dev
    https://golang.org/cl/6545051

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

https://github.com/golang/go/commit/b62847000b3c9c3f7837b54dab0d1234944b0722

元コミット内容

このコミットは、GoプログラムがPlan 9上で子プロセスをfork-execし、その終了をwaitする際に発生する競合状態を修正します。Plan 9では、特定の子プロセスの待機キューに入れることができるのはその親プロセスのみという制約があります。GoのゴルーチンはOSスレッドに厳密に紐付けられていないため、forkwaitのシステムコールが異なるOSスレッドで実行される可能性があり、その結果、wait()がエラーを返したり、意図しない子プロセスを待機してしまったりする問題がありました。

この変更は、forkwaitのシステムコールを同じゴルーチン内で実行させ、そのゴルーチンを子プロセスが終了するまでOSスレッドに固定することで問題を解決します。子プロセスのPIDはfork時に記録され、終了時にデキューされます。Wait APIは、要求されたPIDがキューに現れるのを待機し、関連する統計情報を読み取る合成的な実装に変換されます。

変更の背景

Go言語は、その設計思想として、OSの抽象化とクロスプラットフォーム対応を重視しています。しかし、各OSにはそれぞれ独自のプロセス管理やシステムコールのセマンティクスが存在します。特にPlan 9のようなユニークな設計を持つOSでは、一般的なUnix系OSとは異なる挙動を示すことがあります。

このコミットの背景には、GoのランタイムがゴルーチンをOSスレッドにどのようにマッピングし、スケジューリングするかの特性が深く関わっています。Goのランタイムは、M:Nスケジューリングモデルを採用しており、多数のゴルーチン(M)を少数のOSスレッド(N)に多重化して実行します。これにより、高い並行性と効率性を実現していますが、特定のOSシステムコール、特にプロセス間通信やプロセス待機に関連するシステムコールにおいては、ゴルーチンが実行されるOSスレッドが途中で変更される可能性があるという問題を引き起こすことがあります。

Plan 9のwaitシステムコールは、呼び出し元のプロセス(親プロセス)が子プロセスの終了を待機するためのもので、その子プロセスが生成されたOSスレッドと同じスレッドでwaitが呼び出されることを期待する、あるいは少なくとも親プロセスがその子プロセスの待機キューにアクセスできるという厳密な制約があります。Goのゴルーチンがforkを実行したスレッドとは異なるスレッドでwaitを実行しようとすると、Plan 9のカーネルはそのwait要求を無効と判断し、エラーを返したり、あるいは全く関係のないプロセスからの待機メッセージを受け取ってしまったりする可能性がありました。

この問題は、Goプログラムが外部コマンドを実行し、その結果を待機するようなシナリオで顕在化します。例えば、os/execパッケージを通じて外部プログラムを起動し、その終了を待つような場合です。安定したプロセス管理は、信頼性の高いアプリケーションを構築する上で不可欠であるため、この競合状態の修正はGoのPlan 9サポートにおいて重要な改善でした。

前提知識の解説

このコミットを理解するためには、以下の概念についての前提知識が必要です。

  1. Plan 9 From Bell Labs:

    • ベル研究所で開発された分散オペレーティングシステム。Unixの後継として設計され、"Everything is a file"(すべてはファイルである)という哲学を徹底しています。
    • プロセス管理: Plan 9のプロセス管理はUnixに似ていますが、いくつかの重要な違いがあります。特に、waitシステムコールは、子プロセスを生成した親プロセスのみがその子プロセスの終了を待機できるという厳密なセマンティクスを持ちます。これは、子プロセスの状態変更に関するメッセージ(waitmsg)が、親プロセスの特定の待機キューに送られるためです。
    • 名前空間: 各プロセスは独自のファイルシステム名前空間を持ち、リソースはファイルとして表現されます。
  2. Go言語のゴルーチンとOSスレッド:

    • ゴルーチン (Goroutine): Goランタイムによって管理される軽量な並行実行単位です。OSスレッドよりもはるかに軽量で、数百万個のゴルーチンを同時に実行できます。
    • M:Nスケジューリング: Goランタイムは、多数のゴルーチン(M)を少数のOSスレッド(N)にマッピングして実行します。ランタイムスケジューラがゴルーチンをOSスレッド上で実行し、必要に応じて別のOSスレッドに移動させることができます。
    • runtime.LockOSThread(): この関数は、現在のゴルーチンを現在のOSスレッドに固定します。これにより、そのゴルーチンは他のOSスレッドに移動することなく、特定のOSスレッド上で実行され続けることが保証されます。これは、特定のOSシステムコールがスレッドアフィニティ(スレッドの固定)を要求する場合に特に重要です。
  3. fork-exec-waitパターン:

    • Unix系OSやPlan 9における一般的なプロセス起動と待機のパターンです。
    • fork: 現在のプロセス(親プロセス)のコピーである新しいプロセス(子プロセス)を作成します。子プロセスは親プロセスのメモリ空間、ファイルディスクリプタなどを継承します。
    • exec: forkによって作成された子プロセスが、新しいプログラムをロードして実行します。これにより、子プロセスは全く異なる機能を持つプロセスに変わります。
    • wait: 親プロセスが子プロセスの終了を待機し、子プロセスの終了ステータスなどの情報を取得します。
  4. 競合状態 (Race Condition):

    • 複数の並行プロセスやスレッドが共有リソースにアクセスする際に、そのアクセス順序によって結果が非決定的に変わってしまう状態を指します。このコミットの場合、forkwaitが異なるOSスレッドで実行される可能性が競合状態を引き起こしていました。

技術的詳細

このコミットの技術的な核心は、GoのゴルーチンとPlan 9のwaitシステムコールの間の不整合を解消することにあります。

元の実装では、Goのos/execパッケージが内部的にsyscall.ForkExecを呼び出して子プロセスを生成し、その後os/Process.wait()メソッドでその子プロセスの終了を待機していました。問題は、ForkExecが実行されたゴルーチンが、その後にwaitを実行する際に、Goランタイムのスケジューリングによって異なるOSスレッドに移動してしまう可能性があったことです。

Plan 9のwaitシステムコールは、子プロセスを生成した親プロセス(より正確には、その親プロセスが実行されているOSスレッド)が、その子プロセスの終了メッセージを受け取るための待機キューにアクセスできるという制約があります。もしforkwaitが異なるOSスレッドで実行されると、waitを呼び出したOSスレッドは、期待する子プロセスの待機キューにアクセスできず、結果としてエラーになったり、意図しないプロセスからのメッセージを受け取ったりする可能性がありました。

このコミットは、この問題を解決するために以下の戦略を採用しています。

  1. OSスレッドへのゴルーチンの固定:

    • syscallパッケージにstartProcessという新しい関数が導入されました。この関数は、子プロセスのfork-execとそれに続くwait処理を単一のゴルーチン内で実行します。
    • このゴルーチンは、runtime.LockOSThread()を呼び出すことで、自身を現在のOSスレッドに固定します。これにより、forkwaitの両方のシステムコールが同じOSスレッド上で実行されることが保証され、Plan 9のwaitシステムコールの制約を満たします。
  2. 合成的なWait APIの実装:

    • syscallパッケージにWaitProcessという新しい関数が追加されました。これは、os/Process.wait()から呼び出される新しい待機メカニズムです。
    • startProcess内で、子プロセスが起動されると、そのPIDと関連付けられたチャネルがグローバルなマップprocs.waitsに登録されます。このチャネルは、子プロセスの終了メッセージ(waitmsg)を伝達するために使用されます。
    • startProcess内の固定されたゴルーチンは、子プロセスが終了するまでsyscall.Awaitを呼び出して待機します。子プロセスが終了すると、そのwaitmsgをチャネルに送信します。
    • WaitProcessは、指定されたPIDに対応するチャネルからwaitmsgを受け取ることで、子プロセスの終了を待機します。これにより、os/Process.wait()は直接OSのwaitシステムコールを呼び出すのではなく、Goランタイムが管理する内部キューを通じて待機する形になります。
  3. PIDの記録とデキュー:

    • 子プロセスのPIDはfork時に記録され、procs.waitsマップに登録されます。
    • 子プロセスのwaitmsgが読み取られ、チャネルを通じてWaitProcessに渡されると、そのPIDはprocs.waitsマップから削除(デキュー)されます。これにより、システムが不要な待機情報を保持しないようにします。

このアプローチにより、GoのランタイムはPlan 9のOSスレッドアフィニティの要件を満たしつつ、Goの並行性モデルを維持することができます。os/execパッケージは、この下位レベルの変更を意識することなく、引き続きクロスプラットフォームなプロセス実行APIを提供できます。

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

このコミットで変更された主要なファイルとコードブロックは以下の通りです。

src/pkg/os/exec_plan9.go

  • Process.wait() メソッドが大幅に簡素化されました。
  • 変更前は、syscall.Awaitをループで呼び出し、waitmsg.Pidが現在のプロセスのPIDと一致するかを確認していました。
  • 変更後は、新しく追加されたsyscall.WaitProcess(p.Pid, &waitmsg)を直接呼び出すようになりました。これにより、待機ロジックがsyscallパッケージに委譲され、より抽象化されました。
--- a/src/pkg/os/exec_plan9.go
+++ b/src/pkg/os/exec_plan9.go
@@ -75,20 +75,12 @@ func (p *Process) wait() (ps *ProcessState, err error) {
 	if p.Pid == -1 {
 		return nil, ErrInvalid
 	}
-
-	for true {
-		err = syscall.Await(&waitmsg)
-
-		if err != nil {
-			return nil, NewSyscallError("wait", err)
-		}
-
-		if waitmsg.Pid == p.Pid {
-			p.setDone()
-			break
-		}
-	}
-
+	err = syscall.WaitProcess(p.Pid, &waitmsg)
+	if err != nil {
+		return nil, NewSyscallError("wait", err)
+	}
+
 	p.setDone()
 	ps = &ProcessState{
 		pid:    waitmsg.Pid,

src/pkg/syscall/exec_plan9.go

このファイルが変更の大部分を占めており、新しいロジックが追加されています。

  • procs構造体:
    • sync.Mutexmap[int]chan *waitErrを含むグローバル変数procsが追加されました。これは、実行中の子プロセスとその待機チャネルを管理するためのものです。
  • startProcess関数:
    • ForkExecのラッパーとして新しく導入されました。
    • 内部で新しいゴルーチンを起動し、そのゴルーチン内でruntime.LockOSThread()を呼び出してOSスレッドに固定します。
    • 固定されたゴルーチン内で実際のforkExecを実行し、その結果をforkcチャネルを通じて親ゴルーチンに返します。
    • 子プロセスが正常に起動した場合、そのPIDと関連付けられたwaitcチャネルをprocs.waitsマップに登録します。
    • その後、syscall.Awaitを呼び出して子プロセスの終了を待機し、結果をwaitcチャネルに送信します。
  • StartProcess関数の変更:
    • osパッケージから呼び出されるsyscall.StartProcessが、直接forkExecを呼び出す代わりに、新しく追加されたstartProcessを呼び出すように変更されました。
  • WaitProcess関数:
    • os/Process.wait()から呼び出される新しい待機関数です。
    • procs.waitsマップから指定されたPIDに対応する待機チャネルを取得します。
    • チャネルからwaitErrを受け取り、子プロセスの終了を待ちます。
    • 待機メッセージを受け取った後、procs.waitsマップから該当エントリを削除します。
--- a/src/pkg/syscall/exec_plan9.go
+++ b/src/pkg/syscall/exec_plan9.go
@@ -7,6 +7,7 @@
 package syscall
 
 import (
+	"runtime"
 	"sync"
 	"unsafe"
 )
@@ -499,9 +500,68 @@ func ForkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error)
 	return forkExec(argv0, argv, attr)
 }
 
+type waitErr struct {
+	Waitmsg
+	err error
+}
+
+var procs struct {
+	sync.Mutex
+	waits map[int]chan *waitErr
+}
+
+// startProcess starts a new goroutine, tied to the OS
+// thread, which runs the process and subsequently waits
+// for it to finish, communicating the process stats back
+// to any goroutines that may have been waiting on it.
+//
+// Such a dedicated goroutine is needed because on
+// Plan 9, only the parent thread can wait for a child,
+// whereas goroutines tend to jump OS threads (e.g.,
+// between starting a process and running Wait(), the
+// goroutine may have been rescheduled).
+func startProcess(argv0 string, argv []string, attr *ProcAttr) (pid int, err error) {
+	type forkRet struct {
+		pid int
+		err error
+	}
+
+	forkc := make(chan forkRet, 1)
+	go func() {
+		runtime.LockOSThread()
+		var ret forkRet
+
+		ret.pid, ret.err = forkExec(argv0, argv, attr)
+		// If fork fails there is nothing to wait for.
+		if ret.err != nil || ret.pid == 0 {
+			forkc <- ret
+			return
+		}
+
+		waitc := make(chan *waitErr, 1)
+
+		// Mark that the process is running.
+		procs.Lock()
+		if procs.waits == nil {
+			procs.waits = make(map[int]chan *waitErr)
+		}
+		procs.waits[ret.pid] = waitc
+		procs.Unlock()
+
+		forkc <- ret
+
+		var w waitErr
+		w.err = Await(&w.Waitmsg)
+		waitc <- &w
+		close(waitc)
+	}()
+	ret := <-forkc
+	return ret.pid, ret.err
+}
+
 // StartProcess wraps ForkExec for package os.
 func StartProcess(argv0 string, argv []string, attr *ProcAttr) (pid int, handle uintptr, err error) {
-	pid, err = forkExec(argv0, argv, attr)
+	pid, err = startProcess(argv0, argv, attr)
 	return pid, 0, err
 }
 
@@ -548,3 +608,35 @@ func Exec(argv0 string, argv []string, envv []string) (err error) {
 
 	return e1
 }
+
+// WaitProcess waits until the pid of a
+// running process is found in the queue of
+// wait messages. It is used in conjunction
+// with StartProcess to wait for a running
+// process to exit.
+func WaitProcess(pid int, w *Waitmsg) (err error) {
+	procs.Lock()
+	ch := procs.waits[pid]
+	procs.Unlock()
+
+	var wmsg *waitErr
+	if ch != nil {
+		wmsg = <-ch
+		procs.Lock()
+		if procs.waits[pid] == ch {
+			delete(procs.waits, pid)
+		}
+		procs.Unlock()
+	}
+	if wmsg == nil {
+		// ch was missing or ch is closed
+		return NewError("process not found")
+	}
+	if wmsg.err != nil {
+		return wmsg.err
+	}
+	if w != nil {
+		*w = wmsg.Waitmsg
+	}
+	return nil
+}

コアとなるコードの解説

このコミットの核心は、src/pkg/syscall/exec_plan9.goに追加されたstartProcess関数とWaitProcess関数、そしてそれらを連携させるためのprocsグローバル変数です。

  1. procsグローバル変数:

    • var procs struct { sync.Mutex; waits map[int]chan *waitErr }
    • これは、Goランタイム全体で共有されるデータ構造です。
    • sync.Mutexは、waitsマップへの並行アクセスを保護するためのミューテックスです。
    • waits map[int]chan *waitErrは、子プロセスのPIDをキーとし、その子プロセスの終了メッセージを送信するためのチャネルを値とするマップです。これにより、Goランタイムは、どのゴルーチンがどのPIDの終了を待っているかを追跡できます。
  2. startProcess関数:

    • この関数は、os.StartProcess(最終的にはsyscall.StartProcess)から呼び出されます。
    • go func() { runtime.LockOSThread(); ... }(): ここが最も重要な部分です。新しいゴルーチンを起動し、そのゴルーチン内でruntime.LockOSThread()を呼び出します。これにより、このゴルーチンは、子プロセスをfork-execし、その終了をAwaitするまで、同じOSスレッドに固定されます。Plan 9のwaitシステムコールの制約を満たすために不可欠な処理です。
    • ret.pid, ret.err = forkExec(argv0, argv, attr): 固定されたOSスレッド上で、実際の子プロセス生成(fork-exec)が行われます。
    • procs.Lock(); procs.waits[ret.pid] = waitc; procs.Unlock(): 子プロセスが正常に起動した場合、そのPIDと、子プロセスの終了メッセージを送信するための新しいチャネルwaitcprocs.waitsマップに登録します。
    • w.err = Await(&w.Waitmsg); waitc <- &w; close(waitc): 固定されたゴルーチンは、syscall.Awaitを呼び出して子プロセスの終了を待ちます。AwaitはPlan 9のネイティブな待機システムコールをラップしたものです。子プロセスが終了すると、そのWaitmsgとエラー情報を含むwaitErr構造体をwaitcチャネルに送信し、チャネルを閉じます。
  3. WaitProcess関数:

    • この関数は、os/Process.wait()から呼び出され、特定のPIDの子プロセスの終了を待機します。
    • procs.Lock(); ch := procs.waits[pid]; procs.Unlock(): procs.waitsマップから、待機したいPIDに対応するチャネルchを取得します。
    • wmsg = <-ch: chチャネルからwaitErr構造体を受け取ります。これにより、WaitProcessを呼び出したゴルーチンは、startProcess内の固定されたゴルーチンが子プロセスの終了を検知し、メッセージを送信するまでブロックされます。
    • delete(procs.waits, pid): 待機メッセージを受け取った後、procs.waitsマップから該当エントリを削除します。これにより、リソースが解放され、不要な情報が保持されなくなります。

このメカニズムにより、GoのゴルーチンがOSスレッド間で自由に移動できるという特性を維持しつつ、Plan 9のOSスレッドアフィニティの要件を満たすことが可能になりました。forkwaitの間の競合状態は、startProcess内の単一のOSスレッド固定ゴルーチンが両方の操作を処理し、その結果を内部チャネルを通じて他のゴルーチンに安全に伝達することで解消されています。

関連リンク

参考にした情報源リンク

  • Goのコミットレビューページ: https://golang.org/cl/6545051 (コミットメッセージに記載されているリンク)
  • Plan 9のwaitシステムコールに関する情報 (一般的なOSのドキュメントや論文)
  • Goのスケジューラに関する情報 (Goの公式ブログや設計ドキュメント)
  • 競合状態に関する一般的なプログラミングの概念
  • Unix系OSにおけるfork, exec, waitの動作に関する情報