Go 1.13までのゴルーチンの切り替えは、チャネルの送受信やシステムコール呼び出し、関数呼び出し前にコンパイラが暗黙的に挿入する処理などによって行われていました。そのため、上記の切り替わり操作を全く行わないループなどがあれば、そのゴルーチンがずっと実行されます。
func loop() { // この辺りにコンパイラがコード挿入している for { // 切り替わり処理が行われないので無限に実行される } }
この結果、$GOMAXPROCSが1の場合はプログラムが停止します。コンパイラが挿入するコードは、インライン展開された場合やgo:nosplit
ディレクティブが記述された場合には行われないので、関数呼び出しをしていてもゴルーチンが切り替わらない場合はあります。
Goのスケジューラについてはこの辺りが詳しいです。
これ自体はGoを学び始めた人がよく引っかかるものですね。
何が問題なのか
このように、Goでは特定の場所でしかゴルーチンの切り替えが行われないため、Non-cooperative goroutine preemptionというプロポーザル*1によると、GCのStopTheWorld時間が長くなったりするそうです。
Go 1.14では、上記のようなコードでも切り替えられるように、ゴルーチンの切り替えがSA_RESTARTフラグ付きの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マニュアルに詳しく書かれていました。
自動でカーネルが再開してくれる場合は、当然ですが今までと同じように動作するため対応不要です。Goランタイムが送るSIGURGにはSA_RESTARTフラグが付いているので、ほとんどは大丈夫そう。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が持っているバッファをディスクに書き込もうとしてエラーになる可能性があるので、きちんとエラーを拾いましょう。
2020-03-02追記
ファイルの場合、os.File.Sync でバッファをフラッシュしておいて、Closeではエラーを発生させないようにするのがベストプラクティスのようです。
Linuxカーネル界隈の議論でもこれがベストプラクティスということで議論が決着しております。 https://t.co/KYI3RAdz9O
— 概念と化した恐竜先生 (@gachacomplete) 2020年2月29日
2020-07-11追記
EINTRの発生をmattnさんが調査されていたけれど、Goの標準関数を使う限りは発生しないようでした。
本日までのまとめ。(寝る)
— mattn (@mattn_jp) 2020年3月2日
明日以降は別の切り口で EINTR が返るケースを探す。https://t.co/p9u0OjUVwL
この変更でもう Go は EINTR をユーザに返さないってのが確定したと思っていいかもしれないなぁ。https://t.co/g1aDhLpQCB
— mattn (@mattn_jp) 2020年8月20日