Golang 在 Windows 上的高精度计时器

文摘   2024-10-10 16:40   上海  

Go 的 Windows 移植版在 Go 1.23 中增加了对高精度计时器的支持,将精度从约 15.6 毫秒提高到约 0.5 毫秒。这一增强影响了  time.Timer[1] 、 time.Ticker[2]  以及使 goroutine 休眠的函数,如  time.Sleep[3]

Gopher 图片由  Maria Letta[4]  创作,根据  CC0 1.0[5]  许可使用。

在 Windows 上使用高精度计时器一直是一个长期存在的需求,在  golang/go#44343[6]  中进行了跟踪,并由我们( Microsoft Go 团队[7] )在  CL 488675[8]  中实现。需要注意的是,Go 在 1.16 版本之前在 Windows 上使用了高精度计时器,但在  CL 232298[9]  中被移除,因为它们的实现不幸与 Go 调度器发生冲突,导致延迟问题。直到最近,这个问题还没有一个好的解决方案。

让我们来探讨一下高精度计时器是如何重新回到 Go 标准库中的,以及为什么它很重要。

朴素方法

使用 Win32 高精度计时器 API 的基本 time.Sleep 实现(我们从现在开始称之为朴素 Sleep)可能如下所示:

func Sleep(d Duration) {
    timer := createWaitableTimer()
    setWaitableTimer(timer, d)
    waitForSingleObject(timer)
    closeHandle(timer)
}

然而,这种方法会阻塞 OS 线程,这与 Go 的并发模型不太协调。Go 的 goroutine 被多路复用到少量线程上。直接阻塞线程会严重限制 Go 的并发能力。

考虑使用朴素 Sleep 实现的这段代码:

func main() {
    go func() {
        for i := 0; i < 10; i++ {
            fmt.Print(i)
        }
    }()
    Sleep(time.Second)
    fmt.Println("Done")
}

你可能期望它打印 0123456789Done,但实际上它只打印 Done。为什么?因为单个线程被主 goroutine 中的朴素 Sleep 阻塞,阻止了计数 goroutine 的运行。

请注意,如果朴素 Sleep 使用 syscall 包实现,这段代码是可以正常工作的。Go 运行时总是将系统调用调度到一个单独的线程上,该线程不计入 GOMAXPROCS 限制。不幸的是,为每个计时器可能创建一个新线程是低效的,并可能降低计时器的有效分辨率,所以 Go 标准库中没有使用这个技巧。

Go 调度器解决方案

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

你可能认为朴素 Sleep 在这里可以工作,但在计时器到期之前可能会出现新的工作。Go 调度器需要唤醒休眠的线程来处理这个新工作,而这在朴素实现中是不可能的。

在 Windows 上,Go 调度器通过使用  I/O 完成端口[11]  (IOCP) 解决了并发 I/O 操作和计时器的问题。它调用  GetQueuedCompletionStatusEx[12]  来等待 I/O 工作,最多等待到指定的超时时间,即下一个计时器的到期时间。简化后,Go 1.23 之前的 time.Sleep 实现如下:

func Sleep(d Duration) {
    timer := createTimer(d)
    for !timer.expired() {
        getQueuedCompletionStatusEx(iocp, timer.deadline())
    }
}

问题是这个函数的分辨率约为 15.6 毫秒,这对 time 包来说是不够的。

引入高分辨率计时器

从 Go 1.23 开始,Go 调度器使用 Windows API  NtAssociateWaitCompletionPacket[13]  将高分辨率计时器与 IOCP 端口关联。当计时器到期时,Windows 内核会唤醒休眠的线程 - 如果它还没有被新到达的 I/O 工作唤醒的话。然后,Go 调度器使用该线程来运行休眠的 goroutine。

这个解决方案既优雅又简单,而且因为它只使用了自 Windows 10 以来可用的 API,所以它可以在 Go Windows 移植版支持的所有 Windows 版本上工作。如果能在 Go 1.16 中实现这个解决方案而不是放弃高分辨率计时器就好了。从技术上讲,这是可能的:Windows API 已经存在。然而,NtAssociateWaitCompletionPacket 直到 2023 年才被记录,依赖 未记录的 API[14]  不是一个好主意。

回到简化的代码,最终的实现看起来像这样:

func Sleep(d Duration) {
    timer := createTimer(d)
    for !timer.expired() {
        getQueuedCompletionStatusEx(iocp, timer.deadline())
    }
}

func createTimer(d Duration) {
    timer := createWaitableTimer()
    setWaitableTimer(timer, d)
    ntAssociateWaitCompletionPacket(iocp, timer)
    return timer
}

为什么这很重要?

高分辨率计时器对于需要精确计时的应用程序至关重要。

最近向 Go 问题跟踪器报告的一个例子是  golang/go#61665[15] ,其中默认的 Go CPU 分析器采样率 100 赫兹(每 10 毫秒一个样本)对于普通的 Windows 计时器分辨率来说太高了,导致分析数据不准确。

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

还有一些情况下,高分辨率计时器对于正确的行为并不是必需的,但没有它们可能会导致意外的减速。Go 开发者经常调用 time.Sleep(time.Millisecond) 来等待一小段时间,仅在 GitHub 上就有 至少 24,200 次出现[18] !最糟糕的情况是涉及循环的情况,即使是一个无害的 for range 60 { time.Sleep(time.Millisecond) } 也可能需要 1 秒而不是预期的 0.06 秒来完成。

这项增强功能从 Go 1.23 开始可用,所以请确保更新你的 Go 安装以利用它。

请继续关注更多更新,祝编码愉快! 🚀

参考链接

  1. time.Timer: https://pkg.go.dev/time#Timer
  2. time.Ticker: https://pkg.go.dev/time#Ticker
  3. time.Sleep: https://pkg.go.dev/time#Sleep
  4. Maria Letta: https://creativemarket.com/Maria_Letta
  5. CC0 1.0: https://creativecommons.org/public-domain/cc0/
  6. golang/go#44343: https://github.com/golang/go/issues/44343
  7. Microsoft Go 团队: https://devblogs.microsoft.com/go/welcome-to-the-microsoft-for-go-developers-blog/
  8. CL 488675: https://go-review.googlesource.com/c/go/+/488675
  9. CL 232298: https://go-review.googlesource.com/c/go/+/232298
  10. time: https://pkg.go.dev/time
  11. I/O 完成端口: https://learn.microsoft.com/en-us/windows/win32/fileio/i-o-completion-ports
  12. GetQueuedCompletionStatusEx: https://learn.microsoft.com/en-us/windows/win32/fileio/getqueuedcompletionstatusex-func
  13. NtAssociateWaitCompletionPacket: https://learn.microsoft.com/en-us/windows/win32/devnotes/ntassociatewaitcompletionpacket
  14. 未记录的 API: https://learn.microsoft.com/en-us/windows/win32/w8cookbook/undocumented-apis
  15. golang/go#61665: https://github.com/golang/go/issues/61665
  16. golang/go#44343: https://github.com/golang/go/issues/44343#issuecomment-819032865
  17. golang.org/x/time/rate: https://pkg.dev.go/golang.org/x/time/rate
  18. 至少 24,200 次出现: https://github.com/search?q=language%3AGo+time.Sleep(time.Millisecond)&type=code&p=1

幻想发生器
图解技术本质
 最新文章