skip to content

Where does the Go runtime keep pending timers, and what checks that they are due?

level: middleimportance: should knowfreq 32%

answer

  1. one heap per scheduling context
  2. no dedicated timer goroutine any more
  3. the earliest deadline becomes a wait timeout
  4. the same call waits for I/O and for time
  5. an idle P's timers get adopted

basics

~20 s

Pending timers live in per-P min-heaps ordered by expiry, not one global list. Go's goroutine scheduler fires due ones while finding work, and a thread about to idle blocks in the netpoller until the earliest deadline.

solid answer

~50 s

Every P — the scheduling context a thread needs to run Go code — owns a heap of pending timers ordered by expiry time. Creating a timer pushes onto the local P's heap, so the common path takes no global lock; early Go used a single global heap plus a dedicated timer goroutine, and that became a contention bottleneck. Go's goroutine scheduler checks the current P's heap as part of finding work and fires whatever is due; firing means readying a sleeping goroutine, delivering the time on a `time.Timer` or `time.Ticker` channel, or starting the goroutine for `time.AfterFunc`. When a thread has nothing to run and is about to block in the netpoller, the runtime passes the earliest pending deadline as that call's timeout, so a single `epoll_wait`-style wait covers both I/O readiness and timer expiry. Timers on a P that goes idle are picked up by another P, so they still fire.

go deeper

for a junior

Know that the runtime keeps its own sorted structure of pending deadlines and checks it as part of scheduling. You are not expected to describe per-P ownership, only that the kernel is not tracking each Go timer.

for a middle

Be able to draw it: a min-heap per P keyed by expiry, the scheduler firing due timers on its work-finding pass, and the earliest deadline handed to the netpoller as a timeout when a thread would otherwise idle.

for a senior

Turn the mechanism into diagnosis. Explain why a timer fired late is usually scheduling delay rather than timer error, and what you would look at before changing a duration in production code.

for a principal

Frame the cost side: deadlines on every connection are affordable precisely because timers are heap entries rather than kernel objects, and that assumption should inform how liberally your services set timeouts and how you review a hot path that churns timers.

## The data structure A P is the scheduling context an OS thread must hold to execute Go code; `GOMAXPROCS` bounds how many exist. Each P owns a **min-heap of pending timers ordered by their expiry instant**, guarded by its own lock so that another P can reach in when it needs to. A timer is a small object holding a deadline, a function to run when the deadline passes, and its argument. When code creates a timer — a `time.Sleep`, a `time.NewTimer`, a `time.NewTicker`, a `time.AfterFunc`, an I/O deadline — the runtime pushes it onto the heap of the P the creating goroutine is running on. Pushing and popping are local operations, so timer churn does not serialise the whole program. This was not always the shape. Early Go kept **one global timer heap** protected by a single mutex, serviced by a dedicated runtime goroutine that slept until the next deadline. Under heavy timer traffic — and every network deadline is timer traffic — that single lock and single waker became a measurable bottleneck, and the per-P design replaced it. ## Who checks the heaps There is no separate polling thread. Go's goroutine scheduler consults the current P's timer heap during its normal work-finding pass: before and while it looks for a runnable goroutine, it fires any timer whose deadline has already passed. Under load, then, timers are checked constantly and essentially for free, because the scheduler is going round that loop anyway. The subtle case is an **idle** system, where the scheduler is not looping. When a thread finds no work, it does not spin — it blocks in the runtime's network poller (`epoll_wait`, `kqueue`, or the Windows completion-port equivalent). The runtime computes the earliest deadline among pending timers and passes it as that call's **timeout**. The thread therefore wakes on whichever comes first: an I/O event, or the next timer. This is the key economy of the whole design — arbitrarily many Go timers are represented to the kernel as a single deadline on a wait the runtime was making anyway. A P that parks with pending timers does not strand them. Another P takes responsibility for them, and the runtime's monitoring thread is a backstop that can wake a thread when something is due. So "my P went idle" is never an explanation for a timer that did not fire. ## What firing means Firing runs the timer's stored function: - for a `time.Sleep`, it marks the sleeping goroutine runnable; - for a `time.Timer` or `time.Ticker`, it delivers the current time on the timer's channel; - for `time.AfterFunc`, it starts a **new goroutine** running the supplied function, so a slow callback does not hold up other timers. In every case the timer is removed from (or, for a ticker, re-inserted into) the heap. ## The guarantees this buys, and the ones it does not - **Never early.** A timer is only fired once its deadline has passed on the monotonic clock. - **Possibly late.** Firing makes a goroutine runnable; running it needs a free P. If every P is busy, expiry latency turns into scheduling delay. This is why timer-driven code should be measured with a latency view of the scheduler rather than by trusting the requested duration. - **Cheap at scale.** A million pending deadlines are a million heap entries, not a million kernel objects. This is exactly why Go can afford a read and write deadline on every connection. ## Practical consequences A retry-and-backoff layer that creates and cancels timers on a very hot path concentrates that heap traffic on whichever Ps its goroutines happen to run on; the operations are logarithmic in the size of that P's heap, which is why long-lived tickers are worth reusing instead of recreating per attempt. Conversely, cancelling a timer you no longer need is cheap and worth doing, because a heap full of timers that will never be useful still has to be maintained and still sets wake-up deadlines for idle threads. ## What to say in an interview "Each P owns a min-heap of timers keyed by deadline. The scheduler fires the due ones on its normal work-finding pass, and when a thread is about to idle, the earliest deadline becomes the timeout of its blocking netpoller wait — so all Go timers collapse into one OS-level deadline. If a P goes idle, another one adopts its timers."

  • What happens to pending timers when the P that owns them goes idle?
    They are not stranded. Another P takes responsibility for the idle P's timers, and the runtime's monitoring thread acts as a backstop that can wake a thread when a deadline is due. Timer delivery does not depend on the creating goroutine's P staying awake.
  • Why did Go move from a single global timer heap to per-P heaps?
    The global heap needed one lock for every timer insert, delete and expiry, and network deadlines make that path extremely hot. Per-P heaps make the common case local and lock-free of cross-P contention, and they remove the dedicated timer goroutine that had to be woken and rescheduled on every change.
  • Does a slow time.AfterFunc callback delay other timers on the same heap?
    No, because firing an AfterFunc timer starts a fresh goroutine to run the function rather than running it inline in the scheduler. That goroutine competes for scheduling like any other, so a slow callback costs CPU but does not hold the timer heap or postpone other deadlines.

Each desk keeps its own sorted list of appointments, and whoever is about to nap sets one alarm for the earliest entry on the list.

saying these in an interview costs you the question

  • Says all Go timers live in one global runtime heap
  • Claims each Go timer becomes a kernel timer object
  • Thinks a dedicated goroutine polls timers on a fixed tick
  • Believes a timer whose P went idle never fires
  • Assumes timers fire exactly at their deadline regardless of load