【译文】原文地址
说明本文基于Go1.14
计时器在执行代码时是很有用的。Go在内部会对计时器的创建和执行计划进行管理。后者有点复杂的,因为Go调度器是一个协作调度器,指的是Goroutine必须自己停止(被channel阻塞、系统调用)或者在某个点被调度器暂停。
更多关于抢占式可以阅读Go协程和抢占式调度
生命周期
下面是一个计时器的简单实例:
当一个计时器被创建时,会被保存到一个链接到当前P(process逻辑cpu包含线程上下文)的内部列表中。下面是前面代码的一个表示:
关于Go调度的GMP模型可以阅读Go: Goroutine, OS Thread and CPU Management
正如你在上图看到的,计时器被创建后,会注册一个内部回调函数,这个回调函数将使用Go关键词将其转化为单独的Goroutine并发执行。然后,调度器就会对计时器进行管理。在每一轮的调度中,它都会检查计时器是否到期准备运行,如果到期的话就准备运行。实际上,因为Go调度程序本身不会执行所有的代码,计时器回调函数将加入到本地调取队列中。调度器根据调度机制来找到计时器对应的gouroutine进行调度并执行。如下所示:
根据本地调度队列的大小,计时器的执行可能会有一定延时。实际上,Go1.14中具有异步抢占功能,Goroutine在运行10ms后被抢占,这降低了延时的概率。
延时
为了理解延时的可能性,让我们从一个Goroutine创建大量的计时器来分析下。由于计时器会链接到当前的P(逻辑cpu)一个处于繁忙的P将无法立即执行链接到的计时器。如下程序,创建了数百个计时器,并使当前goroutine保持忙碌状态:
下图可以看到goroutine占用处理器的跟踪轨迹:
由于异步抢占,正在执行的goroutine被划分成大量小块。在这些块中,有一个看起来比其他的更大。我们放大看下:
当计时器必须必须运行时,就出现这种空隙。此时,当前的goroutine已被抢占,并取代Go调度程序。调度程序将计时器回调函数转换成可运行的goroutines,正如我们在图片中看到的那样。然而,当前线程的Go调度程序并不是唯一运行计时器的程序。当前的P如果一直忙碌的话,Go实现了计时器窃取策略来确保计时器能被其他P执行。异步抢占,一般都很少发生,但是在前面的例子中由于计时器数量比较大,确实发生了抢占,如下图所示:
如果不考虑计时器窃取,将会发生如下情况:
所有持有计时器的goroutines都被添加到本地队列。然后,根据工作窃取分配到其他的P上执行。
可以阅读Go: Work-Stealing in Go Scheduler,了解更多关于Go工作窃取策略。
由于异步抢占和工作窃取,延迟不太可能发生。