Go 的并发性与调度器

本篇文章是我对 Go 语言并发性的理解总结,适合初步了解并发,对 Go 语言的并发编程与调度器原理有兴趣的读者。

你真的了解并发吗?

相信读者都对并发有着一定的理解,也都对 Go 语言感兴趣,Go 最吸引人的地方可能就是它的内建并发支持,使用 go 关键字,就可以轻松的实现并发。但是,你真正的了解并发吗?

并发这个词,你去问编程领域中不同的人,会给出不同的答案。对于 WEB 领域的开发人员来说,并发通常是指同一时刻的请求量,WEB 领域的面试官经常会问到的问题:“做过多少并发的项目?”或“接触过高并发的项目吗?”就是应用的这个概念。高并发在这里还有个可能的概念是:同时应对许多请求所使用的技术,这通常与分布式、并行等概念挂钩,需要结合上下文语境来判断。

并发是一个有趣的词,因为它对编程领域中的不同人员意味着不同的事情,在广义概念下,有着许多狭义概念。除了“并发”之外,你可能还听过“并行”、“多线程”、“异步”等词汇,有些人认为这些词意思相同,而其他人则在每个词之间划清界限。

下面,让我们看看 Go 语言编程中,“并发”这个词的概念。

Go 语言中的并发性

Go 语言的并发性并不是 WEB 领域的并发概念,很多人对此有所混淆。在 Go 语言发布之初,大家对 Go 的并发特性都有所疑问:

  • 为什么要有并发?
  • 什么是并发?
  • 这个想法源自哪里?
  • 并发有什么好处?
  • 我该如何使用它?

面对这些问题,Rob Pike(Go 语言作者之一)在2012年的 Google I/O 上做了一次精彩的演讲:《Go Concurrency Patterns》,在这场演讲中,他回答了上述问题,并通过详细的示例讲解了 goroutine、channel 与 select 的使用,建议大家都去看一看这场演讲。

简单的总结一下并发在 Go 语言编程中的概念:

“并发是一种将程序分解成小片段独立执行的程度设计方法”,它是一种结构化程序的方式,独立执行计算的组合。

在上述的演讲中可以看出,Go 语言推荐使用并发,我们也应该遵循这种编程方式。对于程序员来说,代码更有说服力,我们可以通过这个素数筛选程序来理解 Go 的并发编程:

// 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
    }
}

它并不是复杂度最低的算法,特别是寻找大素数方面,但却是最能体现 Go 并发编程、通过通信共享内存的理念,而且非常优雅。

在这段代码中,通过 goroutine 的组合,实现一层层的筛选器,筛选器之间通过 channel 通信,每一个筛选器就是一个素数,每个给 main goroutine 通信的内容也是素数,简直精妙。

通过下面的 gif 动画能清晰的看到程序运行过程:

primesieve

并发不是并行

Go 的并行

只需要 GOMAXPROCS 的值大于1,就可以让 Go 程序在多核机器上实现以并行的形式运行。但并发的程序一定可以并行吗?

我们需要明确一个观点:并发不是为了效率,并发的程序不一定可以并行。还是上面素数的例子,这段代码是并发的,但不可以并行,因为它的每一个执行片段都需要上一个片段的筛选与通信。

正交概念

正交概念:从数学上引进正交这个词,用于表示指相互独立,相互间不可替代,并且可以组合起来。

在广义概念上来讲,并发与并行是正交概念,对于 Go 语言的并发性来讲也是如此。

《Concurrency is not Parallelism》

同样,在 Go 语言发布之初,有很多人混淆了并发与并行的概念,对此,Rob Pike 发表了另一篇演讲《Concurrency is not Parallelism》,通过地鼠烧书的比喻与简单负载均衡器的示例,详细的阐述了并发与并行的区别。

这里不再复述地鼠例子,只是简单的总结,感兴趣的建议去看演讲:

并行是指同时能执行多个事情。

并发关乎结构,是一种结构化程序的方式。

并行关乎执行,表述的是程序的运行状态。

Go 语言是如何支持并发的?

上面一直在讲 Go 语言的并发性,接下来看下 Go 语言是如何做到的并发支持。

我们在使用 Go 编写并发程序的过程中,无需关心线程的维护、调度等一系列问题,只需要关心程序结构的分解与组合、goroutine 之间的通信就可以写出良好的并发程序,这全部都要依赖于 Go 语言内建的 G-P-M 模型。

模型演化过程

在 Go 语言1.0版本时,只有 G-M 模型,Google 工程师 Dmitry Vyukov 在《Scalable Go Scheduler Design Doc》中指出了该模型在并发伸缩性方面的问题:

  1. 所有对 G 的操作:创建、重新调用等由单个全局锁(Sched.Lock)保护,浪费时间。
  2. 当 M 阻塞时,G 需要传递给别的 M 执行,这导致调度延迟增大以及额外的性能损耗;
  3. M 用到的 mCache 属于内核线程,当 M 阻塞后相应的内存资源仍被占用,导致内存占用过高;
  4. 由于 syscall 导致 M 的阻塞和恢复,导致了额外性能损耗。

并且亲自下场,重新设计、改进了 Go scheduler,在 Go1.1 版本中实现了 G-P-M 模型:

gpm-nino

G-P-M 模型

那么这套模型与调度是怎样的呢?先来简单的说一下 G、P、M 的定义:

  • G:表示 Goroutine,G 存储了 goroutine 执行的栈信息,状态,任务函数,可重用。
  • P:Processor,表示逻辑处理器,拥有一个本地队列。对于 G 来说,P 相当于 CPU 内核,只有进入到 P 的队列中,才可以被调度。对于 M 来说,P 提供了相关的执行环境(Context),如内存分配状态,任务队列等。P 的数量就是程序可最大可并行的 G 的数量(前提:物理CPU核数 >= P的数量),由用户设置的 GOMAXPROCS 决定。
  • M:Machine,是对系统线程的抽象,是真正执行计算的部分。M 在绑定 P 后会在队列中获取 G,切换到 G 的执行栈并执行 G 的函数。M 数量不定,但同时只有 P 个 M 在执行,为了防止创建过多系统线程导致系统调度出现问题,目前默认最大限制10000个。

接下来了解这套模型的基本调度,在调度过程中还有一个 work-stealing 的算法:

  • 每个 P 维护一个本地队列;
  • 当一个 G 被创建后,放入当前 P 的本地队列中,如果队列已满,放入全局队列;
  • 当 M 执行完一个 G 后,会在 当前 P 的队列中取出新的 G,队列为空则在全局队列中加锁获取;
  • 如果全局队列也为空,则去其他的 P 的队列中偷出一半的 G,放入自己的本地队列。

Go 语言就是凭借着这套优秀的并发模型与调度,实现了内建的并发支持。

Goroutine 调度器的深入

让我们深入的了解一下 goroutine 调度器。

调度器解决了什么问题?

阻塞问题

如果任务G陷入到阻塞的系统调用中,内核线程M将一起阻塞,于是实际的运行线程少了一个。更严重的,如果所有M都阻塞了,那些本可以运行的任务G将没有系统资源运行。

Go 在执行阻塞的系统调用时会调用 entersyscallblock ,然后通过 handoffp 解绑 M 对应的 P。如果此时 P 的本地队列中还有 G,P 会去寻找别的 M 或创建新的 M 继续执行,若本地队列为空,则进入 pidle 链表,等待有需要时被取出。

如果是调用的 entersyscall,会将 P 的状态置为 _Psyscall。监控线程 sysmon 会通过 retake 循环所有的 P,发现是 _Psyscall 状态,就会调用 handoffp 来释放。

抢占调度

在 Go1.1 版本中,是没有抢占调度的,当前 G 只有涉及到锁操作,读写 channel 才会触发切换。若没有抢占机制,同一个 M 上的其他任务 G 有可能会长时间执行不到,甚至会被死循环锁住。

于是 Dmitry Vyukov 提出了《Go Preemptive Scheduler Design Doc》, 并在1.2版本中引入了初级的抢占。

监控线程 sysmon 会通过 retake 循环所有的 P,发现运行时间超出 forcePreemptNS 限制(10ms)的 P,就会通过 preemptone 发起抢占。

Goroutine 的负载均衡

内核线程M不是从全局任务队列中得到G,而是从M本地维护的G缓存中获取任务。如果某个M的G执行完了,而别的M还有很多G,这时如果G不能切换将造成CPU的浪费。

这部分的实现是在 M 的启动函数 mstartschedule 的调用来实现,它会先查找本地队列,然后查找全局队列,最后是随机偷取其他 P 的一半 G,直到取到 G 或停掉 M。为了防止全局队列被“饿死”,每61次调度,会先在全局队列中查找。

调度器相关源码

调度器部分的代码主要集中在 src/runtime/runtime2.gosrc/runtime/proc.go 这两个文件中。

调度器的4个基本结构:g、m、p、schedt,都在 runtime2.go 中,schedt 可能有些陌生,它是调度器的核心结构,也是全局资源池,用来存储 G 的全局队列,空闲的 P 链表 pidle,空闲的 M 链表 midle 等等。

调度器的具体实现函数都在 proc.go 中,用户的所有代码都是运行在 goroutine 中,Go 在运行时会将 main 中的代码放入 main goroutine 中运行,这时还会启动监控系统 sysmon

更多关于调度器的细节,例如加锁,与 GC 的交互等,需要通过进一步的阅读源码来了解。

结束语

看到这里,相信大家对“并发”会有全新的认识,本文旨在讲清 Go 语言的并发性,在以后的 Go 语言编程过程中,希望更倾向于并发编程。并发编程不仅结构清晰,通常来说也会更容易并行运行,使得程序运行效率提高。

参考文章

《Go Concurrency Patterns》

《Concurrency is not Parallelism》

《go-under-the-hood》

《也谈goroutine调度器》

《Goroutine浅析》

推荐阅读更多精彩内容