Go's Timers: A Per-P Heap and a Timer That Never Blocks a Thread

Go does not give every timer its own thread. Timers live in a heap owned by the P that created them, the scheduler checks that heap at the top of every scheduling round, and a sleeping goroutine parks on a timer that lives on its own g.

TL;DR: A Go timer is not a thread, not a syscall, and not a separate kernel object. It is a struct with a when and a period that lives in a min-heap owned by the P that created it. The scheduler checks that heap at the top of every scheduling round, runs whatever is due, and a goroutine that calls time.Sleep parks itself on a timer attached to its own g.

If you have written Go for more than a week, you have written time.After, time.NewTimer or time.Sleep. If you have written C, you are probably convinced that each of those is secretly an OS thread with a nanosleep in it. It is not. Go’s timers are a data structure.

That data structure has been rewritten fairly recently, and if you have been reading about Go internals for a while you may notice that the function names you remember are gone. There is no addtimer, no deltimer, no modtimer and no checkTimers in the Go 1.28 tree. They were replaced by a per-P heap with timer.modify, timer.maybeAdd and timers.check. Here is how the new design works.

The shape of the problem

A timer has to do three things: know when it is due, wake somebody up at that time, and let the owner change or cancel it without races.

The naive design — one kernel timer per Go timer — fails at scale. A service with a million time.After calls in flight would have a million kernel timer objects, a million interrupts, and enormous scheduler latency. Go’s answer is to keep every timer in user space and let the Go scheduler poll.

The polling is what makes it cheap. When the scheduler has nothing else to do, it computes the earliest due time across all the heaps it knows about, sleeps in netpoll until then, and runs what is due. No dedicated thread, no per-timer signal, no interrupt storm.

The timer struct

go
// src/runtime/time.go:52
// A timer is a potentially repeating trigger for calling t.f(t.arg, t.seq).
// Timers are allocated by client code, often as part of other data structures.
// Each P has a heap of pointers to timers that it manages.
//
// A timer is expected to be used by only one client goroutine at a time,
// but there will be concurrent access by the P managing that timer.
// Timer accesses are protected by the lock t.mu, with a snapshot of
// t's state bits published in t.astate to enable certain fast paths to make
// decisions about a timer without acquiring the lock.
type timer struct {
	// mu protects reads and writes to all fields, with exceptions noted below.
	mu mutex

	astate atomic.Uint8 // atomic copy of state bits at last unlock
	state  uint8        // state bits
	isChan bool         // timer has a channel; immutable; can be read without lock
	isFake bool         // timer is using fake time; immutable; can be read without lock

	blocked uint32 // number of goroutines blocked on timer's channel
	rand    uint32 // randomizes order of timers at same instant; only set when isFake

	// Timer wakes up at when, and then at when+period, ... (period > 0 only)
	// each time calling f(arg, seq, delay) in the timer goroutine, so f must be
	// a well-behaved function and not block.
	//
	// The arg and seq are client-specified opaque arguments passed back to f.
	// When used from netpoll, arg and seq have meanings defined by netpoll
	// and are completely opaque to this code; in that context, seq is a sequence
	// number to recognize and squelch stale function invocations.
	// When used from package time, arg is a channel (for After, NewTicker)
	// or the function to call (for AfterFunc) and seq is unused (0).
	...
	when   int64
	period int64
	f      func(arg any, seq uintptr, delay int64)
	arg    any
	seq    uintptr

	// If non-nil, the timers containing t.
	ts *timers

	// sendLock protects sends on the timer's channel.
	sendLock mutex
	...
	isSending atomic.Int32
}

Four things in there are worth pulling out.

when and period. A one-shot timer has period == 0; a ticker has period > 0 and re-arms itself by when += period.

f and arg. The callback. For time.After and time.NewTimer the callback is sendTime and arg is the channel; for time.AfterFunc the callback is goFunc and arg is your function.

ts *timers. The pointer back to the owning heap. This is what makes “stop a timer that lives on another P” possible: you cannot remove it from the heap directly, so you mark it and let the owner clean it up.

mu plus astate. The timer has its own lock, and a lock-free snapshot of the state bits. The snapshot exists so that the owning P can decide whether a timer needs attention without taking the timer’s lock — which matters, because the P holds its own heap lock at that moment and the lock ordering forbids taking them in the other direction.

The per-P heap

Each P owns one heap of timers:

go
// src/runtime/time.go:135
// A timers is a per-P set of timers.
type timers struct {
	// mu protects timers; timers are per-P, but the scheduler can
	// access the timers of another P, so we have to lock.
	mu mutex

	// heap is the set of timers, ordered by heap[i].when.
	// Must hold lock to access.
	heap []timerWhen

	// len is an atomic copy of len(heap).
	len atomic.Uint32

	// zombies is the number of timers in the heap
	// that are marked for removal.
	zombies atomic.Int32

	// raceCtx is the race context used while executing timer functions.
	raceCtx uintptr

	// minWhenHeap is the minimum heap[i].when value (= heap[0].when).
	// The wakeTime method uses minWhenHeap and minWhenModified
	// to determine the next wake time.
	// If minWhenHeap = 0, it means there are no timers in the heap.
	minWhenHeap atomic.Int64

	// minWhenModified is a lower bound on the minimum
	// heap[i].when over timers with the timerModified bit set.
	// If minWhenModified = 0, it means there are no timerModified timers in the heap.
	minWhenModified atomic.Int64
}

type timerWhen struct {
	timer *timer
	when  int64
}

The heap stores timerWhen — a pair of a pointer and the time the heap believes the timer is due — rather than the timer itself. That duplication is the key to the whole design, and it is why the state bits exist.

The P struct holds it by value:

go
// src/runtime/runtime2.go:896
	// Timer heap.
	timers timers

Ownership diagram:

text
        P0                     P1                     P2
  +---------------+      +---------------+      +---------------+
  | timers.heap   |      | timers.heap   |      | timers.heap   |
  |  [t1, t5, t9] |      |  [t2]        |      |  []           |
  +---------------+      +---------------+      +---------------+
       |                      |                     |
    owned by P0            owned by P1            owned by P2
       ^                      ^                     ^
       |                      |                     |
  a timer created by a goroutine running on P0 lands on P0's heap,
  regardless of which goroutine created it.

Note the comment on mu: “timers are per-P, but the scheduler can access the timers of another P, so we have to lock.” The scheduler steals timers from idle Ps, so the heap lock is not optional.

Three state bits

go
// src/runtime/time.go:204
// Timer state field.
const (
	// timerHeaped is set when the timer is stored in some P's heap.
	timerHeaped uint8 = 1 << iota

	// timerModified is set when t.when has been modified
	// but the heap's heap[i].when entry still needs to be updated.
	// That change waits until the heap in which
	// the timer appears can be locked and rearranged.
	// timerModified is only set when timerHeaped is also set.
	timerModified

	// timerZombie is set when the timer has been stopped
	// but is still present in some P's heap.
	// Only set when timerHeaped is also set.
	// It is possible for timerModified and timerZombie to both
	// be set, meaning that the timer was modified and then stopped.
	// A timer sending to a channel may be placed in timerZombie
	// to take it out of the heap even though the timer is not stopped,
	// as long as nothing is reading from the channel.
	timerZombie
)

These three bits are the whole concurrency story.

timerHeaped — “I live in somebody’s heap right now.”

timerModified — “the time in the heap is stale; the real due time is in t.when.” This is what lets Reset be cheap: it updates t.when and sets a bit, and the heap is rearranged later, when someone takes the heap lock anyway. Without the bit, the heap would have to be searched and fixed up on every reset.

timerZombie — “I am stopped but still physically in the heap.” Removing an arbitrary element from a binary heap is O(n), so Stop does not remove anything; it marks the timer and lets the owner sweep it out.

The heap is a plain binary min-heap keyed on when:

go
// src/runtime/time.go:172
// less reports whether tw is less than other.
func (tw timerWhen) less(other timerWhen) bool {
	switch {
	case tw.when < other.when:
		return true
	case tw.when > other.when:
		return false
	default:
		// When timers wake at the same time, use a per-timer random value to order them.
		// We only set the random value for timers using fake time, since there's
		// no practical way to schedule real-time timers for the same instant.
		return tw.timer.rand < other.timer.rand
	}
}

Creating a timer

package time does not implement timers; it calls into the runtime through go:linkname. The runtime allocates the struct and calls modify:

go
// src/runtime/time.go:390
// newTimer allocates and returns a new time.Timer or time.Ticker (same layout)
// with the given parameters.
//
//go:linkname newTimer time.newTimer
func newTimer(when, period int64, f func(arg any, seq uintptr, delay int64), arg any, c *hchan) *timeTimer {
	t := new(timeTimer)
	t.timer.init(nil, nil)
	t.trace("new")
	if raceenabled {
		racerelease(unsafe.Pointer(&t.timer))
	}
	if c != nil {
		lockInit(&t.sendLock, lockRankTimerSend)
		t.isChan = true
		c.timer = &t.timer
		if c.dataqsiz == 0 {
			throw("invalid timer channel: no capacity")
		}
	}
	if bubble := getg().bubble; bubble != nil {
		t.isFake = true
	}
	t.modify(when, period, f, arg, 0)
	t.self = t
	return t
}

modify is the modern replacement for the old modtimer, and it is where most of the interesting decisions live:

go
// src/runtime/time.go:571
	wake := false
	pending := t.when > 0
	t.when = when
	if t.state&timerHeaped != 0 {
		t.state |= timerModified
		if t.state&timerZombie != 0 {
			// In the heap but marked for removal (by a Stop).
			// Unmark it, since it has been Reset and will be running again.
			t.ts.zombies.Add(-1)
			t.state &^= timerZombie
		}
		// The corresponding heap[i].when is updated later.
		// See comment in type timer above and in timers.adjust below.
		if min := t.ts.minWhenModified.Load(); min == 0 || when < min {
			wake = true
			// Force timerModified bit out to t.astate before updating t.minWhenModified,
			// to synchronize with t.ts.adjust. See comment in adjust.
			t.astate.Store(t.state)
			t.ts.updateMinWhenModified(when)
		}
	}

The wake flag is the interesting part. If the timer’s new due time is earlier than anything the heap currently knows about, the netpoller has to be woken up, because it may be sleeping until the old, later time. That is what wakeNetPoller is for.

And when the timer is not in a heap yet, it has to be added — but not necessarily to the P that is running right now:

go
// src/runtime/time.go:666
// maybeAdd adds t to the local timers heap if it needs to be in a heap.
// The caller must not hold t's lock nor any timers heap lock.
// The caller probably just unlocked t, but that lock must be dropped
// in order to acquire a ts.lock, to avoid lock inversions.
// (timers.adjust holds ts.lock while acquiring each t's lock,
// so we cannot hold any t's lock while acquiring ts.lock).
//
// Strictly speaking it *might* be okay to hold t.lock and
// acquire ts.lock at the same time, because we know that
// t is not in any ts.heap, so nothing holding a ts.lock would
// be acquiring the t.lock at the same time, meaning there
// isn't a possible deadlock. But it is easier and safer not to be
// too clever and respect the static ordering.
// (If we don't, we have to change the static lock checking of t and ts.)
//
// Concurrent calls to time.Timer.Reset or blockTimerChan
// may result in concurrent calls to t.maybeAdd,
// so we cannot assume that t is not in a heap on entry to t.maybeAdd.
func (t *timer) maybeAdd() {
	// Note: Not holding any locks on entry to t.maybeAdd,
	// so the current g can be rescheduled to a different M and P
	// at any time, including between the ts := assignment and the
	// call to ts.lock. If a reschedule happened then, we would be
	// adding t to some other P's timers, perhaps even a P that the scheduler
	// has marked as idle with no timers, in which case the timer could
	// go unnoticed until long after t.when.
	// Calling acquirem instead of using getg().m makes sure that
	// we end up locking and inserting into the current P's timers.
	mp := acquirem()

That comment is a whole bug report in five lines: if the goroutine migrated to another P between choosing the heap and taking its lock, the timer could land on an idle P that nobody is checking, and would not fire until that P woke up for some other reason. acquirem pins the M so the P cannot change underfoot.

Sleeping

time.Sleep is a different path from time.After, and the difference is elegant. A sleeping goroutine gets a timer of its own, stored on its g:

go
// src/runtime/time.go:332
// 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)
		t.init(goroutineReady, gp)
		if gp.bubble != nil {
			t.isFake = true
		}
		gp.timer = t
	}
	var now int64
	if bubble := gp.bubble; bubble != nil {
		now = bubble.now
	} else {
		now = nanotime()
	}
	when := now + ns
	if when < 0 { // check for overflow.
		when = maxWhen
	}
	gp.sleepWhen = when
	if t.isFake {
		// Call timer.reset in this goroutine, since it's the one in a bubble.
		// We don't need to worry about the timer function running before the goroutine
		// is parked, because time won't advance until we park.
		resetForSleep(gp, nil)
		gopark(nil, nil, waitReasonSleep, traceBlockSleep, 1)
	} else {
		gopark(resetForSleep, nil, waitReasonSleep, traceBlockSleep, 1)
	}
}

gopark parks the goroutine; the resetForSleep callback is invoked after the goroutine is parked, which is the subtle part:

go
// src/runtime/time.go:372
// resetForSleep is called after the goroutine is parked for timeSleep.
// We can't call timer.reset in timeSleep itself because if this is a short
// sleep and there are many goroutines then the P can wind up running the
// timer function, goroutineReady, before the goroutine has been parked.
func resetForSleep(gp *g, _ unsafe.Pointer) bool {
	gp.timer.reset(gp.sleepWhen, 0)
	return true
}

Without that ordering, a goroutine sleeping for one microsecond could have its timer fire — and goready it — before it ever parked, and then park forever.

The timer’s function is goroutineReady, which is just goready(arg.(*g), 0): the timer is the mechanism that puts the sleeping goroutine back on the run queue.

The scheduler’s timer check

The check happens at the top of findRunnable, before the scheduler looks for anything else to run:

go
// src/runtime/proc.go:3430
	// now and pollUntil are saved for work stealing later,
	// which may steal timers. It's important that between now
	// and then, nothing blocks, so these numbers remain mostly
	// relevant.
	now, pollUntil, _ := pp.timers.check(0, nil)

timers.check is the modern checkTimers:

go
// src/runtime/time.go:972
// check runs any timers in ts 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 it ran any timers.
// If the time when the next timer should run is not 0,
// it is always larger than the returned time.
// We pass now in and out to avoid extra calls of nanotime.
//
//go:yeswritebarrierrec
func (ts *timers) check(now int64, bubble *synctestBubble) (rnow, pollUntil int64, ran bool) {
	ts.trace("check")
	// If it's not yet time for the first timer, or the first adjusted
	// timer, then there is nothing to do.
	next := ts.wakeTime()
	if next == 0 {
		// No timers to run or adjust.
		return now, 0, false
	}

	if now == 0 {
		now = nanotime()
	}

	// If this is the local P, and there are a lot of deleted timers,
	// clear them out. We only do this for the local P to reduce
	// lock contention on timersLock.
	zombies := ts.zombies.Load()
	if zombies < 0 {
		badTimer()
	}
	force := ts == &getg().m.p.ptr().timers && int(zombies) > int(ts.len.Load())/4

	if now < next && !force {
		// Next timer is not ready to run, and we don't need to clear deleted timers.
		return now, next, false
	}

	ts.lock()
	if len(ts.heap) > 0 {
		ts.adjust(now, false)
		for len(ts.heap) > 0 {
			// Note that runtimer may temporarily unlock ts.
			if tw := ts.run(now, bubble); tw != 0 {
				if tw > 0 {
					pollUntil = tw
				}
				break
			}
			ran = true
		}

Two return values matter: pollUntil is the time of the next timer, which becomes the deadline the netpoller sleeps until, and ran tells the caller whether anything happened.

adjust is where the stale heap entries get fixed up, and it is the messiest part of the whole file — with an excellent comment explaining why:

go
// src/runtime/time.go:852
	// minWhenModified is a lower bound on the earliest t.when
	// among the timerModified timers. We want to make it more precise:
	// we are going to scan the heap and clean out all the timerModified bits,
	// at which point minWhenModified can be set to 0 (indicating none at all).
	//
	// Other P's can be calling ts.wakeTime concurrently, and we'd like to
	// keep ts.wakeTime returning an accurate value throughout this entire process.
	//
	// Setting minWhenModified = 0 *before* the scan could make wakeTime
	// return an incorrect value: if minWhenModified < minWhenHeap, then clearing
	// it to 0 will make wakeTime return minWhenHeap (too late) until the scan finishes.
	// To avoid that, we want to set minWhenModified to 0 *after* the scan.
	//
	// Setting minWhenModified = 0 *after* the scan could result in missing
	// concurrent timer modifications in other goroutines; those will lock
	// the specific timer, set the timerModified bit, and set t.when.
	// To avoid that, we want to set minWhenModified to 0 *before* the scan.
	//
	// The way out of this dilemma is to preserve wakeTime a different way.
	// wakeTime is min(minWhenHeap, minWhenModified), and minWhenHeap
	// is protected by ts.lock, which we hold, so we can modify it however we like
	// in service of keeping wakeTime accurate.

The resolution is to “lock in” the accurate wake time into minWhenHeap before clearing minWhenModified, so the two-step update never exposes a window where the poller would sleep past a timer.

When the scheduler has nothing else to do, it sleeps in the netpoller until pollUntil:

go
// src/runtime/proc.go:3750
	// Poll network until next timer.
	if netpollinited() && (netpollAnyWaiters() || pollUntil != 0) && sched.lastpoll.Swap(0) != 0 {
		sched.pollUntil.Store(pollUntil)
		...
		delay := int64(-1)
		if pollUntil != 0 {
			if now == 0 {
				now = nanotime()
			}
			delay = pollUntil - now
			if delay < 0 {
				delay = 0
			}
		}
		...
		list, delta := netpoll(delay) // block until new work is available

One epoll_wait with a timeout serves both the network poller and the timer heap. That is the whole trick: timers and network I/O share a wakeup mechanism, because they share the thread that sleeps waiting for both.

Stopping and resetting without races

The hard part of a channel-based timer is that Stop and Reset can race with the timer actually sending on the channel. The runtime solves this with a sequence number:

go
// src/runtime/time.go:1178
	if t.isChan {
		// For a timer channel, we want to make sure that no stale sends
		// happen after a t.stop or t.modify, but we cannot hold t.mu
		// during the actual send (which f does) due to lock ordering.
		// It can happen that we are holding t's lock above, we decide
		// it's time to send a time value (by calling f), grab the parameters,
		// unlock above, and then a t.stop or t.modify changes the timer
		// and returns. At that point, the send needs not to happen after all.
		// The way we arrange for it not to happen is that t.stop and t.modify
		// both increment t.seq while holding both t.mu and t.sendLock.
		// We copied the seq value above while holding t.mu.
		// Now we can acquire t.sendLock (which will be held across the send)
		// and double-check that t.seq is still the seq value we saw above.
		// If not, the timer has been updated and we should skip the send.
		// We skip the send by reassigning f to a no-op function.
		//
		// The isSending field tells t.stop or t.modify that we have
		// started to send the value. That lets them correctly return
		// true meaning that no value was sent.
		lock(&t.sendLock)

		if t.period == 0 {
			// We are committed to possibly sending a value
			// based on seq, so no need to keep telling
			// stop/modify that we are sending.
			if t.isSending.Add(-1) < 0 {
				throw("mismatched isSending updates")
			}
		}

		if t.seq != seq {
			f = func(any, uintptr, int64) {}
		}
	}

	f(arg, seq, delay)

The timer copies t.seq while holding t.mu. Stop and Reset bump t.seq while holding both t.mu and t.sendLock. Just before actually sending, the timer takes t.sendLock and compares the sequence number. If it changed, the send is replaced with a no-op.

This is what makes the modern Timer semantics safe.

time.After, NewTimer, Reset and the drain rule

The user-facing API is in package time:

go
// src/time/sleep.go:117
func NewTimer(d Duration) *Timer {
	c := make(chan Time, 1)
	t := newTimer(when(d), 0, sendTime, c, syncTimer(c))
	t.C = c
	return t
}

and After is literally NewTimer(d).C:

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

The old drain rule — “if Stop returns false, you must drain the channel yourself” — is gone as of Go 1.23. The docs say so:

go
// src/time/sleep.go:86
// For a chan-based timer created with NewTimer(d), as of Go 1.23,
// any receive from t.C after Stop has returned is guaranteed to block
// rather than receive a stale time value from before the Stop;
// if the program has not received from t.C already and the timer is
// running, Stop is guaranteed to return true.
// Before Go 1.23, the only safe way to use Stop was insert an extra
// <-t.C if Stop returned false to drain a potential stale value.
// See the [NewTimer] documentation for more details.
func (t *Timer) Stop() bool {

Note that this guarantee comes from the runtime’s seq mechanism, not from the channel’s buffer size — NewTimer still allocates a channel with capacity

  1. What changed is that the runtime will not put a stale value in it.

Reset is the same story:

go
// src/time/sleep.go:145
func (t *Timer) Reset(d Duration) bool {
	t.checkValid("Reset")
	w := when(d)
	return resetTimer(t, w, 0)
}

One more subtlety: the value sent on the channel is not “now” but “the time the timer was supposed to fire”, adjusted for how late the runtime got to it:

go
// src/time/sleep.go:151
// sendTime does a non-blocking send of the current time on c.
func sendTime(c any, seq uintptr, delta int64) {
	// delta is how long ago the channel send was supposed to happen.
	// The current time can be arbitrarily far into the future, because the runtime
	// can delay a sendTime call until a goroutine tries to receive from
	// the channel. Subtract delta to go back to the old time that we
	// used to send.
	select {
	case c.(chan Time) <- Now().Add(Duration(-delta)):
	default:
	}
}

No thread per timer

To be explicit about the thing everybody assumes: there is no timerproc thread, no kernel timer per Go timer, and no interrupt per timer. The runtime has exactly one place where timers turn into wakeups — the netpoller’s timeout — plus a wakeNetPoller call when a timer is moved earlier.

go
// src/runtime/proc.go:4020
// wakeNetPoller wakes up the thread sleeping in the network poller if it isn't
// going to wake up before the when argument; or it wakes an idle P to service
// timers and the network poller if there isn't one already.
func wakeNetPoller(when int64) {
	if sched.lastpoll.Load() == 0 {
		// In findRunnable we ensure that when polling the pollUntil
		// field is either zero or the time to which the current
		// poll is expected to run. This can have a spurious wakeup
		// but should never miss a wakeup.
		pollerPollUntil := sched.pollUntil.Load()
		if pollerPollUntil == 0 || pollerPollUntil > when {
			netpollBreak()
		}
	} else {
		// There are no threads in the network poller, try to get
		// one there so it can handle new timers.
		if GOOS != "plan9" { // Temporary workaround - see issue #42303.
			wakep()
		}
	}
}

netpollBreak writes to a pipe to wake the poller out of epoll_wait early. That is the closest thing Go has to a “timer interrupt”.

Trade-offs

What the user-space design buys you: a million timers cost a million heap entries and zero kernel objects. Timer creation is a mutex, a heap insert and a wakeNetPoller — no syscall in the common case.

What it costs:

  • Latency. A timer can fire late, by however long the scheduler takes to notice. The runtime mitigates this by waking the poller, but under load a timer can be late by milliseconds.
  • The scheduler has to poll. Every findRunnable checks the heap. That is cheap, but it is not free, and it happens on every scheduling round.
  • Coarse granularity for tickers. A ticker re-arms itself from the scheduled time, not from the actual fire time, so a ticker that is late does not drift — it catches up.
  • Lock contention on a hot heap. A program with a million timers on one P has a hot ts.mu.

What the old design bought you: precision, and a guarantee that a timer fires even if the Go scheduler is completely wedged. Go gave that up on purpose: the old timerproc thread existed on Windows and was a known source of latency problems.

Caveats

Timers are not exact. time.Sleep(1 * time.Millisecond) sleeps at least a millisecond, and possibly much longer if the scheduler is busy. Never build anything that depends on timer precision.

A stopped timer is not removed immediately. Stop marks a zombie; the heap entry is swept out later, when the owning P next touches its heap. A program that creates and stops millions of timers will accumulate heap entries until the next sweep.

Reset on a fired timer does not drain the channel for you in the general case — but since Go 1.23 it does not need to, because the runtime suppresses stale sends.

The channel is buffered with capacity 1 even though the docs describe the channel as synchronous. The guarantee comes from the runtime’s sequence numbering, not from the buffer.

AfterFunc runs your function in a new goroutine, and Stop returning false does not mean the function has finished:

go
// src/time/sleep.go:79
// For a func-based timer created with [AfterFunc](d, f),
// if t.Stop returns false, then the timer has already expired
// and the function f has been started in its own goroutine;
// Stop does not wait for f to complete before returning.
// If the caller needs to know whether f is completed,
// it must coordinate with f explicitly.

A timer channel send is non-blocking. If nobody is receiving from t.C, the send is dropped:

go
// src/time/sleep.go:158
	select {
	case c.(chan Time) <- Now().Add(Duration(-delta)):
	default:
	}

The value is not lost forever — the runtime remembers the timer is pending and delivers when someone blocks on the channel (blockTimerChan / maybeRunChan). But it does mean a timer channel is not a queue.

Timer functions run on the scheduler’s goroutine. The callback is invoked from timers.run, on whatever P owns the heap. If your callback blocks, you block the scheduler. The runtime says as much: “f must be a well-behaved function and not block”.

Wrapping up

The one thing to remember: a Go timer is a struct in a per-P min-heap. The scheduler checks that heap at the top of every scheduling round, the netpoller’s timeout doubles as the sleep-until-next-timer, and time.Sleep parks the goroutine on a timer attached to its own g.

The part people get wrong: the function names. There is no addtimer, deltimer, modtimer or checkTimers any more — they were replaced by timer.modify, timer.maybeAdd and timers.check, and the heap now stores a copy of the due time alongside each timer so that resets can be deferred.

What to go do about it: if you have a program that creates a timer per request, replace the timers with a single ticker and a priority queue, or with time.Timer reused via Reset. The per-timer cost is now a heap insert rather than a syscall, but it is still a heap insert, and it is still a lock.

Further reading

  • src/runtime/time.go — the timer and timers types, modify, maybeAdd, adjust, check, run
  • src/runtime/runtime2.go — the p struct’s timer heap and the timerpMask
  • src/runtime/proc.go — findRunnable’s timer check, checkTimersNoP, wakeNetPoller
  • src/time/sleep.go — NewTimer, After, AfterFunc, Reset, Stop, sendTime
  • src/time/tick.go — the ticker, built on the same timer struct with a non-zero period
  • src/runtime/netpoll.go — the network poller that doubles as the timer sleep
  • src/runtime/traceregion.go — the off-heap region allocator used by the tracer

GoInternalsTimersSchedulerConcurrency