Zenn

【Go】もう迷わないtime.Timerの正しい使い方(Go1.22以前と1.23以降まとめ)

2025/03/21に公開

人によっては使うことがあまり無いかもしれないtime.Timer
並行処理をする場合なんかにはお世話になるかもしれない。
そんなtime.Timerだが、Go1.23以降かGo1.22以前かで使い方が異なる部分がある。
今回調べて思ったが、Go1.22以前の場合は特に罠があるので、仕様についてはざっくりとでも把握しておいた方が良い。

話すこと

  • Go1.22以前のtime.Timerの使い方と注意点
  • Go1.23以降のtime.TimerがGo1.22からどう変わったか

話さないこと

  • time.Timeの仕様

Go1.22以前のtime.Timerの使い方と注意点

Timerの基本的な使い方

time.Timerは指定時間経過後に内部のChannel Timer.Cを通じて通知してくれる。

time/sleep.go
// The Timer type represents a single event.
// When the Timer expires, the current time will be sent on C,
// unless the Timer was created by AfterFunc.
// A Timer must be created with NewTimer or AfterFunc.
type Timer struct {
	C <-chan Time
	r runtimeTimer
}

使い方はこんな感じ。
time.NewTimer関数を実行するとタイマーが動き出し、指定時間後にCがtime.Timeのデータを受信する。

package main

import (
	"fmt"
	"time"
)

func main() {
	// 3秒後に発火するタイマーを生成
	timer := time.NewTimer(3 * time.Second)
	// タイマーが発火するまで待機
	t := <-timer.C
	fmt.Println("Timer fired at", t)
}

time.Afterとtime.AfterFuncというヘルパー関数もある。

package main

import (
	"fmt"
	"time"
)

func main() {
	// 2秒後に実行される
	fmt.Println("time.After fired", <-time.After(2*time.Second))

	// 2秒後にdo関数を呼ぶ
	time.AfterFunc((2 * time.Second), do)

	// time.AfterFuncが呼ばれるまでgoroutineが終了しないように待つ
	time.Sleep(3 * time.Second)
}

func do() {
	fmt.Println("called by time.Afterfunc")
}

time.Afterもtime.AfterFuncもtime.Timerのメソッドではないが、内部的にはNewTimer関数を呼び出してTimerを使用している。

また、タイマーは止めたりリセットしたりできる。

package main

import (
	"fmt"
	"time"
)

func main() {
  // 3秒後に発火するタイマーを生成
  timer := time.NewTimer(3 * time.Second)
  // 発火1秒前までスリープ
  time.Sleep(2 * time.Second)
  // タイマーが発火する前に止める
  if !timer.Stop() {
    // ドレイン
    <-timer.C
  }
  // タイマーの時間を3秒にリセット
  timer.Reset(3 * time.Second)
  // 3秒待つ
  t := <-timer.C

  fmt.Println("Timer fired at", t)
}

Stop呼んだらドレイン

上のコードで目を引くのは、ドレインとコメントされた部分である。
これは何をしているのだろうか。

Timerは発火時にTimer.Cチャネルにデータ(time.Time)を送信するが、Stop関数やReset関数ではすでにTimer.Cに送信されたデータはどうにもできない

Timerが発火済みの場合にStopとResetをしたときにどうなるのかそれぞれ見てみる。

まずは発火済みのタイマーに対してStopした場合

func main() {
	// 3秒後に発火するタイマーを生成
	timer := time.NewTimer(3 * time.Second)
	// 4秒待ってタイマーを発火させておく
	time.Sleep(4 * time.Second)

	timer.Stop()

	// Stopしているのに発火済みのデータが残っているので受け取ってしまう。
	t := <-timer.C
	fmt.Printf("Timer fired %v\n", t)
}
% GOTOOLCHAIN=go1.22.0 go run .
Timer fired 2025-03-19 16:07:22.298116 +0900 JST m=+3.001333209

このように、Stopしたにも関わらず発火済みのデータはTimer.C内に残存している。

また、Resetの場合はこうなる。

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("Timer start")
	// 3秒後に発火するタイマーを生成
	timer := time.NewTimer(3 * time.Second)
	// 4秒待ってタイマーを発火させておく
	time.Sleep(4 * time.Second)

	// 発火済みなのでStopはしない
	timer.Reset(3 * time.Second)

	// すでに発火済みのタイマーからの通知
	t1 := <-timer.C
	fmt.Printf("Timer fired 1st %v\n", t1)
	// Resetでセットされたタイマーからの通知
	t2 := <-timer.C
	fmt.Printf("Timer fired 2nd %v\n", t2)
}
$ GOTOOLCHAIN=go1.22.0 go run .
Timer start
Timer fired 1st 2025-03-19 16:27:49.553398 +0900 JST m=+3.001178001
Timer fired 2nd 2025-03-19 16:27:53.554472 +0900 JST m=+7.002179710

こちらもStop同様、先に発火したデータがTimer.C内に残ってしまっている。

というのも、下記の通りTimer.Cはバッファを1つ持っているため、1つ手前のデータがChannelに残ってしまう可能性がある。

time/sleep.go
// NewTimer creates a new Timer that will send
// the current time on its channel after at least duration d.
func NewTimer(d Duration) *Timer {
	c := make(chan Time, 1) // <- here
	t := &Timer{
		C: c,
		r: runtimeTimer{
			when: when(d),
			f:    sendTime,
			arg:  c,
		},
	}
	startTimer(&t.r)
	return t
}

普通に使うとTimerを止めたりリセットするのは発火前だから問題ないように思えるが、発火とこれらの操作がほぼ同タイミングだった場合にはもしかしたら先にタイマーが発火してしまうかもしれない。
そうした事態を避けるために、ドレインを入れている。

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("Timer start")

	timer := time.NewTimer(3 * time.Second)

	// タイマーの待ち時間と同じくらいかかる処理を実行
	
	// Stop()実行中にタイマーが発火してしまった
	// Stop()はすでに発火済み or Stop済みの場合、falseを返す
	if !timer.Stop() {
		// ドレイン(=すでに発火してしまっている1つ前のデータを吸い出す)
		fmt.Println("drain")
		<-timer.C
	}

	timer.Reset(3 * time.Second)
	t := <-timer.C

	fmt.Println("Timer fired at", t)
	// ドレインしたのでTimer.Cに読み残したデータは存在しない
	fmt.Printf("left data in Timer.C is %v\n", len(timer.C))
}
$ GOTOOLCHAIN=go1.22.0 go run .
Timer start
drain
Timer fired at 2025-03-19 17:50:48.606497 +0900 JST m=+7.002386459
left data in Timer.C is 0

並行処理でtime.Timerを使用するのは要注意

このような仕様であるため、並行処理でのTimerの使い方には注意が必要。
というのも、並行処理(go 構文での別goroutine生成)を使用すると、Resetのためにドレインするgoroutineとは別のgoroutineがtimer.Cを読み込んでしまう可能性が高まるからである。
実際にコードを見てみる。

func main() {
	fmt.Println("Timer start")

	timer := time.NewTimer(1 * time.Second)

	// 別goroutineでタイマー発火時の処理を実行
	go func() {
		t := <-timer.C
		fmt.Println("Timer fired at", t)
	}()

	// タイマーを発火させておく
	time.Sleep(2 * time.Second)

	// 発火済みのためドレイン処理を実施
	if !timer.Stop() {
		// 別goroutineですでに消費されているのでロックしてしまう
		fmt.Println("locked")
		<-timer.C
	}
	// ここから先へは到達しない
	timer.Reset(1 * time.Second)
	t2 := <-timer.C
	fmt.Println("reset Timer fired at", t2)
}

$ GOTOOLCHAIN=go1.22.0 go run .
Timer start
Timer fired at 2025-03-19 21:20:03.447193 +0900 JST m=+1.000478126
locked
fatal error: all goroutines are asleep - deadlock!

このように、タイマー発火の読み出しとResetを別のgoroutineで行おうとすると、ロックされる可能性が出る。
そもそも、1.22以前のバージョンのResetのドキュメントには以下のように記述がある。

If a program has already received a value from t.C, the timer is known to have expired and the channel drained, so t.Reset can be used directly. If a program has not yet received a value from t.C, however, the timer must be stopped and—if Stop reports that the timer expired before being stopped—the channel explicitly drained:
 
if !t.Stop() {
 <-t.C
}
t.Reset(d)
 
This should not be done concurrent to other receives from the Timer's channel.

Stopも同様の記述がある(Stopは異なるgoroutineからのTimer.Cの読み出しに加えてStop自体も複数のgoroutineから行わないよう書いてある)。
つまり、ResetかStopを使う際は、異なるgoroutineからTimer.Cの読み出しをしないよう注意しなければならない。

どうしてもやむにやまれず並行処理とResetを組み合わせたいならselectでロックを回避することはできる。

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("Timer start")

	timer := time.NewTimer(1 * time.Second)

	// 別goroutineでタイマー発火時の処理を実行
	go func() {
		t := <-timer.C
		fmt.Println("Timer fired at", t)
	}()

	// タイマーを発火させておく
	time.Sleep(2 * time.Second)

	// 発火済みのためドレイン処理を実施
	if !timer.Stop() {
		// 別goroutineですでに消費されているが、defaultで抜ける
		select {
		case <-timer.C:
		default:
			fmt.Println("timer.C has already been empty")
		}
	}
	timer.Reset(1 * time.Second)
	t2 := <-timer.C
	fmt.Println("reset Timer fired at", t2)
}
$ GOTOOLCHAIN=go1.22.0 go run .
Timer start
Timer fired at 2025-03-19 22:02:58.133373 +0900 JST m=+1.001238460
timer.C has already been empty
reset Timer fired at 2025-03-19 22:03:00.134405 +0900 JST m=+3.002270418

forループでtime.Afterを使う時は要注意

time.Afterは便利なTimerのヘルパーメソッドであり、その中身は非常にシンプルな作りになっている。

time/sleep.go
func After(d Duration) <-chan Time {
	return NewTimer(d).C
}

内部的にNewTimer関数を呼び出して新規にTimerを作成し、Cを返している。

注意しなければいけないのは、Go1.22以前では停止していないTimerは参照されていなくてもGCに回収されないということ。
これが問題になるケースを見てみる。

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	fmt.Println("Timer start")

	intCh := make(chan int)

	go func() {
		for {
			// intChにデータが送信されるたびにselectのcase全てが評価される
			// それによりtime.Afterがループごとに実行され、ループの数だけタイマーも生成される
			select {
			case _, ok := <-intCh:
				// 何らかの処理を実行
				if !ok {
					return
				}
			case <-time.After(3 * time.Second):
				panic("timed out")
			}
		}
	}()

	memBelfore := getAlloc()
	fmt.Printf("Memory used before send intCh: %d KB\n", memBelfore/1024)

	for i := range 10000 {
		// 1万回タイマーを発火させる
		intCh <- i
	}
	close(intCh)

	// 1度目の使用メモリ量の計測
	// time.Afterが作成したタイマーがリークしている
	memAfter := getAlloc()
	fmt.Printf("Memory used after send intCh 1st: %d KB\n", memAfter/1024)

	// 3秒のタイマーが全て発火するのを待つ
	time.Sleep(5 * time.Second)

	// 2度目の使用メモリ量の計測
	// リークしていたタイマーは停止したのでGCに回収される
	memAfter2 := getAlloc()
	fmt.Printf("Memory used after send intCh 2nd: %d KB\n", memAfter2/1024)
}

// getAllocsは現在のヒープ使用量を計測する
func getAlloc() uint64 {
	var m runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&m)
	return m.Alloc
}
$ GOTOOLCHAIN=go1.22.0 go run .
Timer start
Memory used before send intCh: 113 KB
Memory used after send intCh 1st: 2162 KB
Memory used after send intCh 2nd: 208 KB

このように、1度目の計測時点ではタイマーが発火していないのでメモリがリークしている。
2度目の計測ではタイマーが発火するまで待っているのでメモリ使用量が減少している。
タイマー発火までの時間が長い場合には注意が必要である。

上記のような場合、time.Afterを使用せず、作成したTimerを手動でResetして再利用すればループごとにTimerが生成されることがない。

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	fmt.Println("Timer start")

	intCh := make(chan int)

	go func() {
		const timeout = 3 * time.Second
		timer := time.NewTimer(timeout)
		for {
			// timerをリセット
			if !timer.Stop() {
				<-timer.C
			}
			timer.Reset(timeout)

			select {
			case _, ok := <-intCh:
				if !ok {
					return
				}
			case <-timer.C:
				panic("timed out")
			}
		}
	}()

	memBelfore := getAlloc()
	fmt.Printf("Memory used before send intCh: %d KB\n", memBelfore/1024)

	for i := range 10000 {
		// 1万回タイマーを発火させる
		intCh <- i
	}
	close(intCh)

	memAfter := getAlloc()
	fmt.Printf("Memory used after send intCh: %d KB\n", memAfter/1024)
}

intChへのデータ送信前と後でメモリ使用量が大して変わっていないのがわかる。

$ GOTOOLCHAIN=go1.22.2 go run .
Timer start
Memory used before send intCh: 112 KB
Memory used after send intCh: 116 KB

Go1.23以降のtime.TimerがGo1.22からどう変わったか

time.Timer構造体のメソッドと基本的な使い方は変わっていない。
ただ、time.TimerがResetとStopを呼び出した時の内部処理が変わっている。
簡単にいうと、ドレインが不要になったのと、参照されていないTimerがGCの対象になるようになった。

以下は公式解説の機械翻訳

  1. 参照されなくなった停止されていないtimerとtickerは、ガベージ コレクションの対象になります。Go 1.23 より前では、停止されていないtimerは、timerが終了するまでガベージ コレクションの対象にならず、停止されていないtickerはガベージ コレクションの対象にはなりませんでした。Go 1.23 の実装では、t.Stopを使用しないプログラムでのリソース リークを回避します。
  2. timer チャネルは同期的 (バッファなし) になり、t.Resetおよびt.Stop メソッドの保証が強化されました。これらのメソッドのいずれかが返された後、timer チャネルからの将来の受信では、古いtimer設定に対応する古い時間値が観測されることはありません。Go 1.23 より前は、 t.Resetで古い値を回避することは不可能であり、 t.Stopで古い値を回避するには、t.Stopからの戻り値を慎重に使用する必要がありました。Go 1.23 の実装では、この懸念は完全に解消されています。

順に解説する。

ドレインが不要に

timer チャネルは同期的 (バッファなし) になり、t.Resetおよびt.Stop メソッドの保証が強化されました。これらのメソッドのいずれかが返された後、timer チャネルからの将来の受信では、古いtimer設定に対応する古い時間値が観測されることはありません。Go 1.23 より前は、 で古い値を回避することは不可能でありt.Reset、 で古い値を回避するには、t.Stopからの戻り値を慎重に使用する必要がありましたt.Stop。Go 1.23 の実装では、この懸念は完全に解消されています。

なぜ不要になったかというと、公式解説の[2]にある通り、Timer.Cのバッファのサイズが0になり、同期的にデータが送受信されるように内部処理が変更されたからである。

どのような変更がされたか知りたい人向け

実はtime.Timerの内部的な実装において、ドキュメントの記載とは異なり、Timer.Cのチャネルは以前としてバッファを持っている。

// after go1.23

type Timer struct {
	C         <-chan Time
	initTimer bool
}

低レイヤーのruntime/chan.goには、このバッファのサイズを0に見せたり、中身のデータをドレインする処理が定義されており、これらが強制的にバッファ無しChannelのような挙動をさせている。

// timerchandrain removes all elements in channel c's buffer.
// It reports whether any elements were removed.
// Because it is only intended for timers, it does not
// handle waiting senders at all (all timer channels
// use non-blocking sends to fill the buffer).
func timerchandrain(c *hchan) bool {
	// Note: Cannot use empty(c) because we are called
	// while holding c.timer.sendLock, and empty(c) will
	// call c.timer.maybeRunChan, which will deadlock.
	// We are emptying the channel, so we only care about
	// the count, not about potentially filling it up.
	if atomic.Loaduint(&c.qcount) == 0 {
		return false
	}
	lock(&c.lock)
	any := false
	for c.qcount > 0 {
		any = true
		typedmemclr(c.elemtype, chanbuf(c, c.recvx))
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.qcount--
	}
	unlock(&c.lock)
	return any
}

func chanlen(c *hchan) int {
	if c == nil {
		return 0
	}
	async := debug.asynctimerchan.Load() != 0
	if c.timer != nil && async {
		c.timer.maybeRunChan()
	}
	if c.timer != nil && !async {
		// timer channels have a buffered implementation
		// but present to users as unbuffered, so that we can
		// undo sends without users noticing.
		return 0
	}
	return int(c.qcount)
}

etc..

timerchandrain関数ではtimer.Stopとtimer.Resetを呼んだ時に内部的に実行され、チャネルのデータを減らしている。
また、chanlen関数ではコメントにある通り、timer Channelにをバッファ無しChannelに見せかけている。
このような実装がruntime/chan.goにはちらほら見られる。

Stop呼んだらドレインの節のResetのコードをGo1.23で実行してみる。

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	fmt.Println("Timer start")
	// 3秒後に発火するタイマーを生成
	timer := time.NewTimer(3 * time.Second)
	// 4秒待ってタイマーを発火させておく
	time.Sleep(4 * time.Second)

	// 発火済みなのでStopはしない
	timer.Reset(3 * time.Second)

	// 発火済みのタイマーからの通知
	t1 := <-timer.C
	fmt.Printf("Timer fired 1st %v\n", t1)
	// Resetされたタイマーからの通知
	t2 := <-timer.C
	fmt.Printf("Timer fired 2nd %v\n", t2)
}

比較のため、Go1.22の場合とGo1.23の場合の両方の結果を載せる。

Go1.22(再掲)

$ GOTOOLCHAIN=go1.22.0 go run .
Timer start
Timer fired 1st 2025-03-19 16:27:49.553398 +0900 JST m=+3.001178001
Timer fired 2nd 2025-03-19 16:27:53.554472 +0900 JST m=+7.002179710

Go1.23

$ GOTOOLCHAIN=go1.23.0 go run . 
Timer start
Timer fired 1st 2025-03-20 22:20:49.871851042 +0900 JST m=+7.001381668
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        /home/user/main.go:23 +0x118
exit status 2

1.22は相変わらず発火済みのタイマーが残ってしまって2回発火してしまっている。
対して、1.23をよくみると、1度目のタイマーが発火したのが7秒後である(標準出力のm=+7.001381668の部分)。
これはつまり、Sleepの4秒のあとにResetが正常にされて3秒待ち、合計7秒待ったと言うことだ。
その後、main goroutineしか無い状態でChannelを待ち受けたのでデッドロックを検出されてエラーとなってしまったと言うわけだ。

というわけで、Go1.23以降はTimer.Cに発火済みのタイマーが残ってしまうということが起きないことがわかった。
for selectで繰り返しタイマーをリセットするときでもドレインせずに使用できる。

// after go1.23

func someOperation(ch <-chan Data) {
	const limit = time.Second
	timer := time.NewTimer(limit)
	for {
		// ドレインの必要無し
		timer.Reset(limit)
		select {
		case data, ok := <-ch:
			if !ok {
				return
			}
			// exec operation
		case <-timer.C:
			panic("timed out!")
		}
	}
}

このように、Resetはタイマーが発火済みでも未発火でも気にせず呼び出すことができるようになり、タイマーの再利用がとてもしやすくなった。

また、並行処理でtime.Timerを使用するのは要注意の節で、"ドレイン"と"タイマーの受信"を異なるgoroutineでやるとデッドロックする可能性があることを述べたが、この変更のおかげでドレインが不要になったので問題なく並行処理でも使用できる。

公式ドキュメントにもGo1.22以前には記載されていた並行処理に関する注意書きがGo1.23以降消えている。

公式ドキュメント Timer.Reset() 1.22
公式ドキュメント Timer.Reset() 1.23

参照されていないTimerがGCの対象になる

参照されなくなった停止されていないtimerとtickerは、ガベージ コレクションの対象になります。Go 1.23 より前では、停止されていないtimerは、timerが終了するまでガベージ コレクションの対象にならず、停止されていないtickerはガベージ コレクションの対象にはなりませんでした。Go 1.23 の実装では、t.Stopを使用しないプログラムでのリソース リークを回避します。

forループでtime.Afterを使う時は要注意の節で述べたように、Go1.22以前は、停止していないタイマーはガベージコレクションされない。
しかし、上記の機能追加により、forループでtime.Afterを使用しても問題無くなる。

forループでtime.Afterを使う時は要注意のtime.Afterを使用したサンプルコードをGo1.23で実行する。

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	fmt.Println("Timer start")

	intCh := make(chan int)

	go func() {
		for {
			// intChにデータが送信されるたびにselectのcase全てが評価される
			// それによりtime.Afterがループごとに実行され、ループの数だけタイマーも生成される
			select {
			case _, ok := <-intCh:
				// 何らかの処理を実行
				if !ok {
					return
				}
			case <-time.After(3 * time.Second):
				panic("timed out")
			}
		}
	}()

	memBelfore := getAlloc()
	fmt.Printf("Memory used before send intCh: %d KB\n", memBelfore/1024)

	for i := range 10000 {
		// 1万回タイマーを発火させる
		intCh <- i
	}
	close(intCh)

	// 1度目の使用メモリ量の計測
	// time.Afterが作成したタイマーがリークしている
	memAfter := getAlloc()
	fmt.Printf("Memory used after send intCh 1st: %d KB\n", memAfter/1024)

	// 3秒のタイマーが全て発火するのを待つ
	time.Sleep(5 * time.Second)

	// 2度目の使用メモリ量の計測
	// リークしていたタイマーは停止したのでGCに回収される
	memAfter2 := getAlloc()
	fmt.Printf("Memory used after send intCh 2nd: %d KB\n", memAfter2/1024)
}

// getAllocsは現在のヒープ使用量を計測する
func getAlloc() uint64 {
	var m runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&m)
	return m.Alloc
}

比較のため、Go1.22の場合とGo1.23の場合の両方の結果を載せる。

Go1.22(再掲)

$ GOTOOLCHAIN=go1.22.0 go run .
Timer start
Memory used before send intCh: 113 KB
Memory used after send intCh 1st: 2162 KB
Memory used after send intCh 2nd: 208 KB

Go1.23

$ GOTOOLCHAIN=go1.23.0 go run . 
Timer start
Memory used before send intCh: 117 KB
Memory used after send intCh 1st: 128 KB
Memory used after send intCh 2nd: 127 KB

このように、Go1.23の場合は未発火のタイマーが大量に残っている1度目の計測時点で、すでにガベージコレクションが走ってメモリが回収されている。

終わりに

軽い気持ちでtime.Timerの挙動をまとめようと思ってたのだが、思っていた以上に複雑でまとめるのに手間取ってしまった。
ただ、timeパッケージの挙動を追いかけてruntimeパッケージの中を見たが、やはりruntimeパッケージは見ていて楽しい。
今度またじっくりと見てみたい。

ではまた。

Discussion

ログインするとコメントできます