【译文】原文地址
将goroutine从一个操作系统线程切换到另一个线程是有代价的,如果切换太频繁会降低应用程序的速度。随着Go发展,调度器已经解决了这个问题。在并发工作时,调度器提供goroutine和线程的亲和性。先回顾几年前的调度器来理解这种改进过程。
Go老版本存在的问题
在Go 1.0&1.1早期,当创建更多os线程(即将GOMAXPROCS值设置更大)来运行并发程序时将会面临性能下降的问题。让我们从文档中使用channel来计算质数的例子开始:
package main
import "fmt"
//Send the sequence 2, 3, 4, ... to channel 'ch'.
func Generate(ch chan<- int) {
for i := 2; ;i++ {
ch <- i
}
}
//Copy the values from channel 'in' to channel 'out',
//removing those divisable by 'prime'.
func Filter(in <-chan int, out chan<- int, prime int) {
for{
i := <-in //Receive value from 'in'.
if i % prime !=0 {
out <- i //send 'i' to 'out'.
}
}
}
// The prime sieve: Daisy-chain Filter processes.
func main() {
ch := make(chan int)
go Generate(ch)
for i :=0; i <10; i++{
prime := <-ch
fmt.Println(prime)
ch1 := make(chan int)
go Filter(ch, ch1, prime)
ch = ch1
}
}
以下是Go 1.0.3版本,在不同GOMAXPROCS值下,计算10万个质数的基准测试结果:
name time/op
Sieve 19.2s ± 0%
Sieve-2 19.3s ± 0%
Sieve-4 20.4s ± 0%
Sieve-8 20.4s ± 0%
要理解这些结果,我们需要理解此时调度器是如何设计的。在Go第一个版本中,调度器只有一个全局队列,所有的线程都可以推送和获取goroutines。下面是一个应用程序实例,该应用程序最多运行两个操作系统线程M,通过设置GOMAXPROCS=2来实现:
只有一个队列并不能保证goroutine将在同一个线程上恢复执行。第一个线程准备就绪,会获取一个等待的goroutine运行。因此,这里就会涉及到goroutine从一个线程到另一个线程的切换,在性能方面会产生消耗。下面是一个阻塞式channel例子:
-
G7协程阻塞在channel上,等待channel中发送来的数据。一旦channel有数据可接收,该协程会被推送到全局队列当中。
-
然后,channel推送消息,GX协程将在一个准备就绪的线程上运行,而G8协程将阻塞在channel上:
-
此时 G7协程就会被调度到该线程上去:
Goroutine现在在不同的线程上运行。只有一个全局调度队列会迫使调度程序只有一个互斥锁来覆盖所有的goroutine调度操作。以下是使用pprof工具获取的CPU概况:
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在多处理器上没有优势,在Go1.1中已经通过一个新的调度器解决了。
并发时的亲和性
Go 1.1实现了一个新的调度器,并创建了本地调度队列。如果有本地goroutines调度队列并允许他们运行在同一个OS线程上,这个改进避免了锁定整个调度程序。
由于线程可能在系统调用时阻塞,并且这种阻塞的线程数量是没有限制的,Go引入了processes的概念。处理器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的操作只与channels相关。但是,如果调度程序改进了goroutine和线程之间的亲和性,那么在某些情况下,这种亲和性需要降低。
限制亲和性
要了解亲和性的限制,我们必须了解何时会进入本地队列和全局队列。本地队列将用于除了系统调用外的所有操作,例如阻塞在通道上和select操作,以及等待计时器和锁,goroutine都会进入本地调度队列。然而,有两个特例可以限制goroutine和线程的亲和性:
- 工作窃取。当处理器P在本地队列没有足够的goroutine可调度,将会从其他P中窃取,并且全局队列和网络轮询都为空。被窃取的goroutine就会在别的线程执行。
- 系统调用。当发生系统调用(如文件操作,http调用,数据库操作等),Go以阻塞模式挂起正在运行的os线程,让新的线程来处理当前P上的本地队列。
但是,为了更好地管理本地队列优先级,以上两个约束可以避免。Go 1.5为了给goroutine在channel上来回通信提供更多的优先级,因此通过指定线程以优化亲和性。
排序来提高亲和性
goroutine在通道上来回通信导致频繁的阻塞,例如频繁在本地队列中排队。然而,由于本地队列有一个FIFO实现,未阻塞的goroutine不能保证马上得到运行,如果线程被其他goroutine占用。下面是一个关于一个之前被channel阻塞但现在可执行的goroutine例子:
G9在被channel阻塞后恢复。但是,它必须等G2、G5和G4才能执行。在这个例子中,G5将占用线程导致G9延迟执行,会导致G9被其他处理器窃取的风险。从Go 1.5开始,从通道中恢复的goroutine将优先被执行,这主要归功于P的一个特殊属性:
G9现在被标记为下一个可执行goroutine。这个新的优先级允许goroutine从通道中恢复就马上得到执行。然后其他goroutine现在将有运行时间。这一改变总体对Go标准库产生积极影响,改善了一些包的性能。