[インデックス 17419] ファイルの概要
このコミットは、Go言語の os/exec パッケージにおける Cmd.StdinPipe の挙動を修正し、Close メソッドが Cmd.Wait と競合する問題を解決します。具体的には、StdinPipe から返される io.WriteCloser が冪等な Close 操作を提供するように変更され、これによりユーザーが明示的に Close を呼び出しても、Cmd.Wait がパイプを閉じようとした際に競合状態が発生しないようになります。
コミット
commit 10e2ffdf2ca657567fc1708f6387fef69a8445b6
Author: Andrew Gerrand <adg@golang.org>
Date: Thu Aug 29 14:41:44 2013 +1000
os/exec: return idempotent Closer from StdinPipe
Before this fix, it was always an error to use the Close method on the
io.WriteCloser obtained from Cmd.StdinPipe, as it would race with the
Close performed by Cmd.Wait.
Fixes #6270.
R=golang-dev, r, remyoudompheng, bradfitz, dsymonds
CC=golang-dev
https://golang.org/cl/13329043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/10e2ffdf2ca657567fc1708f6387fef69a8445b6
元コミット内容
os/exec: return idempotent Closer from StdinPipe
この修正以前は、Cmd.StdinPipe から取得した io.WriteCloser の Close メソッドを使用することは常にエラーでした。なぜなら、Cmd.Wait によって実行される Close と競合するためです。
このコミットは #6270 を修正します。
変更の背景
このコミットは、Go言語の os/exec パッケージにおける Cmd.StdinPipe の既知の競合状態(レースコンディション)を解決するために導入されました。具体的には、Issue #6270 で報告された問題に対応しています。
os/exec パッケージは、外部コマンドを実行するための機能を提供します。Cmd.StdinPipe() メソッドは、実行されるコマンドの標準入力に接続されるパイプ(io.WriteCloser インターフェースを実装)を返します。このパイプを通じて、親プロセスは子プロセスにデータを書き込むことができます。
問題は、Cmd.StdinPipe() から取得した io.WriteCloser をユーザーが明示的に Close() した際に発生しました。Goの os/exec パッケージの内部実装では、Cmd.Wait() メソッドが呼び出された際に、子プロセスの標準入力パイプも自動的に閉じられます。これは、子プロセスが標準入力からの読み込みを終了し、リソースが適切に解放されることを保証するためです。
しかし、ユーザーが StdinPipe から取得した io.WriteCloser を Cmd.Wait() が呼び出される前に、または同時に Close() した場合、同じファイルディスクリプタに対して二重に Close() が試みられることになり、これが競合状態を引き起こしていました。二重クローズは、通常、syscall.EINVAL (無効な引数) や syscall.EBADF (不正なファイルディスクリプタ) などのエラーを引き起こし、プログラムの予期せぬ終了や不安定な動作につながる可能性があります。
この問題は、特に並行処理を行うGoプログラムにおいて顕著でした。例えば、別のゴルーチンで StdinPipe にデータを書き込み、書き込み完了後に Close() し、メインのゴルーチンで Cmd.Wait() を呼び出すようなシナリオで発生しやすかったのです。
このコミットの目的は、この競合状態を排除し、StdinPipe から返される io.WriteCloser の Close メソッドが安全かつ冪等に(何度呼び出しても同じ結果になるように)動作するようにすることでした。
前提知識の解説
このコミットを理解するためには、以下の概念が重要です。
os/execパッケージ: Go言語で外部コマンドを実行するための標準ライブラリです。Cmd構造体を通じてコマンドの実行、標準入出力のリダイレクト、プロセスの待機などを行います。- パイプ (Pipe): プロセス間通信 (IPC) の一種で、一方のプロセスが書き込んだデータをもう一方のプロセスが読み込むことができる単方向のデータストリームです。
os/execでは、子プロセスの標準入出力と親プロセスを接続するために内部的にパイプが使用されます。 io.WriteCloserインターフェース: Goの標準ライブラリioパッケージで定義されているインターフェースで、Write(p []byte) (n int, err error)とClose() errorメソッドを持ちます。データを書き込み、その後リソースを閉じる機能を提供します。- 競合状態 (Race Condition): 複数のゴルーチン(またはスレッド)が共有リソースに同時にアクセスし、そのアクセス順序によって結果が変わってしまう状態を指します。このコミットのケースでは、
StdinPipeのCloseとCmd.WaitのCloseが共有リソース(パイプのファイルディスクリプタ)に同時にアクセスしようとすることで発生していました。 - 冪等性 (Idempotence): ある操作を複数回実行しても、1回実行した場合と同じ結果になる性質を指します。このコミットでは、
Closeメソッドが冪等になるように修正されています。つまり、一度閉じられたパイプに対して再度Closeを呼び出しても、エラーにならず、最初のCloseの結果が返されるようになります。 sync.Once: Goのsyncパッケージで提供される型で、特定の関数が一度だけ実行されることを保証します。複数のゴルーチンから同時にDoメソッドが呼び出されても、引数として渡された関数は一度だけ実行されます。これは、リソースの初期化や、今回のケースのように一度だけ実行されるべきクローズ処理の実装に非常に有用です。
技術的詳細
このコミットの核心は、os/exec パッケージに closeOnce という新しい内部型を導入し、Cmd.StdinPipe() がこの closeOnce のインスタンスを返すように変更した点です。
以前のコードでは、Cmd.StdinPipe() は単に *os.File 型の書き込み側パイプ (pw) を io.WriteCloser として直接返していました。
// Before the fix
func (c *Cmd) StdinPipe() (io.WriteCloser, error) {
// ...
c.closeAfterWait = append(c.closeAfterWait, pw) // pw is *os.File
return pw, nil
}
この pw は *os.File であり、その Close() メソッドは冪等ではありませんでした。一度閉じられたファイルに対して再度 Close() を呼び出すとエラーが発生します。
新しい実装では、pw を直接返す代わりに、closeOnce 型のインスタンスでラップして返します。
// After the fix
func (c *Cmd) StdinPipe() (io.WriteCloser, error) {
// ...
wc := &closeOnce{File: pw} // Wrap pw in closeOnce
c.closeAfterWait = append(c.closeAfterWait, wc)
return wc, nil
}
type closeOnce struct {
*os.File // Embeds *os.File, so it implicitly implements io.Writer and other *os.File methods
close sync.Once // Ensures Close() is called only once
closeErr error // Stores the error from the first Close() call
}
func (c *closeOnce) Close() error {
c.close.Do(func() {
c.closeErr = c.File.Close() // The actual Close() call on the underlying *os.File
})
return c.closeErr // Always returns the result of the first Close() call
}
closeOnce 型は以下の特徴を持ちます。
*os.Fileの埋め込み:*os.Fileを埋め込むことで、closeOnceはio.Writerやio.ReaderFromなど、*os.Fileが実装するすべてのメソッドを自動的に継承します。これにより、ユーザーはStdinPipeから返されたオブジェクトを*os.Fileとしても扱うことができ、Fd()などのメソッドにアクセスできます。sync.Onceの利用:sync.Onceフィールドcloseを使用して、Close()メソッドが一度だけ実行されることを保証します。Close()が複数回呼び出されても、c.close.Do()に渡された匿名関数は最初の呼び出し時のみ実行されます。- エラーのキャッシュ:
closeErrフィールドは、Close()が最初に実行された際の結果(エラーまたはnil)を保存します。これにより、その後のClose()呼び出しは、実際のクローズ操作を再実行するのではなく、キャッシュされたエラー値を返すだけになります。
この設計により、ユーザーが StdinPipe から取得した io.WriteCloser を明示的に Close() しても、Cmd.Wait() が内部的に Close() を呼び出しても、*os.File の Close() は一度しか実行されません。これにより、二重クローズによる競合状態が完全に解消され、Close() メソッドが冪等になります。
テストケース TestStdinClose が追加され、この修正が正しく機能することを確認しています。このテストでは、StdinPipe を取得し、別のゴルーチンでデータをコピーして stdin.Close() を呼び出し、メインゴルーチンで cmd.Wait() を呼び出すという、競合状態が発生しやすかったシナリオを再現しています。修正後、このテストはエラーなくパスするようになります。
コアとなるコードの変更箇所
src/pkg/os/exec/exec.go の変更点:
import "sync"が追加されました。Cmd.StdinPipe()メソッド内で、pw(*os.File) を直接返す代わりに、&closeOnce{File: pw}でラップして返すように変更されました。closeOnceという新しい構造体と、そのClose()メソッドが追加されました。
--- a/src/pkg/os/exec/exec.go
+++ b/src/pkg/os/exec/exec.go
@@ -13,6 +13,7 @@ import (
"io"
"os"
"strconv"
+ "sync" // 追加
"syscall"
)
@@ -357,8 +358,23 @@ func (c *Cmd) CombinedOutput() ([]byte, error) {
// StdinPipe returns a pipe that will be connected to the command's
// standard input when the command starts.
+// If the returned WriteCloser is not closed before Wait is called,
+// Wait will close it.
func (c *Cmd) StdinPipe() (io.WriteCloser, error) {
if c.Stdin != nil {
return nil, errors.New("exec: Stdin already set")
}
pr, pw, err := os.Pipe()
if err != nil {
return nil, err
}
c.Stdin = pr
c.closeAfterStart = append(c.closeAfterStart, pr)
- c.closeAfterWait = append(c.closeAfterWait, pw)
- return pw, nil
+ wc := &closeOnce{File: pw} // 変更: pwをcloseOnceでラップ
+ c.closeAfterWait = append(c.closeAfterWait, wc)
+ return wc, nil
+}
+
+type closeOnce struct { // 追加
+ *os.File
+
+ close sync.Once
+ closeErr error
+}
+
+func (c *closeOnce) Close() error { // 追加
+ c.close.Do(func() {
+ c.closeErr = c.File.Close()
+ })
+ return c.closeErr
}
// StdoutPipe returns a pipe that will be connected to the command's
src/pkg/os/exec/exec_test.go の変更点:
stdinCloseTestString定数が追加されました。TestStdinCloseという新しいテスト関数が追加されました。このテストは、StdinPipeのCloseがCmd.Waitと競合しないことを検証します。TestHelperProcess関数にstdinCloseケースが追加され、テストヘルパーとして機能します。
--- a/src/pkg/os/exec/exec_test.go
+++ b/src/pkg/os/exec/exec_test.go
@@ -152,6 +152,34 @@ func TestPipes(t *testing.T) {
check("Wait", err)
}
+const stdinCloseTestString = "Some test string." // 追加
+
+// Issue 6270. // 追加
+func TestStdinClose(t *testing.T) { // 追加
+ check := func(what string, err error) {
+ if err != nil {
+ t.Fatalf("%s: %v", what, err)
+ }
+ }
+ cmd := helperCommand("stdinClose")
+ stdin, err := cmd.StdinPipe()
+ check("StdinPipe", err)
+ // Check that we can access methods of the underlying os.File.`
+ if _, ok := stdin.(interface {
+ Fd() uintptr
+ }); !ok {
+ t.Error("can't access methods of underlying *os.File")
+ }
+ check("Start", cmd.Start())
+ go func() {
+ _, err := io.Copy(stdin, strings.NewReader(stdinCloseTestString))
+ check("Copy", err)
+ // Before the fix, this next line would race with cmd.Wait.
+ check("Close", stdin.Close())
+ }()
+ check("Wait", cmd.Wait())
+}
+
// Issue 5071
func TestPipeLookPathLeak(t *testing.T) {
fd0 := numOpenFDS(t)
@@ -507,6 +535,17 @@ func TestHelperProcess(*testing.T) {
os.Exit(1)
}
}
+ case "stdinClose": // 追加
+ b, err := ioutil.ReadAll(os.Stdin)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\\n", err)
+ os.Exit(1)
+ }
+ if s := string(b); s != stdinCloseTestString {
+ fmt.Fprintf(os.Stderr, "Error: Read %q, want %q", s, stdinCloseTestString)
+ os.Exit(1)
+ }
+ os.Exit(0)
case "read3": // read fd 3
fd3 := os.NewFile(3, "fd3")
bs, err := ioutil.ReadAll(fd3)
コアとなるコードの解説
このコミットの最も重要な変更は、os/exec パッケージに導入された closeOnce 型です。
type closeOnce struct {
*os.File
close sync.Once
closeErr error
}
func (c *closeOnce) Close() error {
c.close.Do(func() {
c.closeErr = c.File.Close()
})
return c.closeErr
}
-
type closeOnce struct { ... }:- この構造体は、
*os.Fileを匿名フィールドとして埋め込んでいます。これにより、closeOnceは*os.Fileが実装するすべてのメソッド(Read,Write,Fdなど)を自動的に継承します。これは、StdinPipeがio.WriteCloserを返すだけでなく、必要に応じて基盤となる*os.Fileの機能にもアクセスできるようにするために重要です。 close sync.Once: これはsync.Once型のフィールドです。sync.Onceは、Doメソッドに渡された関数がプログラムの実行中に一度だけ実行されることを保証するために使用されます。複数のゴルーチンが同時にDoを呼び出しても、関数は一度しか実行されず、他のゴルーチンはその完了を待ちます。closeErr error: これは、Close()メソッドが最初に実行された際のエラーを格納するためのフィールドです。
- この構造体は、
-
func (c *closeOnce) Close() error { ... }:- このメソッドは
io.CloserインターフェースのClose()メソッドを実装しています。 c.close.Do(func() { c.closeErr = c.File.Close() }): ここがこの修正の肝です。sync.OnceのDoメソッドを使用することで、c.File.Close()(つまり、基盤となる*os.FileのClose()メソッド)が一度だけ実行されることが保証されます。Close()が複数回呼び出されても、この匿名関数は最初の呼び出し時のみ実行されます。return c.closeErr:Close()が呼び出されるたびに、closeErrにキャッシュされた値が返されます。これにより、最初のClose()の結果が常に返され、その後のClose()呼び出しはエラーを発生させずに、最初のクローズ操作の結果を報告します。
- このメソッドは
この closeOnce 型を Cmd.StdinPipe() が返すことで、ユーザーが明示的にパイプを閉じても、Cmd.Wait() が内部的にパイプを閉じても、基盤となるファイルディスクリプタは一度しか閉じられず、競合状態が解消されます。
関連リンク
- Go Issue #6270: https://github.com/golang/go/issues/6270
- Go CL 13329043: https://golang.org/cl/13329043 (Gerrit Code Review)
参考にした情報源リンク
- Go言語の公式ドキュメント (
os/execパッケージ): https://pkg.go.dev/os/exec - Go言語の公式ドキュメント (
ioパッケージ): https://pkg.go.dev/io - Go言語の公式ドキュメント (
syncパッケージ): https://pkg.go.dev/sync - Go言語における競合状態と
sync.Onceの利用に関する一般的な情報源 (例: Go Concurrency Patterns, Go by Example:sync.Once)