将一个goroutine从一个OS线程切换到另一个线程是有成本的,并且如果这种情况发生得太频繁,可能会使应用程序变慢。但是,随着时间的流逝,Go调度程序已经解决了这个问题。现在,当并发工作时,它可以在goroutine和线程之间提供关联。让我们回溯几年前来了解这种改进。
原始问题
在Go的早期,比如Go 1.0和1.1,当使用更多OS线程(即,更高的GOMAXPROCS
值)运行并发代码时,该语言将面临性能下降的问题。让我们从计算素数的文档中使用一个示例开始:
这是使用多个GOMAXPROCS
值计算前十万个素数时Go 1.0.3的基准:
name time/op
Sieve 19.2s ± 0%
Sieve-2 19.3s ± 0%
Sieve-4 20.4s ± 0%
Sieve-8 20.4s ± 0%
要了解这些结果,我们需要了解此时如何设计调度程序。在Go的第一个版本中,调度程序只有一个全局队列,所有线程都可以在其中推送并获取goroutine。这是一个应用程序的示例,该应用程序最多将两个操作系统线程(以下架构中的M
)运行(通过将GOMAXPROCS
设置为两个来定义):
仅具有一个队列并不能保证goroutine将在同一线程上恢复。准备就绪的第一个线程将提取一个等待的goroutine并将其运行。因此,它涉及将goroutines从一个线程转移到另一个线程,并且在性能方面代价很高。这是一个带有阻塞通道的示例:
- Goroutine#7在通道上阻塞,正在等待消息。收到消息后,goroutine将推入全局队列:
- 然后,通道推送消息,并且goroutine #X将在可用线程上运行,而goroutine#8在通道上阻塞:
- goroutine#7现在在可用线程上运行:
现在,goroutine在不同的线程上运行。具有单个全局队列也将迫使调度程序具有一个覆盖所有goroutines调度操作的单个全局互斥量。这是使用pprof
创建的CPU配置文件,其中GOMAXPROCS
设置为height:
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
procyield
,xchg
,futex
和lock
都与Go调度程序的全局互斥量有关。我们清楚地看到,应用程序将大部分时间都花在了锁定上。
这些问题不允许Go发挥处理器的优势,并且在Go 1.1中使用新的调度程序解决了这些问题。
并发中的亲和性
Go 1.1附带了新调度程序的实现和本地goroutine队列的创建。如果存在本地goroutine,此改进避免了锁定整个调度程序,并允许它们在同一OS线程上工作。
由于线程可以阻塞系统调用,并且不受限制的线程数没有限制,因此Go引入了处理器的概念。处理器P代表一个正在运行的OS线程,它将管理本地goroutine队列。这是新的架构:
这是Go 1.1.2中新计划程序的新基准:
name time/op
Sieve 18.7s ± 0%
Sieve-2 8.26s ± 0%
Sieve-4 3.30s ± 0%
Sieve-8 2.64s ± 0%
Go现在真正利用了所有可用的CPU。 CPU配置文件也已更改:
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的操作仅与通道相关。但是,如果调度程序改善了goroutine和线程之间的亲和力,则在某些情况下可以减少这种亲和力。
亲和性限制
要了解亲和性的限制,我们必须了解对本地和全局队列的处理。本地队列将用于所有需要系统调用的操作,例如阻塞通道和选择的操作,等待计时器和锁定。但是,两个功能可能会限制goroutine和线程之间的关联:
- Worker抢夺。当处理器
P
的本地队列中没有足够的worker时,如果全局队列和网络轮询器为空,它将从其他P
窃取goroutine。当被抢夺时,goroutine将在另一个线程上运行。 - 系统调用。当发生系统调用时(例如文件操作,http调用,数据库操作等),Go会将运行中的OS线程移入阻塞模式,让新线程处理当前P上的本地队列。
但是,通过更好地管理本地队列的优先级,可以避免这两个限制。 Go 1.5旨在为goroutine在通道上来回通信提供更高的优先级,从而优化与分配的线程的亲和力。
为了增强亲和力
如前所述,在通道上来回通信的goroutine会导致频繁的阻塞,即在本地队列中频繁地重新排队。但是,由于本地队列具有FIFO实现,因此,如果另一个goroutine正在占用线程,则unblock goroutine不能保证尽快运行。这是一个goroutine的示例,该例程现在可以运行并且以前在通道上被阻止:
Goroutine#9在通道上被阻塞后恢复。但是,它必须在运行之前等待#2,#5和#4。在此示例中,goroutine#5将占用其线程,从而延迟goroutine#9,并使之处于被其他处理器窃取的危险中。从Go 1.5开始,由于其 P
的特殊属性,从阻塞通道返回的goroutine现在将优先运行:
Goroutine#9现在被标记为下一个可运行的。这种新的优先级划分功能使goroutine可以在再次被阻塞之前迅速运行。然后,其他goroutine将具有运行时间。此更改对Go标准库改善了某些软件包的性能产生了总体积极影响。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。