Go言語と外部プロセスの制御・シグナルハンドリング
株式会社FLINTERSのみやしーです。
Go言語から外部プロセスを起動し、シグナルを用いてその挙動を制御する仕組みについて解説します。
1. 外部プロセスの起動の最小限のサンプル
標準ライブラリの os/exec パッケージを利用して、外部プロセスとしてシェルを起動する例を示します。外部プロセスが完了するまでメインプロセスは待機し、完了と同時に自身も終了します。
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
fmt.Printf("実行開始。PID: %d\n", os.Getpid())
// 実行するコマンドとその引数を指定して、Cmd構造体を作成する
script := `for i in $(seq 1 20); do echo "Child process is running ($i/20)... $(date +%H:%M:%S)"; sleep 1; done`
cmd := exec.Command("sh", "-c", script)
// 子プロセスの出力を現在のターミナルに表示させる
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Runメソッドはコマンドを実行し、プロセスが終了するまで現在のゴルーチンをブロックします
// 外部プロセスが終了すると、このメソッドから戻り、main関数が終了することで自身も終了します
_ = cmd.Run()
}
macOSのターミナルで上記のプログラムの実行中に Ctrl+C を入力すると、実行したプログラムと外部プロセスの両方が終了します。これは、ターミナルの割り込み操作が実行中のプロセスグループに対して SIGINT のシグナルを送信するためです。UNIX系OSでは、プロセスはプロセスグループという単位で管理されており、子プロセスを作成する際はプロセスグループを明示的に指定しない限り、親プロセスのプロセスグループを引き継ぎます。そのため、ターミナルが発した SIGINT がプロセスグループ全体に届き、両方のプロセスが終了します。
一方で、特定の親プロセスに対して kill -2 [PID] コマンドを用いて SIGINT を送信した場合、シグナルはその特定のプロセスのみに届けられます。このとき、子プロセスに対して SIGINT が自動的に伝播することはありません。そのため、親プロセスはシグナルを受けて終了しますが、子プロセスは背後で実行を継続し、処理が完了するまで残り続けることになります。
kill コマンドでプロセスグループ全体にシグナルを送るためには、 kill -2 -[PGID] のように、プロセスグループIDの前にマイナス記号を付与して指定します。親プロセスがプロセスグループリーダーである場合、親のプロセスIDがそのままプロセスグループIDとなるため、 kill -2 -[PID] とすることで親と子の両方を一括で終了させることが可能です。
特定の親プロセスへの直接的なシグナル送信をきっかけに子プロセスを終了させるには、親プロセス側でシグナルをトラップし、明示的に子プロセスへ転送する実装が必要です。
2. シグナルのトラップと子プロセスへのシグナルの送信
親プロセスが受け取ったシグナルを子プロセスへ明示的に転送するには、 os/signal パッケージを利用します。これにより、OSからのシグナルをチャネル経由で受け取り、任意のタイミングで子プロセスへ通知できます。
以下の例では、子プロセスを独自のプロセスグループとして起動し、親が受け取ったシグナルをそのグループ全体に転送しています。
package main
import (
"fmt"
"os"
"os/exec"
"os/signal"
"syscall"
)
func main() {
fmt.Printf("実行開始。PID: %d\n", os.Getpid())
script := `for i in $(seq 1 20); do echo "Child process is running ($i/20)... $(date +%H:%M:%S)"; sleep 1; done`
cmd := exec.Command("sh", "-c", script)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 子プロセスを独立したプロセスグループに設定する
// これにより、親のプロセスグループへのシグナルが子へ送信するのを防ぎつつ、制御を容易にする
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
// 子プロセスの開始
if err := cmd.Start(); err != nil {
fmt.Printf("プロセスの開始に失敗しました: %v\n", err)
return
}
// シグナルを受け取るためのチャネルを作成する
sigCh := make(chan os.Signal, 1)
// SIGINT(Ctrl+Cなど)や SIGTERM をトラップするように設定する
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
// シグナルを待機し、転送するためのゴルーチン
go func() {
sig := <-sigCh
fmt.Printf("シグナルを受け取りました: %v\n", sig)
if cmd.Process != nil {
// 子プロセスのプロセスグループ全体にシグナルを送る。PIDにマイナスをつけていることに注目。
_ = syscall.Kill(-cmd.Process.Pid, sig.(syscall.Signal))
fmt.Println("子プロセスグループにシグナルを送信しました")
}
}()
// 子プロセスの終了を待機する
_ = cmd.Wait()
fmt.Println("プロセスが終了します")
}
この実装では、ターミナルからの Ctrl+C 入力や、特定のプロセスIDに対する kill -2 [PID] コマンドによって送信されるシグナルを親プロセスが捕捉します。捕捉されたシグナルは sigCh を通じて通知され、バックグラウンドで動くゴルーチンによって子プロセスグループへシグナルが送信されます。
子のプロセスは、親のプロセスグループに対するシグナルとこのプログラムからのシグナルを二重に受けないように、独立したプロセスグループに設定する必要があります。また、子が独立したプロセスグループであることによって、親プロセスが終了のプロセスを受けた際に、子プロセス(sh)と子プロセスが生成した孫プロセス(date, sleep)の全てに対して終了処理を行うことが容易になります。子プロセスに Setpgid: true を指定すると、子プロセスは自身のPIDをリーダーとする新しいプロセスグループを形成します。終了のシグナルはこのプロセスグループに対して送信します。
また、メインの処理フローが cmd.Wait() で子プロセスの完了を待機して停止している間も、シグナル受信用のゴルーチンがOSからの割り込みを監視し続けるため、子プロセスの完了と親プロセスへの終了のシグナルのいずれの場合においてもプログラムを終了させることができます。
3. その他のシグナルと SIGKILL
Ctrl+C や kill -2 [PID] で送信される SIGINT の他に、 kill コマンドのデフォルトの SIGTERM に対してもこれらのハンドリングは良好に動作します。
しかし、 kill -9 [PID] による強制終了(SIGKILL)はこのシグナルはプロセス側で捕捉したり無視したりすることが不可能なため、親プロセスないし親プロセスグループに SIGKILL が送られた場合、子のプロセスグループに対するハンドリング処理は一切実行されません。親プロセスが消滅し子プロセスが取り残されるのを完全に防ぐには、Linux環境であれば SysProcAttr の Pdeathsig フィールドを利用して、親が死んだ際に子へ自動的にシグナルを送るようOSカーネルに依頼するなどの追加対策が可能ですが、macOSでは不可能です。
4. まとめ
- Goのプログラムから外部プロセスを立ち上げたり、終了時の制御を行うことができました
- ターミナルで
Ctrl+Cを入力するのと、kill [PID]で特定のプロセスを止めることは違うことがわかりました。 -
kill -9 [PID]では当然子プロセスは止められないし、kill -9 -[PGID]でも子プロセスの立ち上げ方によっては止まらないことがわかりました。
Discussion