背景
Goでは外部コマンドの実行時に os/exec
パッケージの Command
や CommandContext
を利用します。特に CommandContext
を利用することで、 Goのcontextの流儀に従ってコマンドのタイムアウトや中断が可能であり大変便利です。
一方で、LinuxやmacOSなどにおいてContextによるタイムアウト・中断時には外部コマンドに即座に SIGKILL
が送られてキルされてしまうため、一部のケースでは孫プロセスが孤児プロセスとして残ってしまったり、後始末を正しく出来ないままコマンドが終了してしまうという問題が知られていました。
より安全にコマンドを終了させる方法として、まずは SIGINT
や SIGTERM
を送り、一定時間内に終了しなければ SIGKILL
で終了させるなどの方法が知られています。しかし、 CommandContext
を使わずにContextによる中断のハンドリングを自分で行う必要があるなど初学者には難しい状況になっていました。
Go 1.20で導入されたCancelとWaitDelay
ひっそりとリリースノートに記載されている内容なので、あまり気にしておられない方も多いかと思います。 exec.Cmd
構造体に新たに二つのフィールドが追加されました。
Cancel
の型は func() error
、 WaitDelay
の型は time.Duration
です。元々は下記のプロポーザルから実装されたもののようです。当初は KILL されるまでの時間と、ContextがDoneになったときに送信するシグナルを指定できるようにするはずだったようですが、途中で任意の実装を挟み込めるようになったようです。
Cancel
まず Cancel
は、 CommandContext
で渡したContextがDoneになったときに呼び出される関数で、デフォルトでは cmd.Process.Kill()
を呼び出す関数が設定されるようです。
代わりに SIGTERM
を送ったりする関数を与えることで、Contextをキャンセルしたりタイムアウトさせた際に送信するシグナルを指定できる他、任意の処理が出来るので標準入力を閉じたりネットワーク越しにリクエストを送ったりすることもできるようです。要するにこれまで手動でコンテキストをハンドリングして行っていたようなものをここに書いておくだけでよくなるということです。
WaitDelay
ContextがDoneになってから、 cmd.Process.Kill()
されるまでの猶予時間です。この間に Cancel
の処理を終わらせないと SIGKILL
で終了させられてしまいます。
デフォルトは0になっているため、 Cancel
を使う際は WaitDelay
を手動設定しておかないとうまく処理できません。
使い方
SIGINTを送って死ななければSIGKILLで終了させる
まずは以前までの書き方をおさらいします。まず、 exec.CommandContext
を使うとContextがDoneになったときにSIGKILLが送られてしまうのでこれは使えません。 exec.Command
を使い、Contextのハンドリングは自分で行わなければいけません。
package main import ( "context" "fmt" "os" "os/exec" "os/signal" "time" ) func run() error { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() cmd := exec.Command("bash", "-c", "trap 'echo \"signal received\"; sleep 10; echo \"done\"' SIGINT; sleep 120") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return err } errCh := make(chan error, 1) go func() { defer close(errCh) errCh <- cmd.Wait() }() for { select { case exitErr := <-errCh: return exitErr case <-ctx.Done(): fmt.Println("Send SIGINT") cmd.Process.Signal(os.Interrupt) select { case exitErr := <-errCh: return exitErr case <-time.After(5 * time.Second): fmt.Println("Send SIGKILL") cmd.Process.Kill() return <-errCh } } } } func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } }
(適当に書いたので穴があるかも知れない)
ここではシグナルを受け取るとContextをキャンセルし、子プロセスに SIGINT
を送って5秒待機、まだ終了しなければ SIGKILL
を送って待ちます。
Cancel
と WaitDelay
を使うと以下の様に書けます。
package main import ( "context" "fmt" "os" "os/exec" "os/signal" "time" ) func run() error { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() cmd := exec.CommandContext(ctx, "bash", "-c", "trap 'echo \"signal received\"; sleep 1; echo \"done\"' SIGINT; sleep 120") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } cmd.WaitDelay = 5 * time.Second return cmd.Run() } func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } }
実行してみると下記の様にシグナルを受け取っていることがわかります。
❯ go run main.go ^Csignal received done Error: exit status 130 exit status 1
シグナルを受け取ってからのスリープの時間を10秒などに延ばすと、 SIGKILL
が送られていることがわかります。
❯ go run main.go ^Csignal received Error: signal: killed exit status 1
注意点
Windowsでは cmd.Process.Signal
が実装されておらず、今回の方法で正しくプロセスが終了できるとは限らないことに注意が必要です。実際Windowsでのプロセスの正しい終了の仕方を全然知らない……知っている方がおられれば是非教えて頂きたいですね。