Goで0秒待つとどうなるか

こんにちは。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()OKTimeout を半々の確率で表示します。

どちらも内部では 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() // 注: 現在時刻をナノ秒で返す関数
    }
    // 略
}

time/sleep.go#L29-L31

そのため、タイマーが発火する前に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) }
    }
    // 略
}

context.go#L630-L634

そのため、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月中盤のリリースを楽しみに待ちたいと思います。