time.Timer#Reset() の正しい使い方

time.Timer は使ってますか? とても基本的な要素なのですが意外と正しい使い方は難しいのです。 つい先日ハマった事例とともにその正しい使い方を紹介します。

この記事は Go Advent Calendar 2019 の21日目の記事です。

皆さんは time.Timer は使ってますか? Timer は指定時間経過後に C <-chan Time に現在時刻が 1回だけ投入されるという極めてシンプルな型です。 メソッドも Stop()Reset() の2つだけと少なくシンプルです。 そう…シンプルなのですが正しく使うには2つほど大事な注意事項があります。 この記事ではそれらを解説します。

TL;DR

  • time.Timer#Stop() 後に time.Timer.C を読み捨てなければいけない場合がある

    ti := time.NewTimer(5 * time.Second)
    // ...
    if !ti.Stop() {
        <-ti.C
    }
    
  • time.Timer#Reset() はタイマーが停止した状態で呼び出す必要がある

    // ...
    if !ti.Stop() {
        <-ti.C
    }
    ti.Reset(3 * time.Second) // wait more 3 seconds
    

    タイマーがすでに停止していた場合は上記コードの <-ti.C でブロックしてしまうのでうまく避けるべし。 一例として以下のようなコードが使えるが大げさすぎる場合もある。

    select {
    case <-ti.C
    default
    }
    
  • これらの time.Timer への操作は並列に(同時に)行ってはならない

Stop() のお作法

すでに示したとおり time.TimerStop() を呼ぶ際の正しいお作法は以下の通りになります。

if !ti.Stop() {
    <-ti.C
}

Stop() の戻り値はタイマーがこの呼出しで止まった場合に true を返します。 しかしすでに止まっていた場合 false を返します。 この Stop()false を返したときタイマーは発火済みであり C <-chan Time にデータが投入されています。 それを読み捨てる(=ドレインする)ために上記のコードが必要となります。

Reset() のお作法

Reset() はタイマーの時間を再設定するメソッドです。 ユースケースとしては以下の2つが考えられます。

  1. すでに発火したタイマーを再利用する
  2. まだ発火していないタイマーの時間を再設定・延長する

特に後者のユースケースでは現在動いているタイマーをいったん止めて発火までの新しい時間を設定します。 事実 Reset() 内部では低レイヤーのタイマーを止める手続きがあるのですが、 Reset() を呼び出してから低レイヤータイマーが止まるまでの間に発火してしまう場合があります。 これを無視すると指定時刻が経過してないのに発火したかのような状態になってしまうので、 以下のようなコードで確実に止めてあげる必要があります。

if !ti.Stop() {
    <-ti.C
}
ti.Reset(3 * time.Second) // wait more 3 seconds

ただしこのコードを前者のユースケース、発火済みかつ ti.C をすでに消費済み、 で使用してしまうと <-ti.C の部分で読み込むものが存在しないためにブロックしてしまいます。 特にタイマーの発火の監視は以下のダメなコードのように別の goroutine で行う場合が多いので注意が必要です。

ti := time.NewTimer(5 * seconds)

go func() {
    <-ti.C
    // ... do something when the timer fired ...
}()

// ... do something in background of the timer ...

// extend the timer 3 seconds, if some condition is fulfilled
if someCondition {
    if !ti.Stop() {
        <-ti.C // THIS MIGHT BLOCK
    }
    ti.Reset(3 * time.Second)
}

繰り返しますが上のコードはダメな例です。 コピペで使わないでください。 正しいタイマーの Reset() 操作は次のようになります。

ti := time.NewTimer(5 * time.Second)

go func() {
    <-ti.C
    // ... do something when timer fired ...
}()

// ... do something in background of the timer ...

// extend the timer 3 seconds, if some condition is fulfilled
if someCondition {
    if !ti.Stop() {
        select {
        case <-ti.C:
        default:
        }
    }
    ti.Reset(3 * time.Second)
}

並列操作の禁止

さてここまで time.Timer を見てきたのですが似たような機能に time.Ticker があります。 Ticker は一定間隔ごとに繰り返し発火するタイマーなのですが、 ちょっと発火間隔という点では柔軟性に欠けます。 たとえば30秒間隔で繰り返す操作を特定のタイミングで30秒延長したい、 といった具体的には KeepAlive の PING を撃つただし別の操作をしたら PING のタイマーをリセットする、 みたいな用途には Ticker は使えません。 しかし Timer ならば可能です。

ここに非同期の Stop() 操作を加味するとさらにややこしくなります。 前段で示したように time.Timer への同時操作は予期せぬ動作をする場合があります。 そのため同時操作はしないこととドキュメントにも次のように記載があります。

time.Timer#Reset より:

This should not be done concurrent to other receives from the Timer’s channel.

time.Timer#Stop より:

This cannot be done concurrent to other receives from the Timer’s channel.

これらを踏まえるともういっそ time.Timer の操作は1つの goroutine に集約してしまうのが良さそうです。 ちょっとざっくり書いてみましょう。

// manage lifecycle of the timer
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var mu sync.Mutex
rst := make(chan time.Duration)

go func() {
    ti := time.NewTimer(30 * time.Second)
    running := true
    stop := func() {
        if running && !ti.Stop() {
            <-ti.C
        }
        running = false
    }
    for {
        select {
        case <-ctx.Done():
            mu.Lock()
            close(rst)
            rst = nil
            mu.Unlock()
            stop()
            return
        case d := <-rst:
            stop()
            ti.Reset(d)
            running = true
        case <-ti.C:
            running = false
            // ... do something when timer fired ...
        }
    }
}

reset := func(d time.Duration) {
    mu.Lock()
    if rst != nil {
        rst <- d
    }
    mu.Unlock()
}

// cancel() - discard the timer
// reset(30 * time.Second) - reset the timer to fire after 30 seconds

この例では Stop() 時の <-ti.Cselect で囲むのをやめて変数 running でガードしてみました。 Timer へのアクセスが goroutine の中に集約されているため簡易的な方法でも十分になります。

まとめ

とても基本的な time.Timer とは言え、ちゃんと使おうとすると考えるべき点が多いことがわかりました。 慢心せず常にドキュメントを丁寧に読んで適切なコードを書きましょう(2敗)