并发与调度亲和性
写在前面
微信公众号:[double12gzh]
关注容器技术、关注
Kubernetes
。问题或建议,请公众号留言。
将一个goroutine从一个操作系统线程切换到另一个线程是有代价的,如果发生得太频繁,会拖慢应用程序的速度。然而,通过一段时间的努力,Go调度器已经解决了这个问题。它现在提供了goroutine和线程并发工作时的亲和力。让我们回到几年前来了解一下这个改进吧。
在Go的早期,Go 1.0 & 1.1期间,当运行并发代码时,如果OS线程较多,即GOMAXPROCS的值较高,语言就会面临性能下降的问题。让我们从文档中使用的一个计算质数的例子开始说起,如下:
// A concurrent prime sieve
package main
// Send the sequence 2, 3, 4, ... to channel 'ch'.
func Generate(ch chan<- int) {
for i := 2; ; i++ {
ch <- i // Send 'i' to channel 'ch'.
}
}
// Copy the values from channel 'in' to channel 'out',
// removing those divisible 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) // Create a new channel.
go Generate(ch) // Launch Generate goroutine.
for i := 0; i < 10; i++ {
prime := <-ch
print(prime, "\n")
ch1 := make(chan int)
go Filter(ch, ch1, prime)
ch = ch1
}
}
下面是在多个不同的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。下面是一个应用程序运行的例子,在下面的模式下,通过将GOMAXPROCS设置为2可以定义最多运行两个操作系统线程(M
)。
只有一个队列并不能保证goroutine会在同一个线程上继续运行。第一个准备好的线程会接收到一个等待的goroutine,并将其运行。因此,它涉及到goroutine从一个线程到另一个线程,这在性能上是有代价的。下面是一个有阻塞通道的例子,
G7这个goroutine在通道上阻塞,并等待一个消息。一旦收到消息,goroutine就会被推送到全局队列中。
然后,通道推送消息,goroutine #X将在一个可用线程上运行,而goroutine G8则在通道上阻塞。
下面G7将会调度到可用的线程上被执行:
goroutines现在运行在不同的线程上。有了一个全局队列,也迫使调度器有一个全局mutex来管理所有goroutines的调度操作。下面是由pprof创建的CPU profile文件:
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调度器的全局mutex有关。我们可以清楚地看到,应用程序的大部分时间都是处于locked
状态。由于这些问题的存在,导致Go不能发挥处理器的优势,因此在Go 1.1中已经用新的调度器解决了。
并发调度与亲和性
Go 1.1带来了一个新的调度器的实现和本地goroutine队列的创建。这一改进避免了在有本地goroutine的情况下锁定整个调度器,并允许它们在同一个操作系统线程上工作。
由于线程可以在系统调用上阻塞,而且阻塞的线程数量不受限制,Go引入了处理器的概念。一个处理器P代表一个正在运行的OS线程,并将管理本地goroutines队列。
下图展示了调度器的新模型:
在这个新模型下,我们在Go 1.1.2中做了benckmark测试,得到如下结果:
name time/op
Sieve 18.7s ± 0%
Sieve-2 8.26s ± 0%
Sieve-4 3.30s ± 0%
Sieve-8 2.64s ± 0%
新模型下,Go可以比较充分的利用所有可用的CPU了,此时再通过pprof得到的CPU profile如下:
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和线程之间的亲和力。
-
工作窃取(work-stealing)。当一个处理器P的本地队列中没有足够的工作时,如果全局队列和网络轮询器是空的,它就会从其他P中窃取goroutine。完成工作窃取后,goroutine就会在另一个线程上运行。
-
系统调用(system call)。当发生系统调用时(如文件操作、http调用、数据库操作等),Go会将正在运行的操作系统线程移动到阻塞模式,让新的线程处理当前P上的本地队列。
然而,通过更好地管理本地队列的优先级,可以避免这两个限制。Go 1.5的目的是给在通道上来回通信的goroutine更多的优先权,从而优化与分配线程的亲和力。
亲和性增强
一个goroutine在一个通道上来回通信,会导致频繁的阻塞,也就是频繁的在本地队列中重新排队。但是,由于本地队列有FIFO的实现,如果有其他goroutine占用了线程,处于unblock状态的goroutine就不能保证尽快运行。下面这个例子中,有一个之前在通道上是被阻塞的但现在可以运行的goroutine。
G9程序在通道上被封锁后恢复。然而,它必须等待G2、G5和G4之后才能运行。在这个例子中,G5的goroutine会占用它的线程,延迟G9的goroutine,使它有可能被其他处理器窃取。从Go 1.5开始,从阻塞通道返回的goroutine现在将被优先运行(这要归功于它的一个特殊属性P)。
goroutine G9现在被标记为下一个可运行的程序。这个新的优先级允许goroutine在再次被通道阻塞之前快速运行。然后,其他goroutine现在将有运行时间,这个变化对Go标准库有一个整体的积极影响,提高了一些包的性能。