From 69e386c2792b211bffcb3ed70bab91af6347a40e Mon Sep 17 00:00:00 2001 From: JR Lanteigne Date: Sat, 15 Aug 2026 20:56:48 -0300 Subject: [PATCH] runtime: recompute timer channel send delay at send time The timer struct documents the delay argument as nanotime() - t.when. time.sendTime uses it to reconstruct the delivered value as Now().Add(-delta). unlockAndRun computes delta from the now cached by the caller. The heap update, the sendLock acquisition, other timers due in the same timers.run pass, and preemption of the timer-running M all happen after that clock read. Their duration lands in the delivered value as one-sided, load-dependent error. The common case is a few hundred nanoseconds. Under scheduler or interrupt pressure the error reaches several microseconds. Consumers that bin ticker values against a wall-clock grid see the outliers as phantom missed ticks. A value that crosses a period boundary makes floor arithmetic count two elapsed periods where one elapsed. Recompute delta immediately before the send. This matches its documented definition at the moment of use, and the reconstruction then recovers the fire time to within two adjacent clock reads. Measured on linux/amd64 (i5-8500, idle), the mean error of 1s ticker values against the scheduled grid drops from ~600ns with one-sided tails beyond 1.4us to 0ns +/- 0.5us with no observed tails. The cost is one nanotime call per channel-timer firing. Synctest bubbles keep their fake-clock delta. Fixes #80893 --- src/runtime/time.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/runtime/time.go b/src/runtime/time.go index daf20cf4331197..477b342d2f58d7 100644 --- a/src/runtime/time.go +++ b/src/runtime/time.go @@ -1202,6 +1202,9 @@ func (t *timer) unlockAndRun(now int64, bubble *synctestBubble) { } } + if t.isChan && bubble == nil { + delay += nanotime() - now + } f(arg, seq, delay) if t.isChan {