Go 1.13までのゴルーチンの切り替えは、チャネルの送受信やシステムコール呼び出し、関数呼び出し前にコンパイラが暗黙的に挿入する処理などによって行われていました。そのため、上記の切り替わり操作を全く行わないループなどがあれば、そのゴルーチンがずっと実行されます。
func loop() { // この辺りにコンパイラがコード挿入している for { // 切り替わり処理が行われないので無限に実行される } }
この結果、$GOMAXPROCSが1の場合はプログラムが停止します。コンパイラが挿入するコードは、インライン展開された場合やgo:nosplitディレクティブが記述された場合には行われないので、関数呼び出しをしていてもゴルーチンが切り替わらない場合はあります。
Goのスケジューラについてはこの辺りが詳しいです。
これ自体はGoを学び始めた人がよく引っかかるものですね。
何が問題なのか
このように、Goでは特定の場所でしかゴルーチンの切り替えが行われないため、Non-cooperative goroutine preemptionというプロポーザル*1によると、GCのStopTheWorld時間が長くなったりするそうです。
Go 1.14では、上記のようなコードでも切り替えられるように、ゴルーチンの切り替えがSIGURGによっても行われるようになりました。ざっとコードを眺めた雰囲気だと、SIGURGはGCの処理で送っているみたいですね。この結果、Go 1.14のリリースノートによると、
A consequence of the implementation of preemption is that on Unix systems, including Linux and macOS systems, programs built with Go 1.14 will receive more signals than programs built with earlier releases. This means that programs that use packages like syscall or golang.org/x/sys/unix will see more slow system calls fail with EINTR errors.
と書かれています。システムコール実行中にシグナルを受け取ると、システムコールはEINTRを返す場合があるので、syscallやgolang.org/x/sys/unixを直接使っている場合は適切なエラーハンドリングしましょう、とのことです。osやnetなど、他のパッケージを使っている場合もたぶん同じで、Go 1.13まではエラーにならなかったシステムコールがEINTRエラーになるパターンが増えます。
どう対応するべきか
Linuxの場合*2はシステムコールや呼び出し時のオプションによって、シグナルで中断した場合の動作は以下の2通りに分かれます。
- シグナルを処理した後で再開される
- EINTRエラーを返す
どのシステムコールがどんな条件でEINTRを返すのかは、Linuxマニュアルに詳しく書かれていました。
自動でカーネルが再開してくれる場合は、当然ですが今までと同じように動作するため対応不要です。EINTRを返すことがあるシステムコールの場合は、そのままエラーを返すかリトライするかを決める必要があるでしょう。syscall.EINTRを直接使ってしまうと、他のOSに移植する際にとても困ります*3。Goのsyscall.ErrnoはTemporaryメソッドを実装していて、EINTRの場合はtrueを返すので、これを使うと良いと思います。
type temporaryer interface { Temporary() bool } _, err := r.Read(buf) if err != nil { if e, ok := err.(temporaryer); ok { fmt.Println(e.Temporary()) } }
ただし、close(2)の場合、システムコールから戻った時点でファイルディスクリプタは無効になっており、同じファイルディスクリプタが他のファイルに割り当てられる可能性があるため、リトライしてはいけないようです。
UNIXはclose(2)のときだけEINTRで再処理してはいけないというクソルールがあり、かつ明文化されていないという畜生ぶり。これはまた被害者が増えるな https://t.co/g1lcSKoYyh
— 令和うまれの恐竜 (@gachacomplete) 2020年2月26日
おまけ
Goでファイルの存在確認でも似たようなことを書きましたが、ファイルの書き込みを行う場合、deferを使って、
w, err := os.OpenFile(file, os.O_WRONLY|os.O_APPEND, 0660) if err != nil { return err } defer w.Close() _, err := w.Write(data) return err
みたいに書いてしまうと、close(2)のエラーが拾えません。その結果、書き込みが失敗しているのに成功扱いしてしまいます。読み込みだけの場合はこれで問題ありませんが、書き込みをしたファイルのCloseは、OSが持っているバッファをディスクに書き込もうとしてエラーになる可能性があるので、きちんとエラーを拾いましょう。