0%

神奇的Goroutine

并行和并发

在操作系统中,一组程序按独立异步的速度执行,无论从微观还是宏观,程序都是一起执行的,我们把它叫做并行;对比地,并发是指:在同一个时间段内,两个或多个程序执行,有时间上的重叠(宏观上是同时,微观上仍是顺序执行)。

并行的计算机操作系统中多进程和多线程都不具备真正并行能力,它们通过毫秒甚至微秒级的进程(线程)切换,给人们一种错觉,它们是并行执行的,实际上严格来说,在某一时刻,cpu只能运行一个进程(线程)。

我们知道go语言的Goroutine是具备多任务处理能力的,往往都能听人说到,go的协程可以轻松实现单机并发几千甚至几万,并且具备并行的能力…针对这些听到的内容,总会有一种朦胧感,也会抱着疑惑,比如说,在生产环境中,Goroutine我开几千几万个协程是否真的没问题?它是否真的具备让任务能够达到并行的能力?
产生这些疑惑的原因根本还是我们不知道Goroutine底层是怎么运作的,也没法印证这些言论是否是正确的

想解决这些疑惑,需要弄清楚Goroutine的底层是怎么运作的,也可以说是Goroutine的调度模型

数据结构

G

Goroutine 在Go语言运行时使用私有结构体runtime.g表示。这个私有结构体非常复杂,总共包含 40 多个用于表示各种状态的成员变量,这里也不会介绍所有的字段,仅会挑选其中的一部分,首先是与栈相关的两个字段:

1
2
3
4
type g struct {
stack stack
stackguard0 uintptr
}

stack记录了lowhigh,由于是栈区信息记录,可以理解为栈顶和栈底,用于表示栈区内存范围

1
2
3
4
type stack struct {
lo uintptr
hi uintptr
}

每一个Goroutine上都持有两个分别存储 deferpanic 对应结构体的链表:

1
2
3
4
type g struct {
_panic *_panic // 最内侧的 panic 结构体
_defer *_defer // 最内侧的延迟函数结构体
}
1
2
3
4
5
6
type g struct {
m *m
sched gobuf
atomicstatus uint32
goid int64
}
  • m — 当前 Goroutine 占用的线程,可能为空;
  • atomicstatus — Goroutine的状态;
  • sched — 存储Goroutine的调度相关的数据;
  • goid — GoroutineID

上述四个字段中,我们需要展开介绍sched字段的runtime.gobuf结构体中包含哪些内容:

1
2
3
4
5
6
7
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ret sys.Uintreg
...
}
  • sp — (stack point)栈指针;
  • pc — (program counter)程序计数器;
  • g — 持有runtime.gobufGoroutine
  • ret — 系统调用的返回值;
    这些内容会在调度器保存或者恢复上下文的时候用到,其中的栈指针和程序计数器会用来存储或者恢复寄存器中的值,改变程序即将执行的代码。
    结构体runtime.gatomicstatus字段存储了当前Goroutine的状态。除了几个已经不被使用的以及与GC相关的状态之外,Goroutine可能处于以下 9 种状态:
    状态描述
    _Gidle刚刚被分配并且还没有被初始化
    _Grunnable没有执行代码,没有栈的所有权,存储在运行队列中
    _Grunning可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
    _Gsyscall正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
    _Gwaiting由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
    _Gdead没有被使用,没有执行代码,可能有分配的栈
    _Gcopystack栈正在被拷贝,没有执行代码,不在运行队列上
    _Gpreempted由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
    _GscanGC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在

上述状态中比较常见是 _Grunnable_Grunning_Gsyscall_Gwaiting_Gpreempted五个状态,虽然Goroutine在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:

  • 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting_Gsyscall_Gpreempted几个状态;
  • 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable
  • 运行中:Goroutine 正在某个线程上运行,即 _Grunning
    img_2.png

M

Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有GOMAXPROCS个活跃线程能够正常运行。

在默认情况下,运行时会将GOMAXPROCS设置成当前机器的核数,我们也可以在程序中使用runtime.GOMAXPROCS来改变最大的活跃线程数。

Go 语言会使用私有结构体runtime.m表示操作系统线程,这个结构体也包含了几十个字段,这里先来了解几个与Goroutine相关的字段:

1
2
3
4
5
type m struct {
g0 *g
curg *g
...
}

g0是一个运行时中比较特殊的Goroutine,它会深度参与运行时的调度过程,包括Goroutine的创建、大内存分配和CGO函数的执行
在后面Goroutine的调度过程中,g0是负责调度工作的核心worker

P

调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。

因为调度器在启动时就会创建GOMAXPROCS个处理器,所以 Go 语言程序的处理器数量一定会等于GOMAXPROCS,这些处理器会绑定到不同的内核线程上。

runtime.p是处理器的运行时表示,作为调度器的内部实现,它包含的字段也非常多,其中包括与性能追踪、垃圾回收和计时器相关的字段,这些字段也非常重要,但是在这里就不展示了,我们主要关注处理器中的线程和运行队列:

1
2
3
4
5
6
7
8
type p struct {
m muintptr
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
...
}

归纳总结

  • G — 表示 Goroutine,它是一个待执行的任务;
  • M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
  • P — 表示处理器,它可以被看做运行在线程上的本地调度器;

调度

Goroutine的调度流出比较复杂,这里只列举它的调度流程,不影响流程分析的地方直截图调度的关键函数,一些重要的过程分析会贴代码分析

Go语言运行时会调用runtime.mstart以及runtime.mstart1,并调用 runtime.schedule 进入调度循环:
img_3.png
其中在mstart1中会启动g0 Goroutine
img_4.png
mstart1最后调用schedule开始进行调度工作
img_5.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func schedule() {
_g_ := getg()

top:
var gp *g
var inheritTime bool

if gp == nil {
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())
}
if gp == nil {
gp, inheritTime = findrunnable()
}

execute(gp, inheritTime)
}

runtime.schedule 函数会从下面几个地方查找待执行的 Goroutine

  • 为了保证公平,当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine
  • 从处理器本地的运行队列中查找待执行的 Goroutine
  • 如果前两种方法都没有找到 Goroutine,会通过 runtime.findrunnable 进行阻塞地查找 Goroutine

通过schedule获取的Goroutine会调用execute,通过runtime.gogo将 Goroutine 调度到当前线程上。
img_6.png

runtime·gogo通过汇编插入指令在新的协程栈中插入goexit栈帧,目的是为了在后面执行完业务函数后能够回调回来,
同时MOVL gobuf_pc(BX)指令还会记录执行函数的程序计数器,最后执行JMP跳转到程序计数器代码位置执行代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TEXT runtime·gogo(SB), NOSPLIT, $8-4
MOVL buf+0(FP), BX // 获取调度信息
MOVL gobuf_g(BX), DX
MOVL 0(DX), CX // 保证 Goroutine 不为空
get_tls(CX)
MOVL DX, g(CX)
MOVL gobuf_sp(BX), SP // 将 runtime.goexit 函数的 PC 恢复到 SP 中
MOVL gobuf_ret(BX), AX
MOVL gobuf_ctxt(BX), DX
MOVL $0, gobuf_sp(BX)
MOVL $0, gobuf_ret(BX)
MOVL $0, gobuf_ctxt(BX)
MOVL gobuf_pc(BX), BX // 获取待执行函数的程序计数器
JMP BX

Goroutine中运行的函数返回时,程序会跳转到runtime.goexit所在位置执行该函数 :

1
2
3
4
5
6
TEXT runtime·goexit(SB),NOSPLIT,$0-0
CALL runtime·goexit1(SB)

func goexit1() {
mcall(goexit0)
}

注意goexit0,该函数在g0栈上,可在runtime.goexit0找到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func goexit0(gp *g) {
_g_ := getg()

casgstatus(gp, _Grunning, _Gdead)
gp.m = nil
...
gp.param = nil
gp.labels = nil
gp.timer = nil

dropg()
gfput(_g_.m.p.ptr(), gp)
schedule()
}

在最后runtime.goexit0会重新调用runtime.schedule触发新一轮的Goroutine调度,Go语言中的运行时调度循环会从runtime.schedule开始,最终又回到runtime.schedule,我们可以认为调度循环永远都不会终止。

调度流程

整个调度链路比较长,我们可以通过一张图来整理下它的工作流程,其中业务代码逻辑为

1
2
3
4
5
6
7
8
go do1()
func do1() {
...
return do2()
}
func do2() {
....
}

img_8.png

GMP模型

通过上述流程我们可以归纳出gmp模型

img_1.png

总结

再回到一开始的问题,

  • 再生产环境中,goroutine我开几千几万个协程是否真的没问题?
    • 针对问题1,我们已经有了很明确的答案,go协程和g1协程的并发执行是在M(线程)中执行的,由于不需要保存线程切换时的cpu上下文信息,是很轻量的,它是通过在用户空间中维护了一套Goroutine的调度管理池子,操作系统中并不知道Goroutine存在,不涉及到cpu上下文切换,所以可以很明确的回答是可以很轻松实现单机创建上千甚至上万个协程的
      当然也不是任何时候都可以这样创建,当发生系统调用时,需要m线程发生系统调用,所以会产生两次用户态内核态的切换,这时会涉及到cpu上下文切换,这一点还是需要注意的
      至于为什么不在协程里去做系统调用,这一点我们在进程与线程-线程实现方式小节中阐述过用户空间实现的线程的缺点,同时,goroutine的这种协程实现方式也是属于进程与线程-线程实现方式-混合实现方式
  • 它是否真的具备让任务能够达到并行的能力?
    • 第二个问题,通过gmp模型的归纳可以知道,当操作系统核心数>1时,M是多个,是可以实现真正意义上的并行的

参考文献: