Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Go 1.14でシステムコールがEINTRエラーを返すようになった

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によっても行われるようになりました。ざっとコードを眺めた雰囲気だと、SIGURGGCの処理で送っているみたいですね。この結果、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を返す場合があるので、syscallgolang.org/x/sys/unixを直接使っている場合は適切なエラーハンドリングしましょう、とのことです。osnetなど、他のパッケージを使っている場合もたぶん同じで、Go 1.13まではエラーにならなかったシステムコールEINTRエラーになるパターンが増えます。

どう対応するべきか

Linuxの場合*2システムコールや呼び出し時のオプションによって、シグナルで中断した場合の動作は以下の2通りに分かれます。

  • シグナルを処理した後で再開される
  • EINTRエラーを返す

どのシステムコールがどんな条件でEINTRを返すのかは、Linuxマニュアルに詳しく書かれていました。

自動でカーネルが再開してくれる場合は、当然ですが今までと同じように動作するため対応不要です。Goランタイムが送るSIGURGにはSA_RESTARTフラグが付いているので、ほとんどは大丈夫そう。EINTRを返すことがあるシステムコールの場合は、そのままエラーを返すかリトライするかを決める必要があるでしょう。とはいえsyscall.EINTRを直接使ってしまうと、他のOSに移植する際にとても困ります*3。Goのsyscall.ErrnoTemporaryメソッドを実装していて、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)の場合、システムコールから戻った時点でファイルディスクリプタは無効になっており、同じファイルディスクリプタが他のファイルに割り当てられる可能性があるため、リトライしてはいけないようです。

おまけ

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ではエラーを発生させないようにするのがベストプラクティスのようです。

*1:issueはgolang/go#24543です

*2:他のOSでも概ね同じだと思いますが

*3:syscallが提供している型を公開メソッドで直接参照するのは良くないと思います