Go runtime之 Goroutine 原理
Goroutine
定义
Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。一个运行的程序由一个或更多个 goroutine 组成。它与线程、写成、进程等不同。它是一个 goroutine。
– Rob Pike
Goroutine 在同一个用户地址空间里并行独立执行 functions
,channels
则用于 goroutines
间的通信和同步访问控制。
可执行程序可以分成两个层:用户代码和运行时。
运行时提供接口函数供用户代码调用,用来管理 goroutines
,channels
和其他一些内置抽象结构。
用户代码对操作系统 API
的任何调用都会被运行时层截取,以方便调度和垃圾回收。
goroutine 和 thread 的区别
例如在一个进程的虚拟地址空间内,从低位 0x00000000
到高位 0x7fffffff
,堆地址从低地址位到高地址位使用,栈从高地址位到地址位。
在进程内开启多个线程,是从栈上进行分配。
- 内存占用:创建一个
goroutine
的栈内存消耗为2KB
(Linux AMD64 GO v1.4后),运行过程中,如果栈空间不够用,会自动进行扩容。
创建一个 thread
为了尽量避免极端情况下操作系统线程栈的溢出,默认会为其分配一个较大的栈内存( 1-8 MB 栈内存,线程标准 POSIX Thread),而且还需要一个被称为 guard page
的区域用于和其他 thread
的栈空间进行隔离(当进程中有内存溢出,使用 guard page
内存时,可也就是当有应用态使用这部分内存,则可能会被操作系统干掉。避免溢出时操作的是其他线程上的内存。)。而栈内存空间一旦创建和初始化完成之后,其大小就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的风险。而且线程创建过多,也会导致栈内存耗尽。
- 创建、销毁:线程创建和销毁都会有巨大的消耗,是内核级的交互(
trap
)
POSIX 线程(定义了创建和操作线程的一套 API)通常是在已有的进程模型中增加的逻辑扩展,所以线程控制和进程控制很相似。而进入内核调度所消耗的性能代价比较高,开销较大。goroutine
是用户态线程,是由 go runtime
管理,创建和销毁的消耗非常小。
- 调度切换:抛开陷入内核,线程切换会消耗 1000-1500 纳秒(上下文保存成本高,较多寄存器,公平性,复杂时间计算统计),一个纳秒平均可以执行 12-18 条指令。
所以由于线程切换,执行指令的条数会减少 12000-18000。goroutine
的切换约为 200ns
(用户态、3个寄存器),相当于 2400-3600 条指令。因此 goroutines
切换成本比 threads
要小得多。
- 复杂性:线程的创建和退出复杂,多个
thread
间通讯复杂(share memory
)
不能大量创建线程(参考早期 httpd
),成本高,使用网络多路复用,存在大量 callback
(参考 twemproxy
,nginx
的代码)。对于应用服务线程门槛高,例如需要做第三方库隔离,需要考虑引用线程池等。
M:N 模型
Go 创建 M 个线程( CPU 执行调度的单元,内核的 task_struct
),之后创建的 N 个 goroutine
都会依附在这 M 个线程上执行,即 M:N
模型。
它们能够同时运行,与线程类似,但相比之下非常轻量。因此,程序运行时,goroutines
的个数应该是远大于线程的个数的(pthread
是内核线程,在计算机领域,POSIX 线程(通常称为 pthreads
)是一种独立于编程语言的执行模型,也是一种并行执行模型。它允许程序控制多个在时间上重叠的不同工作流。)
同一个时刻,一个线程只能跑一个 goroutine
。当 goroutine
发生阻塞(chan
阻塞、mutex
、syscall
等等)时,Go
会把当前的 goroutine
调度走,让其他 goroutine
来继续执行,而不是让线程阻塞休眠,尽可能多的分发任务出去,让 CPU
忙。
GMP 调度模型
G :
goroutine
的缩写,每次go func()
都代表一个G,无限制。使用
struct runtime.g
,包含了当前goroutine
的状态、堆栈、上下文。M:工作线程(
OS thread
)也被称为Machine
,使用struct runtime.m
,所有 M 是有线程栈的如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则
M.stack
->G.stack
(M 的栈,指向 G 的栈),M 的 PC 寄存器指向 G 提供的函数,然后去执行。(执行的时候,内存指向 G 的栈,切换到G的寄存器,执行完了之后,再切换回来,也就是g0
。)P:在
golang
早期(go1.2 之前)没有 P 的概念。只有GM
模型。
go/src/runtime/runtime2.go
1 | type g struct { |
GM 调度器
Go 1.2 前的调度器实现,限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。
每个 goroutine
对应于 runtime
中的一个抽象结构:G;而 thread
作为 物理CPU
的存在而被抽象为一个结构:M(machine
)。
所有创建的 G 都会在一个全局队列中排队,等待被 M 执行(M从 global queue
中拿取任务执行)。此时 global queue
会有一个全局大锁 Sched.Lock
,会影响 global queue
吞吐。
当 goroutine
调用了一个阻塞的系统调用,运行这个 goroutine
的线程就会被阻塞,这时至少应该再创建、唤醒一个线程来运行别的没有阻塞的 goroutine
。线程这里可以创建不止一个,可以按需不断的创建,而活跃的线程(处于非阻塞状态的线程)的最大个数存储在变量 GOMAXPROCS
中。(创建、唤醒一个线程的开销是比较大的。)
- 遇到
syscall
唤醒一个 M,避免有 G 在等; - 遇到
chan blocking
切换 G 状态为waiting
,当前 M 去执行其他 G,waiting G chan
完成后,恢复为runable
,等待 M 来重新执行(不一定是之前的M)
GM 调度模型的问题
单一全局互斥锁(
Sched.Lock
)和集中状态存储导致所有
goroutine
相关操作,比如:创建、结束、重新调度等都要上锁Goroutine
传递问题M 经常在 M 之间传递
可运行
的goroutine
,这导致调度延迟增大以及额外的性能损耗(刚创建的 G 放到了全局队列,而不是本地M执行,不必要的开销和延迟;以及当一个goroutine
阻塞的时候,需要从 M 放回global queue
,等到runable
的时候,再从global queue
中可能被其他的 M 拿到并执行)Per-M
持有内存缓存(M.mcache
)类似
TCMalloc
的结构,每个 M 持有mcache
和stack alloc
,然后只有在 M 运行 Go 代码时才需要使用的内存(每个mcache
可以高达2mb
),当 M 在处于syscall
时并不需要。运行 Go 代码和阻塞在syscall
的 M 的比例高达1:100
,造成了很大的浪费。同时内存亲缘性也较差,G 当前在 M 运行后对 M 的内存进行了预热,因为现在 G 调度到同一个 M 的概率不高,数据局部性不好。(
proc a new proc b
)严重的线程阻塞/解锁
在系统调用的情况下,工作线程经常被阻塞和取消阻塞,这增加了很多开销。比如 M 找不到G,此时 M 就会进入频繁阻塞/唤醒来进行检查的逻辑,以便及时发现新的 G 来执行
GM 调度模型的问题可以参考这边文章
Scalable Go Scheduler Design Doc
[翻译]Scalable Go Scheduler Design Doc[Go 可扩展调度设计文档]
GMP 概念
P:
Processor
是一个抽象的概念,并不是真正的物理 CPUDmitry Vyukov 的方案是引入一个结构 P,它代表了 M 所需的上下文环境,也是处理用户级代码逻辑的处理器。
它负责衔接 M 和 G 的调度上下文,将等待执行的 G 与 M 对接。
当 P 有任务时需要创建或者唤醒一个 M 来执行它队列里的任务。所以
P/M
需要进行绑定,构成一个执行单元。P 决定了并行任务的数量,可通过
runtime.GOMAXPROCS
来设定。在GO 1.5
之后GOMAXPROCS
被默认设置可用的核数,而之前默认为1。
Tips:uber-go/automaxprocs
Automatically set GOMAXPROCS to match Linux container CPU quota。
自动设置GOMAXPROCS以匹配Linux容器CPU配额。
cache
, timer
,g
,cache/stackallc
从 M 移到了 P,而 G 队列也被分为两类,保留全局 G 队列,同时每个 P 中都会有一个本地的 G 队列。(g0
还是放在M里面)
GMP 调度器
引入了 local queue
,因为 P 的存在,runtime
并不需要做一个集中式的 goroutine
调度,每一个 M 都会在 P's local queue
、global queue
或者其他 P 队列中找 G 执行,减少全局锁对性能的影响。
这也是 GMP
Work-stealing
调度算法的核心。
注意 P 的本地 G 队列还是可能面临一个并发访问的场景,为了避免加锁,这里 P 的本地队列是一个 LockFree
的队列,窃取 G 时使用 CAS 原子操作来完成。
关于 LockFree
和 CAS
的知识参见:简化概念下的 lock-free 编程
Work-stealing 调度算法
当一个 P 执行完本地所有的 G 之后,并且全局队列为空的时候,会随机尝试挑选一个受害者 P,从它的 G 队列中窃取一半的 G。否则会从全局队列中获取(当前个数/GOMAXPROCS
)个 G。
为了保证公平性,从随机位置上的 P 开始,而且遍历的顺序也随机化了(选择一个小于 GOMAXPROCS
,且和它互为质数的步长),保证遍历的顺序也随机化了。
找到任务之后,切换调用栈执行任务。再循环不断的获取任务,直到进入休眠。
光窃取是不够的,可能会导致全局对了饥饿。(优先级:1/61
概率去全局队列拿;全局队列没有或者是60/61
再去本地队列;本地队列没有的话,再去同其他 P steal
;其他队列也没有再检查全局队列;全局队列没有,就 poll network
,处理网络请求中已经 ready
的。)
P 的调度算法中还会每个 N 轮调度之后,就去全局队列拿一个 G。
谁放入的全局队列呢?
新建 G 时,P 的本地 G 队列放不下,已满并达到 256 个的时候会放半数 G 到全局队列去,阻塞的系统调用返回时找不到空闲 P 也会放到全局队列。
Syscall
Go 有自己封装的 syscall
,也就是进入和退出 syscall
的时候执行 entersyscall/exitsyscall
,也只有被封装了系统调用才有可能出发重新调度,它将改变 P 的状态为 syscall
。
系统监视器(system monitor
),称为 sysmon
,会定时扫描。在执行系统调用时,如果某个 P 的 G 执行超过一个 sysmon tick
,则脱离 M。
调用 syscall
后会解绑 P,然后 M 和 G 进入阻塞,而 P 此时的状态就是 syscall
,表明这个 P 的 G 正在 syscall
中,这时的 P 是不能被调度给别的 M 的。
如果在短时间内阻塞的 M 就唤醒了,那么 M 会优先来重新获取这个 P,能够取到就继续绑回去,这样有利于数据的局部性。
在执行 syscall
时,如果某个 P 的 G 执行超过一个 sysmon tick
(10ms),就会把他设为 idle
,重新调度给需要的 M,强制解绑。上图 P3 与 M 解绑,进入 P sched
。M 调用 G 依然执行 syscall
。
P1 和 M 脱离后目前在 idle list
中等待被绑定(处于 syscall
状态)。G35 在 syscall
结束之后,会被自己放到全局队列,等待被 P 获取。
而 syscall
结束后 M 按照如下规则执行直到满足其中一个条件:
- 尝试获取同一个 P(P1),恢复执行 G(获取同一个 P,应该是考虑情缘性,执行相同的 G。)
- 尝试获取
idle list
中的其他空闲 P,恢复执行 G - 找不到空闲 P,把 G 放回
global queue
,M 放回到idle list
OS thread
当使用了 syscall
,Go 无法限制 Blocked OS threads
的数量。
The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit. This package’s GOMAXPROCS function queries and changes the limit.
GOMAXPROCS 变量限制了可以同时执行用户级 Go 代码的操作系统线程数量。在系统调用中代表 Go 代码被阻塞的线程数量没有限制;这些线程不计入 GOMAXPROCS 限制。本软件包的 GOMAXPROCS 函数可查询并更改限制。
Tips
:使用 syscall
写程序要认真考虑 pthread exhaust
问题。syscall
会产生 Blocked OS threads
。
Sping thread
线程自旋是相对于线程阻塞而言的,表象就是循环执行一个指定逻辑(调度逻辑,目的是不停地寻找 G)。
这样做的问题显而易见,如果 G 迟迟不来,CPU 会白白浪费在这无意义的计算上。但好处也很明显,降低了 M 的上下文切换成本,提高了性能。(自旋时间从 20us
,然后 1ms
后翻倍,直到 10ms
,不断重置)
Go 的设计者倾向于高性能的并发表现,选择了后者。
在两个地方引入自旋:
- 类型 1:M 不带 P 的找 P 挂载(一有 P 释放就结合)
- 类型2 :M 带 P 的找 G 运行(一有
runable
的 G 就执行)
为了避免过多浪费 CPU 资源,自旋的 M 最多只允许 GOMAXPROCS
(Busy P
)。同时当有类型1 的自旋 M 存在时,类型 2 的自旋 M 就不阻塞,阻塞会释放 P,一释放 P 就马上被类型 1 的自旋 M 抢走了,没必要。
对于未绑定的 游离态 的 M,会进入休眠阻塞态。
在新 G 被创建、M 进入系统调用、M 从空闲被激活者三种状态变化前,调度器会确保至少有一个自旋 M 存在(唤醒或创建一个 M),除非没有空闲的 P。
- 当新 G 创建,如果有可用 P,就意味着新 G 可以被立即执行,即便不再同一个 P 也无妨,所以我们保留一个自旋的 M (这时应该不存在类型 1 的自旋,只有类型 2 的自旋)就可以保证新 G 很快被运行。
- 当 M 进入系统调用,意味着 M 不知道何时可以醒来,那么 M 对应的 P 中剩下的 G 就得有新的 M 来执行,所以我们保留一个自旋的 M 来执行剩下的 G (这时应该不存在类型 2 的自旋,只有类型 1 的自旋)。
- 如果 M 从空闲变成活跃,意味着可能一个处于自旋状态的 M 进入工作状态了,这时要检查并确保还有一个自旋 M 存在,以防还有 G 或者还有 P 空着的。
sysmon
sysmon
也叫监控线程,它无需 P 也可以运行,他是一个死循环,每 20us~10ms
循环一次,循环完一次就 sleep
一会,为什么会是一个变动的周期呢,主要是避免空转,如果每次循环都没什么需要做的事,那么 sleep
的时间就会加大。
- 释放闲置超过 5分钟的
span
物理内存; - 如果超过 2分钟没有垃圾回收,强制执行;
- 将长时间未处理的
netpoll
添加到全局队列; - 向长时间运行的 G 任务发出抢占调度;
- 收回因
syscall
长时间阻塞的 P;
协作式抢占,当 P 在 M 上执行时间超过 10ms,sysmon
调用 preemptone
将 G 标记为 stackPreempt
(代表这个 G 为可被抢占)。因此需要在某个地方触发检测逻辑,Go 当前是在检查栈是否溢出的地方判定(morestack()
),M 会保存当前 G 的上下文,重新进入调度逻辑。
死循环:非抢占式:runtime: golang scheduler is not preemptive - it’s cooperative? #11462
信号抢占:golang 基于信号的抢占式调度的设计实现原理
异步抢占,注册 sigurg
信号,通过 sysmon
检测,对 M 对应的线程发送信号,触发注册的 handler
,它往当前 G 的 PC 中(指令的寄存器中)插入一条指令(调用某个方法实现跳出),在处理完 handler
,G 恢复后,自己把自己推到了 global queue
中。
Tips
:发生程序 hang
死情况时,通常可以使用一些工具做诊断:
go tool pprof
perf
,top
Network poller
Go 所有的 I/O
都是阻塞的。然后通过 goroutine
+ channel
来处理并发。
因此所有的 IO
逻辑都是直来直去的,不再需要回调,不再需要 future
,要的仅仅是 step by step
。这对于代码的可读性是很有帮助的。
G 发起网络 I/O
操作也不会导致 M 被阻塞(仅阻塞 G ),从而不会导致大量 M 被创建出来。将异步 I/O
转换为阻塞的 I/O
的部分称为 netpoller
。
打开或接受连接都被设置为非阻塞模式。如果你试图对其进行 I/O
操作,并且文件描述符数据还没有准备好,它将返回一个错误代码,然后调用 netpoller
,等待后续被 runtime network poller
将其调度到 global queue
。G 会进入 gopark
函数,将当前正在执行的 G 状态保存起来,然后切换到新的堆栈上执行新的 G。
那么 G 什么时候被调度回来呢?
netowrk poller
触发点:
sysmon
schedule()
:M 找 G 的调度函数- GC:
start the world
调用 netpoll()
在某一次调度 G 的过程中,处于就绪状态的 fd
对应的 G 就会被调度回来。(也就是从 ready
的网络事件中恢复 G)。
G 的 gopark
状态: G 置为 waiting
状态,等待显示 goready
唤醒,在 poller
中用得较多,还有锁、chan
等。
Scheduler Affinity
GM 调度器时代的,chan
操作导致的切换代价。
coroutine #7
正在等待消息,阻塞在chan
。一旦收到消息,这个goroutine
就被推到全局队列然后,
chan
推送消息,goroutine #X
将在可用线程上运行,而goroutine #8
将阻塞在chan
goroutine #7
现在在可用线程上运行
在 chan
来回通信的 goroutine
会导致频繁的 blocks
,即频繁地在本地队列中重新排队。
然而,由于本地队列是 FIFO
实现,如果另一个 goroutine
占用线程,unblock goroutine
不能保证尽快运行。
同时 Go 情缘性调度的一些限制:
work-stealing
当 P 的
local queue
任务不够,同时global queue
、network poller
也会空,这时从其他 P 窃取任务运行,然后任务就运行到了其他线程,情缘性就没有意义。系统调用。
当
syscall
产生,Go 把当前线程设置为blocking mode
,让一个新的线程接管了这个 P (过一个sysmon tick
才会交给其他 M,大多数syscall
都是很快的)
goroutine #9
在 chan
被阻塞后恢复。但是,它必须等待 #2
、#5
和#4
之后才能运行。
goroutine #5
将阻塞其线程,从而延迟 goroutine #9
,并使其面临被另一个 P 窃取的风险。
针对 communicate-and-wait
模式,进行了情缘性调度的优化。
当前 local queue
使用了 FIFO
实现,unlock
的 G 无法尽快执行,如果队列中前面存在占用线程的其他 G 。
Go 1.5
在 P 中引入了 runnext
特殊的一个字段,可以高优先级执行 unblock G
。
goroutine #9
现在被标记为runnext
(下一个可运行的)。这种新的优先级排序允许 goroutine
在再次被阻塞之前快速运行。此时就会被其他的 P 给 steal
过去。
这一变化对运行中的标准库产生了总体上的积极影响,提高了一些包的性能。
GMP 问题总结
单一全局互斥锁(
Sched.Lock
)和集中状态存储G 被分成全局队列和 P 的本地队列,全局队列依旧是全局锁,但是使用场景明显很少,P 本地队列使用无锁队列,使用原子操作来面对可能的并发场景(其他 P
steal
)Goroutine 传递问题
G 创建时,就在 P 的本地队列,可以避免在 G 之间传递(窃取除外),G 对 P 的数据局部性好;当 G 开始执行了,系统调用返回后 M 会尝试获取可用 P,获取到了的话可以避免在 M 之间传递。而且优先获取调用阻塞前的 P,所以 G 对 M 数据局部性好,G 对 P 的数据局部性也好。
Per-M
持有内存缓存(M.mcache
)内存
mcache
只存在 P 的结构中,P 最多只有GOMAXPROCS
个,远小于 M 的个数,所以内存没有过多的消耗。严重的线程阻塞/解锁
通过引入自旋,保证任何时候都有处于等待状态的自旋 M,避免在等待可用的 P 和 G 时频繁的阻塞和唤醒。
– by Dmitry Vyukov Scalable Go Scheduler Design Doc
Goroutine Lifecycle
Go 程序启动
整个程序始于一段汇编,而在随后的 runtime.rt0_go
(也是汇编程序)中,会执行很多初始化工作。
go/src/runtime/asm_amd64.s
1 | // _rt0_amd64 is common startup code for most amd64 systems when using |
- 绑定
m0
和g0
,m0
就是程序的主程序,程序启动必然会拥有一个主线程,这个就是m0
。g0
负责调度,即schedule()
函数。 - 创建 P,绑定
m0
和p0
,首先会创建逻辑 CPU 核数个 P,存储在sched
的空闲链表(pidle
),也就是 P 的初始化 - 新建任务
g
到p0
本地队列,m0
的g0
会创建一个指向runtime.main()
的g
,并放到p0
的本地队列。
runtime.main()
:启动 sysmon
线程;启动 GC
协程;执行 init
,即代码中的各种 init
函数;执行 main.main
函数。
OS thread 创建
准备运行新 goroutine
将唤醒 P 以更好的分发工作。这个 P 将创建或者唤醒一个与之关联的 M 绑定到一个 OS thread。
go func()
中出发 Wakeup
唤醒机制:
有空闲的 P 而没有在 spining
(自旋) 状态的 M 时候,需要去唤醒一个空闲(睡眠)的 M 或者新建一个。
M0 main
程序启动后,Go 已经将主线程和 M 绑定 (rt0_go
)。
当
goroutine
创建完后,它是放在当前 P 的local queue
还是global queue
?
runtime.runqput
这个函数会尝试把 newg
放到本地队列上,如果本地队列满了,它会将本地队列的前半部分和 newg
迁移到全局队列中。剩下的事情就等待 M 自己去拿任务了。
当线程首次创建时,会执行一个特殊的 G,即 g0
,它负责管理和调度 G。Go:g0,特殊的 Goroutine
Reference
Go: Goroutine, OS Thread and CPU Management
goroutine背后的系统知识
Go’s work-stealing scheduler
Go的核心 goroutine sysmon
深入Golang调度器之GMP模型
Golang Goroutine的调度机制
Go语言调度器之调度main goroutine(14)
Go语言调度器之盗取goroutine(17)
Go调度详解
Golang/Go goroutine调度器原理/实现【原】
Golang源码探索(二) 协程的实现原理
golang scheduler
The Go netpoller
Go: g0, 特殊的goroutine
Go 语言调度(一): 系统调度
Go 语言调度(三): 并发
Go的核心 goroutine sysmon
Go: How Does a Goroutine Start and Exit?
Go: g0, Special Goroutine
Go: How Does Go Recycle Goroutines?
Go: What Does a Goroutine Switch Actually Involve?
golang 基于信号的抢占式调度的设计实现原理
Go runtime 源码分析
Go 1.2 Runtime Symbol Information
Go: Asynchronous Preemption
Go: Goroutine and Preemption
Go: gsignal, Master of Signals