并行和并发
在操作系统中,一组程序按独立异步的速度执行,无论从微观还是宏观,程序都是一起执行的,我们把它叫做并行;对比地,并发是指:在同一个时间段内,两个或多个程序执行,有时间上的重叠(宏观上是同时,微观上仍是顺序执行)。
并行的计算机操作系统中多进程和多线程都不具备真正并行能力,它们通过毫秒甚至微秒级的进程(线程)切换,给人们一种错觉,它们是并行执行的,实际上严格来说,在某一时刻,cpu只能运行一个进程(线程)。
我们知道go语言的Goroutine
是具备多任务处理能力的,往往都能听人说到,go的协程可以轻松实现单机并发几千甚至几万,并且具备并行的能力…针对这些听到的内容,总会有一种朦胧感,也会抱着疑惑,比如说,在生产环境中,Goroutine
我开几千几万个协程是否真的没问题?它是否真的具备让任务能够达到并行的能力?
产生这些疑惑的原因根本还是我们不知道Goroutine
底层是怎么运作的,也没法印证这些言论是否是正确的
想解决这些疑惑,需要弄清楚Goroutine
的底层是怎么运作的,也可以说是Goroutine
的调度模型
数据结构
G
Goroutine
在Go语言运行时使用私有结构体runtime.g
表示。这个私有结构体非常复杂,总共包含 40 多个用于表示各种状态的成员变量,这里也不会介绍所有的字段,仅会挑选其中的一部分,首先是与栈相关的两个字段:
1 | type g struct { |
stack
记录了low
和high
,由于是栈区信息记录,可以理解为栈顶和栈底,用于表示栈区内存范围
1 | type stack struct { |
每一个Goroutine
上都持有两个分别存储 defer
和 panic
对应结构体的链表:
1 | type g struct { |
1 | type g struct { |
- m — 当前
Goroutine
占用的线程,可能为空; - atomicstatus —
Goroutine
的状态; - sched — 存储
Goroutine
的调度相关的数据; - goid —
Goroutine
的ID
上述四个字段中,我们需要展开介绍sched
字段的runtime.gobuf
结构体中包含哪些内容:
1 | type gobuf struct { |
- sp — (stack point)栈指针;
- pc — (program counter)程序计数器;
- g — 持有
runtime.gobuf
的Goroutine
; - ret — 系统调用的返回值;
这些内容会在调度器保存或者恢复上下文的时候用到,其中的栈指针和程序计数器会用来存储或者恢复寄存器中的值,改变程序即将执行的代码。
结构体runtime.g
的atomicstatus
字段存储了当前Goroutine
的状态。除了几个已经不被使用的以及与GC
相关的状态之外,Goroutine
可能处于以下 9 种状态:状态 描述 _Gidle
刚刚被分配并且还没有被初始化 _Grunnable
没有执行代码,没有栈的所有权,存储在运行队列中 _Grunning
可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P _Gsyscall
正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上 _Gwaiting
由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上 _Gdead
没有被使用,没有执行代码,可能有分配的栈 _Gcopystack
栈正在被拷贝,没有执行代码,不在运行队列上 _Gpreempted
由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒 _Gscan
GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在
上述状态中比较常见是 _Grunnable
、_Grunning
、_Gsyscall
、_Gwaiting
和 _Gpreempted
五个状态,虽然Goroutine
在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:
- 等待中:
Goroutine
正在等待某些条件满足,例如:系统调用结束等,包括_Gwaiting
、_Gsyscall
和_Gpreempted
几个状态; - 可运行:
Goroutine
已经准备就绪,可以在线程运行,如果当前程序中有非常多的Goroutine
,每个Goroutine
就可能会等待更多的时间,即_Grunnable
; - 运行中:
Goroutine
正在某个线程上运行,即_Grunning
;
M
Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有GOMAXPROCS
个活跃线程能够正常运行。
在默认情况下,运行时会将GOMAXPROCS
设置成当前机器的核数,我们也可以在程序中使用runtime.GOMAXPROCS
来改变最大的活跃线程数。
Go 语言会使用私有结构体runtime.m
表示操作系统线程,这个结构体也包含了几十个字段,这里先来了解几个与Goroutine
相关的字段:
1 | type m struct { |
g0
是一个运行时中比较特殊的Goroutine
,它会深度参与运行时的调度过程,包括Goroutine
的创建、大内存分配和CGO
函数的执行
在后面Goroutine
的调度过程中,g0
是负责调度工作的核心worker
P
调度器中的处理器 P 是线程和 Goroutine
的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine
,它能在 Goroutine
进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。
因为调度器在启动时就会创建GOMAXPROCS
个处理器,所以 Go 语言程序的处理器数量一定会等于GOMAXPROCS
,这些处理器会绑定到不同的内核线程上。
runtime.p
是处理器的运行时表示,作为调度器的内部实现,它包含的字段也非常多,其中包括与性能追踪、垃圾回收和计时器相关的字段,这些字段也非常重要,但是在这里就不展示了,我们主要关注处理器中的线程和运行队列:
1 | type p struct { |
归纳总结
- G — 表示
Goroutine
,它是一个待执行的任务; - M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
- P — 表示处理器,它可以被看做运行在线程上的本地调度器;
调度
Goroutine
的调度流出比较复杂,这里只列举它的调度流程,不影响流程分析的地方直截图调度的关键函数,一些重要的过程分析会贴代码分析
Go语言运行时会调用runtime.mstart
以及runtime.mstart1
,并调用 runtime.schedule
进入调度循环:
其中在mstart1
中会启动g0 Goroutine
mstart1
最后调用schedule
开始进行调度工作
1 | func schedule() { |
runtime.schedule
函数会从下面几个地方查找待执行的 Goroutine
- 为了保证公平,当全局运行队列中有待执行的
Goroutine
时,通过schedtick
保证有一定几率会从全局的运行队列中查找对应的Goroutine
; - 从处理器本地的运行队列中查找待执行的
Goroutine
; - 如果前两种方法都没有找到
Goroutine
,会通过runtime.findrunnable
进行阻塞地查找Goroutine
通过schedule
获取的Goroutine
会调用execute
,通过runtime.gogo
将 Goroutine 调度到当前线程上。
runtime·gogo
通过汇编插入指令在新的协程栈中插入goexit
栈帧,目的是为了在后面执行完业务函数后能够回调回来,
同时MOVL gobuf_pc(BX)
指令还会记录执行函数的程序计数器,最后执行JMP
跳转到程序计数器代码位置执行代码
1 | TEXT runtime·gogo(SB), NOSPLIT, $8-4 |
当Goroutine
中运行的函数返回时,程序会跳转到runtime.goexit
所在位置执行该函数 :
1 | TEXT runtime·goexit(SB),NOSPLIT,$0-0 |
注意goexit0
,该函数在g0
栈上,可在runtime.goexit0
找到
1 | func goexit0(gp *g) { |
在最后runtime.goexit0
会重新调用runtime.schedule
触发新一轮的Goroutine
调度,Go语言中的运行时调度循环会从runtime.schedule
开始,最终又回到runtime.schedule
,我们可以认为调度循环永远都不会终止。
调度流程
整个调度链路比较长,我们可以通过一张图来整理下它的工作流程,其中业务代码逻辑为
1 | go do1() |
GMP模型
通过上述流程我们可以归纳出gmp模型
总结
再回到一开始的问题,
- 再生产环境中,
goroutine
我开几千几万个协程是否真的没问题?- 针对问题1,我们已经有了很明确的答案,go协程和g1协程的并发执行是在M(线程)中执行的,由于不需要保存线程切换时的cpu上下文信息,是很轻量的,它是通过在用户空间中维护了一套
Goroutine
的调度管理池子,操作系统中并不知道Goroutine
存在,不涉及到cpu上下文切换,所以可以很明确的回答是可以很轻松实现单机创建上千甚至上万个协程的
当然也不是任何时候都可以这样创建,当发生系统调用时,需要m线程发生系统调用,所以会产生两次用户态
到内核态
的切换,这时会涉及到cpu上下文切换,这一点还是需要注意的
至于为什么不在协程里去做系统调用,这一点我们在进程与线程-线程实现方式小节中阐述过用户空间实现的线程的缺点,同时,goroutine
的这种协程实现方式也是属于进程与线程-线程实现方式-混合实现方式
- 针对问题1,我们已经有了很明确的答案,go协程和g1协程的并发执行是在M(线程)中执行的,由于不需要保存线程切换时的cpu上下文信息,是很轻量的,它是通过在用户空间中维护了一套
- 它是否真的具备让任务能够达到并行的能力?
- 第二个问题,通过
gmp
模型的归纳可以知道,当操作系统核心数>1时,M是多个,是可以实现真正意义上的并行的
- 第二个问题,通过
参考文献:
- 进程与线程-线程实现方式
- [Go语言设计与实现]