time Package Concurrency — Middle¶
If you arrived here from the Basic page, you know how to call time.NewTimer, time.NewTicker, time.After, time.AfterFunc. You probably also know that time.After allocates, that time.Tick is a leak hazard, and that you should defer ticker.Stop(). The Middle page is the level where we stop treating the time package as a black box and start walking the actual source files.
We will read:
src/time/time.gosrc/time/sleep.gosrc/time/tick.gosrc/runtime/time.go- a few helper functions in
src/runtime/proc.goandsrc/runtime/netpoll.go
The reason this matters: every Go program that touches a deadline, a timeout, an HTTP ReadTimeout, a context.WithDeadline, a select { ... case <-time.After(d): }, a backoff retry — every one of them ends up in the runtime's per-P timer heap. If you don't have a mental model of that heap and its state machine, you will write code that allocates 50,000 timers per second, hold goroutines in Waiting state for hours after they should have died, or worse, see a single slow timer callback freeze the whole P for a millisecond.
The version we target is Go 1.23 with sidenotes on what was different in 1.13/1.14 (global heap → per-P heaps) and 1.22 (Timer/Ticker GC rules) and what shipped in 1.23 (the Reset/Stop semantics overhaul). Source line numbers drift between releases, so when we cite runtime/time.go:413 treat it as a landmark — the function is there even if the line number moved by a dozen.
1. time.Time — wall, ext, loc¶
Open src/time/time.go. The first non-trivial type is Time:
// A Time represents an instant in time with nanosecond precision.
//
// Programs using times should typically store and pass them as values,
// not pointers. That is, time variables and struct fields should be of
// type time.Time, not *time.Time.
type Time struct {
// wall and ext encode the wall time seconds, wall time nanoseconds,
// and optional monotonic clock reading in nanoseconds.
//
// From high to low bit position, wall encodes a 1-bit flag (hasMonotonic),
// a 33-bit seconds field, and a 30-bit wall time nanoseconds field.
// The nanoseconds field is in the range [0, 999999999].
// If the hasMonotonic bit is 0, then the 33-bit field must be zero
// and the full signed 64-bit wall seconds since Jan 1, year 1 is stored in ext.
// If the hasMonotonic bit is 1, then the 33-bit field holds a 33-bit
// unsigned wall seconds since Jan 1, year 1885, and ext holds a
// signed 64-bit monotonic clock reading, nanoseconds since process start.
wall uint64
ext int64
// loc specifies the Location that should be used to
// determine the minute, hour, month, day, and year
// that correspond to this Time.
// The nil location means UTC.
// All UTC times are represented with loc==nil, never loc==&utcLoc.
loc *Location
}
This is one of the densest 24 bytes in the standard library, and reading it slowly pays off.
1.1 Wall encoding¶
wall is 64 bits laid out:
bit 63 : hasMonotonic flag
bits 62..30 : 33-bit unsigned seconds since Jan 1, 1885 (if hasMonotonic)
OR all zero (if not hasMonotonic, with seconds in ext)
bits 29..0 : 30-bit nanoseconds within the current second [0, 1e9)
Why 1885? Because 1885 + 2^33 / (365.25 * 24 * 3600) ≈ 1885 + 272 ≈ 2157. The window covers roughly 1885–2157, which is enough for any practical wall clock that also carries a monotonic reading. Times outside that window (very old historical dates, very far future) fall back to "no monotonic" mode where the full seconds count lives in ext.
1.2 Monotonic vs wall¶
Every time.Now() reading carries two clocks:
- Wall clock — seconds + nanoseconds since the Unix epoch. Can jump forward or backward due to NTP, manual clock changes, leap seconds, suspend.
- Monotonic clock — nanoseconds since some arbitrary process-relevant origin (boot, process start, depending on platform). Strictly non-decreasing.
Most time arithmetic you actually want — "did 5 seconds elapse?" — must use the monotonic clock. The trick time.Time plays is that a single value can carry both and the package routes the right one to the right operation.
Look at time.Since:
// Since returns the time elapsed since t.
// It is shorthand for time.Now().Sub(t).
func Since(t Time) Duration {
if t.wall&hasMonotonic != 0 {
return subMono(runtimeNano()-startNano, t.ext)
}
return Now().Sub(t)
}
If t carries a monotonic reading (hasMonotonic bit set), Since does a direct subtraction of monotonic nanoseconds — no wall clock involved, immune to NTP. If t was stripped of its monotonic bit (e.g. round-tripped through JSON, or t.Round(0) was called), it falls back to wall subtraction.
1.3 Round(0) strips monotonic¶
// Round returns the result of rounding t to the nearest multiple of d
// (since the zero time).
// Round returns a copy of t stripped of any monotonic clock reading
// but otherwise unchanged.
func (t Time) Round(d Duration) Time {
t.stripMono()
...
}
Calling t.Round(0) is the documented way to drop the monotonic part. This matters when serializing — JSON MarshalJSON and gob don't carry monotonic, so the value you read back is wall-only. If you then Sub it from a fresh time.Now(), you are doing wall-clock subtraction. Set a system clock back and you can get negative durations.
1.4 loc *Location¶
The Location pointer is shared. time.UTC is a sentinel — but the actual field stored is nil to mean UTC, so a Time{} zero value is "Jan 1, year 1, 00:00:00 UTC" with no monotonic reading. The Location itself contains the zone abbreviation table, transitions, and the tzdata bytes. Multiple Time values share the same *Location, so the cost is one pointer per value plus a shared zone database.
2. time.Now() — vDSO, monotonic, linkname¶
Now is the entry point everything else builds on. Its body in src/time/time.go:
// Now returns the current local time.
func Now() Time {
mono := runtimeNano() - startNano
sec, nsec, mono := now_monotonic(mono) // pseudocode; actual implementation in runtime
if mono == 0 {
return Time{uint64(nsec), sec + unixToInternal, Local}
}
sec -= unixToInternal
return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local}
}
In the actual source you will find:
func Now() Time {
mono := runtimeNano() - startNano
sec, nsec := now()
mono += startNano
sec += unixToInternal - minWall
if uint64(sec)>>33 != 0 {
// Wall seconds out of range; fall back to ext-only.
return Time{uint64(nsec), sec + minWall, Local}
}
return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local}
}
Two //go:linkname directives bring functions in from the runtime:
// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)
//go:linkname runtimeNano runtime.nanotime
func runtimeNano() int64
var startNano int64 = runtimeNano() - 1
runtime.nanotime is the monotonic clock. On Linux/amd64 it goes through the vDSO (virtual dynamic shared object) — a memory-mapped page provided by the kernel that lets userspace read CLOCK_MONOTONIC without a syscall. On a modern box that's around 15–25 ns per call. On systems without vDSO (some container sandboxes, some BSDs), nanotime is a real syscall and costs hundreds of nanoseconds.
runtime.now in src/runtime/timestub.go (or the platform-specific time_<os>.go) returns wall seconds, wall nanoseconds, and monotonic nanoseconds together — one trip into the runtime, one vDSO read on Linux.
2.1 Why startNano - 1¶
startNano is initialized to runtime.nanotime() - 1. The "-1" prevents the monotonic reading stored in a time.Time from ever being zero, which is the sentinel value used to mean "no monotonic reading present." A Time{} zero value has ext == 0, which is interpreted as no-monotonic when paired with hasMonotonic == 0. Subtracting 1 ensures any real monotonic reading is at least 1, never colliding with the sentinel.
2.2 Cost of time.Now()¶
Run:
Typical results on Linux/amd64:
Zero allocations — Time is a value, not a pointer. ~30 ns per call. If you call time.Now() in a hot loop millions of times per second the CPU cost shows up. Some HFT-style codebases cache nanosecond timestamps in a per-CPU variable; for normal code, time.Now() is essentially free.
3. time.Sleep(d Duration) — gopark with a deadline¶
In src/time/sleep.go:
// Sleep pauses the current goroutine for at least the duration d.
// A negative or zero duration causes Sleep to return immediately.
func Sleep(d Duration)
The body is implemented in the runtime; the time package just has the declaration. The implementation lives in src/runtime/time.go:
// timeSleep puts the current goroutine to sleep for at least ns nanoseconds.
//
//go:linkname timeSleep time.Sleep
func timeSleep(ns int64) {
if ns <= 0 {
return
}
gp := getg()
t := gp.timer
if t == nil {
t = new(timer)
gp.timer = t
}
t.f = goroutineReady
t.arg = gp
t.nextwhen = nanotime() + ns
if t.nextwhen < 0 { // check for overflow
t.nextwhen = maxWhen
}
gopark(resetForSleep, unsafe.Pointer(t), waitReasonSleep, traceBlockSleep, 1)
}
Walk this:
gp := getg()— current goroutine.t := gp.timer— eachghas a cached*timerfield for reuse across sleeps. First call allocates; subsequent calls reuse.t.f = goroutineReady— the function to call when the timer fires.t.arg = gp— the goroutine to wake.t.nextwhen = nanotime() + ns— absolute deadline on the monotonic clock.gopark(...)— park the goroutine. The first argument is a function the runtime will call fromm0/scheduler context to actually start the timer.
The resetForSleep callback is:
func resetForSleep(gp *g, ut unsafe.Pointer) bool {
t := (*timer)(ut)
resetTimer(t, t.nextwhen)
return true
}
It calls resetTimer, which inserts the timer into the current P's heap.
When the deadline arrives, runtime.runtimer (described below) finds this timer at the heap root, removes it, and calls goroutineReady(t.arg). That function calls goready(gp, 0) which schedules gp back into runnable state.
3.1 "At least" the duration¶
The docstring says "pauses ... for at least the duration d." Not exactly. The guarantee is one-sided: you will not wake before nanotime() + ns, but you may wake later. How much later depends on:
- How quickly the per-P timer heap's earliest timer is checked. The scheduler checks on every
findRunnablecall andsysmonpolls timers every 10ms in the worst case. - Whether another goroutine on the same P is hogging the CPU. Go is cooperative-preemptive; up to Go 1.14 a long compute loop could delay timer firing by 10ms+. Async preemption since 1.14 reduces this.
- OS scheduler latency. If all your Ms are runnable and the OS hasn't given you a CPU back, your goroutine waits.
For sleeps under ~50 microseconds you should not use time.Sleep. The scheduler overhead and notetsleepg minimum latency means a 1us sleep often becomes 50us+ in practice. Use a spin loop or runtime.Gosched.
4. time.NewTimer(d) — *Timer + chan Time + startTimer¶
// 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)
t := &Timer{
C: c,
r: runtimeTimer{
when: when(d),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}
Things to notice:
-
Allocations: one for the
*Timerstruct, one for thechan Time(a buffered channel of capacity 1 — itshchanis ~96 bytes plus the element buffer). So roughly two heap allocations perNewTimercall. Cheap if amortized, expensive in a hot loop. -
runtimeTimeris atimepackage mirror of the runtime's internaltimerstruct. Field layout must match exactly because the runtime casts throughunsafe. -
when(d)computes the absolute deadline:
func when(d Duration) int64 {
if d <= 0 {
return runtimeNano()
}
t := runtimeNano() + int64(d)
if t < 0 { // overflow
// The "maxWhen" sentinel — far enough in the future to never fire.
t = 1<<63 - 1
}
return t
}
sendTimeis the firing callback:
func sendTime(c any, seq uintptr) {
// Non-blocking send of current time on c.
// c cannot be nil since startTimer rejected nil.
select {
case c.(chan Time) <- Now():
default:
}
}
Note the non-blocking send. If nobody is reading the channel and its buffer is full, the send is dropped. That's correct for time.After-style usage where you only care about the next tick; it's a footgun for tickers that you stopped reading from.
startTimer(&t.r)is a linkname into the runtime:
// Implemented in runtime.
func startTimer(*runtimeTimer)
func stopTimer(*runtimeTimer) bool
func resetTimer(*runtimeTimer, int64) bool
func modTimer(*runtimeTimer, int64, int64, func(any, uintptr), any, uintptr)
These four functions are the entire surface area time uses against the runtime for timer management.
5. (*Timer).Stop(), (*Timer).Reset(d), and the Go 1.23 reset¶
// Stop prevents the Timer from firing.
// It returns true if the call stops the timer, false if the timer has
// already expired or been stopped.
func (t *Timer) Stop() bool {
if t.r.f == nil {
panic("time: Stop called on uninitialized Timer")
}
return stopTimer(&t.r)
}
stopTimer returns true if the timer was still pending (and we successfully removed it before firing), false if it had already fired or had already been stopped.
5.1 Pre-Go 1.23 Reset semantics¶
// Reset changes the timer to expire after duration d.
// It returns true if the timer had been active, false if the timer had
// expired or been stopped.
//
// For a Timer created with NewTimer, Reset should be invoked only on
// stopped or expired timers with drained channels. For example, assuming
// the program has not received from t.C already:
//
// if !t.Stop() {
// <-t.C
// }
// t.Reset(d)
//
// This should not be done concurrent to other receives from the Timer's channel.
The documented dance: Stop, drain if needed, Reset. The reason: a Timer's channel has buffer 1. If the timer fired between your Stop call and your Reset call, a value sits in the channel. If you Reset without draining, your next receive returns the stale time, not the new one.
This was a classic race source. Consider:
t := time.NewTimer(d)
for {
select {
case <-t.C:
// do work
case <-someEvent:
if !t.Stop() {
<-t.C // can deadlock if t.C was already drained
}
t.Reset(d)
}
}
If the goroutine racing with the Timer's fire happens to lose, you can hit the documented panic-or-block path. Many bugs around this were filed.
5.2 Go 1.23 Reset semantics¶
Go 1.23 changed the rules:
As of Go 1.23, the channel of a Timer or Ticker is unbuffered (capacity 0), so that
StopplusResetno longer requires draining the channel. Additionally, Timers and Tickers are no longer recycled — once you stop them, the runtime garbage-collects them as soon as no goroutine holds a reference, even if the channel was never received from.
Concretely, after 1.23:
// Stop semantics: stops without needing to drain.
t := time.NewTimer(d)
if !t.Stop() {
// No drain needed. Channel is unbuffered; if a send was in
// progress, the new model handles it.
}
t.Reset(d)
And the GC change matters: previously, a Timer whose C you never read but whose Stop you never called would be kept alive by the runtime's timer heap until it fired. After 1.23, if the Timer becomes unreachable from user code, it can be removed from the heap and collected.
The implementation lives in commits around runtime/time.go adding new timer states and a finalizer-like cleanup. We won't reproduce the whole diff here, but the key point for code review: on Go 1.23+ you can stop caring about the drain-after-Stop dance, on earlier versions you must still do it.
5.3 Stop interaction with AfterFunc¶
For a Timer created with AfterFunc:
stopped == true means fn definitely will not run (we removed the timer before the runtime got to it). stopped == false means either fn already ran or it's running concurrently right now. You cannot tell the difference without your own synchronization (a sync.Once, an atomic flag in fn).
6. time.After(d) — the hot-loop allocator¶
// After waits for the duration to elapse and then sends the current time
// on the returned channel.
// It is equivalent to NewTimer(d).C.
//
// The underlying Timer is not recovered by the garbage collector until the
// timer fires. If efficiency is a concern, use NewTimer instead and call
// Timer.Stop if the timer is no longer needed.
func After(d Duration) <-chan Time {
return NewTimer(d).C
}
It's a one-liner. The problem is what disappears: the returned <-chan Time gives you no handle on the Timer. You cannot Stop it. The Timer lives in the runtime heap until it fires.
The pre-1.23 GC problem made this worse: each time.After(d) call in a hot select loop allocated a Timer that stayed alive for d. If you had:
every iteration that received from ch allocated a fresh 1-second Timer that then sat in the runtime heap for a full second. At 100,000 msgs/sec on ch, you accumulate 100,000 dead-walking timers in the heap, all scheduled to fire within a 1-second window. Heap operations become O(log n) where n is the backlog. GC sees the channels and Timers in the live set. Memory grows.
The idiom for pre-1.23 code:
t := time.NewTimer(1 * time.Second)
defer t.Stop()
for {
if !t.Stop() {
select { case <-t.C: default: } // drain
}
t.Reset(1 * time.Second)
select {
case msg := <-ch:
handle(msg)
case <-t.C:
return
}
}
Ugly but correct. On Go 1.23+, time.After is much closer to safe — the unused Timer can be collected — but the channel allocation per call still costs you. Use NewTimer + Reset for hot loops regardless.
7. time.AfterFunc(d, f) — function-firing Timer¶
// AfterFunc waits for the duration to elapse and then calls f
// in its own goroutine. It returns a Timer that can
// be used to cancel the call using its Stop method.
func AfterFunc(d Duration, f func()) *Timer {
t := &Timer{
r: runtimeTimer{
when: when(d),
f: goFunc,
arg: f,
},
}
startTimer(&t.r)
return t
}
func goFunc(arg any, seq uintptr) {
go arg.(func())()
}
goFunc spawns a fresh goroutine for f each time the Timer fires. That implies:
- Even a cheap
fcosts you a goroutine startup (~1us, ~2KB stack). fruns concurrently with the goroutine that calledStop. If you want to know whetherfran, the only safe approach is to makefitself record that.fdoes not block the runtime's timer routine. The timer routine just doesgo f()and continues. This matters: a slowfdoes not delay subsequent timer firings on the same P. But it does consume a runtime goroutine slot.
Reset on an AfterFunc Timer is safe — it doesn't have a channel, so no draining issues. The semantics are: if the timer hadn't fired yet, reschedule it; if it had fired, schedule a fresh firing.
A pattern with AfterFunc that's worth knowing — single-shot watchdog:
func WithTimeout(parent context.Context, d time.Duration, fn func() error) error {
done := make(chan error, 1)
timer := time.AfterFunc(d, func() {
done <- context.DeadlineExceeded
})
defer timer.Stop()
go func() { done <- fn() }()
return <-done
}
(The standard library's context.WithTimeout uses essentially this pattern under the hood, with extra propagation rules.)
8. time.NewTicker(d) — re-arming Timer¶
// NewTicker returns a new Ticker containing a channel that will send
// the current time on the channel after each tick. The period of the
// ticks is specified by the duration argument.
// The ticker will adjust the time interval or drop ticks to make up for
// slow receivers.
// The duration d must be greater than zero; if not, NewTicker will panic.
// Stop the ticker to release associated resources.
func NewTicker(d Duration) *Ticker {
if d <= 0 {
panic(errors.New("non-positive interval for NewTicker"))
}
c := make(chan Time, 1)
t := &Ticker{
C: c,
r: runtimeTimer{
when: when(d),
period: int64(d),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}
The new field is period. When the runtime fires a timer with period != 0, it does not remove it from the heap; instead it advances when += period and sifts it back into position. That's the "re-arming" behavior. From the runtime's view, a Ticker is a single timer; from the user's view, it delivers an unbounded stream.
The "drop ticks to make up for slow receivers" line in the comment matters. sendTime does a non-blocking send. If the receiver is busy and the channel's buffer is full (capacity 1), the tick is dropped. The next tick fires period later regardless. You can't tell from the channel how many ticks you missed.
8.1 Ticker drift¶
A common worry: does a Ticker drift over time? Look at the firing logic. When the runtime fires a periodic timer, it computes:
So when is incremented by exactly period regardless of how late the actual firing was. That means the next firing is scheduled relative to the ideal time, not the actual fire time. Over many ticks, the average period is exactly d. Individual ticks may be late, but they don't accumulate drift.
What can happen: if the runtime fell behind by more than one period (rare, usually means GC pause or scheduler starvation), it skips ahead:
if t.when < now {
// We're behind. Catch up by computing how many periods to skip.
delta := now - t.when
t.when += (delta/t.period + 1) * t.period
}
The skipped ticks are simply lost. From your goroutine's perspective, your select-case received fewer ticks than you expected if you count by wall time, but the next tick will be in the right place modulo period.
9. (*Ticker).Stop() — the famous leak¶
// Stop turns off a ticker. After Stop, no more ticks will be sent.
// Stop does not close the channel, to prevent a read from the channel
// succeeding incorrectly.
func (t *Ticker) Stop() {
stopTimer(&t.r)
}
Note "Stop does not close the channel." If you write:
then ticker.Stop() will not terminate that goroutine. The for t := range ticker.C waits forever for the next send, which never comes. You leak the goroutine.
The correct pattern:
ticker := time.NewTicker(1 * time.Second)
done := make(chan struct{})
go func() {
for {
select {
case t := <-ticker.C:
handle(t)
case <-done:
ticker.Stop()
return
}
}
}()
// later:
close(done)
Or, equivalently, use a context.
9.1 Why not close the channel?¶
The comment says "to prevent a read from the channel succeeding incorrectly." A closed channel reads zero values forever. If we closed t.C, every subsequent receive would return Time{} immediately, and code like select { case <-ticker.C: doX(); ... } would spin-loop calling doX. Better to never close and force users to write explicit termination logic.
10. time.Tick(d) — the deliberate footgun¶
// Tick is a convenience wrapper for NewTicker providing access to the ticking
// channel only. While Tick is useful for clients that have no need to shut
// down the Ticker, be aware that without a way to shut it down the
// underlying Ticker cannot be recovered by the garbage collector; it "leaks".
// Unlike NewTicker, Tick will return nil if d <= 0.
func Tick(d Duration) <-chan Time {
if d <= 0 {
return nil
}
return NewTicker(d).C
}
The function exists and is documented as leaky. Returns the channel without the Ticker handle, so the user cannot call Stop. The Ticker stays in the runtime heap forever. If you call time.Tick once per long-lived service instance, fine. If you call it inside a request handler, you have a leak that scales with traffic.
The reason to ever use it: top-level program patterns like:
The Ticker outlives the program; never needs Stop. For any code that might be called more than once, prefer NewTicker and defer Stop().
11. Per-P timer heap¶
Now we cross into src/runtime/time.go. Open src/runtime/runtime2.go and find the p struct (the runtime representation of a GOMAXPROCS slot):
type p struct {
...
// The when field of the first entry on the timer heap.
// This is 0 if the timer heap is empty.
timer0When atomic.Int64
// The earliest known nextwhen field of a timer with
// timerModifiedEarlier status. Because the timer may have been
// modified again, there need not be any timer with this value.
// This is 0 if there are no timerModifiedEarlier timers.
timerModifiedEarliest atomic.Int64
// Per-P timer heap.
timers []*timer
// Number of timers in P's heap.
numTimers atomic.Uint32
// Number of timerDeleted timers in P's heap.
deletedTimers atomic.Uint32
// Race context used while executing timer functions.
timerRaceCtx uintptr
...
}
A timer is:
type timer struct {
pp puintptr // p that holds this timer; nil if no p has it yet
when int64 // when timer fires, in monotonic ns
period int64 // 0 = one-shot; >0 = period of repeating timer
f func(any, uintptr) // function to call
arg any // argument to f
seq uintptr // sequence for sendTime equality
nextwhen int64 // when to fire on next reset
status atomic.Uint32 // timerXxxx state
}
Several things to note:
-
No global lock. Each P has its own heap. Operations on a timer go through atomic CAS on
status, plus possibly a lock on the owning P (for heap modifications). -
A timer remembers which P owns it via
pp. When a goroutine moves to a different P, the timers it created stay with the original P; they fire there, and the firing function (sendTime,goFunc, etc.) handles cross-P delivery (channel send, goroutine spawn). -
numTimersanddeletedTimersare atomic counters. The runtime periodically compacts the heap ifdeletedTimers / numTimersgets above a threshold (roughly 1/4 of the heap is deleted entries).
11.1 Layout of the heap¶
Standard 4-ary min-heap rooted at index 0, ordered by when. The 4-ary (not binary) variant trades slightly more comparisons during siftdown for half the depth, which reduces cache misses. Look at siftupTimer and siftdownTimer in runtime/time.go:
func siftupTimer(t []*timer, i int) int {
if i >= len(t) {
badTimer()
}
when := t[i].when
if when <= 0 {
badTimer()
}
tmp := t[i]
for i > 0 {
p := (i - 1) / 4 // parent
if when >= t[p].when {
break
}
t[i] = t[p]
i = p
}
if tmp != t[i] {
t[i] = tmp
}
return i
}
Divide by 4 to find the parent. Children of node i are at 4i+1, 4i+2, 4i+3, 4i+4.
func siftdownTimer(t []*timer, i int) {
n := len(t)
if i >= n {
badTimer()
}
when := t[i].when
if when <= 0 {
badTimer()
}
tmp := t[i]
for {
c := i*4 + 1 // first child
c3 := c + 2 // third child
if c >= n {
break
}
w := t[c].when
if c+1 < n && t[c+1].when < w {
w = t[c+1].when
c++
}
if c3 < n {
w3 := t[c3].when
if c3+1 < n && t[c3+1].when < w3 {
w3 = t[c3+1].when
c3++
}
if w3 < w {
w = w3
c = c3
}
}
if w >= when {
break
}
t[i] = t[c]
i = c
}
if tmp != t[i] {
t[i] = tmp
}
}
Standard heap restoration with 4-way fanout.
12. Timer status state machine¶
The full set of states in src/runtime/time.go:
const (
// timerNoStatus - timer has no status set yet
timerNoStatus = iota
// timerWaiting - timer is in P's heap, waiting to fire
timerWaiting
// timerRunning - timer is being run, possibly running its f
timerRunning
// timerDeleted - timer is in heap but marked for removal
timerDeleted
// timerRemoving - timer is being removed from heap
timerRemoving
// timerRemoved - timer has been removed from heap
timerRemoved
// timerModifying - timer is being modified
timerModifying
// timerModifiedEarlier - timer modified to an earlier time, in heap at old position
timerModifiedEarlier
// timerModifiedLater - timer modified to a later time, in heap at old position
timerModifiedLater
// timerMoving - timer is being moved between heaps (P shutdown)
timerMoving
)
Why so many states? Because we want lock-free fast paths.
When a Timer's owner calls Reset from a different P than the timer's home P, we don't want to take the home P's heap lock. Instead:
- CAS
statusfromtimerWaitingtotimerModifying. - Write the new
nextwheninto the timer. - CAS
statusto eithertimerModifiedEarlierortimerModifiedLater.
The timer is still in the heap at its old position. Next time the owning P runs cleantimers or adjusttimers, it will see the timerModified* status and re-sift the timer into position.
For timerModifiedEarlier, the runtime also updates the pp.timerModifiedEarliest field with an atomic min: this is the wake-up hint that lets the scheduler know to potentially wake earlier than timer0When suggests.
12.1 State transitions diagram¶
A simplified transition map:
timerNoStatus ─addtimer→ timerWaiting
timerWaiting ─runtimer→ timerRunning ─done→ timerRemoved (one-shot)
timerWaiting ─runtimer→ timerRunning ─done→ timerWaiting (periodic)
timerWaiting ─deltimer→ timerDeleted
timerDeleted ─cleantimers→ timerRemoving → timerRemoved
timerWaiting ─modtimer→ timerModifying → timerModifiedEarlier
→ timerModifiedLater
timerModifiedEarlier ─adjusttimers→ timerWaiting (heap repaired)
timerModifiedLater ─adjusttimers→ timerWaiting (heap repaired)
timerWaiting ─moveTimers→ timerMoving → timerWaiting (on new P)
Real source has more edge cases (interaction with timerRunning during modification, etc.) — read func deltimer(t *timer) bool and func modtimer(t *timer, when, period int64, f func(any, uintptr), arg any, seq uintptr) bool for the gory details.
13. Why per-P heaps — the Go 1.14 change¶
Before Go 1.14, the runtime had a global timer heap with a global lock. That mattered because every time.After, every HTTP timeout, every context.WithDeadline poked at that lock. Under high concurrency, lock contention on timer operations would saturate.
The Go 1.14 release notes:
The runtime now uses a per-P timer heap, reducing lock contention for applications that use many timers.
Implementation sketch:
runtime.startTimercallsaddtimer(t)which acquires the current P's timer lock and inserts into that P's heap. The timer is bound to the P that started it.- The scheduler (
findRunnable) on each P checks its own heap. If the next timer is due, fire it locally. - Cross-P operations (modtimer called from a different goroutine that happens to be running on a different P) use the lock-free
timerModifyingstate machine described above.
The benefit is dramatic. Look at a microbenchmark like BenchmarkStartStop1000 in runtime/time_test.go — pre-1.14 throughput was limited by the global lock; post-1.14 it scales nearly linearly with GOMAXPROCS.
The cost: more code complexity, the state machine above, the pp.timerModifiedEarliest hint, and the adjusttimers/cleantimers periodic maintenance. The runtime has to balance "how often to call adjusttimers" against the cost of stale timerModified* entries delaying correct firing.
14. addtimer, deltimer, modtimer, cleantimers¶
The four core operations:
14.1 addtimer¶
// addtimer adds a timer to the current P.
// This should only be called with a newly created timer.
// That avoids the risk of changing the when field of a timer in some P's heap,
// which could cause the heap to become unsorted.
func addtimer(t *timer) {
if t.when <= 0 {
throw("addtimer called with non-positive when")
}
if t.when >= maxWhen {
t.when = maxWhen
}
if t.status.Load() != timerNoStatus {
throw("addtimer called with initialized timer")
}
t.status.Store(timerWaiting)
when := t.when
pp := getg().m.p.ptr()
lock(&pp.timersLock)
cleantimers(pp)
doaddtimer(pp, t)
unlock(&pp.timersLock)
wakeNetPoller(when)
}
Notice the call to wakeNetPoller(when). The netpoller might be sleeping in an epoll_wait(timeout) with a longer timeout than our new timer's deadline. We need to wake it up so it can re-arm with a shorter timeout.
14.2 deltimer¶
// deltimer deletes the timer t. It may be on some other P, so we can't
// actually remove it from the timers heap. We can only mark it as deleted.
// It will be removed in due course by the P whose heap it is on.
// Reports whether the timer was removed before it was run.
func deltimer(t *timer) bool {
for {
switch s := t.status.Load(); s {
case timerWaiting, timerModifiedLater:
// Prevent preemption while the timer is in timerModifying.
// This could lead to a self-deadlock. See #38070.
mp := acquirem()
if t.status.CompareAndSwap(s, timerModifying) {
tpp := t.pp.ptr()
if !t.status.CompareAndSwap(timerModifying, timerDeleted) {
badTimer()
}
releasem(mp)
tpp.deletedTimers.Add(1)
return true
}
releasem(mp)
case timerModifiedEarlier:
...
case timerDeleted, timerRemoving, timerRemoved:
return false
case timerRunning, timerMoving:
// The timer is being run. Wait until it's done.
osyield()
case timerNoStatus:
return false
default:
badTimer()
}
}
}
Cross-P delete is the canonical lock-free CAS loop:
- Read status.
- Decide what to do based on status.
- CAS to the next state.
- If CAS fails, loop and retry.
The acquirem/releasem prevents preemption while we're in the brief timerModifying state. If the goroutine got preempted there, another P trying to operate on the same timer would spin forever waiting for timerModifying to leave.
14.3 modtimer¶
modtimer covers both Reset (same f and arg, new when) and the more general "change everything" case used internally. It's a longer function; the key path is the same CAS loop pattern as deltimer: read status, acquire M (prevent preemption), CAS to timerModifying, mutate fields, CAS to the right post-state (timerWaiting if newly added, timerModifiedEarlier/timerModifiedLater if already in heap), release M.
If the new when is earlier than the existing one, the post-state is timerModifiedEarlier and the function calls wakeNetPoller(when) so any sleeping M can re-arm. If later, timerModifiedLater — no wakeup needed since the existing deadline is sooner anyway.
This is a great example of lock-free state-machine code with retries.
14.4 cleantimers¶
// cleantimers cleans up the head of the timer queue. This speeds up
// programs that create and delete timers; leaving them in the heap
// slows down adjusttimers. We don't need to do this if there are
// no timers in deleted state. Reports whether no timer problems were
// found. The caller must have locked the timers for pp.
func cleantimers(pp *p) {
gp := getg()
for {
if len(pp.timers) == 0 {
return
}
t := pp.timers[0]
if t.pp.ptr() != pp {
throw("cleantimers: bad p")
}
switch s := t.status.Load(); s {
case timerDeleted:
if !t.status.CompareAndSwap(s, timerRemoving) {
continue
}
dodeltimer0(pp)
if !t.status.CompareAndSwap(timerRemoving, timerRemoved) {
badTimer()
}
pp.deletedTimers.Add(-1)
case timerModifiedEarlier, timerModifiedLater:
if !t.status.CompareAndSwap(s, timerMoving) {
continue
}
t.when = t.nextwhen
dodeltimer0(pp)
doaddtimer(pp, t)
if !t.status.CompareAndSwap(timerMoving, timerWaiting) {
badTimer()
}
default:
// Head of timers does not need adjustment.
return
}
}
}
cleantimers opportunistically processes the heap head: if it's a deleted or modified timer, fix it up. The function bounds its work — once it finds a clean head, it stops. The heavier function adjusttimers walks the whole heap and is called less frequently.
15. runtime.runtimer — the firing function¶
// runtimer examines the first timer in timers. If it is ready based on now,
// it runs the timer and removes or updates it.
// Returns 0 if it ran a timer, -1 if there are no more timers, or the time
// when the first timer should run.
// The caller must have locked the timers for pp.
func runtimer(pp *p, now int64) int64 {
for {
t := pp.timers[0]
if t.pp.ptr() != pp {
throw("runtimer: bad p")
}
switch s := t.status.Load(); s {
case timerWaiting:
if t.when > now {
// Not ready to run.
return t.when
}
if !t.status.CompareAndSwap(s, timerRunning) {
continue
}
runOneTimer(pp, t, now)
return 0
case timerDeleted:
if !t.status.CompareAndSwap(s, timerRemoving) {
continue
}
dodeltimer0(pp)
if !t.status.CompareAndSwap(timerRemoving, timerRemoved) {
badTimer()
}
pp.deletedTimers.Add(-1)
if len(pp.timers) == 0 {
return -1
}
case timerModifiedEarlier, timerModifiedLater:
if !t.status.CompareAndSwap(s, timerMoving) {
continue
}
t.when = t.nextwhen
dodeltimer0(pp)
doaddtimer(pp, t)
if !t.status.CompareAndSwap(timerMoving, timerWaiting) {
badTimer()
}
case timerModifying:
osyield()
case timerNoStatus, timerRemoved:
badTimer()
case timerRunning, timerRemoving, timerMoving:
badTimer()
default:
badTimer()
}
}
}
And runOneTimer:
func runOneTimer(pp *p, t *timer, now int64) {
if t.period > 0 {
// Leave timer in the heap; update when, sift down.
delta := t.when - now
t.when += t.period * (1 + -delta/t.period)
siftdownTimer(pp.timers, 0)
if !t.status.CompareAndSwap(timerRunning, timerWaiting) {
badTimer()
}
updateTimer0When(pp)
} else {
// One-shot. Remove from heap, mark as removed.
dodeltimer0(pp)
if !t.status.CompareAndSwap(timerRunning, timerRemoved) {
badTimer()
}
}
// Run the timer's function. Unlock first since f might block.
f := t.f
arg := t.arg
seq := t.seq
unlock(&pp.timersLock)
if raceenabled {
...
}
f(arg, seq)
lock(&pp.timersLock)
}
Two important details:
-
Periodic timers:
t.when += t.period * (1 + -delta/t.period)handles the catch-up case. If we're 5 periods behind, we advance 5 periods and skip those firings. We don't fire 5 times. -
Lock release around
f(...): ThetimersLockis dropped before runningf. That's mandatory —fmay besendTime, which does a non-blocking send to a channel; the send is non-blocking so it can't deadlock, but iffweregoFunccalling user code that grabs locks, holdingtimersLockwould create lock-order issues.
The function caller is checkTimers in runtime/proc.go:
// checkTimers runs any timers for the P that are ready.
// If now is not 0 it is the current time.
// It returns the passed time or the current time if now was passed as 0.
// and the time when the next timer should run or 0 if there is no next timer,
// and reports whether any timers were run but not removed because they
// don't need to be re-run.
func checkTimers(pp *p, now int64) (rnow, pollUntil int64, ran bool) {
if int64(pp.timer0When.Load()) == 0 && int64(pp.timerModifiedEarliest.Load()) == 0 {
return now, 0, false
}
if now == 0 {
now = nanotime()
}
// ... lock and run timers ...
lock(&pp.timersLock)
if len(pp.timers) > 0 {
adjusttimers(pp, now)
for len(pp.timers) > 0 {
if tw := runtimer(pp, now); tw != 0 {
if tw > 0 {
pollUntil = tw
}
break
}
ran = true
}
}
// ... compact heap if too many deletes ...
unlock(&pp.timersLock)
return now, pollUntil, ran
}
checkTimers is called from findRunnable (the scheduler's main loop) every time it looks for work. It is also called from sysmon, the monitor thread that runs without a P attached, to ensure timers fire even when no P is actively running.
16. Firing — channel send vs goroutine spawn¶
Two callbacks dominate:
16.1 sendTime (Timer, Ticker)¶
func sendTime(c any, seq uintptr) {
// Non-blocking send. If buffer full, drop.
select {
case c.(chan Time) <- Now():
default:
}
}
The non-blocking semantics are essential. The runtime can't afford to block in runOneTimer — that would freeze the timer heap. If a receiver is slow, we drop the tick. For time.After-style one-shot use this is fine; the channel has buffer 1 and there's only one send, so it always succeeds. For Tickers it means slow consumers see ticks dropped.
16.2 goFunc (AfterFunc)¶
Spawns a goroutine. The runtime returns immediately. The user code runs on its own G, with the standard scheduler treatment.
Note that go f() itself isn't free — it allocates a g (~5KB amortized with stack reuse), a stack, and enqueues onto the local run queue. For high-frequency AfterFunc usage on hot paths you may want to pool goroutines yourself and use AfterFunc to signal a worker via a channel.
17. Sleeping the M — notetsleepg + pollUntil¶
When all goroutines on a P are blocked and there's no work, the M (OS thread) goes to sleep. How long? It needs to wake up by the next timer's fire time. The mechanism:
In runtime/proc.go, findRunnable ends with something like:
// We have nothing to do. If we're holding the P (after a thread-blocking syscall),
// release it. Then go to sleep.
pollUntil := nextTimerDeadline(pp)
mPark(pollUntil)
mPark calls into notetsleepg (or notesleep for blocking) with a deadline. The actual sleep uses futex on Linux (or WaitForSingleObject on Windows, etc.).
There's a coupling with the netpoller. The runtime wants to wake on either:
- A timer expiring (
pollUntildeadline). - A network event (epoll/kqueue/IOCP delivers a wakeup).
The netpoller is consulted by findRunnable with the deadline:
netpoll(deadline) calls epoll_wait with a timeout of deadline - now. When epoll_wait returns (either due to event or timeout), the function returns any ready Gs. The scheduler then re-runs checkTimers and either fires the expired timer or returns to user code.
If a timer is added concurrently from another P (addtimer calls wakeNetPoller(when)), the netpoller's sleeping M needs to wake to re-arm. wakeNetPoller writes a byte to the netpoll pipe (Linux) or posts an IOCP completion (Windows), causing epoll_wait to return early.
18. for range time.Tick antipattern¶
// BAD: leaks the ticker if the loop ever exits.
func processWithTicker(stop <-chan struct{}) {
for now := range time.Tick(time.Second) {
if shouldStop() {
return
}
doWork(now)
}
}
Several problems:
time.Tickreturns a channel with no handle. Cannot Stop. The Ticker sits in the runtime heap re-arming itself forever.- The loop has no
selectwithstop. Only way out is theifinside, which means you wait up to one full period before noticing. - If the goroutine exits via
return, the Ticker keeps firing into a channel that nobody is reading.sendTime's non-blocking send means no goroutine leak from the runtime side — but the Ticker itself leaks forever.
The correct version:
func processWithTicker(stop <-chan struct{}) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case now := <-ticker.C:
doWork(now)
case <-stop:
return
}
}
}
defer ticker.Stop() ensures the runtime heap entry is removed on function exit.
18.1 Detection¶
go vet does not catch this; it requires data flow analysis it doesn't do. You catch it through:
- Code review (look for
time.Tickoutside ofmain-level loops). - A linter rule (some custom analyzers exist).
- Profiling: a service that leaks tickers will eventually show many timers in
runtime.timers(visible viaruntime.NumGoroutineif AfterFunc, or via runtime heap inspection).
19. Reading the source — landmarks¶
If you open src/runtime/time.go and try to read it cover to cover, you will drown. Better strategy: target specific functions in order of importance.
19.1 Recommended reading order¶
type timer struct(~line 100) — the data structure.addtimer(~line 250) — how a new timer enters the system.runtimer(~line 700) — how timers fire.runOneTimer(~line 750) — how period and callback interact.deltimer(~line 350) — how cancellation works lock-free.modtimer(~line 450) — how Reset works lock-free.cleantimers(~line 600) — heap maintenance.adjusttimers(~line 650) — periodic heap repair.siftupTimer/siftdownTimer(~line 900) — the 4-ary heap.checkTimersinproc.go— the scheduler integration.netpoll(deadline)innetpoll.go— the deadline plumbing.
19.2 Where line numbers come from¶
The numbers above are approximate for Go 1.23's runtime/time.go. They will drift release to release. Use git grep on the actual repo:
cd $(go env GOROOT)/src/runtime
git grep -n 'func runtimer'
git grep -n 'func addtimer'
git grep -n 'func deltimer'
You can also use go doc for the user-facing functions:
19.3 Testing¶
The runtime's own tests are in src/runtime/time_test.go. They include benchmarks like BenchmarkStartStop1000 that exercise the start/stop pattern at scale, and BenchmarkAdjustTimers that exercises the heap maintenance path. If you ever change anything in runtime/time.go (and you should not, unless you're a runtime maintainer), these are the tests to run.
For your own code, the testing package's synctest (experimental in recent Go versions, stabilized in 1.24+) provides controllable time for unit tests — you can advance "fake time" deterministically and avoid real sleeps in tests. Before synctest, you'd write your code with an injected clock interface and use a fake clock in tests.
20. Worked example — measuring timer-heap behavior¶
Let's write a small program that exercises the timer system and observe the runtime stats.
package main
import (
"fmt"
"runtime"
"runtime/metrics"
"sync"
"time"
)
func main() {
const N = 100_000
var wg sync.WaitGroup
samples := []metrics.Sample{
{Name: "/sched/goroutines:goroutines"},
{Name: "/memory/classes/heap/objects:bytes"},
}
snapshot := func(tag string) {
metrics.Read(samples)
var mstat runtime.MemStats
runtime.ReadMemStats(&mstat)
fmt.Printf("[%s] goroutines=%d heap=%d MB allocs=%d\n",
tag, samples[0].Value.Uint64(),
samples[1].Value.Uint64()/(1<<20),
mstat.Mallocs)
}
snapshot("before")
wg.Add(N)
for i := 0; i < N; i++ {
go func(i int) {
defer wg.Done()
t := time.NewTimer(50 * time.Millisecond)
<-t.C
}(i)
}
snapshot("during")
wg.Wait()
snapshot("after")
}
Run this. You'll observe:
before: ~1 goroutine, small heap.during: ~100,000 goroutines, heap up by ~100,000 * (sizeof Timer + sizeof hchan + sizeof g) bytes. Multi-MB.after: back to ~1 goroutine, heap back down (after GC).
If you replace time.NewTimer(50 * time.Millisecond) with time.After(50 * time.Millisecond), the behavior is identical at this scale because each Timer is one-shot and fires within the goroutine's lifetime. The hot-loop allocation problem of time.After only shows up when you call it many times in the same goroutine (each call leaves a Timer in the heap).
20.1 Hot-loop allocation experiment¶
func leakAfter(ctx context.Context, ch <-chan int) {
for {
select {
case <-ctx.Done():
return
case <-ch:
// hot path
case <-time.After(1 * time.Hour):
// never fires in practice
return
}
}
}
If ch receives 1,000 messages per second, each iteration creates a fresh time.After(1 * time.Hour). After 10 seconds, you have 10,000 Timers in the heap, all scheduled to fire one hour from various points. Memory grows roughly linearly until either GC reclaims them (pre-1.23: not until they fire) or your process is killed.
The fix:
func goodAfter(ctx context.Context, ch <-chan int) {
t := time.NewTimer(1 * time.Hour)
defer t.Stop()
for {
if !t.Stop() {
select { case <-t.C: default: }
}
t.Reset(1 * time.Hour)
select {
case <-ctx.Done():
return
case <-ch:
// hot path
case <-t.C:
return
}
}
}
On Go 1.23+, the drain-after-Stop is unnecessary; the loop simplifies. But the principle stands: one Timer reused, not N Timers allocated.
21. Interaction with select¶
select over timer channels is the most common usage. A key fact: each case <-ch: in a select is independent — the select doesn't know that the channel is a timer's C. It treats it as any channel receive.
This has consequences:
21.1 Timer fires before select even runs¶
t := time.NewTimer(0)
runtime.Gosched()
select {
case <-t.C:
fmt.Println("fired") // prints
case <-time.After(time.Hour):
fmt.Println("hour") // does not print
}
The 0-duration timer fires almost immediately. By the time select runs, t.C is ready. The select picks it. The time.After Timer was just allocated — and now leaks until it fires an hour later (pre-1.23) or until GC sees no references (Go 1.23+).
21.2 Multiple ready cases¶
If both t.C and another channel are ready when select runs, Go picks one pseudo-randomly. You cannot rely on timer cases being prioritized.
ch := make(chan int, 1)
ch <- 1
t := time.NewTimer(0)
time.Sleep(time.Microsecond)
select {
case <-t.C:
fmt.Println("timer")
case <-ch:
fmt.Println("ch")
}
// Either "timer" or "ch" can print.
For deterministic priority, restructure with a polled non-blocking select:
select {
case x := <-ch:
handle(x)
default:
select {
case x := <-ch:
handle(x)
case <-t.C:
timeout()
}
}
Two-stage. First a non-blocking peek; if not ready, fall through to a blocking select. The first stage ensures ch is always taken when ready.
22. context.WithDeadline / WithTimeout under the hood¶
// Skeleton of context.WithDeadline.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c)
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded, nil)
return c, func() { c.cancel(false, Canceled, nil) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, nil)
})
}
return c, func() { c.cancel(true, Canceled, nil) }
}
Every context.WithTimeout allocates an AfterFunc Timer that fires c.cancel. When you defer cancel() and cancel() runs, it calls c.timer.Stop(). If cancel() doesn't run, the Timer fires at the deadline, cancellation propagates, and the Timer is then removed from the heap by the runtime (one-shot).
If you never call cancel and the deadline is far in the future, the Timer holds the context alive until it fires. The familiar advice "always defer cancel" is partly about this — even if the deadline will cancel the context eventually, the Timer is wasting heap space until then.
22.1 time.AfterFunc from many contexts¶
A common shape: HTTP handler calls context.WithTimeout(r.Context(), 5*time.Second), defers cancel, does work, returns. Per request, one AfterFunc Timer enters the heap and (almost always) is stopped on return.
At 10,000 req/s, that's 10,000 timer additions per second + 10,000 removals. Each operation is roughly O(log N) on the heap. The per-P heap design keeps N small per P, so log N is small. Empirically this is fine — the Go standard library net/http server has been doing this for years at high scale.
23. net.Conn deadlines¶
type Conn interface {
...
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}
How do these interact with the timer system? Answer: they don't, directly. net.Conn deadlines are implemented at the netpoll level: each FD has read/write deadline fields in internal/poll.FD, and the netpoller's runtime_pollSetDeadline arms a runtime timer that, when it fires, marks the FD as expired and wakes any goroutine blocked in epoll_wait/equivalent. The blocked goroutine then sees the expiration flag and returns a timeout error.
Under the hood, runtime_pollSetDeadline uses the same runtime.timer machinery — but the firing callback is the netpoller-specific netpollDeadline, not sendTime. So when you read runtime/time.go, you'll see deadlines pass through the same heap and state machine. There is not a separate "network deadline" timer system.
// runtime/netpoll.go
func netpollDeadline(arg any, seq uintptr) {
pd := arg.(*pollDesc)
netpolldeadlineimpl(pd, seq, true, true)
}
func netpollReadDeadline(arg any, seq uintptr) {
pd := arg.(*pollDesc)
netpolldeadlineimpl(pd, seq, true, false)
}
func netpollWriteDeadline(arg any, seq uintptr) {
pd := arg.(*pollDesc)
netpolldeadlineimpl(pd, seq, false, true)
}
The seq field on the timer matters here: if the user reset the deadline (arming a new timer with a new seq), an old firing should not affect the current goroutine. The seq mismatch lets netpolldeadlineimpl no-op stale firings.
24. Time in tests — synctest¶
Go 1.24 stabilized testing/synctest (after experimental availability in 1.23). It provides controllable time for tests:
import "testing/synctest"
func TestRetryBackoff(t *testing.T) {
synctest.Run(func() {
start := time.Now()
err := retryWithBackoff(operation, 3, 100*time.Millisecond)
elapsed := time.Since(start)
if elapsed < 300*time.Millisecond || elapsed > 700*time.Millisecond {
t.Errorf("unexpected elapsed: %v", elapsed)
}
})
}
Inside synctest.Run, time.Now, time.Sleep, time.NewTimer, etc., use a synthetic clock that advances only when all goroutines in the bubble are blocked. So a test of "sleep 100ms and check" runs instantly instead of really sleeping.
This means the runtime's timer machinery has hooks for synctest — internally, when running inside a synctest bubble, timers are routed to a different firing path that advances the synthetic clock. From the user's perspective, the API is identical.
Before synctest, you'd inject a Clock interface yourself:
with a real implementation backed by the time package and a fake implementation for tests. The synctest package removes that boilerplate.
25. The time.Duration type itself¶
// A Duration represents the elapsed time between two instants
// as an int64 nanosecond count. The representation limits the
// largest representable duration to approximately 290 years.
type Duration int64
int64 nanoseconds. The constant time.Hour is 60 * 60 * 1e9. Doing arithmetic like 10 * time.Second works because both sides are Duration/int64 and the compiler picks the right multiplication.
Edge cases:
-
Overflow:
time.Duration(math.MaxInt64) + 1 == math.MinInt64. Adding a large deadline totime.Now()can overflow if you're not careful. The runtime'swhenfunction clamps tomaxWhen. -
Negative:
time.NewTimer(-1)fires immediately (when ≤ 0 ⇒ when := runtimeNano()). -
String formatting:
time.Duration.String()prints "1h30m45s" style. It allocates. Don't put it in a hot logging path without thinking.
The time.Duration constants (Nanosecond, Microsecond, Millisecond, Second, Minute, Hour) are pre-computed int64 values. No allocation, just constant folding.
26. Summary — what to remember¶
The user-visible types time.Timer, time.Ticker, and the convenience wrappers time.After, time.Tick, time.AfterFunc, time.Sleep are all backed by the same runtime machinery:
- A per-P 4-ary min-heap of
*timerstructs ordered by absolute deadline on the monotonic clock. - A lock-free state machine on each timer's
statusfield allowing cross-P modification without taking the owning P's heap lock. - A firing function (
sendTime,goFunc,goroutineReady,netpollDeadline, etc.) that runs from the timer routine on the owning P. - Integration with the scheduler's
findRunnableandsysmon, plus the netpoller, so that an M sleeping onepoll_waitis woken when the next timer is due.
Things to keep in mind in everyday code:
- Always Stop tickers you don't intend to outlive the program.
- Avoid
time.Afterin hot select loops. UseNewTimer+Reset. - Avoid
time.Tickoutside ofmain-level forever loops. It cannot be stopped. - For Go ≤ 1.22, drain the channel after Stop before Reset. For Go 1.23+, the new semantics make this unnecessary.
AfterFunc'sfruns in its own goroutine. It can race withStop.time.Now()is cheap (~30 ns).time.Sleepminimum latency is in the tens of microseconds in practice. Don't try to micro-sleep.- Monotonic readings are stripped by
Round(0), JSON, gob. Use thetime.Timeyou got back directly when measuring intervals. - A timer's home P is wherever it was created. Modifications can come from anywhere via the lock-free state machine.
If you want to keep going, the Advanced page covers the runtime-internal contracts (go:linkname boundaries with the time package, the runtime.timer.pp retargeting on P shutdown, and the specific changes that came with Go 1.23's timer overhaul), plus patterns for high-throughput timer use (timing wheels, batched expirations, pooled Timers).