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

[インデックス 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.WriteCloserClose メソッドを使用することは常にエラーでした。なぜなら、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.WriteCloserCmd.Wait() が呼び出される前に、または同時に Close() した場合、同じファイルディスクリプタに対して二重に Close() が試みられることになり、これが競合状態を引き起こしていました。二重クローズは、通常、syscall.EINVAL (無効な引数) や syscall.EBADF (不正なファイルディスクリプタ) などのエラーを引き起こし、プログラムの予期せぬ終了や不安定な動作につながる可能性があります。

この問題は、特に並行処理を行うGoプログラムにおいて顕著でした。例えば、別のゴルーチンで StdinPipe にデータを書き込み、書き込み完了後に Close() し、メインのゴルーチンで Cmd.Wait() を呼び出すようなシナリオで発生しやすかったのです。

このコミットの目的は、この競合状態を排除し、StdinPipe から返される io.WriteCloserClose メソッドが安全かつ冪等に(何度呼び出しても同じ結果になるように)動作するようにすることでした。

前提知識の解説

このコミットを理解するためには、以下の概念が重要です。

  1. os/exec パッケージ: Go言語で外部コマンドを実行するための標準ライブラリです。Cmd 構造体を通じてコマンドの実行、標準入出力のリダイレクト、プロセスの待機などを行います。
  2. パイプ (Pipe): プロセス間通信 (IPC) の一種で、一方のプロセスが書き込んだデータをもう一方のプロセスが読み込むことができる単方向のデータストリームです。os/exec では、子プロセスの標準入出力と親プロセスを接続するために内部的にパイプが使用されます。
  3. io.WriteCloser インターフェース: Goの標準ライブラリ io パッケージで定義されているインターフェースで、Write(p []byte) (n int, err error)Close() error メソッドを持ちます。データを書き込み、その後リソースを閉じる機能を提供します。
  4. 競合状態 (Race Condition): 複数のゴルーチン(またはスレッド)が共有リソースに同時にアクセスし、そのアクセス順序によって結果が変わってしまう状態を指します。このコミットのケースでは、StdinPipeCloseCmd.WaitClose が共有リソース(パイプのファイルディスクリプタ)に同時にアクセスしようとすることで発生していました。
  5. 冪等性 (Idempotence): ある操作を複数回実行しても、1回実行した場合と同じ結果になる性質を指します。このコミットでは、Close メソッドが冪等になるように修正されています。つまり、一度閉じられたパイプに対して再度 Close を呼び出しても、エラーにならず、最初の Close の結果が返されるようになります。
  6. 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 型は以下の特徴を持ちます。

  1. *os.File の埋め込み: *os.File を埋め込むことで、closeOnceio.Writerio.ReaderFrom など、*os.File が実装するすべてのメソッドを自動的に継承します。これにより、ユーザーは StdinPipe から返されたオブジェクトを *os.File としても扱うことができ、Fd() などのメソッドにアクセスできます。
  2. sync.Once の利用: sync.Once フィールド close を使用して、Close() メソッドが一度だけ実行されることを保証します。Close() が複数回呼び出されても、c.close.Do() に渡された匿名関数は最初の呼び出し時のみ実行されます。
  3. エラーのキャッシュ: closeErr フィールドは、Close() が最初に実行された際の結果(エラーまたは nil)を保存します。これにより、その後の Close() 呼び出しは、実際のクローズ操作を再実行するのではなく、キャッシュされたエラー値を返すだけになります。

この設計により、ユーザーが StdinPipe から取得した io.WriteCloser を明示的に Close() しても、Cmd.Wait() が内部的に Close() を呼び出しても、*os.FileClose() は一度しか実行されません。これにより、二重クローズによる競合状態が完全に解消され、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 という新しいテスト関数が追加されました。このテストは、StdinPipeCloseCmd.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 など)を自動的に継承します。これは、StdinPipeio.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.OnceDo メソッドを使用することで、c.File.Close()(つまり、基盤となる *os.FileClose() メソッド)が一度だけ実行されることが保証されます。Close() が複数回呼び出されても、この匿名関数は最初の呼び出し時のみ実行されます。
    • return c.closeErr: Close() が呼び出されるたびに、closeErr にキャッシュされた値が返されます。これにより、最初の Close() の結果が常に返され、その後の Close() 呼び出しはエラーを発生させずに、最初のクローズ操作の結果を報告します。

この closeOnce 型を Cmd.StdinPipe() が返すことで、ユーザーが明示的にパイプを閉じても、Cmd.Wait() が内部的にパイプを閉じても、基盤となるファイルディスクリプタは一度しか閉じられず、競合状態が解消されます。

関連リンク

参考にした情報源リンク

  • 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)