并发和调度的关系

注:该文章基于Go 1.13

将Go协程从一个系统线程切换到另一个系统线程有一定开销,若触发非常频繁,将降低应用的性能。但随着时间的推移,Go调调度器已经解决了这个问题。它负责协调并发工作时协程和系统线程间的关系。让我们回到几年前,看看这个问题是如何改进的。

原始问题

在Go的早期,1.0和1.1版本,在多线程上(如提高GOMAXPROCS数值)执行并发的代码有性能问题。我们看看文档中素数筛的代码示例:

这是GOMAXPROCS非值1时,Go 1.0.3版本,计算前10万素数的压测数据。

1
2
3
4
5
name     time/op
Sieve 19.2s ± 0%
Sieve-2 19.3s ± 0%
Sieve-4 20.4s ± 0%
Sieve-8 20.4s ± 0%

为了理解上面的测试结果,需要先了解当时的调度器是如何设计的。在Go的第一个版本中,调度器只有一个全局队列,所有的线程都从这里存取协程。下图所示为运行了两个系统线程M的程序,通过设置GOMAXPROCS=2实现:

只有一个队列无法保证协程恢复时,仍在相同的线程上执行。第一个就绪的线程会选取一个等待中的协程并运行它。因此,必然导致协程在另一个线程上执行,同时付出相当大的性能代价。以阻塞的管道举例:

  • 协程#7的管道阻塞并等待消息。当消息到达时,协程被推入全局队列。

  • 然后,管道发送消息,协程#X将在空闲线程上执行,而协程#8因管道阻塞

  • 协程#7此时将在空闲线程上执行

此时协程在不同的线程上执行了。只有一个全局队列也迫使调度器使用全局锁来控制对协程的调度操作。这里是通过pprof的CPU分析

1
2
3
4
5
6
7
8
9
10
11
Total: 8679 samples
3700 42.6% 42.6% 3700 42.6% runtime.procyield
1055 12.2% 54.8% 1055 12.2% runtime.xchg
753 8.7% 63.5% 1590 18.3% runtime.chanrecv
677 7.8% 71.3% 677 7.8% dequeue
438 5.0% 76.3% 438 5.0% runtime.futex
367 4.2% 80.5% 5924 68.3% main.filter
234 2.7% 83.2% 5005 57.7% runtime.lock
230 2.7% 85.9% 3933 45.3% runtime.chansend
214 2.5% 88.4% 214 2.5% runtime.osyield
150 1.7% 90.1% 150 1.7% runtime.cas

procyieldxchgfutexlock都是调度器的全局锁相关。显而易见,程序的大部分消耗都在锁上。

这些问题使得Go无法利用多核,因此在Go 1.1版本中修复了。

并发亲和性(Affinity in concurrency)

Go1.1带来了新的调度器实现及本地协程队列的创建。这个优化使用本地协程队列避免了对整个调度器加锁,并允许协程在相同的系统线程上运行。

由于线程会因系统调用而阻塞,且阻塞线程的数量是无限制的,Go引入了处理器(processor)的概念。一个处理器P表示一个运行的系统线程并管理者本地协程队列。新的图示:

这是用Go 1.1.2中新的调度器压测的结果:

1
2
3
4
5
name     time/op
Sieve 18.7s ± 0%
Sieve-2 8.26s ± 0%
Sieve-4 3.30s ± 0%
Sieve-8 2.64s ± 0%

此时,Go真正发挥了多核的优势。CPU分析也改变了:

1
2
3
4
5
6
7
8
9
10
11
Total: 630 samples
163 25.9% 25.9% 163 25.9% runtime.xchg
113 17.9% 43.8% 610 96.8% main.filter
93 14.8% 58.6% 265 42.1% runtime.chanrecv
87 13.8% 72.4% 206 32.7% runtime.chansend
72 11.4% 83.8% 72 11.4% dequeue
19 3.0% 86.8% 19 3.0% runtime.memcopy64
17 2.7% 89.5% 225 35.7% runtime.chansend1
16 2.5% 92.1% 280 44.4% runtime.chanrecv2
12 1.9% 94.0% 141 22.4% runtime.lock
9 1.4% 95.4% 98 15.6% runqput

大部分锁相关的操作被移除了,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标准库有全面的积极影响