この記事は 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.Timer
の Stop()
を呼ぶ際の正しいお作法は以下の通りになります。
if !ti.Stop() {
<-ti.C
}
Stop()
の戻り値はタイマーがこの呼出しで止まった場合に true
を返します。
しかしすでに止まっていた場合 false
を返します。
この Stop()
が false
を返したときタイマーは発火済みであり C <-chan Time
にデータが投入されています。
それを読み捨てる(=ドレインする)ために上記のコードが必要となります。
Reset()
のお作法
Reset()
はタイマーの時間を再設定するメソッドです。
ユースケースとしては以下の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.C
を select
で囲むのをやめて変数 running
でガードしてみました。
Timer
へのアクセスが goroutine の中に集約されているため簡易的な方法でも十分になります。
まとめ
とても基本的な time.Timer
とは言え、ちゃんと使おうとすると考えるべき点が多いことがわかりました。
慢心せず常にドキュメントを丁寧に読んで適切なコードを書きましょう(2敗)