The Go Windows port added support for high-resolution timers in Go 1.23, boosting resolution from ~15.6ms to ~0.5ms. This enhancement affects time.Timer, time.Ticker, and functions that put the goroutine to sleep, such as time.Sleep.
Gopher image by Maria Letta, licensed under CC0 1.0.
Using high resolution timers on Windows has been a long-standing request, tracked in golang/go#44343 and implemented by us (the Microsoft Go team) in CL 488675. It is important to note that Go used high-resolution timers on Windows before Go 1.16, but they were removed in CL 232298 because their implementation unfortunately conflicted with the Go scheduler, leading to latency issues. There wasn’t a good solution to this problem until recently.
Let’s explore how high-resolution timers made their way back to Go’s standard library and why it’s important.
The Naive Approach
A basic time.Sleep
implementation (let’s call it the naive Sleep
from now on) that uses the Win32 high-resolution timer API might look like this:
func Sleep(d time.Duration) {
// Pseudo-code
timer, _ := CreateWaitableTimerExW(0, 0, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS)
SetWaitableTimer(timer, d, 0, 0, 0, 0)
WaitForSingleObject(timer, windows.INFINITE)
}
However, this approach blocks the OS thread, which doesn’t mesh well with Go’s concurrency model. Go’s goroutines are multiplexed onto a small number of threads. Blocking a thread directly can severely limit Go’s concurrency capabilities.
Consider this code using the naive Sleep
implementation:
func count() {
for i := 0; i < 10; i++ {
Sleep(1 * time.Second)
fmt.Print(i)
}
}
func main() {
runtime.GOMAXPROCS(1) // Limit the number of threads to 1
go count()
Sleep(12 * time.Second)
fmt.Println("Done")
}
You might expect it to print 0123456789Done
, but it actually prints Done
.
Why? Because the single thread is blocked by the naive Sleep
in the main goroutine, preventing the count goroutine from running.
Note that if the naive Sleep
were implemented using the syscall
package, this would work.
The Go runtime always schedules syscalls on a separate thread that doesn’t count against the GOMAXPROCS
limit.
Unfortunately, potentially creating a new thread for each timer is inefficient and could reduce the timer’s effective resolution, so this trick isn’t used in the Go standard library.
The Go Scheduler Solution
The time package integrates tightly with the Go scheduler, which is responsible for deciding which goroutine should be executed next and scheduling the goroutines to the kernel threads.
When time.Sleep
is called, the scheduler adds a virtual timer to a list of active timers.
The scheduler checks this list to wake up the associated goroutine when a timer expires.
If no work is pending and no timers have expired, the scheduler puts the current thread to sleep until the next timer’s expiration time.
You might think the naive Sleep
could work here, but new work might arrive before the timer expires.
The Go scheduler needs to wake up the sleeping thread to handle this new work, which isn’t possible with the naive implementation.
On Windows, the Go scheduler solves the concurrent I/O operations and timers issue by using I/O Completion Ports (IOCP).
It calls GetQueuedCompletionStatusEx to wait for I/O work up to a specified timeout, which is the next timer’s expiration time.
Simplifying, the time.Sleep
implementation before Go 1.23 was this:
func Sleep(d time.Duration) {
var entries [64]OVERLAPPED_ENTRY
var n int
err := GetQueuedCompletionStatusEx(iocph, &entries[0], len(entries), &n, d, 0)
if err == WAIT_TIMEOUT {
// A timer expired.
}
// There is new I/O work to do.
}
The problem is that this function’s resolution is ~15.6ms, which isn’t sufficient for the time
package.
Enter High-Resolution Timers
As of Go 1.23, the Go scheduler associates a high-resolution timer with the IOCP port using the Windows API NtAssociateWaitCompletionPacket. When the timer expires, the Windows kernel wakes up the sleeping thread –if it hasn’t already been woken up by new I/O work arriving. Then, the Go scheduler uses the thread to run the sleeping goroutine.
The solution is elegant and simple, and because it only uses APIs available since Windows 10, it works on all versions of Windows supported by the Go Windows port.
It would have been nice to implement this solution rather than dropping high-resolution timers in Go 1.16.
It was technically possible: the Windows APIs did already exist.
However, NtAssociateWaitCompletionPacket
was only documented in 2023, and relying on undocumented APIs is not a good idea.
Back to the simplified code, the final implementation looks like this:
func Sleep(d time.Duration) {
// Create high-resolution timer.
timer := CreateWaitableTimerExW(0, 0, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, 0)
SetWaitableTimer(timer, d, 0, 0, 0, 0)
// Associate the timer with the IOCP port.
NtAssociateWaitCompletionPacket(waitiocphandle, iocph, timer, highResKey, 0, 0, 0)
// Wait for work or the timer to expire.
var entries [64]OVERLAPPED_ENTRY
var n int
err := GetQueuedCompletionStatusEx(iocph, &entries[0], len(entries), &n, 0, 0)
for _, entry := range entries[:n] {
if entry.Key == highResKey {
// A high-resolution timer expired.
}
// There is new I/O work to do.
}
}
Why Is This Important?
High-resolution timers are crucial for applications that require precise timing.
One example recently reported to the Go issue tracker is golang/go#61665, where the default Go CPU profiler sampling rate, 100 Hertz (one sample every 10 ms), was too high for the normal Windows timer resolution, leading to inaccurate profiling data.
Another example reported to the Go issue tracker is golang/go#44343, in which golang.org/x/time/rate was limiting too aggressively on Windows when the rate limit was too high, leading to a lower throughput than expected.
There are other situations where high-resolution timers aren’t necessary for correct behavior, but not having them can lead to unexpected slowdowns.
It is common for Go developers to call time.Sleep(time.Millisecond)
when they want to wait for a short period, there are at least 24,200 occurrences on GitHub alone!
The worse cases are the ones that involve a loop, as even a harmless for range 60 { time.Sleep(time.Millisecond) }
can take 1 second to complete instead of the intended 0.06 seconds.
This enhancement is available as of Go 1.23, so make sure to update your Go installation to take advantage of it.
Stay tuned for more updates, and happy coding! 🚀
0 comments