go之GPM
[TOC]
免费的性能大餐
CPU设计者主要从三个方面提升CPU性能:
时钟速度
提升时钟速度将增大单位时间的时钟周期数。让CPU跑得更快,就意味着能让同样工作或多或少更快完成。
执行优化
可以在每个时钟周期内完成更多工作。
目前的CPU中,一些指令被不同程度地做了优化,如管线、分支预测、同一时钟周期内执行更多指令,甚至指令流再排序支持乱序执行等。引入这些技术的目的是让指令流更好、更快执行,降低延迟时间,挖掘每一时钟周期内芯片的工作潜能。
指令再排序可能改变程序原意,造成程序不响应程序员的正常要求,但是性能优化>让程序员吃苦头
缓存
增大与RAM分离的片内高速缓存。
RAM一直比CPU慢很多,因此让数据近可能靠近处理器就很重要——当然那就是片内了
过去任何方法带来的速度提升,无论是顺序(非并行的单线程或单进程)、还是并发执行的程序,都能直接受益。当然,适当时候,我们重新编译程序,可以利用CPU的新指令(如MMX、SSE[译注9])和新特性提升系统性能。但总的来说,即使放弃使用新指令和新特性,不做任何更改,老程序在新CPU也会跑得更快,让人心花怒放。
曾经的世界是这般美好,可如今,她就要变了颜色。
受制于一些物理学问题,如散热(发热量太大且难以驱散)、功耗(太高)以及泄漏问题等,时钟速度的提升已经越来越难。
大多数现在的应用软件将不再可能不作大规模重构,就能像过去那样从处理器免费获益。
接下来数年里,新型芯片的性能提升将主要从三个方面入手,其中仅有一个沿袭是过去的:
超线程
是指在单个CPU内,并行两个或多个线程。
多核
主要是指在一块芯片上运行两个或多个处理器。
片内缓存
CPU和主存交互的代价是巨大的,如果能避免,那就尽量不要和它打交道。在目前的系统里,从主存获取数据所花时间,通常是从缓存获得数据的10到50倍。
空间就是速度
Go语言的GPM调度器是什么?
🔗导读
相信很多人都听说过Go语言天然支持高并发,原因是内部有协程(goroutine)加持,可以在一个进程中启动成千上万个协程。那么,它凭什么做到如此高的并发呢?那就需要先了解什么是并发模型。
🔗并发模型
🔗七种并发模型
- 线程与锁
- 函数式编程
- Clojure之道
- actor
- 通讯顺序进程(CSP)
- 数据级并行
- Lambda架构
🔗CSP
CSP,全称Communicating Sequential Processes,意为通讯顺序进程,它是七大并发模型中的一种,它的核心观念是将两个并发执行的实体通过通道channel连接起来,所有的消息都通过channel传输。其实CSP概念早在1978年就被东尼·霍尔提出,由于近来Go语言的兴起,CSP又火了起来。
🔗GPM调度模型
🔗流程图
G 会在 M 上得到执行,内核线程是在 CPU 核心上调度,而 G 则是在 M 上进行调度。
GPM代表了三个角色,分别是Goroutine、Processor、Machine。
Goroutine:就是咱们常用的用go关键字创建的执行体,它对应一个结构体g,主要存储执行栈、goroutine的状态信息、CPU 的一些寄存器的值(SP、BP、PC)
Machine:它代表一个工作线程,或者说系统线程。G 需要调度到 M 上才能运行,M 是真正工作的人。结构体 m 就是我们常说的 M,它保存了 M 自身使用的栈信息、当前正在 M 上执行的 G 信息、与之绑定的 P 信息……
当 M 没有工作可做的时候,在它休眠前,会“自旋”地来找工作:检查全局队列,查看 network poller,试图执行 gc 任务,或者“偷”工作。
Processor:取 processor 的首字母,为 M 的执行提供“上下文”,保存 M 执行 G 时的一些资源,例如本地可运行 G 队列,memory cache 等。
一个 M 只有绑定 P 才能执行 goroutine,当 M 被阻塞时,整个 P 会被传递给其他 M ,或者说整个 P 被接管。
GPM 三足鼎力,共同成就 Go scheduler。G 需要在 M 上才能运行,M 依赖 P 提供的资源,P 则持有待运行的 G。你中有我,我中有你。
🔗1、Goroutine-协程
先看 G,取 goroutine 的首字母,主要保存 goroutine 的一些状态信息以及 CPU 的一些寄存器的值,例如 IP 寄存器,以便在轮到本 goroutine 执行时,CPU 知道要从哪一条指令处开始执行。
当 goroutine 被调离 CPU 时,调度器负责把 CPU 寄存器的值保存在 g 对象的成员变量之中。
当 goroutine 被调度起来运行时,调度器又负责把 g 对象的成员变量所保存的寄存器值恢复到 CPU 的寄存器。
Goroutine就是代码中使用go关键词创建的执行单元,也是大家熟知的有“轻量级线程”之称的协程,协程是不为操作系统所知的,它由编程语言层面实现,上下文切换不需要经过内核态,再加上协程占用的内存空间极小,所以有着非常大的发展潜力。
在Go语言中,Goroutine由一个名为runtime.go
的结构体表示,该结构体非常复杂,有40多个成员变量,主要存储执行栈、状态、当前占用的线程、调度相关的数据。
type g struct {
stack struct {
lo uintptr
hi uintptr
} // 栈内存:[stack.lo, stack.hi)
stackguard0 uintptr //用于调度器抢占式调度
stackguard1 uintptr
_panic *_panic //_panic链表头。头插法
_defer *_defer //_defer链表头。头插法
m *m //当前 Goroutine 占用的线程,可能为空;
sched gobuf //存储 Goroutine 的调度相关的数据,goroutine 的运行现场;
stktopsp uintptr // 期望 sp 位于栈顶,用于回溯检查
param unsafe.Pointer // wakeup 唤醒时候传递的参数
atomicstatus uint32 //Goroutine 的状态
goid int64 // Goroutine 的 ID,该字段对开发者不可见,Go 团队认为引入 ID 会让部分 Goroutine 变得更特殊,从而限制语言的并发能力
preempt bool // 抢占信号,stackguard0 = stackpreempt 的副本
timer *timer // 为 time.Sleep 缓存的计时器
...
}
Goroutine调度相关的数据存储在sched,在协程切换、恢复上下文的时候用到。
type gobuf struct {
sp uintptr //栈指针
pc uintptr //程序计数器
g guintptr //持有 runtime.gobuf 的 Goroutine;
ret sys.Uintreg //系统调用的返回值
...
}
//这些内容会在调度器保存或者恢复上下文的时候用到,其中的栈指针和程序计数器会用来存储或者恢复寄存器中的值,改变程序即将执行的代码
🔗G状态流转
虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:
- 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括
_Gwaiting
、_Gsyscall
和_Gpreempted
几个状态; - 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即
_Grunnable
; - 运行中:Goroutine 正在某个线程上运行,即
_Grunning
;
上图展示了 Goroutine 状态迁移的常见路径,其中包括创建 Goroutine 到 Goroutine 被执行、触发系统调用或者抢占式调度器的状态迁移过程。
🔗2、Machine-系统线程
默认情况下:GOMAXPROCS=逻辑核心数=M的个数=P的个数
M就是对应操作系统的线程,最多会有GOMAXPROCS个活跃线程能够正常运行,默认情况下GOMAXPROCS被设置为逻辑核心数,假如有四个逻辑核心,那么默认就创建四个活跃的操作系统线程,每一个线程对应一个runtime.m结构体。
🔗为什么设置线程数为逻辑核心数?
在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于逻辑核心数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销。
type m struct {
g0 *g // goroutine with scheduling stack
curg *g
...
}
M里面存了两个比较重要的东西,一个是g0,一个是curg。
- g0:会深度参与运行时的调度过程,比如Goroutine 的创建、大内存分配和 CGO 函数的执行
- curg:代表当前正在线程上执行的goroutine。
刚才说P是负责M与G的关联,所以M里面还要存储与P相关的数据。
type m struct {
...
p puintptr
nextp puintptr
oldp puintptr
}
- p:正在运行代码的处理器
- nextp:暂存的处理器
- old:系统调用之前的线程的处理器
M 的状态变化:
M 只有自旋和非自旋两种状态。自旋的时候,会努力找工作;找不到的时候会进入非自旋状态,之后会休眠,直到有工作需要处理时,被其他工作线程唤醒,又进入自旋状态。
type note struct {
key uintptr
}
func notesleep(n *note) {}
func notewakeup(n *note) {}
note 的底层实现机制跟操作系统相关,不同系统使用不同的机制,比如 linux 下使用的 futex 系统调用,而 mac 下则是使用的 pthread_cond_t 条件变量,note 对这些底层机制做了一个抽象和封装。
🔗线程种类
在 runtime 中有三种线程,一种是主线程,一种是用来跑 sysmon 的线程,一种是普通的用户线程。主线程在 runtime 由对应的全局变量: runtime.m0
来表示。用户线程就是普通的线程了,和 p 绑定,执行 g 中的任务。虽然说是有三种,实际上前两种线程整个 runtime 就只有一个实例。用户线程才会有很多实例。
🔗1、主线程 m0
主线程中用来跑 runtime.main
,流程线性执行,没有跳转:
graph TD
runtime.main --> A[init max stack size]
A --> B[systemstack execute -> newm -> sysmon]
B --> runtime.lockOsThread
runtime.lockOsThread --> runtime.init
runtime.init --> runtime.gcenable
runtime.gcenable --> main.init
main.init --> main.main
主线程也是需要和 p 绑定来运行的,绑定过程在 procresize -> acquirep 中。
🔗2、sysmon线程
sysmon 是在 runtime.main
中启动的,不过需要注意的是 sysmon 并不是在 m0 上执行的。因为:
systemstack(func() {
newm(sysmon, nil, -1)
})
创建了新的 m,但这个 m 又与普通的线程不一样,因为不需要绑定 p 就可以执行。是与整个调度系统脱离的。
sysmon 内部是个死循环,主要负责以下几件事情:
- checkdead,检查是否所有 goroutine 都已经锁死(死锁),如果是的话,直接调用 runtime.throw,强制退出。这个操作只在启动的时候做一次
- 将 netpoll 返回的结果注入到全局 sched 的任务队列
- 收回因为 syscall 而长时间阻塞的 p,同时抢占那些执行时间过长的 g
- 如果 span 内存闲置超过 5min,那么释放掉
🔗3、普通线程 M
普通线程就是我们 G/P/M 模型里的 M 了,M 对应的就是操作系统的线程。
🔗线程生命周期
Go 语言的运行时会通过 [runtime.startm
]启动线程来执行处理器 P,如果我们在该函数中没能从闲置列表中获取到线程 M 就会调用 [runtime.newm
]创建新的线程:
func newm(fn func(), _p_ *p, id int64) {
mp := allocm(_p_, fn, id)
mp.nextp.set(_p_)
mp.sigmask = initSigmask
...
newm1(mp)
}
func newm1(mp *m) {
if iscgo {
...
}
newosproc(mp)
}
创建新的线程需要使用如下所示的 [runtime.newosproc
],该函数在 Linux 平台上会通过系统调用 clone
创建新的操作系统线程,它也是创建线程链路上距离操作系统最近的 Go 语言函数:
func newosproc(mp *m) {
stk := unsafe.Pointer(mp.g0.stack.hi)
...
ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))
...
}
使用系统调用 clone
创建的线程会在线程主动调用 exit
、或者传入的函数 [runtime.mstart
]返回会主动退出,[runtime.mstart
] 会执行调用 [runtime.newm
]时传入的匿名函数 fn
,到这里也就完成了从线程创建到销毁的整个闭环。
🔗如何销毁一个M?
// KillOne kills a thread
func KillOne() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runtime.LockOSThread()
return
}()
wg.Wait()
}
// LockOSThread wires the calling goroutine to its current operating system thread.
// The calling goroutine will always execute in that thread,
// and no other goroutine will execute in it,
// until the calling goroutine has made as many calls to
// UnlockOSThread as to LockOSThread.
// If the calling goroutine exits without unlocking the thread,
// the thread will be terminated.
//
// All init functions are run on the startup thread. Calling LockOSThread
// from an init function will cause the main function to be invoked on
// that thread.
//
// A goroutine should call LockOSThread before calling OS services or
// non-Go library functions that depend on per-thread state.
func LockOSThread() {
...
}
LockOSThread函数会把当前goroutine绑定在当前的系统线程上,这个goroutine总是在这个线程中执行,而且也不会有其它goroutine在这个线程中执行。只有这个goroutine调用了相同次数的UnlockOSThread函数之后,才会进行解绑。如果goroutine在退出的时候没有unlock这个线程,那么这个线程会被终止。
我们正好可以利用这个特性将线程杀掉。我们可以启动一个goroutine,调用LockOSThread占住一个线程,尽然当前有很多空闲的线程,所以正好可以重用一个,goroutine退出的时候不调用UnlockOSThread,也就导致这个线程被终止了.
🔗3、Processor-处理器
首先是 Processor(简称P),其作用类似CPU 核,用来控制可同时并发执行的任务数量。每个工作线程都必须绑定一个有效P 才被允许执行任务,否则只能休眠,直到有空闲P 时被唤醒。P 还为线程提供执行资源,比如对象分配内存、本地任务队列等。线程独享所绑定的P 资源,可在无锁状态下执行高效操作。
type p struct {
m muintptr
runqhead uint32
runqtail uint32
runq [256]guintptr //待执行的 Goroutine 列表
runnext guintptr //线程下一个需要执行的 Goroutine。
...
}
[runtime.p
]结构体中的状态 status
字段会是以下五种中的一种:
状态 | 描述 |
---|---|
_Pidle | 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空 |
_Prunning | 被线程 M 持有,并且正在执行用户代码或者调度器 |
_Psyscall | 没有执行用户代码,当前线程陷入系统调用 |
_Pgcstop | 被线程 M 持有,当前处理器由于垃圾回收被停止 |
_Pdead | 当前处理器已经不被使用 |
表 7-4 处理器的状态
🔗4、sched-调度器
// 调度器
var sched schedt
// 保存调度器的信息
type schedt struct {
// Global runnable queue.
runq gQueue
runqsize int32
}
🔗Go程序初始化过程概括
- call osinit。初始化系统核心数。
- call schedinit。初始化调度器。
- make & queue new G。创建新的 goroutine。
- call runtime·mstart。调用 mstart,启动调度。
- The new G calls runtime·main。在新的 goroutine 上运行 runtime.main 函数。
🔗g0的作用
用于执行调度器的代码。
g0 其实就是线程栈,我们知道每个线程被创建出来之时都需要操作系统为之分配一个初始固定的线程栈,g0 栈就代表了这个线程栈,因此每一个 m 都需要绑定一个 g0 来执行调度器代码,然后跳转到执行用户代码的地方。
🔗main Goroutine
// The main goroutine.
func main() {
...
// 执行栈最大限制:1GB(64位系统)或者 250MB(32位系统)
if goarch.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
// 1、启动系统后台监控(定期垃圾回收、抢占调度等等)
if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
systemstack(func() {
newm(sysmon, nil, -1)
})
}
// 2、执行 runtime.init。运行时包中有多个 init 函数,编译器会将他们链接起来。
//例如:1、垃圾回收器所需的参数检查 2、创建强制启动 GC 的监控 Goroutine 3、确定 defer 的运行时类型
doInit(&runtime_inittask) // Must be before defer.
// 3、启动垃圾回收器后台操作
gcenable()
// 4、执行用户态 init 函数,这意味着所有的 init 均在同一个主 Goroutine 中执行
doInit(&main_inittask)
// 5、执行用户 main 包中的 main 函数
fn := main_main
fn()
//退出
exit(0)
}
🔗main Goroutine与普通Goroutine的区别
对于 main goroutine,在执行完用户定义的 main 函数的所有代码后,直接调用 exit(0) 退出整个进程,非常霸道。
对于普通 goroutine 则没这么“舒服”,需要经历一系列的过程。先是跳转到提前设置好的 goexit 函数的第二条指令,然后调用runtime.goexit1
,接着调用 mcall(goexit0)
,而 mcall 函数会切换到 g0 栈,运行 goexit0 函数,清理 goroutine 的一些字段,并将其添加到 goroutine 缓存池里,然后进入 schedule 调度循环。到这里,普通 goroutine 才算完成使命。
🔗生产端-创建Goroutine
想要启动一个新的 Goroutine 来执行任务时,我们需要使用 Go 语言的 go
关键字,编译器会通过 [cmd/compile/internal/gc.state.stmt
] 和 [cmd/compile/internal/gc.state.call
]两个方法将该关键字转换成 [runtime.newproc
]函数调用:
//go func() 转换成 newproc
func newproc(siz int32, fn *funcval) {
//argp为栈上的参数起始地址
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
//获取程序计数器pc,即newproc返回时要执行的下一条指令的地址
pc := getcallerpc()
//systemstack 在 runtime 中用的也比较多,其功能为让 m 切换到 g0 上执行各种调度函数
// systemstack 的作用是切换到 g0 栈执行作为参数的函数,如果是创建main goroutine则不需要切换g0栈,因为本身runtime·rt0_go 函数本身就是在g0栈上执行的。
// 用 g0 系统栈创建 goroutine 对象
// newproc1传递的参数包括 fn 函数入口地址,argp 参数起始地址,siz 参数长度,调用方 pc(CALL runtime·newproc(SB)指令的下一条指令地址)
systemstack(func() {
//1、获取或者创建新的 Goroutine 结构体;
//2、将传入的参数移到 Goroutine 的栈上;
//3、更新 Goroutine 调度相关的属性;
newg := newproc1(fn, argp, siz, gp, pc)
_p_ := getg().m.p.ptr()
//4、将g加入处理器的运行队列
runqput(_p_, newg, true)
//主go启动后,唤醒
if mainStarted {
wakep()
}
})
}
🔗1、获取或者创建新的 Goroutine 结构体
G结构体会复用,对可复用的G管理类似于待运行的G管理,也有Local队列(p.gfree)和Global队列(sched.gfree)之分,获取算法差不多,优先从p.gfree中获取(无锁操作),否则从sched.gfree中获取并批量转移一部分(有锁操作),源代码参考src/runtime/proc.go:gfget函数。从Goroutine的角度来看,通过go func()
创建时,会从当前闲置的G队列取得可复用的G,如果没有则通过malg新建一个G
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
// 因为已经切换到 g0 栈,所以无论什么场景都是 _g_ = g0
_g_ := getg()
siz := narg
siz = (siz + 7) &^ 7
// 获取g0绑定的p0
_p_ := _g_.m.p.ptr()
// 从 p0 的本地缓冲或者sched的g缓存里获取一个没有使用的 g,初始化时为空,返回 nil
newg := gfget(_p_)
if newg == nil {
//_StackMin=2048(The minimum size of stack used by Go code)
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg)
}
...
}
通过两种不同的方式获取新的 runtime.g:
- 从 Goroutine 所在p的
gFree
列表或者调度器的sched.gFree
列表中获取 [runtime.g
]; - 调用 [
runtime.malg
] 生成一个新的 [runtime.g
] 并将结构体追加到全局的 Goroutine 列表allgs
中。
🔗2、将传入的参数移到 Goroutine 的栈上
我们会调用 runtime.memmove
将 fn
函数的所有参数拷贝到栈上,argp
和 narg
分别是参数的内存空间和大小,我们在该方法中会将参数对应的内存空间整块拷贝到栈上:
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
...
totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize
totalSize += -totalSize & (sys.SpAlign - 1)
sp := newg.stack.hi - totalSize
spArg := sp
if narg > 0 {
memmove(unsafe.Pointer(spArg), argp, uintptr(narg))
}
...
}
🔗3、设置Goroutine调度相关的信息
type g struct {
。。。
sched gobuf //存储 Goroutine 的调度相关的数据;
。。。
}
type gobuf struct {
sp uintptr // runtime.goexit 函数的程序计数器
pc uintptr // 传入函数(go func()语句中的func())的程序计数器;pc 寄存器的作用就是存储程序接下来运行的位置
g guintptr //持有 runtime.gobuf 的 Goroutine;
ret sys.Uintreg //系统调用的返回值
...
}
//这些内容会在调度器保存或者恢复上下文的时候用到,其中的栈指针和程序计数器会用来存储或者恢复寄存器中的值,改变程序即将执行的代码
调度信息的 sp
中存储了 [runtime.goexit
]函数的程序计数器,而 pc
中存储了传入函数的程序计数器
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
...
newg.sched.sp = sp
newg.stktopsp = sp
//[runtime.goexit]函数的程序计数器先赋给newg.sched.pc,之后赋给newg.sched.sp
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
...
}
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
sp := buf.sp
if sys.RegSize > sys.PtrSize {
sp -= sys.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = 0
}
//sp栈顶指针向下移8字节
sp -= sys.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = buf.pc
//sp 中存储了 [runtime.goexit]函数的程序计数器
buf.sp = sp
//传入函数(go func()语句中的func())的程序计数器;pc 寄存器的作用就是存储程序接下来运行的位置
buf.pc = uintptr(fn)
buf.ctxt = ctxt
}
🔗4、将Goroutine放入运行队列
尝试将G添加到当前P的runnext中,作为下一个执行的G
否则放到Local队列runq中(无锁)
如果以上操作都失败,则添加到Global队列sched.runq中(有锁操作,也会顺便将当Local队列runq中一部分的G转移到sched.runq)
操作全局 sched 时,需要获取全局 sched.lock 锁,全局锁争抢的开销较大,所以才称之为 slow。p 和 g 在 m 中交互时,因为现场永远是单线程,所以很多时候不用加锁。
处理器本地的运行队列是一个使用数组构成的环形链表,它最多可以存储 256 个待执行任务。
如下图,放在三个绿色位置中的其中的一个
🔗消费端-调度循环
调度器启动之后,Go 语言运行时会调用 [runtime.mstart
]以及 [runtime.mstart1
],前者会初始化 g0 的 stackguard0
和 stackguard1
字段,后者会初始化线程并调用 [runtime.schedule
]进入调度循环:
func schedule() {
//获取当前运行的g
_g_ := getg()
top:
var gp *g
var inheritTime bool
//查找待执行的g,如果从所有地方都获取不到,阻塞等待
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()
}
...
//执行获取到的g
execute(gp, inheritTime)
}
🔗1、给M找到待执行的 G
从多个地方查找待执行的G
- 从P的runnext和本地运行队列、从sched全局运行队列中查找;
- 从network poller 网络轮询器中查找是否有 Goroutine 等待运行;
- 通过 [
runtime.runqsteal
]尝试从其他随机的P中窃取待运行的 Goroutine,该函数还可能窃取处理器的计时器;
🔗本地运行队列
从P的runnext和本地队列获取可运行 goroutine 时,两种方式都使用CAS防止其他线程“偷工作”。
// Executed only by the owner P.
func runqget(_p_ *p) (gp *g, inheritTime bool) {
// If there's a runnext, it's the next G to run.
next := _p_.runnext
if next != 0 && _p_.runnext.cas(next, 0) { //从runnext中获取
return next.ptr(), true
}
for {
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers
t := _p_.runqtail
if t == h {
return nil, false
}
// 获取队列头的 g
gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()
// 原子操作,防止这中间被其他线程因为偷工作而修改
if atomic.CasRel(&_p_.runqhead, h, h+1) { // cas-release, commits consume
return gp, false
}
}
}
🔗全局运行队列
从sched全局运行队列中获取可运行 goroutine 时,需要加锁访问,开销大。把全局队列中的可运行 goroutine 转移到本地队列,给了全局队列中可运行 goroutine 运行的机会,不然全局队列中的 goroutine 一直得不到运行。
func globrunqget(_p_ *p, max int32) *g {
if sched.runqsize == 0 {
return nil
}
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
n = sched.runqsize
}
if max > 0 && n > max {
n = max
}
if n > int32(len(_p_.runq))/2 {
n = int32(len(_p_.runq)) / 2 //最多偷取P本地队列长度的一半
}
sched.runqsize -= n
gp := sched.runq.pop()
n--
for ; n > 0; n-- {
gp1 := sched.runq.pop()
runqput(_p_, gp1, false)//// 尝试将 gp1 放入 P 本地,使全局队列得到更多的执行机会
}
return gp
}
🔗偷取、netpoll
//直到有活,才返回;如果没活,休眠等待被唤醒
func findrunnable() (gp *g, inheritTime bool) {
top:
...
//再次调runqget、globrunqget、netpoll找活
...
gp, inheritTime, tnow, w, newWork := stealWork(now)//从其他P中偷G
...
//延迟调netpoll,看看有没有活
list := netpoll(delay) // block until new work is available
...
stopm() //实在找不到活了,休眠,直到有工作需要处理时,被其他工作线程唤醒
goto top //返回函数首
}
🔗2、将G调度到当前线程M上
接下来由 [runtime.execute
] 执行获取的 Goroutine,做好准备工作后,它会通过 [runtime.gogo
]将 Goroutine 调度到当前线程上。
func execute(gp *g, inheritTime bool) {
_g_ := getg()
_g_.m.curg = gp
gp.m = _g_.m
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + _StackGuard
if !inheritTime {
_g_.m.p.ptr().schedtick++
}
。。。
//将 Goroutine 调度到当前线程上
gogo(&gp.sched)
}
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 // 开始执行
正常的函数调用都会使用 CALL
指令,该指令会将**调用方的返回地址(runtime.goexit 函数的 PC)**加入栈寄存器 SP 中,然后跳转到目标函数;当目标函数返回后,会从栈中查找调用的地址并跳转回调用方继续执行剩下的代码(即执行runtime.goexit)。
[runtime.gogo
]就利用了 Go 语言的调用惯例成功模拟这一调用过程,通过以下几个关键指令模拟 CALL
的过程:
MOVL gobuf_sp(BX), SP // 将 runtime.goexit 函数的 PC 恢复到栈寄存器SP中
MOVL gobuf_pc(BX), BX // 获取待执行函数的程序计数器
JMP BX // 开始执行
🔗3、 G中运行的函数返回
上图展示了调用 JMP
指令后的栈中数据,当 Goroutine 中运行的函数返回时,程序会跳转到 [runtime.goexit
] 所在位置执行该函数:
TEXT runtime·goexit(SB),NOSPLIT,$0-0
CALL runtime·goexit1(SB)
func goexit1() {
mcall(goexit0)
}
经过一系列复杂的函数调用,我们最终在当前线程的 g0 的栈上调用 [runtime.goexit0
]函数,该函数会将 Goroutine 转换会 _Gdead
状态、清理其中的字段、移除 Goroutine 和线程的关联并调用 [runtime.gfput
]重新加入处理器的 Goroutine 空闲列表 gFree
:
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
],我们可以认为调度循环永远都不会返回。
图 6-36 调度循环
🔗sysmon
sysmon是我们的保洁阿姨,它是一个M,又叫监控线程,不需要P就可以独立运行,每20us~10ms会被唤醒一次出来打扫卫生,主要工作就是回收垃圾、netpoll、回收长时间系统调度阻塞的P、向长时间运行的G发出抢占调度。
🔗垃圾回收
如果超过2分钟没有垃圾回收,强制执行;
// check if we need to force a GC
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
lock(&forcegc.lock)
forcegc.idle = 0
var list gList
list.push(forcegc.g)
injectglist(&list)
unlock(&forcegc.lock)
}
🔗netpoll
获取可读可写的网络IO事件
// poll network if not polled for more than 10ms
if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
list := netpoll(0) // non-blocking - returns list of goroutines
incidlelocked(-1)
injectglist(&list)
incidlelocked(1)
}
}
🔗抢占式调度-retake
抢占处于系统调用的 P,让其他 m 接管它,以运行其他的 goroutine。
将运行时间过长的 goroutine 调度出去,给其他 goroutine 运行的机会。
// retake P's blocked in syscalls // and preempt long running G's if retake(now) != 0 { idle = 0 } else { idle++ } func retake(now int64) uint32 { for i := 0; i < len(allp); i++ { if s == _Prunning || s == _Psyscall { // Preempt G if it's running for too long. t := int64(_p_.schedtick) if int64(pd.schedtick) != t { pd.schedtick = uint32(t) pd.schedwhen = now } else if pd.schedwhen+forcePreemptNS <= now { preemptone(_p_) //G的时间片10ms,超过则抢占 // In case of syscall, preemptone() doesn't // work, because there is no M wired to P. sysretake = true } } if s == _Psyscall { if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now { continue } //当P处于_Psyscall状态时,若满足以下三个条件中的一个,抢占P: // 1. p 的运行队列里面有等待运行的 goroutine // 2. 没有无所事事的 p // 3. 从上一次监控线程观察到 p 对应的 m 处于系统调用之中到现在已经超过 10 毫秒 // Drop allpLock so we can take sched.lock. unlock(&allpLock) incidlelocked(-1) if atomic.Cas(&_p_.status, s, _Pidle) { _p_.syscalltick++ handoffp(_p_) //满足以上三个条件中的一个且P为_Psyscall,抢占P } incidlelocked(1) lock(&allpLock) } } }
🔗相关问题
🔗runtime.GOMAXPROCS(1)
GOMAXPROCS设为 1,并发不并行,访问map也是不安全的,也需要加锁。
没有锁、channel或其他并发原语,多个goroutine访问同一个数据时,无法保证串行化访问。
🔗为什么需要P这个组件?
当一个线程阻塞的时候(如系统调用),可以将和它绑定的 P 上的 goroutines 转移到其他线程。
🔗进程与线程的区别?
根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的
影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行
🔗goroutine和线程的区别?
- 内存占用
创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 2 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。Golang 程序中可以轻松支持10w 级别的 Goroutine 运行,而线程数量达到 1k 时,内存占用就已经达到 2G。
对于一个用 Go 构建的 HTTP Server 而言,对到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果用一个使用线程作为并发原语的语言构建的服务,例如 Java 来说,每个请求对应一个线程则太浪费资源了,很快就会出 OOM 错误(OutOfMermoryError)。
- 创建和销毀
Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。
- 上下文切换
Goroutine 上下文切换只涉及到三个寄存器(PC / SP / DX)的值修改;而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP…等寄存器的刷新;
当 threads 切换时,需要保存各种寄存器,以便将来恢复:
16 general purpose registers, PC (Program Counter), SP (Stack Pointer), segment registers, 16 XMM registers, FP coprocessor state, 16 AVX registers, all MSRs etc.
而 goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。
一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。
Goroutine 的切换约为 200 ns,相当于 2400-3600 条指令。
因此,goroutines 切换成本比 threads 要小得多。
🔗调度器如何给M找待执行的 G?
共经历三个过程:先从本地队列找,定期会从全局队列找,最后实在没办法,就去别的 P 偷。如下图所示:
if gp == nil {
// Check the global runnable queue once in a while to ensure fairness.
// Otherwise two goroutines can completely occupy the local runqueue
// by constantly respawning each other.
// 为了公平,每调用 schedule 函数 61 次就要从全局可运行 goroutine 队列中获取
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())
// We can see gp != nil here even if the M is spinning,
// if checkTimers added a local goroutine via goready.
}
if gp == nil {
/*
1、从本地运行队列、全局运行队列中查找;
2、从网络轮询器中查找是否有 Goroutine 等待运行;
3、通过 runtime.runqsteal 尝试从其他随机的处理器中窃取待运行的 Goroutine,该函数还可能窃取处理器的计时器;
当前函数一定会返回一个可执行的 Goroutine,如果当前不存在就会阻塞等待
*/
gp, inheritTime = findrunnable() // blocks until work is available
}
这说明,并不是每次调度都会从全局队列获取可运行的 goroutine。实际情況是调度器每调度 61 次并且全局队列有可运行 goroutine 的情况下才会调用 globrunqget
函数尝试从全局获取可运行 goroutine。毕竟,从全局获取需要上锁,这个开销可就大了,能不做就不做。
🔗goroutine 调度时机有哪些?
- 主动挂起 —
runtime.gopark
->runtime.park_m
- 系统调用 —
runtime.exitsyscall
->runtime.exitsyscall0
- 协作式调度 —
runtime.Gosched
->runtime.gosched_m
->runtime.goschedImpl
- 系统监控 —
runtime.sysmon
->runtime.retake
->runtime.preemptone
🔗主动挂起-gopark
🔗gopark发生了什么?
- 当前g栈切换到g0栈(线程栈)
- 将当前g栈的SP、PC、BP保存在g.sched
- 将当前 g 的状态从
_Grunning
切换至_Gwaiting
- 触发新一轮调度
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
if reason != waitReasonSleep {
checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
}
mp := acquirem()
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = unlockf
gp.waitreason = reason
mp.waittraceev = traceEv
mp.waittraceskip = traceskip
releasem(mp)
mcall(park_m) //切换到g0栈执行
}
//mcall 从当前g切换到g0栈并调用 fn(g)。 mcall 将g的当前SP、PC保存在g.sched中,以便之后可以恢复
//fn 绝对不能返回;通常它以调用 schedule 结束,让 m 运行其他 goroutine
func mcall(fn func(*g))
func park_m(gp *g) {
_g_ := getg()
//将当前 Goroutine 的状态从 `_Grunning` 切换至 `_Gwaiting`
casgstatus(gp, _Grunning, _Gwaiting)
//移除线程和 Goroutine 之间的关联
dropg()
//触发新一轮的调度
schedule()
}
mcall:
// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall<ABIInternal>(SB), NOSPLIT, $0-8
MOVQ AX, DX // DX = fn
// save state in g->sched
MOVQ 0(SP), BX // caller's PC
MOVQ BX, (g_sched+gobuf_pc)(R14)//设置g.sched.pc
LEAQ fn+0(FP), BX // caller's SP
MOVQ BX, (g_sched+gobuf_sp)(R14)//设置g.sched.sp
MOVQ BP, (g_sched+gobuf_bp)(R14)//设置g.sched.bp
// switch to m->g0 & its stack, call fn
MOVQ g_m(R14), BX
MOVQ m_g0(BX), SI // SI = g.m.g0
CMPQ SI, R14 // if g == m->g0 call badmcall
JNE goodm
JMP runtime·badmcall(SB)
goodm:
MOVQ R14, AX // AX (and arg 0) = g
MOVQ SI, R14 // g = g.m.g0
get_tls(CX) // Set G in TLS
MOVQ R14, g(CX)
MOVQ (g_sched+gobuf_sp)(R14), SP // sp = g0.sched.sp
PUSHQ AX // open up space for fn's arg spill slot
MOVQ 0(DX), R12
CALL R12 // fn(g)
POPQ AX
JMP runtime·badmcall2(SB)
RET
🔗goready发生了什么?
- 将 g 的状态从
_Gwaiting
切换至_Grunnable
- 把g放到当前g所在的处理器的
runnext
上等待执行,该处理器在下一次调度时就会立刻唤醒g
当 Goroutine 等待的特定条件满足后,运行时会调用 runtime.goready
将因为调用 runtime.gopark
而陷入休眠的 Goroutine 唤醒。
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
func ready(gp *g, traceskip int, next bool) {
_g_ := getg()
casgstatus(gp, _Gwaiting, _Grunnable)
runqput(_g_.m.p.ptr(), gp, next)
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 {
wakep()
}
}
🔗系统调用
Syscall与RawSyscall
由于直接进行系统调用会阻塞当前的线程,所以只有可以立刻返回的系统调用才可能会被设置成 RawSyscall
类型,例如:SYS_EPOLL_CREATE
、SYS_EPOLL_WAIT
(超时时间为 0)、SYS_TIME
等。
🔗entersyscall
func reentersyscall(pc, sp uintptr) {
_g_ := getg()
_g_.m.locks++
_g_.stackguard0 = stackPreempt
_g_.throwsplit = true
save(pc, sp)
_g_.syscallsp = sp
_g_.syscallpc = pc //保存当前的程序计数器 PC 和栈指针 SP 中的内容
casgstatus(_g_, _Grunning, _Gsyscall)//g状态_Grunning->_Gsyscall
_g_.m.syscalltick = _g_.m.p.ptr().syscalltick
_g_.m.mcache = nil
pp := _g_.m.p.ptr()
pp.m = 0
_g_.m.oldp.set(pp)
_g_.m.p = 0
atomic.Store(&pp.status, _Psyscall)//更新处理器的状态到 _Psyscall
if sched.gcwaiting != 0 {
systemstack(entersyscall_gcwait)
save(pc, sp)
}
_g_.m.locks--
}
🔗exitsyscall
func exitsyscall() {
_g_ := getg()
oldp := _g_.m.oldp.ptr()
_g_.m.oldp = 0
if exitsyscallfast(oldp) {
_g_.m.p.ptr().syscalltick++
casgstatus(_g_, _Gsyscall, _Grunning)
...
return
}
mcall(exitsyscall0)//切换到g0执行
_g_.m.p.ptr().syscalltick++
_g_.throwsplit = false
}
func exitsyscall0(gp *g) {
casgstatus(gp, _Gsyscall, _Grunnable)//g状态_Gsyscall-->_Grunnable
dropg()
lock(&sched.lock)
var _p_ *p
if schedEnabled(gp) {
_p_ = pidleget()
}
var locked bool
if _p_ == nil {
//将当前 Goroutine 放到全局的运行队列中,等待调度器的调度
globrunqput(gp)
locked = gp.lockedm != 0
} else if atomic.Load(&sched.sysmonwait) != 0 {
atomic.Store(&sched.sysmonwait, 0)
notewakeup(&sched.sysmonnote)
}
unlock(&sched.lock)
if _p_ != nil {
//有空闲的p,使用空闲p处理当前 Goroutine
acquirep(_p_)
execute(gp, false) // Never returns.
}
if locked {
stoplockedm()
execute(gp, false) // Never returns.
}
stopm()
schedule() // Never returns.
}
🔗协作式调度-主动让出P
// Gosched 会让出当前的 P,并允许其他 Goroutine 运行。
// 它不会推迟当前的 Goroutine,因此执行会被自动恢复
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}
/ Gosched 在 g0 上继续执行
func gosched_m(gp *g) {
...
goschedImpl(gp)
}
func goschedImpl(gp *g) {
status := readgstatus(gp)
if status&^_Gscan != _Grunning {
dumpgstatus(gp)
throw("bad g status")
}
//g的状态_Grunning-->_Grunnable
casgstatus(gp, _Grunning, _Grunnable)
// 使当前 m 放弃 g
dropg()
lock(&sched.lock)
// 并将 g 放回全局队列中
globrunqput(gp)
unlock(&sched.lock)
// 重新进入调度循环
schedule()
}
🔗抢占式调度-sysmon
- 抢占处于系统调用的 P,让其他 m 接管它,以运行其他的 goroutine。
- 将运行时间过长的 goroutine 调度出去,给其他 goroutine 运行的机会。
🔗系统加载可执行文件的几个阶段?
- 从磁盘上读取可执行文件,加载到内存
- 创建进程和主线程
- 为主线程分配栈空间
- 把由用户在命令行输入的参数拷贝到主线程的栈
- 把主线程放入操作系统的运行队列等待被调度
🔗Go 为什么要使用GPM?
再回头来看,Go 为什么要使用GPM?而不是像大多数调度器一样只有两层关系GM,直接用M(OS线程)的数量来限制并发能力。我粗浅的理解是为了更好地处理syscall,当某个M陷入系统调用时,P则”抛妻弃子”,与M解绑,让阻塞的M和G等待被OS唤醒,而P则带着local queue剩下的G去找一个(或新建一个)idle的M,当阻塞的M被唤醒时,它会尝试给G找一个新的归宿(idle的P,或扔到global queue,等待被领养)。多么忧桑的故事。
🔗Go scheduler设计哲学
就像操作系统线程是在core上进行上下文切换,Goroutines是在M上进行上下文切换
压榨更多CPU的性能
从本质上讲,Go将 IO/Blocking work变成了操作系统层面上的CPU-bound work。由于所有的上下文切换都发生在应用层面,我们不会像使用OS Threads时那样,每次上下文切换损失约12k指令(平均)。在Go中,这些相同的上下文切换会让你损失约200纳秒或约2.4k指令。调度器也有助于提高缓存线的效率和NUMA。这就是为什么我们不需要比我们的虚拟核心更多的线程。在Go中,随着时间的推移,我们有可能完成更多的工作,因为Go调度器试图使用更少的线程,在每个线程上做更多的工作,这有助于减少操作系统和硬件的负载。
🔗Go更快的原因
数据结构
Go 允许您创建紧凑的数据结构,避免不必要的间接引用。
紧凑的数据结构更好地利用缓存。
更好的缓存利用率会带来更好的性能
内联
减少了函数调用开销
Go 编译器通过将函数体视为调用者的一部分来内联函数
内联是有代价的;它增加了可执行程序的大小
逃逸分析
逃逸分析确定对值的任何引用是否会逃离声明该值的函数。
如果引用没有逃离,则该值可以安全地存储在栈中。
存储在栈中的值不需要分配或释放。
逃逸分析通过自动将分配从堆移动到栈来减少垃圾收集器的压力
Goroutines
轻量级线程,用户态切换,开销小。
栈
初始栈较小,为2kb
在每个函数调用前,检查栈空间是否够用,不够用分配更大的栈空间
创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 2 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。