High-precision replacements for Go's
time.Sleep,time.Ticker, andtime.Timerusing OS-specific timing primitives.
Go's standard time.Ticker, time.Timer, and time.Sleep route through the
runtime's internal timer system, which batches and coalesces timers to reduce
scheduling overhead. The result is typical resolution of 1–15ms depending on
OS, load, and garbage collector activity. For many applications this is fine, but
for latency-sensitive workloads it's a problem:
- Audio synthesis and DSP need sample-accurate callbacks at 44.1/48/96 kHz (10–23us periods). A 5ms scheduling jitter produces audible glitches.
- Game loops and render ticks targeting 120–240 Hz (4–8ms frames) lose precision to timer coalescing, causing frame pacing stutter.
- High-frequency trading and market data require sub-millisecond wakeups for order management and event processing.
- Real-time control systems (robotics, hardware-in-the-loop simulation) rely on deterministic timing that can't tolerate scheduler jitter.
The root causes in Go are:
- Timer coalescing — the runtime groups nearby timers into a single wake-up to amortize context switch costs, adding up to several milliseconds of delay.
- Goroutine scheduling — a timer firing means "the goroutine becomes runnable," not "the goroutine runs now." It waits in the run queue behind other goroutines.
- GC stop-the-world pauses — all goroutines are frozen during STW phases (typically <100us, but unbounded under memory pressure), adding unpredictable jitter to any Go-scheduled timer.
hpt addresses all three by bypassing the Go scheduler entirely:
- Each timer locks its goroutine to a dedicated OS thread and sleeps using
the kernel's native high-precision primitives (
clock_nanosleepon Linux,keventwithNOTE_CRITICALon macOS, high-resolution waitable timers on Windows). - Tick deadlines are computed as absolute monotonic times (
start + N*period) rather than relative sleeps, preventing accumulated drift. - When cgo is available, the
Tickerloop runs on a raw pthread that is invisible to Go's garbage collector — making tick timing completely immune to GC pauses.
go get github.com/daniel-sullivan/go-hpt
hpt.Sleep(500 * time.Microsecond)ticker := hpt.NewTicker(1 * time.Millisecond)
defer ticker.Stop()
for tick := range ticker.C {
process(tick)
}timer := hpt.NewTimer(10 * time.Millisecond)
<-timer.C
// Or call a function after a delay.
hpt.AfterFunc(5*time.Millisecond, func() {
fmt.Println("fired")
})
// Channel shorthand.
<-hpt.After(1 * time.Millisecond)Use the same high-precision clock the library uses internally:
start := hpt.Now()
hpt.Sleep(d)
elapsed := hpt.Since(start)| Linux | macOS | Windows | |
|---|---|---|---|
| Clock | clock_gettime(CLOCK_MONOTONIC) |
mach_absolute_time (cgo) / clock_gettime (no cgo) |
QueryPerformanceCounter |
| Sleep | clock_nanosleep(TIMER_ABSTIME) |
kevent(NOTE_CRITICAL) + spin |
CreateWaitableTimerExW(HIGH_RESOLUTION) + spin |
| Ticker | pthread (cgo) / LockOSThread (no cgo) |
pthread (cgo) / LockOSThread (no cgo) |
LockOSThread |
When cgo is available (the default with a C compiler), the Ticker runs on a
dedicated pthread immune to GC pauses. With CGO_ENABLED=0 or when
cross-compiling, it falls back to a goroutine with runtime.LockOSThread — still
far more precise than stdlib, but subject to GC jitter. Windows always uses the
pure-Go path.
Results below were measured on a dedicated local machine (Apple M4 Max, macOS). Run benchmarks on your own hardware — no clone required:
go run github.com/daniel-sullivan/go-hpt/cmd/bench@latestPass -json results.json to emit a machine-readable report.
Generated on 2026-04-16 —
mise run benchmarks
Lower is better for all metrics. Impr. = how many times more precise hpt is vs time. Columns without "no cgo" use the default cgo build (pthread ticker, GC-immune).
| macOS (arm64) | macOS (arm64) no cgo | ||||||
|---|---|---|---|---|---|---|---|
hpt |
time |
Impr. | hpt |
time |
Impr. | ||
| Sleep | 100µs | 1.185µs |
21.009µs |
17.7x | 104.537µs |
120.121µs |
1.1x |
| 500µs | 5.704µs |
93.619µs |
16.4x | 162.25µs |
118.288µs |
0.7x | |
| 1ms | 5.364µs |
143.086µs |
26.7x | 27.581µs |
325.73µs |
11.8x | |
| 5ms | 12.473µs |
588.726µs |
47.2x | 106.258µs |
622.392µs |
5.9x | |
| Ticker | Median jitter | 3.708µs |
4.209µs |
— | 3µs |
6µs |
— |
| Mean jitter | 10.537µs |
10.07µs |
1.0x | 17.504µs |
20.232µs |
1.2x | |
| p95 jitter | 44.5µs |
42.792µs |
— | 53µs |
85µs |
— | |
| p99 jitter | 92.458µs |
87.417µs |
0.9x | 178µs |
178µs |
1.0x | |
| Max jitter | 275.458µs |
465µs |
— | 2.281ms |
941µs |
— | |
| Total drift | -48.875µs |
109.667µs |
— | 27µs |
110µs |
— | |
| Timer | 1ms | 8.756µs |
143.843µs |
16.4x | 5.762µs |
136.42µs |
23.7x |
| 5ms | 34.285µs |
634.364µs |
18.5x | 25.262µs |
585.256µs |
23.2x | |
-
Thread consumption — each active
Ticker,Timer, orSleepconsumes a dedicated OS thread. Don't create thousands of concurrent hpt timers. This package is for a small number of high-precision timing sources, not general-purpose scheduling. -
Overshoot, not undershoot — the library guarantees it will never return before the requested deadline. A small overshoot of a few clock cycles is expected. Use
hpt.Now()/hpt.Since()to measure with the same monotonic clock the sleep primitives use. -
GC and the channel — with cgo, the pthread fires ticks precisely, but the Go goroutine forwarding them to the channel can still be briefly paused by GC. The tick timing is GC-immune; the channel delivery has minimal GC jitter.
MIT — see LICENSE.
