[インデックス 15814] ファイルの概要
このコミットは、Go言語の os/exec
パッケージにおけるファイルディスクリプタ (FD) リークのバグを修正するものです。具体的には、Command
オブジェクトの LookPath
メソッドが失敗し、その後に StdinPipe
、StdoutPipe
、または StderrPipe
が呼び出された場合に、これらのパイプに関連するFDが適切にクリーンアップされずにリークする問題に対処しています。
コミット
commit 9db0583007e1f644b16d957c2e567ad5e5922338
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date: Mon Mar 18 09:52:39 2013 -0700
os/exec: fix fd leak with Std*Pipe + LookPath
If LookPath in Command fails, sets a sticky error, and then
StdinPipe, StdoutPipe, or StderrPipe were called, those pipe
fds were never cleaned up.
Fixes #5071
R=golang-dev, rogpeppe
CC=golang-dev
https://golang.org/cl/7799046
---
src/pkg/os/exec/exec.go | 2 ++
src/pkg/os/exec/exec_test.go | 27 +++++++++++++++++++++++++++
2 files changed, 29 insertions(+)
diff --git a/src/pkg/os/exec/exec.go b/src/pkg/os/exec/exec.go
index 8368491b0f..a3bbcf3005 100644
--- a/src/pkg/os/exec/exec.go
+++ b/src/pkg/os/exec/exec.go
@@ -235,6 +235,8 @@ func (c *Cmd) Run() error {
// Start starts the specified command but does not wait for it to complete.
func (c *Cmd) Start() error {
if c.err != nil {
+ c.closeDescriptors(c.closeAfterStart)
+ c.closeDescriptors(c.closeAfterWait)
return c.err
}
if c.Process != nil {
diff --git a/src/pkg/os/exec/exec_test.go b/src/pkg/os/exec/exec_test.go
index 611ac02676..dfcf4be231 100644
--- a/src/pkg/os/exec/exec_test.go
+++ b/src/pkg/os/exec/exec_test.go
@@ -151,6 +151,33 @@ func TestPipes(t *testing.T) {
check("Wait", err)
}
+// Issue 5071
+func TestPipeLookPathLeak(t *testing.T) {
+ fd0 := numOpenFDS(t)
+ for i := 0; i < 4; i++ {
+ cmd := Command("something-that-does-not-exist-binary")
+ cmd.StdoutPipe()
+ cmd.StderrPipe()
+ cmd.StdinPipe()
+ if err := cmd.Run(); err == nil {
+ t.Fatal("unexpected success")
+ }
+ }
+ fdGrowth := numOpenFDS(t) - fd0
+ if fdGrowth > 2 {\n\t\tt.Errorf("leaked %d fds; want ~0", fdGrowth)
+ }
+}
+
+func numOpenFDS(t *testing.T) int {
+ lsof, err := Command("lsof", "-n", "-p", strconv.Itoa(os.Getpid())).Output()
+ if err != nil {
+ t.Skip("skipping test; error finding or running lsof")
+ return 0
+ }
+ return bytes.Count(lsof, []byte("\n"))
+}
+
var testedAlreadyLeaked = false
// basefds returns the number of expected file descriptors
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/9db0583007e1f644b16d957c2e567ad5e5922338
元コミット内容
os/exec
: Std*Pipe
と LookPath
によるFDリークを修正。
Command
内の LookPath
が失敗し、その後に StdinPipe
、StdoutPipe
、または StderrPipe
が呼び出された場合、これらのパイプのファイルディスクリプタはクリーンアップされなかった。
Issue #5071 を修正。
変更の背景
Go言語の os/exec
パッケージは、外部コマンドを実行するための機能を提供します。このパッケージを使用する際、プロセス間通信のためにパイプ(StdinPipe
, StdoutPipe
, StderrPipe
)を確立することがよくあります。これらのパイプは、内部的にファイルディスクリプタ(FD)を使用します。
このコミットが行われる前の os/exec
パッケージには、特定の条件下でファイルディスクリプタがリークするという問題がありました。具体的には、exec.Command
で指定された実行可能ファイルのパスを解決する LookPath
メソッドが失敗した場合(例えば、指定されたコマンドが見つからない場合など)、Command
オブジェクトは内部的にエラー状態(c.err
)を設定します。
しかし、このエラー状態が設定された後でも、ユーザーが StdinPipe()
、StdoutPipe()
、または StderrPipe()
を呼び出すことが可能でした。これらのメソッドは、たとえコマンドの実行が不可能であることが事前に分かっていても、パイプを作成し、それに対応するファイルディスクリプタを割り当てていました。問題は、Command.Start()
メソッドが c.err
が設定されていることを検出してすぐにエラーを返した場合、これらの新しく作成されたパイプのファイルディスクリプタが閉じられずに残ってしまう点にありました。これにより、アプリケーションが多数のコマンド実行を試み、その多くが LookPath
の失敗によって中断されるようなシナリオでは、システムのリソースであるファイルディスクリプタが徐々に枯渇し、最終的には「Too many open files」のようなエラーが発生する可能性がありました。
このリークは、特にサーバーアプリケーションや、頻繁に外部コマンドを呼び出すツールにおいて深刻な問題となり得ました。そのため、このファイルディスクリプタのリークを修正し、リソースの適切な解放を保証することが必要とされました。
前提知識の解説
1. ファイルディスクリプタ (File Descriptor, FD)
ファイルディスクリプタは、Unix系オペレーティングシステムにおいて、プロセスが開いているファイルやその他のI/Oリソース(パイプ、ソケットなど)を識別するために使用される抽象的なハンドルです。各プロセスは、開いているリソースごとに一意の非負の整数値のFDを受け取ります。プログラムがファイルやパイプを操作する際には、このFDを通じてOSにアクセスを要求します。FDは有限のリソースであり、リークが発生するとシステム全体の安定性に影響を与える可能性があります。
2. os/exec
パッケージ
Go言語の標準ライブラリである os/exec
パッケージは、外部コマンドを実行するための機能を提供します。主なコンポーネントは以下の通りです。
exec.Command(name string, arg ...string) *Cmd
: 指定されたコマンドと引数を持つCmd
オブジェクトを作成します。この関数は、コマンドの実行パスを解決するために内部的にLookPath
を呼び出すことがあります。Cmd
構造体: 実行するコマンドに関する情報(パス、引数、環境変数、標準入出力など)を保持します。Cmd.Start() error
: コマンドを新しいプロセスとして開始しますが、完了を待ちません。Cmd.Run() error
: コマンドを開始し、完了するまで待ちます。これはStart()
とWait()
の組み合わせに相当します。Cmd.StdinPipe() (io.WriteCloser, error)
: コマンドの標準入力に接続するためのパイプを返します。Cmd.StdoutPipe() (io.ReadCloser, error)
: コマンドの標準出力に接続するためのパイプを返します。Cmd.StderrPipe() (io.ReadCloser, error)
: コマンドの標準エラー出力に接続するためのパイプを返します。exec.LookPath(file string) (string, error)
: 指定されたファイル名(コマンド名)を、システムのPATH環境変数に基づいて実行可能なファイルの絶対パスに解決します。コマンドが見つからない場合はエラーを返します。
3. パイプ (Pipe)
パイプは、プロセス間でデータをやり取りするための単方向の通信チャネルです。os/exec
パッケージでは、子プロセスの標準入出力と親プロセスの間でデータを送受信するためにパイプが使用されます。パイプは、読み取り側と書き込み側の2つのファイルディスクリプタによって表現されます。
4. リソースリーク (Resource Leak)
リソースリークとは、プログラムが使用したシステムリソース(メモリ、ファイルディスクリプタ、ネットワークソケットなど)を、使用後に適切に解放しないために、それらのリソースがシステム内に残り続ける現象を指します。リソースリークが続くと、利用可能なリソースが枯渇し、システムのパフォーマンス低下やクラッシュにつながる可能性があります。ファイルディスクリプタのリークは、特に長期間稼働するサーバーアプリケーションで問題となります。
技術的詳細
このFDリークは、os/exec
パッケージの Cmd
オブジェクトのライフサイクル管理における特定のシーケンスで発生していました。
exec.Command
の呼び出し:cmd := exec.Command("something-that-does-not-exist-binary")
のように、存在しないコマンドを指定してCommand
を呼び出すと、内部的にexec.LookPath
が実行されます。LookPath
の失敗:LookPath
は指定されたコマンドを見つけられないため、エラーを返します。このエラーはCmd
オブジェクトの内部フィールドc.err
に設定されます。この時点で、コマンドは実行不可能であることが確定しています。Std*Pipe()
の呼び出し:cmd.StdoutPipe()
、cmd.StderrPipe()
、cmd.StdinPipe()
のいずれか、または複数が呼び出されます。これらのメソッドは、c.err
が設定されているかどうかに関わらず、パイプを作成し、対応するファイルディスクリプタを割り当てます。Cmd.Run()
またはCmd.Start()
の呼び出し:cmd.Run()
またはcmd.Start()
が呼び出されます。これらのメソッドは、実行を開始する前にc.err
をチェックします。- 早期リターンとリーク:
c.err
が設定されているため、Start()
メソッドはすぐにエラーを返して終了します。このとき、ステップ3で作成されたパイプのファイルディスクリプタは閉じられることなく、開いたままになってしまいます。通常、これらのFDは子プロセスが開始され、親プロセスがWait()
を呼び出す際にクリーンアップされることを期待しますが、子プロセスが開始されないため、このクリーンアップパスが実行されません。
このコミットの修正は、Cmd.Start()
メソッドの冒頭で、c.err
が設定されている場合に、コマンドに関連付けられたファイルディスクリプタを明示的に閉じる処理を追加することで、このリークを防ぎます。c.closeDescriptors(c.closeAfterStart)
と c.closeDescriptors(c.closeAfterWait)
が追加され、Start()
が早期にエラーを返す前に、すでに割り当てられている可能性のあるパイプのFDを確実に閉じます。これにより、コマンドが実際に実行されなかった場合でも、リソースが適切に解放されるようになります。
テストケース TestPipeLookPathLeak
は、この問題を再現し、修正が機能することを確認するために追加されました。このテストは、存在しないコマンドに対して複数回 Command
と Std*Pipe
を呼び出し、Run()
を実行した後、開いているファイルディスクリプタの数を lsof
コマンドを使用して計測します。FDの増加が期待値(ほぼ0)を超えないことを確認することで、リークが修正されたことを検証します。
コアとなるコードの変更箇所
--- a/src/pkg/os/exec/exec.go
+++ b/src/pkg/os/exec/exec.go
@@ -235,6 +235,8 @@ func (c *Cmd) Run() error {
// Start starts the specified command but does not wait for it to complete.
func (c *Cmd) Start() error {
if c.err != nil {
+ c.closeDescriptors(c.closeAfterStart)
+ c.closeDescriptors(c.closeAfterWait)
return c.err
}
if c.Process != nil {
--- a/src/pkg/os/exec/exec_test.go
+++ b/src/pkg/os/exec/exec_test.go
@@ -151,6 +151,33 @@ func TestPipes(t *testing.T) {
check("Wait", err)
}
+// Issue 5071
+func TestPipeLookPathLeak(t *testing.T) {
+ fd0 := numOpenFDS(t)
+ for i := 0; i < 4; i++ {
+ cmd := Command("something-that-does-not-exist-binary")
+ cmd.StdoutPipe()
+ cmd.StderrPipe()
+ cmd.StdinPipe()
+ if err := cmd.Run(); err == nil {
+ t.Fatal("unexpected success")
+ }
+ }
+ fdGrowth := numOpenFDS(t) - fd0
+ if fdGrowth > 2 {
+ t.Errorf("leaked %d fds; want ~0", fdGrowth)
+ }
+}
+
+func numOpenFDS(t *testing.T) int {
+ lsof, err := Command("lsof", "-n", "-p", strconv.Itoa(os.Getpid())).Output()
+ if err != nil {
+ t.Skip("skipping test; error finding or running lsof")
+ return 0
+ }
+ return bytes.Count(lsof, []byte("\n"))
+}
+
var testedAlreadyLeaked = false
// basefds returns the number of expected file descriptors
コアとなるコードの解説
src/pkg/os/exec/exec.go
の変更
Cmd.Start()
メソッドの冒頭に以下の2行が追加されました。
if c.err != nil {
c.closeDescriptors(c.closeAfterStart)
c.closeDescriptors(c.closeAfterWait)
return c.err
}
if c.err != nil
: これは、Command
オブジェクトが既にエラー状態にあるかどうかをチェックします。このエラーは、通常、exec.LookPath
がコマンドを見つけられなかった場合などに設定されます。c.closeDescriptors(c.closeAfterStart)
:closeAfterStart
は、コマンドが開始された後に閉じるべきファイルディスクリプタのリストを保持しています。LookPath
の失敗後でもStd*Pipe
が呼び出された場合、これらのパイプのFDがこのリストに含まれる可能性があります。この呼び出しにより、それらのFDが明示的に閉じられます。c.closeDescriptors(c.closeAfterWait)
: 同様に、closeAfterWait
はコマンドの完了を待った後に閉じるべきFDのリストを保持しています。ここにもリークの原因となるFDが含まれる可能性があるため、同様に閉じられます。
これらの変更により、Start()
メソッドが c.err
のために早期にリターンする場合でも、それまでに作成されたパイプのファイルディスクリプタが確実に閉じられ、FDリークが防止されます。
src/pkg/os/exec/exec_test.go
の変更
TestPipeLookPathLeak
という新しいテスト関数が追加されました。
func TestPipeLookPathLeak(t *testing.T) {
fd0 := numOpenFDS(t) // テスト開始時のオープンFD数を記録
for i := 0; i < 4; i++ {
cmd := Command("something-that-does-not-exist-binary") // 存在しないコマンドを作成
cmd.StdoutPipe() // パイプを作成
cmd.StderrPipe() // パイプを作成
cmd.StdinPipe() // パイプを作成
if err := cmd.Run(); err == nil { // コマンドを実行 (LookPathが失敗するためエラーになるはず)
t.Fatal("unexpected success") // 成功したらエラー
}
}
fdGrowth := numOpenFDS(t) - fd0 // テスト後のオープンFD数と初期値の差を計算
if fdGrowth > 2 { // FDの増加が2を超えたらリークと判断
t.Errorf("leaked %d fds; want ~0", fdGrowth)
}
}
func numOpenFDS(t *testing.T) int {
lsof, err := Command("lsof", "-n", "-p", strconv.Itoa(os.Getpid())).Output()
if err != nil {
t.Skip("skipping test; error finding or running lsof")
return 0
}
return bytes.Count(lsof, []byte("\n")) // lsofの出力行数からFD数をカウント
}
TestPipeLookPathLeak
: このテストは、存在しないコマンドに対してCommand
を作成し、Std*Pipe
を呼び出し、Run()
を実行するという一連の操作を複数回繰り返します。これにより、FDリークが発生するシナリオをシミュレートします。numOpenFDS(t *testing.T) int
: このヘルパー関数は、現在のプロセスのオープンされているファイルディスクリプタの数をlsof
コマンドを実行して取得します。lsof -n -p <pid>
は、指定されたPIDのプロセスが開いているすべてのファイルに関する情報を表示し、その行数を数えることでFDの数を概算します。- テストの最後に、
fdGrowth
が2を超える場合にエラーを報告します。これは、ごく少数のFD(例えば、lsof
自体が使用するFDなど)の増加は許容しつつ、それ以上の増加をリークと見なすための閾値です。このテストの追加により、この特定のFDリーク問題が将来的に再発しないことを保証するための自動化された検証メカニズムが提供されました。
関連リンク
- Go CL: https://golang.org/cl/7799046
- Go Issue #5071: https://go.dev/issue/5071 (Web検索結果から推測されるリンク)
参考にした情報源リンク
- stackoverflow.com: https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQFbDbUBkmyjLLNMfR1QOtbXnc17QNYSN1j6xEe41GGh-HyGskNJy9KtbsiHLO1UPZvtZ5axNcBAkIe6dmcJu_saGC9a_n2I2en2YmXoCMCafR3N3uuZOilCZWK9Q4OLtdzT-rmFrGehp8NFtqqAwoas_S-ZTaxPb4cY6b0hjS5hIUoh2_e2aQl3ZPnn9nIsOsqFGUPl1rPDqvyo_Sq-qTpcrw==
- go.dev: https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQFbhmXz7BjV6ktP3XKLtevwnWKOZ4ABpBmBHO-MU3azAIFMmoZlOp-zpjEfoI0dS5GHebpL6Bo2AlxkkaVhF24xsSOJoTNXmGYL-5dsIPqQLFKiQJtDz
- dolthub.com: https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQHSneCnfL4OGPxnpjflh1org0qo6llIAYRCz8SuF9NFPWlcsqvpLG-XgGUtR390dHSnmy_gB-Ub4v7D3O9_DOp3ildQhL8opAxpb1Ics-PmRMC_fu4DmUupnL6-MoDgb9RYpjDDfh4paQa54ZfjyVF0vdgpXdjF4A==