Skip to content

daniel-sullivan/go-hpt

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

24 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

linux: tests linux: coverage macos: tests macos: coverage windows: tests windows: coverage
hpt logo

High-precision replacements for Go's time.Sleep, time.Ticker, and time.Timer using OS-specific timing primitives.

Background

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:

  1. Timer coalescing — the runtime groups nearby timers into a single wake-up to amortize context switch costs, adding up to several milliseconds of delay.
  2. Goroutine scheduling — a timer firing means "the goroutine becomes runnable," not "the goroutine runs now." It waits in the run queue behind other goroutines.
  3. 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_nanosleep on Linux, kevent with NOTE_CRITICAL on 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 Ticker loop runs on a raw pthread that is invisible to Go's garbage collector — making tick timing completely immune to GC pauses.

Installation

go get github.com/daniel-sullivan/go-hpt

Usage

Sleep

hpt.Sleep(500 * time.Microsecond)

Ticker

ticker := hpt.NewTicker(1 * time.Millisecond)
defer ticker.Stop()

for tick := range ticker.C {
    process(tick)
}

Timer

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)

Monotonic Clock

Use the same high-precision clock the library uses internally:

start := hpt.Now()
hpt.Sleep(d)
elapsed := hpt.Since(start)

Platform Details

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.

Benchmark Results

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@latest

Pass -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

Caveats

  • Thread consumption — each active Ticker, Timer, or Sleep consumes 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.

License

MIT — see LICENSE.

About

High Precision Ticker for Go

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors