Go 语言在 Windows 上的高精度计时器

665 阅读5分钟

原文链接:High-Resolution Timers on Windows | Microsoft for Go Developers

原文作者:Quim Muntal

Go Windows 移植在 Go 1.23 中增加了对高分辨率计时器的支持,分辨率从 ~15.6ms 提高到 ~0.5ms。 这一改进会影响 time.Timertime.Ticker 以及使 goroutine 进入休眠状态的函数,如 time.Sleep

image.png

Gopher image by Maria Letta, licensed under CC0 1.0.

在 Windows 实现高精度计时器是一个长期存在的请求,在 golang/go#44343 中进行了跟踪,并由我们(Microsoft Go 团队)在 CL 488675 中实现。值得注意的是,在 Go 1.16 之前,Go 曾在 Windows 上使用高精度定时器,但在 CL 232298 中被移除,因为它们的实现不幸与 Go 调度器冲突,导致延迟问题。直到最近,这个问题才有了很好的解决方案。

让我们来探究一下高精度计时器是如何回归 Go 标准库的,以及它的重要性。

不成熟的方法

使用 Win32 高分辨率定时器 API 的基本 time.Sleep 实现(从现在起,我们称其为 “不成熟” Sleep)可能如下所示:

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)
}

然而,这种方法会阻塞操作系统线程,这与 Go 的并发模型不符。Go 的 goroutines 被复用到少量线程上。直接阻塞线程会严重限制 Go 的并发能力。

请看这段使用不成熟的 Sleep 实现的代码:

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")
}

你可能以为它会打印 0123456789Done,但实际上它打印的是 Done。为什么呢?因为单线程被主 goroutine 中的不成熟 Sleep 阻塞,导致计数 goroutine 无法运行。

需要注意的是,如果使用 syscall 软件包实现不成熟的 Sleep,就可以正常运行。Go 运行时总是将系统调用安排在一个单独的线程上,该线程不计入 GOMAXPROCS 限制。不幸的是,为每个定时器创建一个新线程的效率很低,而且可能会降低定时器的有效精度,因此 Go 标准库中没有使用这种技巧。

Go 调度器解决方案

time 包与 Go 调度器紧密集成,后者负责决定下一个应该执行的 goroutine,并将 goroutine 调度到内核线程。调用 time.Sleep 时,调度器会将一个虚拟计时器添加到活动计时器列表中。调度程序会检查该列表,以便在计时器到期时唤醒相关的 goroutine。如果没有工作待处理,也没有定时器到期,调度程序会让当前线程进入休眠状态,直到下一个定时器到期。

你可能会认为不成熟的 Sleep 可以在这里奏效,但新工作可能会在定时器到期前到达。 Go 调度程序需要唤醒睡眠线程来处理这些新工作,这在不成熟的实现中是不可能的。

在 Windows 上,Go 调度程序通过使用 I/O Completion Ports (IOCP) 解决了并发 I/O 操作和计时器的问题。 它调用 GetQueuedCompletionStatusEx 来等待 I/O 工作,直至指定的超时(即下一个计时器的到期时间)。 简单来说,Go 1.23 之前的 time.Sleep 实现是这样的:

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.
}

问题是,该函数的精度为 ~15.6 毫秒,这对于 time 包来说是不够的。

使用高精度计时器

从 Go 1.23 开始,Go 调度器使用 Windows API NtAssociateWaitCompletionPacket 将高精度计时器与 IOCP 端口关联起来。 当计时器到期时,Windows 内核会唤醒睡眠的线程(如果新的 I/O 工作尚未到来)。 然后,Go 调度器就会使用该线程来运行 sleeping goroutine。

该解决方案既优雅又简单,而且由于它只使用了 Windows 10 之后可用的 API,因此它适用于 Go Windows 移植所支持的所有 Windows 版本。 如果能实现这一解决方案,而不是在 Go 1.16 中放弃高分辨率计时器,那就更好了。 这在技术上是可行的:Windows APIs 确实已经存在。 不过,NtAssociateWaitCompletionPacket 在 2023 年才被记录在案,依赖未记录的 APIs 并不是一个好主意。

回到简化后的代码,最终的实现过程如下:

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.
    }
}

为什么这很重要

高精度定时器对于需要精确定时的应用程序至关重要。 最近向 Go 问题跟踪器报告的一个示例是 golang/go#61665,其中 Go CPU 剖析器的默认采样率 100 赫兹(每 10 毫秒采样一次)对于正常的 Windows 定时器分辨率来说太高了,导致剖析数据不准确。

向 Go 问题跟踪器报告的另一个例子是 golang/go#44343,其中 golang.org/x/time/rate 在 Windows 上的限制过于苛刻,当速率限制过高时,导致吞吐量低于预期。

在其他情况下,高精度定时器并非正确行为的必要条件,但没有高精度定时器可能会导致意想不到的速度变慢。 当 Go 开发人员想要等待一小段时间时,调用 time.Sleep(time.Millisecond) 是很常见的,仅在 GitHub 上就有至少 24,200 次! 更糟糕的情况是涉及循环的情况,因为即使是一个无伤大雅的 for range 60 { time.Sleep(time.Millisecond) } 也可能需要 1 秒钟才能完成,而不是预期的 0.06 秒。

这一增强功能在 Go 1.23 中可用,因此请确保更新您的 Go 安装并从中获益。

敬请期待更多更新,祝您编码愉快! 🚀 3