Timer Leaks — Specification¶
Table of Contents¶
- Introduction
time.After(Specification)time.NewTimer(Specification)Timer.Stop(Specification)Timer.Reset(Specification)time.AfterFunc(Specification)time.Tickandtime.NewTicker(Specification)Ticker.StopandTicker.Reset(Specification)time.Sleep(Specification)context.WithTimeoutandcontext.WithDeadline(Specification)- Runtime Timer Lifecycle
- Behavioural Differences Across Go Versions
- Memory Model Interactions
- Documented vs Unspecified Behaviour
- Common Misreadings
- References
Introduction¶
This document is the reference specification for time-based concurrency primitives in Go, with emphasis on the behavioural details that distinguish a correct use from a leak. Three normative sources govern this material:
- The
timepackage documentation (pkg.go.dev/time) — defines the public API forTimer,Ticker,After,AfterFunc,Sleep,Tick, and related functions. - The
contextpackage documentation (pkg.go.dev/context) — definesWithTimeout,WithDeadline, and the cancel-function lifecycle. - The
runtimepackage and Go source code — defines the implementation of timers, which is not specified normatively but is consistent across versions.
Many behaviours are intentionally implementation-defined to allow the runtime to evolve. The largest such change to date is Go 1.23's timer rewrite, which altered several previously-relied-upon behaviours. Where versions differ, this document calls out the version-specific behaviour explicitly.
time.After (Specification)¶
Signature¶
Documentation (from pkg.go.dev/time)¶
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, useNewTimerinstead and callTimer.Stopif the timer is no longer needed.
Key normative points¶
- The return type is
<-chan Time. The caller cannot close it, send on it, or otherwise manipulate it. - The duration is measured from the call to
After. There is no separate "start" operation. - Internally,
Afterallocates a*Timer. The*Timeris not returned and cannot be stopped. - Documentation explicitly notes the GC interaction: pre-1.23, the timer is not collectible until it fires.
Go 1.23 change¶
From the Go 1.23 release notes:
The implementation of
time.After,time.Tick, and similar functions has been changed to allow the garbage collector to reclaim the underlying timer when the channel is no longer referenced, even before the timer has fired. This change reduces memory usage for some programs that use these functions in loops.
After 1.23, the language-level guarantee is unchanged (the channel still delivers one tick after d), but the memory guarantee is improved: if no goroutine holds a reference to the returned channel, the timer can be collected.
Subtle points¶
time.After(0)is well-defined: a timer with zero duration fires "immediately" (i.e., as soon as the scheduler runs it).time.After(-d)ford > 0is well-defined: equivalent totime.After(0).- The channel has buffer size 1; the runtime's send into it is non-blocking. If the caller does not read, the value sits in the channel buffer.
time.NewTimer (Specification)¶
Signature¶
Documentation¶
NewTimer creates a new Timer that will send the current time on its channel after at least duration d.
Key normative points¶
- The returned
*Timeris the canonical handle for the timer. Through it the caller canStoporResetthe timer. - The field
Cis the timer's channel; the caller receives the fired value fromC. - The duration
dis a lower bound: the channel is sent to at leastdafter the call. Late firing (e.g., due to GC or scheduling delay) is permitted. - The runtime guarantees the channel receives exactly one value per fire.
Subtle points¶
NewTimer(d).Cis exactly whatAfter(d)returns. The difference is thatNewTimerkeeps the*Timerreachable, so the caller canStopit.- The channel
Chas buffer size 1. Subsequent calls toResetafter a fire interact with this buffer; see theResetspecification below. - Multiple goroutines may receive from
C, but the runtime sends only once per fire. Only one receiver will get the value; others block until the next fire (which never happens unlessResetis called).
Timer.Stop (Specification)¶
Signature¶
Documentation¶
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. Stop does not close the channel, to prevent a read from the channel succeeding incorrectly.
To ensure the channel is empty after a call to Stop, check the return value and drain the channel. For example, assuming the program has not received from t.C already:
This cannot be done concurrent to other receives from the Timer's channel or other calls to the Timer's Stop method.
For a timer created with
NewTimer,Resetshould be invoked only on stopped or expired timers with drained channels.
Key normative points¶
Stopreturnstrueif it prevented the firing;falseotherwise.Stopdoes not closeC.- After
Stopreturnsfalse,Cmay contain a value (if the timer fired beforeStopwas called). The drain pattern is required. Stopis not safe for concurrent use with otherStoporResetcalls on the same timer, or with receives fromCfrom other goroutines.
Go 1.23 change¶
From the Go 1.23 release notes:
Timer.Stop now also discards any pending value in the channel
t.C, so the drain pattern is no longer necessary.
After 1.23, Stop ensures C is empty on return. The drain pattern still works but is redundant.
Subtle points¶
Stopis idempotent in the sense that it can be called repeatedly without panic, but subsequent calls returnfalse.- For a timer that fires periodically (
time.AfterFuncwith a periodic argument — note: not supported in standard library, only via custom logic),Stopcancels future fires. - On a
*Timercreated viatime.AfterFunc,Stopreturnsfalseif the function has already started running. It does not interrupt the running function.
Timer.Reset (Specification)¶
Signature¶
Documentation (Go 1.22 and earlier)¶
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.
Reset should be invoked only on stopped or expired timers with drained channels. If a program has already received a value from
t.C, the timer is known to have expired and the channel drained, sot.Resetcan be used directly. If a program has not yet received a value fromt.C, however, the timer must be stopped and—ifStopreports that the timer expired before being stopped—the channel explicitly drained.
Documentation (Go 1.23+)¶
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. Reset also discards any pending value in the channel.
Key normative points¶
Resetre-arms a timer to fire afterd.- Return value indicates whether the timer was active (true) or expired/stopped (false).
- Pre-1.23: the caller is responsible for ensuring the channel is drained before calling
Reset, or risks receiving a stale value. - Post-1.23:
Resetclears the channel automatically.
Cross-version compatibility¶
Code that must work on both pre-1.23 and post-1.23 should use the drain pattern. It is safe on all versions, merely redundant on 1.23.
Subtle points¶
Resetdoes not allocate a new timer; it reuses the existing one. This is the entire reason for the pattern of long-lived timers withReset.- Concurrent
Resetcalls have undefined behaviour. Synchronize externally. Reset(0)fires the timer immediately (after the current goroutine yields).
time.AfterFunc (Specification)¶
Signature¶
Documentation¶
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.
Key normative points¶
fis called in a new goroutine, not in the caller's goroutine.- The returned
*Timercan be used to cancelfviaStop. Stopreturnstrueif it preventedffrom running;falseiffhas already started (andStopdoes not interrupt it).fruns at most once perAfterFunccall — there is no built-in periodic firing.
Subtle points¶
- The
*Timerreturned byAfterFunchas aCfield, but it is unused. Receiving from it blocks forever. Reseton anAfterFunctimer reschedulesf. AfterReset,fwill run after the new duration (assuming it hasn't already started).- If
fpanics, the panic propagates out of its goroutine, which terminates the program (just as any unrecovered panic does). - The runtime does not guarantee which goroutine will execute
f. It may be the runtime's own scheduler goroutine, a worker from a pool, or a freshly spawned goroutine. For most purposes this is invisible, but it matters for any code that inspects goroutine identity (which is discouraged anyway).
time.Tick and time.NewTicker (Specification)¶
Signatures¶
func Tick(d Duration) <-chan Time
func NewTicker(d Duration) *Ticker
type Ticker struct {
C <-chan Time
// unexported fields
}
Tick documentation¶
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.
NewTicker documentation¶
NewTicker returns a new Ticker containing a channel that will send the current time 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.
Key normative points¶
TickisNewTickerwith the*Tickerdiscarded; the leak is documented and intentional.NewTicker(0)orNewTicker(-d)panics.- The ticker drops ticks if the receiver is slow; the channel never blocks the runtime.
- The channel
Chas buffer size 1.
Tick-dropping behaviour¶
If the receiver consumes a tick slower than the ticker's period, the runtime drops ticks rather than queueing them. This is by design — the alternative would be unbounded memory usage for slow receivers. Code that needs to count "missed" ticks must implement that logic separately (e.g., by comparing time.Now() against the expected tick time).
Subtle points¶
- Tickers are documented as not being phase-stable. After missed ticks, the next tick fires based on the current time, not the original phase.
- The ticker's resolution is bounded by the OS scheduler. On Linux with
CONFIG_HZ=1000, ticks finer than 1ms are not reliable. time.Tickin any function that is not the program'smainis almost always a bug.staticcheckflags this asSA1018.
Ticker.Stop and Ticker.Reset (Specification)¶
Signatures¶
Documentation¶
Stop turns off a ticker. After Stop, no more ticks will be sent. Stop does not close the channel, to prevent a concurrent goroutine reading from the channel from seeing an erroneous "tick".
Reset stops a ticker and resets its period to the specified duration. The next tick will arrive after the new period elapses. (Added in Go 1.15.)
Key normative points¶
Stopis the only way to clean up a ticker. It must be called.Stopdoes not closeC. AfterStop, the channel may still contain one pending tick.Resetwas added in Go 1.15. Code targeting earlier versions mustStopand create a new ticker.Reset(0)orReset(-d)is implementation-defined behaviour (may panic in some versions).
Subtle points¶
- The "drain after stop" pattern applies to tickers as it does to timers: after
Stop,Cmay have one pending value. - A goroutine reading from a stopped ticker's channel may receive one final tick after
Stop. Use a sentinel or checktime.Now()against an expected next-fire if precise behaviour is needed.
time.Sleep (Specification)¶
Signature¶
Documentation¶
Sleep pauses the current goroutine for at least the duration d. A negative or zero duration causes Sleep to return immediately.
Key normative points¶
Sleepblocks the caller's goroutine, not the OS thread. Other goroutines on the samePcontinue to run.- The duration is a lower bound; the goroutine may sleep longer due to scheduling or GC delays.
Sleep(0)returns immediately.Sleep(-d)returns immediately.Sleepdoes not allocate a*Timeror expose a cancellation mechanism. To make a cancellable sleep, use a timer with a select on a context.
Sleep vs. context-aware sleep¶
time.Sleep is not cancellable. The standard pattern for cancellable sleep:
func Sleep(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
This pattern is so common that some teams add it to a shared package.
context.WithTimeout and context.WithDeadline (Specification)¶
Signatures¶
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
Documentation¶
WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).
Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete:
Key normative points¶
- The cancel function must be called. Failure to call it leaks the internal timer.
- The timer is sized to fire at the deadline. The cancel function stops the timer.
- The cancel function is idempotent. Multiple calls are safe.
- The context is automatically cancelled when its deadline arrives, even if cancel is not called. So the leak is bounded by the timeout duration.
Internal mechanism¶
WithTimeout internally calls WithDeadline, which creates a timerCtx:
The timer field is a *time.Timer created with time.AfterFunc, scheduled to fire at the deadline. The cancel function calls timer.Stop(). If the caller discards the cancel function, timer is never stopped; it fires at the deadline and is cleaned up by the runtime then.
Implications for leak debugging¶
In a heap profile, leaked WithTimeout contexts appear under context.(*timerCtx).Done or context.WithDeadline. The fix is universal: assign and call the cancel function.
The go vet -lostcancel check detects calls to WithCancel, WithTimeout, and WithDeadline whose cancel function is provably not called on some execution path.
Runtime Timer Lifecycle¶
This section describes the implementation-level lifecycle of a runtime timer. The behaviour is not normatively specified, but it has been consistent for many versions and is unlikely to change in ways that affect user code.
States¶
A runtime.timer (the internal struct) has a state field. The states are:
timerNoStatus— uninitializedtimerWaiting— armed, waiting to firetimerRunning— currently executing its functiontimerDeleted— marked for removaltimerRemoving— being removed from the heaptimerRemoved— removed; struct is free for reusetimerModifying— being modified (mid-ResetorStop)timerModifiedEarlier— modified to fire earlier; needs re-insertiontimerModifiedLater— modified to fire later; needs re-insertiontimerMoving— being moved to a different P
Transitions between these states are atomic, allowing concurrent Stop and runtime firing without locks.
The Timer Heap¶
Each P owns a min-heap of *runtime.timer pointers, sorted by when. The heap is heap-ordered by when (smallest first).
When a goroutine calls time.NewTimer, the runtime:
- Allocates a
time.Timer(containing aruntimeTimerand achan Time). - Sets
when = nanotime() + int64(d). - Atomically adds the timer to the current P's heap.
When the runtime detects that a timer is ready to fire (i.e., when <= nanotime()):
- Removes the timer from the heap.
- Calls the timer's function
f. For atime.NewTimer,fsendsnanotime()on the timer's channel. - If the timer is periodic (which standard
time.NewTimeris not), reschedules it.
When are timers checked?¶
The runtime checks for due timers in several places:
- Sysmon goroutine: a background goroutine that wakes up every 10–20ms and walks the timer heaps of idle
Ps. - Scheduler work-stealing: when a
Pruns out of work, it checks its own timer heap and steals from others. - Goroutine park: when a goroutine parks (e.g., in a select), the scheduler checks if any timer is due before parking the M.
This multi-source checking ensures timers fire promptly even when individual Ps are idle or busy.
GC interactions¶
A timer in the heap is GC-reachable through the runtime's internal references. This is the source of the pre-1.23 leak: even when user code drops all references, the runtime's heap reference keeps the timer alive.
Go 1.23 added a weak-reference mechanism: the runtime's reference is weak if no goroutine is reading from t.C. When the GC determines that the timer's channel is otherwise unreachable, the timer becomes eligible for collection even before firing.
This change requires careful implementation in the runtime — the timer's internal state must remain consistent across the GC's race with Stop/Reset calls. The details are in src/runtime/time.go.
Behavioural Differences Across Go Versions¶
This section tabulates timer-related behavioural changes across recent Go versions. It is intended as a reference for engineers who must support code across multiple versions.
Go 1.0 – Go 1.13¶
- Single global timer heap protected by a mutex.
- High contention under timer-heavy workloads.
- All semantics of
After,NewTimer,Stop,Resetas documented.
Go 1.14¶
- Per-P timer heaps introduced.
- Major performance improvement: timer operations no longer require a global lock.
- No user-visible API changes.
- Async preemption added (unrelated to timers but affects scheduler behaviour during long timer-heavy operations).
Go 1.15¶
Ticker.Resetadded.- Minor performance improvements to timer firing path.
Go 1.16¶
runtime/metricspackage added.- Several timer-related metrics exposed.
Go 1.17 – Go 1.20¶
- Incremental optimizations.
- No semantic changes to documented timer behaviour.
Go 1.21¶
slogpackage added (unrelated to timers).- Stable Go 1.21 release notes do not mention timer changes.
minandmaxbuiltins added (unrelated).
Go 1.22¶
for-rangeloop variable scoping changes.- No direct timer changes.
Go 1.23 — Major Timer Rewrite¶
This is the largest timer-related change since 1.14. Highlights:
- GC eligibility: timers without active channel receivers can be collected before firing.
- Synchronous channel delivery: timer channels switched from async send-to-buffer to synchronous delivery.
Stopclears the channel:Stopnow also drains any pending value int.C.Resetclears the channel:Resetnow also drains any pending value.- Performance improvements: timer operations are roughly 2-3× faster on most workloads.
The release notes state:
If a goroutine no longer holds a reference to the channel returned by
time.After,time.NewTimer, ortime.NewTicker, the underlying Timer or Ticker can be garbage collected immediately, even if it has not yet fired.
This invalidates the long-standing recommendation to avoid time.After in loops. On Go 1.23+, time.After in a loop is merely wasteful (extra allocations and CPU) rather than catastrophic (unbounded heap growth).
Go 1.24¶
- Continued refinement of the 1.23 timer model.
- Additional trace events for timer lifecycle.
Practical Implications¶
If your fleet is on Go 1.21 or earlier: - All historical warnings about time.After apply at full strength. - Drain-before-Reset is mandatory. - Plan a Go 1.23 upgrade as a memory-pressure mitigation.
If your fleet is on Go 1.23 or later: - The drain pattern still works and is harmless. - time.After in loops is less catastrophic, but still wasteful — avoid in hot paths. - Heap profiles still show timer-related allocations; the diagnostic technique is unchanged.
If your fleet is mixed: - Treat the oldest version as the floor for any leak-resistance guarantee. - Test your code on the oldest version you support.
Memory Model Interactions¶
This section covers the happens-before relationships involving timers, which are subtle and occasionally surprising.
Send on timer channel¶
When a timer fires, the runtime sends a value (time.Now()) on the timer's channel. This send happens-before the corresponding receive in user code (standard channel semantics).
t := time.NewTimer(d)
go func() {
<-t.C // receives the fired value
doWork() // happens-after the timer fire
}()
doWork() is guaranteed to see all writes made by the runtime up to the moment of the channel send. In practice, this means timer fires are properly synchronized with surrounding code.
Concurrent Stop and fire¶
If a goroutine calls Stop while the timer is in the process of firing, the semantics are:
- If
Stopsucceeds (returnstrue), the runtime guarantees that no send ont.Cwill occur. - If
Stopfails (returnsfalse), the runtime may have already sent ont.C. The caller must drain.
This is a race-free interface as long as the caller respects the contract. Internally the runtime uses atomic operations on the timer's state field.
time.Now() and monotonic clock¶
time.Now() returns a Time containing both wall-clock and monotonic components. Arithmetic on Time values uses the monotonic component, which is immune to NTP adjustments and DST. For timer-related code, always use time.Since(t) and time.Until(t) rather than manual time.Now().Sub(t).
The runtime uses monotonic time exclusively for timer scheduling. Wall-clock adjustments do not affect timer fire times.
Sleep and goroutine state¶
time.Sleep(d) parks the goroutine in Gwaiting state with waitReason = waitReasonSleep. The goroutine remains parked until the timer fires; then it is moved back to Grunnable.
Other goroutines may not observe writes made by the sleeping goroutine after it parks. This is implicit in the memory model but worth stating: a goroutine in Sleep has, effectively, paused all of its writes.
Documented vs Unspecified Behaviour¶
This section separates what the Go specification guarantees from what is implementation behaviour subject to change.
Guaranteed¶
Timer.Chas buffer size 1.Timer.Stopreturnstrueif it prevented firing,falseotherwise.Ticker.Stopdoes not close the channel.NewTicker(d <= 0)panics.time.After(d)returns a channel that will deliver one value at time>= now + d.time.Sleep(d)blocks for at leastd.context.WithTimeout's cancel function, when called, releases the underlying timer.
Implementation-defined¶
- Per-P vs global timer heap (per-P since 1.14, may change).
- The exact number of OS threads used to fire timers.
- The precise timing of when GC collects unreferenced timers (Go 1.23+).
- The order in which two timers with the same
whenfire. - Whether
Reset(0)fires the timer in the current goroutine's iteration or the next. - The size of the
Timerstruct in memory. - The exact CPU cost of timer operations.
Subject to Change¶
The Go team has stated intent to continue improving timer performance and behaviour. Reliance on implementation-defined behaviour should be marked in code comments and reviewed on every Go upgrade.
Common Misreadings¶
A few common misreadings of the timer specification, with corrections.
Misreading 1: "time.After is leak-free"¶
False on Go ≤ 1.22. The underlying timer is retained by the runtime until it fires, even if the returned channel is unreachable. On Go 1.23+, the leak is mitigated for unreferenced channels, but a channel held in a select is still referenced.
Misreading 2: "Stop immediately frees the timer"¶
False. Stop marks the timer as cancelled. The runtime may take some time (typically until the next GC or until the runtime's internal cleanup) to remove the timer from its data structures.
Misreading 3: "A stopped timer can be reused"¶
True, but only via Reset. You cannot reuse a *Timer by allocating a new chan Time for it; the channel and the timer are bound together.
Misreading 4: "Reset is safe for concurrent use"¶
False. Concurrent Reset calls on the same timer have undefined behaviour. Synchronize externally.
Misreading 5: "time.AfterFunc(d, f) runs f in the calling goroutine"¶
False. f runs in a goroutine of the runtime's choosing, separate from the caller's.
Misreading 6: "Ticker fires exactly every d"¶
False. Tickers fire approximately every d. Phase drift, dropped ticks, and OS-level scheduling jitter all affect actual fire times.
Misreading 7: "Discarding the cancel from WithTimeout is OK if the timeout is short"¶
True only in the sense that the leak is bounded by the timeout. But the leak still occurs, and at high QPS it accumulates. Always call cancel.
Misreading 8: "Garbage collection cleans up leaked timers"¶
False on Go ≤ 1.22, partially true on Go 1.23+. Even on 1.23+, GC cannot collect a timer whose channel is reachable through any active goroutine.
Misreading 9: "time.Tick is fine for short-lived programs"¶
True. The "leak" is irrelevant for programs that run for seconds. But staticcheck flags it because the pattern is dangerous in long-running code.
Misreading 10: "Setting MemProfileRate = 0 disables profiling entirely"¶
True, but disables all memory profiling. To merely reduce profiling overhead, set a large positive value (e.g., 1<<20).
References¶
Normative¶
- Go language specification:
https://go.dev/ref/spec - Go memory model:
https://go.dev/ref/mem timepackage documentation:https://pkg.go.dev/timecontextpackage documentation:https://pkg.go.dev/contextruntimepackage documentation:https://pkg.go.dev/runtimeruntime/metricspackage documentation:https://pkg.go.dev/runtime/metrics
Release notes¶
- Go 1.14 release notes:
https://go.dev/doc/go1.14(per-P timers) - Go 1.15 release notes:
https://go.dev/doc/go1.15(Ticker.Reset) - Go 1.16 release notes:
https://go.dev/doc/go1.16(runtime/metrics) - Go 1.19 release notes:
https://go.dev/doc/go1.19(GOMEMLIMIT) - Go 1.23 release notes:
https://go.dev/doc/go1.23(timer rewrite)
Source code¶
src/time/sleep.go—Timer,NewTimer,After,Stop,Reset,AfterFunc.src/time/tick.go—Ticker,NewTicker,Tick.src/runtime/time.go— internal timer implementation.src/runtime/proc.go— scheduler integration.src/context/context.go—cancelCtx,timerCtx,WithTimeout,WithDeadline.
Tools¶
go vet:https://pkg.go.dev/cmd/vetstaticcheck:https://staticcheck.io/docs/goleak:https://github.com/uber-go/goleakpprof:https://pkg.go.dev/net/http/pprof
Community¶
- Go GitHub issue tracker:
https://github.com/golang/go/issues - Go memory model proposal:
https://go.googlesource.com/proposal/+/master/design/memorymodel.md - Go 1.23 timer proposal discussion: search Go's issue tracker for "timer GC"
Appendix A: Quick Reference Card¶
A one-page summary of the timer API contracts.
| Function | Returns | Caller obligation |
|---|---|---|
time.After(d) | <-chan Time | Receive before next call (or accept leak) |
time.NewTimer(d) | *Timer | Call Stop when done |
time.AfterFunc(d, f) | *Timer | Optionally call Stop to cancel f |
time.Tick(d) | <-chan Time | None (intentional leak) |
time.NewTicker(d) | *Ticker | Call Stop when done |
time.Sleep(d) | nothing | None |
context.WithTimeout(p, d) | (Context, CancelFunc) | Always call cancel |
context.WithDeadline(p, t) | (Context, CancelFunc) | Always call cancel |
| Method | Returns | Behaviour |
|---|---|---|
Timer.Stop() | bool | Prevents firing; returns false if already fired/stopped |
Timer.Reset(d) | bool | Re-arms; returns true if was active |
Ticker.Stop() | nothing | Stops the ticker |
Ticker.Reset(d) | nothing | Re-arms with new period (Go 1.15+) |
Appendix B: Cross-Version Compatibility Matrix¶
For code that must support a range of Go versions, here is which APIs and behaviours are available where.
| Feature | Minimum Go version |
|---|---|
time.After, NewTimer, Stop, Reset | 1.0 |
time.Tick, NewTicker | 1.0 |
time.AfterFunc | 1.0 |
time.Sleep | 1.0 |
context.WithTimeout, WithDeadline | 1.7 |
Ticker.Reset | 1.15 |
runtime/metrics | 1.16 |
GOMEMLIMIT | 1.19 |
time.After GC eligibility | 1.23 |
Timer.Stop drains channel | 1.23 |
Timer.Reset drains channel | 1.23 |
If you must support Go 1.16+, you have access to runtime/metrics, Ticker.Reset, and all standard timer APIs. The 1.23 improvements are bonus on newer runtimes; don't depend on them in code that must compile against older versions.
Appendix C: Specification Gotchas Encountered in Practice¶
Beyond the misreadings listed above, a few additional subtleties have caused production bugs and are worth highlighting.
Gotcha 1: Timer.C after AfterFunc¶
time.AfterFunc returns a *Timer, and *Timer has a C field. But for AfterFunc timers, C is unused. Receiving from t.C after AfterFunc(d, f) blocks forever.
This trips up code that tries to use a single *Timer interface for both NewTimer and AfterFunc cases. Don't do that; treat them as distinct.
Gotcha 2: time.Until and negative durations¶
time.Until(t) returns t.Sub(time.Now()). If t is in the past, the result is negative. Passing this to time.NewTimer or time.After fires the timer immediately, which may not be the intended behaviour.
Always clamp before scheduling:
Gotcha 3: select with multiple timer cases¶
Three timers are allocated (two for After calls, plus the runtime internals). The first one fires after 1 second. The second leaks for 2 seconds. Avoid this pattern; use one timer with the smallest duration.
Gotcha 4: for range t.C and Stop¶
Stop does not close t.C. The range loop will block forever waiting for the next tick that never comes. Either close a separate done channel, or break out via context cancellation.
Correct pattern:
t := time.NewTicker(d)
done := make(chan struct{})
go func() {
for {
select {
case <-t.C:
work()
case <-done:
return
}
}
}()
// later:
close(done)
t.Stop()
Gotcha 5: Closures over loop variables¶
Pre-Go 1.22:
for _, item := range items {
time.AfterFunc(d, func() {
process(item) // bug: item is the same variable across all iterations
})
}
The closure captures the loop variable item by reference, not by value. All scheduled functions see the last item value. Fixed in Go 1.22 by changing loop semantics; on older versions, manually copy:
This is not directly a timer-leak issue but commonly co-occurs with timer code.
Appendix D: Glossary¶
Timer: a *time.Timer value, which combines a channel and a runtime-managed scheduled fire.
Ticker: a *time.Ticker value, which periodically sends on a channel.
runtimeTimer: the runtime's internal representation of a timer, distinct from but pointed to by time.Timer.
Timer heap: a per-P min-heap of pending timers, sorted by their when field.
when: the monotonic time at which a timer should fire, in nanoseconds.
Fire: the act of a timer's scheduled function running. For NewTimer, this is sending on the channel. For AfterFunc, this is calling the function.
Drain: the pattern of receiving from a timer's channel to clear any pending fired value, typically before Reset.
Stop: the operation of preventing a future fire.
Reset: the operation of re-arming a timer with a new duration.
Cancel function: the function returned from context.WithCancel, WithTimeout, or WithDeadline, which must be called to release the context's resources.
Cancellation propagation: the mechanism by which context.Context informs its children that they have been cancelled.
Per-P heap: the per-processor timer heap introduced in Go 1.14.
Sysmon: the runtime's background goroutine that handles, among other things, due-timer detection on idle Ps.
Appendix E: Summary¶
The Go timer subsystem is one of the language's most useful and most subtle features. Its public API is small and stable; its implementation has evolved significantly across versions. The specification, as documented in this file, is the contract you can rely on; the implementation details, as described in the professional file, are the operational realities you must navigate.
For most uses, the specification is straightforward: create a timer, use it, stop it. The standard idioms — defer cancel() after context.WithTimeout, reusable timer with Reset, drain before Reset on pre-1.23 — cover the common cases.
The cases where leaks occur are predictable: time.After in loops, missing Stop, missing cancel. The diagnostic and prevention techniques described in this specification's companion document cover them comprehensively.
When in doubt: read the source. The time and runtime packages are not large; the relevant files total perhaps 2,000 lines of Go. A careful reading takes an afternoon and pays off for the rest of your Go career.
End of specification document.