百日半狂乱

Shut the fuck up and write some code!!

読了、Goならわかるシステムプログラミング: Linuxシグナル再訪 in Go

Goならわかるシステムプログラミングを読んだ。

「第12章 シグナルによるプロセス間の通信」の冒頭で大昔に書いたブログ記事が引用されていてぶったまげる*1と同時に、ちょうど良い機会なのでGoの素振りも兼ねつつシグナルを再訪してみる。

SIGSTOPで停止したプロセスにSIGKILL以外のシグナルを送ってもSIGCONTを送るまでシグナルがキューイングされる

元記事では、シェルスクリプトで呼び出したsleepコマンドをSIGSTOPで止めてからSIGTERMを送っても、SIGCONTで再開するまで送ったSIGTERMがキューイングされて、sleepを即座に終了できないことを確認した。

ただ、当時は観測する以上の突っ込んだ議論ができなかったので、今回は特にシグナルのキューイングについて、もう少しだけ詳細に実装を見る。

シグナルのキューイングを確認

/proc/<pid>/status内にSigQという項目があり、これがキューイングされているシグナルの数を表している。

root/go# sleep 1000 &
[1] 208
root/go# egrep 'State|SigQ' /proc/208/status
State:  S (sleeping)
SigQ:   0/7867
root/go# kill -s SIGSTOP 208
root/go# egrep 'State|SigQ' /proc/208/status
State:  T (stopped)
SigQ:   0/7867

[1]+  Stopped                 sleep 1000
root/go# kill -s SIGTERM 208
root/go# egrep 'State|SigQ' /proc/208/status
State:  T (stopped)
SigQ:   1/7867
root/go# kill -s SIGINT 208
root/go# egrep 'State|SigQ' /proc/208/status
State:  T (stopped)
SigQ:   2/7867
root/go# kill -s SIGCONT 208
root/go# egrep 'State|SigQ' /proc/208/status
grep: /proc/208/status: No such file or directory
[1]+  Interrupt               sleep 1000

プロセス停止中にシグナルを複数送る(上の場合、SIGTERMSIGINT)と、

  1. SIGKILLSIGCONTシグナル以外はキューイングされプロセスが再開するまで待機状態となる
    • もし、同じ種類のシグナルが複数送られた場合は、全て一つにまとめられる
  2. キューに溜まったシグナルはSIGCONTを受け取ったタイミイングで一斉に処理される
    • 処理の順番がどう決まるのかまでは調べられていない(要調査)
    • 上ではSIGINTを受けてInterruptになっている(デフォルトの挙動はプロセスの終了なのでここで処理終了)

以下では、この辺りの事実を読んだ本を参考にGoで確かめてみる。

ちなみにmacosだと停止中のプロセスのデフォルトの挙動が異なる。ここではLinuxにおけるシグナルの挙動についてのみ議論する。

$ sleep 100 &
[1] 78973
$ kill -s SIGSTOP 78973
[1]  + 78973 suspended (signal)  sleep 100
$ kill -s SIGTERM 78973
[1]  + 78973 terminated  sleep 100

Linuxシグナル再訪 in Go

今回の実験用にオプションでシグナルの送信側にも受信側にもなれるプログラムを書いた。

linux-signals-in-go/sigexp.go at master · doi-t/linux-signals-in-go · GitHub

シグナル受信側は、SIGKILLSIGSTOP以外のあらゆるシグナルをハンドルできる状態でシグナルを待ち受け、シグナルを受信する度に何を受信したかを単純に出力する(おまけ機能としてSIGUSR1を受信するとSIGINTSIGTERMの挙動をリセットするようにしている)。

receiver:$ ./sigexp -mode=receiver

シグナル送信側は、送信先のプロセスIDと送信するシグナルの名前を指定することで、単発のシグナルを送信する。

sender:$ ./sigexp -mode=sender -pid=$(pgrep sigexp) -signal=SIGSTOP

シグナルを指定しない場合、signalMapに定義されているシグナルを全て送信先プロセスに送信する(後述)。

sender:$ ./sigexp -mode=sender -pid=$(pgrep sigexp)

Signal Handling in Go

Goではsignal.Notifyで簡単にシグナルをハンドルすることができる*2

   signals := make(chan os.Signal, len(signalMap))
    signal.Notify(signals)

signal.Notifyに何も指定していない場合、受信したSIGKILLSIGSTOP以外の全てのシグナルがチャネル(signals)に届く。

実験

これらの準備の上で、色々なシグナルを色々な順番で送ってみた結果をまとめた: linux-signals-in-go/how_sigstop_and_sigcont_works.md at master · doi-t/linux-signals-in-go · GitHub

長くなるので、ここでは停止中のプロセスにシグナルを送れるだけ全部送った場合の結果を見てみる。

シグナル送信側

送信側は、最初にSIGSTOPで対象のプロセスを止めた上で、syscall/zerrors_linux_amd64.goに定義されている35種類のシグナルを全部送り*3、最後にSIGCONTを送っている。出力のためにSIGPIPEなどシグナルの名前を文字列としてsyscall.SIGPIPEから逆引きする仕組みが欲しかったので、元の定義をコピーして自前のmapを用意してこれを使っている。シグナルはGoのmapの特性上、実行する度にランダムな順番で送信される。

sender:$ ./sigexp -mode=sender -pid=$(pgrep sigexp)
Sent: SIGSTOP (stopped (signal))
Sent: SIGUSR1 (user defined signal 1)
(Skipped to send SIGCONT (continued))
Sent: SIGFPE (floating point exception)
Sent: SIGPROF (profiling timer expired)
Sent: SIGQUIT (quit)
Sent: SIGSEGV (segmentation fault)
Sent: SIGCHLD (child exited)
Sent: SIGSTOP (stopped (signal))
Sent: SIGTTIN (stopped (tty input))
Sent: SIGXCPU (CPU time limit exceeded)
Sent: SIGTTOU (stopped (tty output))
Sent: SIGBUS (bus error)
Sent: SIGHUP (hangup)
(Skipped to send SIGKILL (killed))
Sent: SIGPIPE (broken pipe)
Sent: SIGSTKFLT (stack fault)
Sent: SIGCLD (child exited)
Sent: SIGILL (illegal instruction)
Sent: SIGIO (I/O possible)
Sent: SIGPWR (power failure)
Sent: SIGUNUSED (bad system call)
Sent: SIGALRM (alarm clock)
Sent: SIGIOT (aborted)
Sent: SIGTSTP (stopped)
Sent: SIGURG (urgent I/O condition)
Sent: SIGTERM (terminated)
Sent: SIGTRAP (trace/breakpoint trap)
Sent: SIGUSR2 (user defined signal 2)
Sent: SIGPOLL (I/O possible)
Sent: SIGSYS (bad system call)
Sent: SIGVTALRM (virtual timer expired)
Sent: SIGWINCH (window changed)
Sent: SIGXFSZ (file size limit exceeded)
Sent: SIGABRT (aborted)
Sent: SIGINT (interrupt)
Sent: SIGCONT (continued)

また、ここでシグナルの番号と意味のテーブルが定義されている。 プログラムの出力中にあるSent: SIGPIPE (broken pipe)broken pipeは、この定義が元になっている。

シグナル受信側

受信側では、SIGCONTを受信した瞬間に(後述するように一部のシグナルを除いて)キューイングされていたシグナルが一斉に処理される。Go上では後述するようにSignalチャネルのバッファサイズを適切に設定しておかないと、キューイングされていたシグナルを全て処理し切ることができない。

receiver:$ ./sigexp -mode=receiver
PID: 285

[1]+  Stopped                 ./sigexp -mode=receiver
receiver:$ Received: SIGHUP (hangup)
Received: SIGINT (interrupt)
Received: SIGQUIT (quit)
Received: SIGILL (illegal instruction)
Received: SIGTRAP (trace/breakpoint trap)
Received: SIGABRT SIGIOT (aborted)
Received: SIGBUS (bus error)
Received: SIGFPE (floating point exception)
Received: SIGUSR1 (user defined signal 1)
(Reset SIGINT and SIGTERM. Now you can interrupt this program with SIGINT and SIGTERM)
Received: SIGSEGV (segmentation fault)
Received: SIGUSR2 (user defined signal 2)
Received: SIGPIPE (broken pipe)
Received: SIGALRM (alarm clock)
Received: SIGTERM (terminated)
Received: SIGSTKFLT (stack fault)
Received: SIGCLD SIGCHLD (child exited)
Received: SIGCONT (continued)
Received: SIGURG (urgent I/O condition)
Received: SIGXCPU (CPU time limit exceeded)
Received: SIGXFSZ (file size limit exceeded)
Received: SIGVTALRM (virtual timer expired)
Received: SIGWINCH (window changed)
Received: SIGIO SIGPOLL (I/O possible)
Received: SIGPWR (power failure)
Received: SIGSYS SIGUNUSED (bad system call)

出力の途中でReceived: SIGCONT (continued)が確認できる。これはつまりSIGCONTがGoのプログラムでハンドルされているわけだが、なぜハンドルしている(=デフォルトの挙動を無視して処理を握り潰している)はず*4のシグナルSIGCONTでプロセスの再開処理が可能なのか?

以下に言及されているようにSIGCONTはプログラム内で常に無視するようにしていたとしても、停止中のプロセスに対してはプロセスの再開処理を必ず行う。当然ながら停止中のプロセスで走るGoのプログラムは停止中なので、SIGCONTがプロセスを再開するまでSIGCONTのハンドルができない。ただ、プロセス再開後にSIGCONTが改めてハンドルされている点については疑問が残る。プロセスを再開した後は、その役割を果たして消えると思っていたがどうも違うらしい。

While a process is stopped, no more signals can be delivered to it until it is continued, except SIGKILL signals and (obviously) SIGCONT signals. The signals are marked as pending, but not delivered until the process is continued. The SIGKILL signal always causes termination of the process and can’t be blocked, handled or ignored. You can ignore SIGCONT, but it always causes the process to be continued anyway if it is stopped. Sending a SIGCONT signal to a process causes any pending stop signals for that process to be discarded. Likewise, any pending SIGCONT signals for a process are discarded when it receives a stop signal.

Ref. Job Control Signals (The GNU C Library)

このLinuxにおけるSIGCONTの挙動はlinux/signal.hにも説明がある。

プロセス停止用のシグナルがキューイングされた場合は再開時に破棄される

また、上記引用文中に、SIGCONTでプロセスを再開する際にpending stop signalsが破棄されるとある。受信側の結果からわかるように、SIGCONT受信時に以下のシグナルについてはGoのチャネルが反応せずハンドルできなかった*5。確かに再開直後にキューに溜まったシグナルでプロセスを再停止されても誰も嬉しくない。

  • SIGSTOP (stopped (signal))
  • SIGTSTP (stopped)
  • SIGTTIN (stopped (tty input))
  • SIGTTOU (stopped (tty output))

SIGPROF

もう一つ、SIGPROFも再開時にハンドルできなかった。

  • SIGPROF (profiling timer expired)

The SIGPROF signal is handled directly by the Go runtime to implement runtime.CPUProfile.

Ref. signal - The Go Programming Language

とあるようにGoのランタイムが直接ハンドルしているらしいが、本当にこれが関係しているかまでは追えていない。

キューイングされたシグナルの処理される順番

ところでこの実験の中では、送信側は毎回ランダムにシグナルを送っているのに、受信側ではプロセス再開時に毎回同じ順番で処理される。どうもこの順番は定義されているシグナルの番号と一致しているようだが、これはGo内部の実装によるものなのかはっきりしていない。

Signalチャネルのバッファサイズ

Goのチャネルのサイズを1にすると、プロセス停止中にどんなにシグナルを送っても、再開後にハンドルされるシグナルは2つだけとなる。処理し切れなかった残りのシグナルは全て破棄されるように見受けられる。

   signals := make(chan os.Signal, 1)

signal.Notifyのドキュメントにもレートに気をつけてバッファを確保せよとある。この辺りからもわかるように、シグナルがハンドルされるかどうかは受信側次第なので、送信側は送ったシグナルが本当に届いたどうか把握することは基本的にできない。

Package signal will not block sending to c: the caller must ensure that c has sufficient buffer space to keep up with the expected signal rate. For a channel used for notification of just one signal value, a buffer of size 1 is sufficient.

Ref. signal - The Go Programming Language

Goにおけるシグナルの実用

シグナル周りはCでソケットプログラミングをしていた頃に死ぬほど悩まされたので、Goにおける実用上の注意点も大いに気になる。

今回調べている中で見つけたもので言えば、例えばtimeoutの実装に関するスライドで、プロセスを正しく終了させるためにシグナル送信後にSIGCONTで停止中のプロセスを強制再開させる話が出てくる。今回見たシグナルのキューイングを頭に入れた上でコードを見ると、もし対象のプロセスが停止中だった場合、確かにSIGCONTをシグナル送信後に送らない限り送ったシグナルは停止中のプロセスには届かないし、SIGKILLで強制終了する以外に停止中のプロセスを終了する方法がないことがわかる。実際、SIGTSTPは例えば停止しているecho中のプロセスを正しく終了させるためにハンドルすべきとある。

The SIGTSTP signal is an interactive stop signal. Unlike SIGSTOP, this signal can be handled and ignored.

Your program should handle this signal if you have a special need to leave files or system tables in a secure state when a process is stopped. For example, programs that turn off echoing should handle SIGTSTP so they can turn echoing back on before stopping.

Ref. Job Control Signals (The GNU C Library)

他にもGoでのSIGPIPEの取り扱いや、コンテナ内でのシグナル、contextを駆使したシグナル操作等、色々気になる点はあるが力尽きたのでまた今度。

System Programming in Go inside Golang Container

余談だけど、今回macos上でLinux環境下のシステムプログラミングをGoでやるにあたって、golangのDockerイメージにカレントディレクトリのファイルをマウントして、コンテナ内でビルドやバイナリの実行等の作業をしたが、これが快適だった。

linux-signals-in-go/run_golang_container.sh at master · doi-t/linux-signals-in-go · GitHub

例えば、docker runで起動する度に必ず若いプロセス番号から始まり、psコマンドでプロセスを見た時に最小限のプロセスしか出てこないので、目障りなものが一切ない。システムコールを呼んだり、OSの状況を頻繁に見るシステムプログラミングを必要最小限の環境でやると、細いところで不要な混乱をしなくて良いので、大変相性が良いように思う。コンテナイメージのビルド等をしているわけでもないので、環境破棄と再現が素早くクリーンな環境を維持できる上に人に共有しやすい。

読了、Goならわかるシステムプログラミング

読んでいてシグナルのブログを書いた当時読んでいたふつうのLinuxプログラミングを思い出した。GoならわかるシステムプログラミングはWeb連載が読めるが、ふつうのLinuxプログラミングも全4回のWeb連載が今でも読める。自分にはこの連載にある説明*6Linux学習の初期の段階で、ファイルシステム、プロセス、ストリームなどの重要な概念を理解するのに大いに役に立った(おすすめ)。両方とも目指すところはかなり近いと思うので、相互に補完するように読むと理解が捗ると思う。Goそのものについては、2章のインターフェースの話が標準ライブラリを見る目を変えてくれて大変参考になった。個人的にはこういった理解に役立つ設計思想の話や歴史的経緯の話は冗長なくらいしてくれた方が嬉しい。

久しぶりにシステムプログラミングっぽいことをやったが、やっぱり面白いし無限に時間が溶ける。Goだと自分で作って動かしてみるまでの道のりがCと比べて短いので、試しながらやりやすい。わからなくなった時に実装の最深部までショートカットキー連打で気軽に行くことができ、行った先もGoで書かれているのも良い。ただ、キリがない。書いている最中にも、sigqueue.goなるものを見つけたり、proc(5)のマニュアルにSigQ以外に色々SigXXXがあるのを見つけたり、実行中のプロセスに複数のシグナル送信した場合にもシグナルがpending状態になることに気が付いたり、書けば書くほど内容が不完全な気がしてくるが、全部やっていたら文字通り日が暮れるので今回はこの辺で。

*1:実際には会社の先輩に教えてもらってぶったまげてから1年以上経ってしまった...

*2:本で言及されているように、C言語でハンドラ関数を登録していたのと比べると随分と趣が異なる。こうしてCと見比べるとGoは本当にシンプルに見える。

*3:SIGKILLとSIGCONTはスキップ

*4:厳密にはSIGCONTは実行中のプロセスにおいて元からデフォルトで無視なので、 ここではむしろわざわざハンドルしてPrintfしている。

*5:この辺は順番が本当は逆で、全シグナルを送っても一部ハンドルされないので調べてみたら再開時に破棄されることがわかった

*6:もちろん本の方がよくまとまっている。