并发与调度亲和性

2020/09/16 golang 共 3577 字,约 11 分钟

目录

pic-0

写在前面

微信公众号:[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)。

pic-1

只有一个队列并不能保证goroutine会在同一个线程上继续运行。第一个准备好的线程会接收到一个等待的goroutine,并将其运行。因此,它涉及到goroutine从一个线程到另一个线程,这在性能上是有代价的。下面是一个有阻塞通道的例子, G7这个goroutine在通道上阻塞,并等待一个消息。一旦收到消息,goroutine就会被推送到全局队列中。

pic-2

然后,通道推送消息,goroutine #X将在一个可用线程上运行,而goroutine G8则在通道上阻塞。

pic-3

下面G7将会调度到可用的线程上被执行:

pic-4

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

procyieldxchgfutexlock都与Go调度器的全局mutex有关。我们可以清楚地看到,应用程序的大部分时间都是处于locked状态。由于这些问题的存在,导致Go不能发挥处理器的优势,因此在Go 1.1中已经用新的调度器解决了。

并发调度与亲和性

Go 1.1带来了一个新的调度器的实现和本地goroutine队列的创建。这一改进避免了在有本地goroutine的情况下锁定整个调度器,并允许它们在同一个操作系统线程上工作。 由于线程可以在系统调用上阻塞,而且阻塞的线程数量不受限制,Go引入了处理器的概念。一个处理器P代表一个正在运行的OS线程,并将管理本地goroutines队列。

下图展示了调度器的新模型:

pic-5

在这个新模型下,我们在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。

pic-6

G9程序在通道上被封锁后恢复。然而,它必须等待G2、G5和G4之后才能运行。在这个例子中,G5的goroutine会占用它的线程,延迟G9的goroutine,使它有可能被其他处理器窃取。从Go 1.5开始,从阻塞通道返回的goroutine现在将被优先运行(这要归功于它的一个特殊属性P)。

goroutine G9现在被标记为下一个可运行的程序。这个新的优先级允许goroutine在再次被通道阻塞之前快速运行。然后,其他goroutine现在将有运行时间,这个变化对Go标准库有一个整体的积极影响,提高了一些包的性能。

Search

    Table of Contents