Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

意外と難しいos/execの話

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
}

余談になりますが、StdoutPipepipe(2)を作っているので、ファイルが2つopenされた状態になります。ただしこのpipeは、StartでエラーになったりWaitが呼ばれたりすると閉じられるので、大きなリークに繋がることはおそらく無いでしょう。