Goのos/execパッケージは、別のコマンドを扱うためのパッケージですが、使い方を間違えたり、気が散っていたりするとリソースリークを引き起こす可能性があります。こないだリークするコードを目にしたので、どういった理由なのかも含めて紹介します。ここに書いたコードは単純化していますが、大筋は現実のコードと同じです。
v1
まず問題のあるコード。これは
3 4 9
のような1行に1つ数値が書かれたテキストを出力するコマンドを実行して、数値を合計を返す関数です。
func Sum(name string, args ...string) (int, error) { cmd := exec.Command(name, args...) r, err := cmd.StdoutPipe() if err != nil { return 0, err } if err := cmd.Start(); err != nil { return 0, err } var sum int scanner := bufio.NewScanner(r) for scanner.Scan() { s := strings.TrimSpace(scanner.Text()) n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("invalid number: %w", err) } sum += n } if err := scanner.Err(); err != nil { return 0, err } if err := cmd.Wait(); err != nil { return 0, err } return sum, nil }
この実装の問題は、exec.Cmd.Wait
しないところです。exec.Cmd.Start
で生成したプロセスは、処理を終えても親プロセスに終了コードを返すまでは残り続けます。親プロセスはexec.Cmd.Wait
で終了コードを取り出すため、この実装ではstrconv.Atoi
がエラーになってしまうとプロセスを回収することができません。サービスのように稼働し続けるプログラムの場合、回収されないプロセスがそのうちプロセス数の上限に達してしまい、それ以上プロセスが作られずエラーになります。運が悪い場合は、kill(1)さえ起動できなくなって再起動するしか方法がなくなります。そのため、exec.Cmd.Start
した場合は必ずexec.Cmd.Wait
しなければなりません。
v2
次に、return
する前にexec.Cmd.Wait
を呼ぶようにしたバージョンです。
func Sum(name string, args ...string) (int, error) { cmd := exec.Command(name, args...) r, err := cmd.StdoutPipe() if err != nil { return 0, err } if err := cmd.Start(); err != nil { return 0, err } var sum int scanner := bufio.NewScanner(r) for scanner.Scan() { s := strings.TrimSpace(scanner.Text()) n, err := strconv.Atoi(s) if err != nil { cmd.Wait() // Waitするように変更、エラーはAtoiの方を返すので無視する return 0, fmt.Errorf("invalid number: %w", err) } sum += n } if err := cmd.Wait(); err != nil { // scanner.Errより先にWaitするように変更 return 0, err } if err := scanner.Err(); err != nil { return 0, err } return sum, nil }
これでstrconv.Atoi
に失敗した場合は概ねプロセスが回収されるようになりますが、まだ問題は残っています。exec.Cmd.Wait
プロセスが終了するまで待つので、何らかの原因によりプロセスが終了できない場合は無限に待ち続けてしまいます。具体的には、
#!/bin/sh awk ' BEGIN { for(i = 1; i <= 100000; i++) print "10000a" } '
をSum
に与えると、macOSの場合は途中で停止します(環境によって異なる場合があります)。呼び出す側はこんな雰囲気。
func main() { n, err := Sum("sh", "long.sh") if err != nil { log.Println("Sum:", err) continue } log.Println("Sum:", n) }
この原因は、exec.Cmd.StdoutPipe
でコマンドの出力をpipe(2)していますが、実はパイプにはバッファが存在するのでプログラムの出力がパイプのバッファを超えると、バッファが空くまでOSによって止められます。正常な場合はscanner.Scan
が読み込みをするとバッファが空いて、後続の出力を書き出せるようになりすべての出力が終わればプロセスは終了しますが、上記のSum
関数はエラーになったら以降を読まないため、ずっとバッファが解放されずにプロセスが終わりません。結果、Wait
が無限に待ち続けることになります。
v3
これを解決する方法はいくつかありますが、個人的にはCommandContext
を使う方法が無難かなと思います。
func Sum(name string, args ...string) (int, error) { ctx, cancel := context.WithCancel(context.Background()) cmd := exec.CommandContext(ctx, name, args...) r, err := cmd.StdoutPipe() if err != nil { return 0, err } if err := cmd.Start(); err != nil { return 0, err } var sum int scanner := bufio.NewScanner(r) for scanner.Scan() { s := strings.TrimSpace(scanner.Text()) n, err := strconv.Atoi(s) if err != nil { cancel() cmd.Wait() // Atoiのエラーを優先するのでWaitのエラーは無視 return 0, fmt.Errorf("invalid number: %w", err) } sum += n } if err := scanner.Err(); err != nil { cancel() cmd.Wait() // scannerのエラーを優先するのでWaitのエラーは無視 return 0, err } if err := cmd.Wait(); err != nil { return 0, err } return sum, nil }
余談になりますが、StdoutPipe
はpipe(2)を作っているので、ファイルが2つopenされた状態になります。ただしこのpipeは、Start
でエラーになったりWait
が呼ばれたりすると閉じられるので、大きなリークに繋がることはおそらく無いでしょう。