こんにちは。yebis0942です。GoとTypeScriptを書いています。夏祭りのおみくじで「待ち人来る」を引いたので、最近のちょっとした待ち事例についてご紹介します。
Goでタイムアウト時間を指定する関数を呼び出したとき、待機時間を0秒にすると何が起きるのか?という点が社内のレビューで少し話題になりました。
気になって調べてみたところ、同じ0秒のタイムアウト処理でも、内部の実装によって振る舞いが異なるケースがあることが分かりました。
よく見るタイムアウト処理
Go言語では、一定時間だけあるchannelを待つというタイムアウト処理は以下のように time.After()
を使って書くことができます。
func timeAfter(c chan int, duration time.Duration) { select { case <-time.After(duration): // durationの経過後に値が入るchannelを返す fmt.Println("Timeout") case <-c: fmt.Println("OK") } }
context.WithTimeout()
を使って、次のように書き換えることもできます。
func contextWithTimeout(c chan int, duration time.Duration) { ctx, _ := context.WithTimeout(context.Background(), duration) select { case <-ctx.Done(): // durationの経過後に値が入るchannelを返す fmt.Println("Timeout") case <-c: fmt.Println("OK") } }
ちなみにtime.After()
もcontext.WithTimeout()
も、内部的にはtime.NewTimer()
を使ってタイマー処理を実現しています。
振る舞いの違い
外側も内側もよく似たコードですが、duration
を0秒にして、値を送信済みのchannel c
を渡してみると、振る舞いが異なることが分かります。
timeAfter()
はほぼ常にOK
を表示しますが、contextWithTimeout()
はOK
とTimeout
を半々の確率で表示します。
どちらも内部では time.NewTimer()
を使っていて、同じようにselect
で待ち受けているのに、いったいなぜ…?
理由
この振る舞いの違いは、それぞれのchannelからの受信がブロックされるかどうかの違いによります。
たとえばバッファなしのchannelの場合、channelからの受信がブロックされるとは以下のような状況を指します。
c := make(chan int) go func() { c <- 1 }() <-c // 別のgoroutineから値を送信しているのでブロックされない <-c // 受信できる値がないのでブロックされる
time.After()
は待機時間が0秒であってもGoの内部のタイマーに登録して待ち受け処理を行なう
待機時間として0以下の値が指定された場合には、以下のように発火予定時刻を現在時刻に丸めてからタイマーに登録します。
func when(d Duration) int64 { if d <= 0 { return runtimeNano() // 注: 現在時刻をナノ秒で返す関数 } // 略 }
そのため、タイマーが発火する前にselect
の評価が開始されると、time.After()
のchannelからの受信はまだブロックされたままです。
context.WithTimeout()
は待機時間が0秒以下であれば即座に自身をキャンセルする
一方で、context.WithTimeout()
は待機時間が0秒以下であればタイマーを登録しません。
以下のように、即座に自身をキャンセルします。
// 注: 実際の処理はWithDeadlineCause()に集約されている func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) { // 略 dur := time.Until(d) if dur <= 0 { c.cancel(true, DeadlineExceeded, cause) // deadline has already passed return c, func() { c.cancel(false, Canceled, nil) } } // 略 }
そのため、contextWithTimeout
ではctx.Done()
のchannelからの受信はブロックされていないことが保証されます。
select
はブロックされていないchannelをランダムに選択する
では、なぜselect
で上に置かれているchannel ctx.Done()
ではなくchannel c
が選択されることがあるのでしょうか?
The Go Programming Language Specification - Select statementsでは、select
のchannelの選び方を次のように説明しています。
If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection.
参考訳: 1つ以上の通信が進行可能であれば、偏りのない擬似ランダムな選択方法によって、進行可能な通信を1つ選ぶ。
これにより、<-ctx.Done()
と<-c
が均等に選択されることになります。
むすび
Go言語のタイムアウト処理のコーナーケースについてご紹介しました。
ちなみにGo 1.23ではtimerのchannelの仕様が改善されており、Reset()
とStop()
が安心して利用できるようになっています。8月中盤のリリースを楽しみに待ちたいと思います。