注:该文章基于Go 1.13
将Go协程从一个系统线程切换到另一个系统线程有一定开销,若触发非常频繁,将降低应用的性能。但随着时间的推移,Go调调度器已经解决了这个问题。它负责协调并发工作时协程和系统线程间的关系。让我们回到几年前,看看这个问题是如何改进的。
原始问题
在Go的早期,1.0和1.1版本,在多线程上(如提高GOMAXPROCS
数值)执行并发的代码有性能问题。我们看看文档中素数筛的代码示例:
这是GOMAXPROCS
非值1时,Go 1.0.3版本,计算前10万素数的压测数据。
1 |
|
为了理解上面的测试结果,需要先了解当时的调度器是如何设计的。在Go的第一个版本中,调度器只有一个全局队列,所有的线程都从这里存取协程。下图所示为运行了两个系统线程M
的程序,通过设置GOMAXPROCS=2
实现:
只有一个队列无法保证协程恢复时,仍在相同的线程上执行。第一个就绪的线程会选取一个等待中的协程并运行它。因此,必然导致协程在另一个线程上执行,同时付出相当大的性能代价。以阻塞的管道举例:
协程#7的管道阻塞并等待消息。当消息到达时,协程被推入全局队列。
然后,管道发送消息,协程#X将在空闲线程上执行,而协程#8因管道阻塞
协程#7此时将在空闲线程上执行
此时协程在不同的线程上执行了。只有一个全局队列也迫使调度器使用全局锁来控制对协程的调度操作。这里是通过pprof
的CPU分析
1 |
|
procyield
,xchg
,futex
和lock
都是调度器的全局锁相关。显而易见,程序的大部分消耗都在锁上。
这些问题使得Go无法利用多核,因此在Go 1.1版本中修复了。
并发亲和性(Affinity in concurrency)
Go1.1带来了新的调度器实现及本地协程队列的创建。这个优化使用本地协程队列避免了对整个调度器加锁,并允许协程在相同的系统线程上运行。
由于线程会因系统调用而阻塞,且阻塞线程的数量是无限制的,Go引入了处理器(processor)的概念。一个处理器P
表示一个运行的系统线程并管理者本地协程队列。新的图示:
这是用Go 1.1.2中新的调度器压测的结果:
1 |
|
此时,Go真正发挥了多核的优势。CPU分析也改变了:
1 |
|
大部分锁相关的操作被移除了,chanXXXX
相关的操作仅和管道相关。但是,若调度器优化了协程与线程间的亲和性,在某些场景下亲和性会被减弱。
亲和性受限(Affinity limitation)
为理解亲和性受限,我们需要理解被放入本地和全局队列的东西是什么。所有操作,如管道/select
阻塞,等待定时器,锁都会使用本地队列除了系统调用
。因此,两个特性会限制协程和线程的亲和性:
- 协程窃取。当处理器
P
没有足够的本地队列任务时,它会从其他P
中窃取协程,若全局队列和network poller中都是空的。发生协程窃取时,协程会在另一个线程上运行。 - 系统调用。发生系统调用时(如文件操作,http调用,数据库操作等),Go将运行的系统线程变更为阻塞状态,让另一个新的线程接管当前
P
进而处理本地写成队列。
但,为了更好的管理本地队列的优先级,这两个限制可以避免。Go 1.5旨在给在管道中通信的协程更高的优先级,从而优化协程与线程的亲和性。
排序以强化亲和性(Ordering to enhance affinity)
在管道上来回通信的协程会频繁的阻塞,亦即频繁的重入本地队列。但,本地队列遵守FIFO
原则,若有协程独占线程,被解锁的协程无法保证被及时执行。下图为被阻塞协程恢复可运行的例子:
在因管道阻塞后协程#9恢复了(管道接收到消息)。但,它必须等待协程#2,#5和#4结束才能运行。在这个例子中,协程#5将独占线程,使得协程#9被延期执行,从而有被其他线程窃取的风险。从Go 1.5开始,从阻塞管道返回的协程会有更高的优先级(通过处理器P
的一个特殊属性实现):
协程#9此时被标记为下一个可运行的。这个优先级特性使得协程能尽快被处理避免被再次阻塞。然后,其他协程才能获得运行时间。这个改变对Go标准库有全面的积极影响。