3

更精准的sleep

 9 months ago
source link: https://colobu.com/2023/12/07/more-precise-sleep/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

更精准的sleep

书接上回。昨天我写了一篇《这个限流库两个大bug存在了半年之久,没人发现?》,提到了Go语言中的time.Sleep函数的问题。有网友也私下和我探讨,提到这个可能属于系统的问题,因为现代的操作系统都是分时操作系统,每个线程可能会分配一个或者多个时间片,Windows默认线程时间精度在15毫秒,Linux在1毫秒,所以time.Sleep的精度不可能那么高。

嗯,理论上这可以解释time.Sleep的行为,但是没有办法解释网友提出的在go 1.16之前的版本中,time.Sleep的精度更高,而go 1.16之后的版本中,time.Sleep的精度更低的问题。

这个问题在Go的bug系统中有很多,不只是单单上篇文章介绍的#44343, 比如#29485、#61456、#44476、#44608、#61042。这些bug中Ian Lance Taylor的有些评论很有价值,对于了解Go运行时的Sleep很有帮助。但是阅览了这么多的bug,没有人给出为啥go 1.16之后的版本中,time.Sleep的精度更低的解释,到底发生了啥?或许和Timer调度的变化有关。

Linux和Windows提供了更高精度的Sleep, Go开发者也在尝试解决Windows中过长的问题。

为了把这个问题说明白,我们举一个典型的例子,这里我使用了loov/hrtime,它能提供更高精度的时间和benchmark方法。看到作者的名字我觉得眼熟,果然,作者的一个项目lensm也非常有名。

intervals := []time.Duration{time.Nanosecond, time.Millisecond, 50 * time.Millisecond}
for _, interval := range intervals {
fmt.Printf("sleep %v\n", interval)
b := hrtime.NewBenchmark(100)
for b.Next() {
time.Sleep(interval)
fmt.Println(b.Histogram(10))

我们尝试使用time.Sleep休眠1纳秒、1微秒和50微秒,可以看到实际休眠的时间基本在380ns1ms50ms。我是在腾讯云上的一台Linux轻量级服务器上测试的,可以看到time.Sleep休眠1毫秒以上还是和实际差不太多的,但是休眠1纳秒是不太可能的,这也符合我们的预期,只是实际休眠的时间是380纳秒还是挺长的。

ubuntu@lab:~/workplace/timer$ go run main.go
sleep 1ns
avg 726ns; min 380ns; p50 476ns; max 22.4µs;
p90 670ns; p99 22.4µs; p999 22.4µs; p9999 22.4µs;
380ns [ 99] ████████████████████████████████████████
5µs [ 0]
10µs [ 0]
15µs [ 0]
20µs [ 1]
25µs [ 0]
30µs [ 0]
35µs [ 0]
40µs [ 0]
45µs [ 0]
sleep 1ms
avg 1.06ms; min 1.02ms; p50 1.06ms; max 1.09ms;
p90 1.07ms; p99 1.09ms; p999 1.09ms; p9999 1.09ms;
1.02ms [ 2] █▌
1.03ms [ 6] █████
1.04ms [ 0]
1.05ms [ 1] ▌
1.06ms [ 48] ████████████████████████████████████████
1.07ms [ 39] ████████████████████████████████
1.08ms [ 3] ██
1.09ms [ 1] ▌
1.1ms [ 0]
1.11ms [ 0]
sleep 50ms
avg 50.1ms; min 50.1ms; p50 50.1ms; max 50.1ms;
p90 50.1ms; p99 50.1ms; p999 50.1ms; p9999 50.1ms;
50.1ms [ 2] ██
50.1ms [ 0]
50.1ms [ 0]
50.1ms [ 1] █
50.1ms [ 13] ███████████████
50.1ms [ 34] ████████████████████████████████████████
50.1ms [ 31] ████████████████████████████████████
50.2ms [ 15] █████████████████▌
50.2ms [ 2] ██
50.2ms [ 2] ██

其实Linux提供了一个更高精度的系统调用nanosleep,可以提供纳秒级别的休眠,它是一个阻塞的系统调用,会阻塞当前线程,直到睡眠结束或被中断。

nanosleep系统调用和标准库的time.Sleep的主要区别:

  • 阻塞方式不同:
    • nanosleep 会阻塞当前线程,直到睡眠结束或被中断
    • time.Sleep 会阻塞当前 goroutine
  • 精度不同:
    • nanosleep 可以精确到纳秒
    • time.Sleep 最高只能精确到毫秒
  • 中断处理不同:
    • nanosleep 可以通过信号中断并立即返回
    • time.Sleep 不可以中断,只能等待睡眠期满
  • 用途不同:
    • nanosleep 主要用于需要精确睡眠时间的低级控制
    • time.Sleep 更适合高级逻辑控制,不需要精确睡眠时间

我们使用上面的测试代码,使用nanosleep替换time.Sleep,看看效果:

for _, interval := range intervals {
fmt.Printf("nanosleep %v\n", interval)
req := syscall.NsecToTimespec(int64(interval))
b := hrtime.NewBenchmark(100)
for b.Next() {
syscall.Nanosleep(&req, nil)
fmt.Println(b.Histogram(10))

运行这段代码可以得到结果:

nanosleep 1ns
avg 60.4µs; min 58.7µs; p50 60.2µs; max 77.5µs;
p90 61.2µs; p99 77.5µs; p999 77.5µs; p9999 77.5µs;
58.8µs [ 33] █████████████████████▌
60µs [ 61] ████████████████████████████████████████
62µs [ 1] ▌
64µs [ 3] █▌
66µs [ 0]
68µs [ 0]
70µs [ 1] ▌
72µs [ 0]
74µs [ 0]
76µs [ 1] ▌
nanosleep 1ms
avg 1.06ms; min 1.03ms; p50 1.06ms; max 1.07ms;
p90 1.06ms; p99 1.07ms; p999 1.07ms; p9999 1.07ms;
1.04ms [ 1]
1.04ms [ 0]
1.05ms [ 0]
1.05ms [ 0]
1.06ms [ 0]
1.06ms [ 5] ██
1.07ms [ 92] ████████████████████████████████████████
1.07ms [ 1]
1.08ms [ 1]
1.08ms [ 0]
nanosleep 50ms
avg 50ms; min 50ms; p50 50ms; max 50ms;
p90 50ms; p99 50ms; p999 50ms; p9999 50ms;
50.1ms [ 3] ███▌
50.1ms [ 5] ██████
50.1ms [ 26] █████████████████████████████████▌
50.1ms [ 31] ████████████████████████████████████████
50.1ms [ 18] ███████████████████████
50.1ms [ 16] ████████████████████▌
50.1ms [ 1] █
50.1ms [ 0]
50.1ms [ 0]
50.1ms [ 0]

可以看到在程序休眠1纳秒时, nanosleep实际休眠60纳秒,相比于tome.Sleep的380纳秒,精度提高了很多。但是在休眠1毫秒和50毫秒时,nanosleep和time.Sleep的精度差不多,都是1毫秒和50毫秒。

既然nanosleep可以提高精度,那么我们能不能以后就使用这个系统调用来代替time.Sleep呢?答案是视情况而定,你需要注意nanosleep是一个阻塞的系统调用,Go程序在调用它时,会将当前线程阻塞,直到休眠结束或者被中断,它会额外占用一个线程。如果你的程序中有很多的goroutine,那么你的程序可能会因为阻塞而导致性能下降。所以你需要权衡一下,如果你的程序中有很多的goroutine,而且你的程序中的goroutine需要休眠,那么你可以考虑使用time.Sleep,如果你的程序中的goroutine不多,而且你的程序中的goroutine需要精确的休眠时间,那么你可以考虑使用nanosleep

而且,当前Go并不会将nanosleep占用的线程主动释放,而且放在池中备用,在并发nanosleep调用的时候,可能会导致线程数暴增,下面的代码演示了这个情况:

func Threads() {
var threadProfile = pprof.Lookup("threadcreate")
fmt.Printf(("threads in starting: %d\n"), threadProfile.Count())
var sleepTime time.Duration = time.Hour
req := syscall.NsecToTimespec(int64(sleepTime))
for i := 0; i < 100; i++ {
go func() {
syscall.Nanosleep(&req, nil)
time.Sleep(10 * time.Second)
fmt.Printf(("threads in nanosleep: %d\n"), threadProfile.Count())

在我的轻量级服务器上,显示结果如下:

threads in starting: 4
threads in nanosleep: 103

nanosleep并发运行的时候,可以看到线程数达到了103个。线程数暴增会导致系统资源的浪费,而且程序性能也会下降。

当然如果你对threadcreate有疑义,也可以使用pstree查看程序当前的线程数。

线程不会释放的问题,已经在Go的bug系统中提出了,但是目前还没有解决,不过你可以通过增加runtime.LockOSThread()这个技巧来释放线程。注意没有调用UnlockOSThread():

for i := 0; i < 100; i++ {
go func() {
syscall.Nanosleep(&req, nil)
runtime.LockOSThread()

本文并没有对生产环境做任何的建议,只是分析了:

  • time.Sleepnanosleep的精度问题
  • nanosleep的使用方法
  • nanosleep的陷阱

算是对上一篇文章的延伸。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK