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浅析》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • 介绍 上一篇文章我对操作系统级别的调度进行了讲解,这对理解 Go 语言的调度器是很重要的。这篇文章,我将解释下 G...
    达菲格阅读 7,846评论 1 30
  • [toc] 原文:Scheduling In Go : Part II - Go Scheduler 前言 这是本...
    豆腐匠阅读 980评论 0 8
  • 理解并发和并行并发:同时管理多件事情。并行:同时做多件事情。表示同时发生了多件事情,通过时间片切换,哪怕只有单一的...
    Chuck_Hu阅读 5,959评论 7 44
  • http://skoo.me/go/2013/11/29/golang-schedule?hmsr=studygo...
    baboon阅读 2,180评论 0 3
  • 江山易改,本性难移。每个人都有自己比较稳定的个性特点。 在每个人的心里都可以体会到这一点。 人格很复杂,它是由身心...
    鱼无泪阅读 511评论 0 2