My External Storage

Sep 6, 2020 - 4 minute read - Comments - go

[Go] signal.Notifyを使うときは必ずバッファ付きチャネルで利用すること

Goでsingal.Notify関数を使うときは必ずバッファありチャネルを利用しなくてはいけない。
なぜバッファなしチャネルを使ってはいけないのかまとめた。

TL;DR

singal.Notify関数

Goでシグナルハンドリングをするときに利用するのがsingal.Notify関数だ。

func Notify(c chan<- os.Signal, sig ...os.Signal)

この関数は第1引数にシグナルを受信するためのチャネルを指定し、第2引数以降はハンドリングしたいシグナルを羅列していく。
このときチャネルはバッファありチャネルでないといけない。
これはsingal.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.

シグナルハンドリングについて

シグナルは身近なところでいうとコマンドラインツールをコントロールCLIなどで終了したり、フリーズしたソフトやプロセスに対してキルコマンドを実行したときに発生する。
ただ、Webサービスを作る上でもシグナルを意識する必要はある。
たとえばECSやk8s上で起動するコンテナはコンテナ外部からシグナルを受信したとき適切に終了する必要がある。
シグナルをハンドリングしてグレースフルシャットダウンをする実装をしていないと、処理の終了を待たずにコンテナが強制遮断されうる。

チャネルの仕様の簡単な確認

Goのチャネルにはバッファありチャネルとバッファなしチャネルが存在する。
バッファなしチャネルを介した場合、何もしないと送信側goroutine、受信側goroutineの間にはhappens beforeの関係が成り立つ。

これは受信側goroutineが受信を完了するまで、送信側goroutineが再度起動されないことを意味する。

2020/09/06 20:12 追記

happens beforeはあくまで変数の可視性の話なのでゴルーチンの動きを説明するものではない。と指摘していただきました。
チャネルの挙動を決めているのは言語仕様でした。

バッファなしチャネルを介した場合、送信は受信側が受信を開始するまで成功しない(処理が完了しない)。

If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready.

A send on an unbuffered channel can proceed if a receiver is ready.

(追記終わり。)

言い換えると、受信が完了するまで送信側では送信が行われず処理を再開できない。

ch := make(chan int)

ch <- 10 // どこかで受信されるまで処理が進まない

signal.Notifyでバッファありチャネルを使わないといけない理由

ある行儀の悪いバッファなしチャネルがシグナルを待機(受信)していたとする。

c := make(chan os.Signal) // バッファなしの行儀が悪いチャネル
signal.Notify(c, os.Interrupt, os.Kill)

シグナルが発生したとき、このバッファなしチャネルが受信するまで送信側goroutineの処理が止まってしまってはいけない。
他のチャネルも受信待機しているかもしれないので適切なシグナルハンドリングができなくなってしまう。
そのため、signalパッケージのシグナル送信側の処理は冒頭で引用した仕様の通り、ノンブロッキングな送信を行う。

Package signal will not block sending to c

具体的なコードを見ると、まずsignalパッケージは以下のようなループでシグナルを処理している。 (signal_recv)は私にはちょっとむずかしいので説明省略)

https://github.com/golang/go/blob/a538b59fd2428ba4d13f296d7483febf2fc05f97/src/os/signal/signal_unix.go#L21-L25

func loop() {
	for {
		process(syscall.Signal(signal_recv()))
	}
}

process関数の中をみてみると、singal.Notify関数で登録されたチャネルそれぞれにシグナルを送信している。
このとき、チャネルが送信ブロック状態だった場合はdefault節があるのでそのチャネルを無視して次のチャネルへシグナルを送信する

https://github.com/golang/go/blob/a538b59fd2428ba4d13f296d7483febf2fc05f97/src/os/signal/signal.go#L240-L248

	for c, h := range handlers.m { // cがsignal.Notifyの第一引数のチャネル
		if h.want(n) {
			// send but do not block for it
			select {
			case c <- sig:
			default:
			}
		}
	}

このため、バッファなしチャネルを使ってsignal.Notify関数を使った場合、チャネルが受信待機するまえに送信されたシグナルは検知できなくなる。

おわりに

signal.Notify関数はバッファなしチャネルで利用してはいけない。
これはsignal.Notify関数の仕様にも明記されている。
今回はドキュメントの注意書きを無視した場合どのような懸念事項が発生するのか確認することができた。

参考

関連記事