本文主要介绍golang的协程创建和调度的相关知识。主要分为以下几个部分:
- 协程的介绍及调度模型
- 主协程的创建到退出
- 非主协程的创建到退出
- 协程调度
一、协程的介绍及调度模型
1、进程和线程
进程和线程大部分人都很熟悉了,这边只做简单的介绍。
- 进程是分配资源(资源管理)的最小单元,并运行在彼此独立的空间上;
- 线程是调度资源(程序执行)的最小单元,它是比进程更小的能独立运行的基本单位,且基本不会占用系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他线程共享进程所拥有的全部资源,且线程的切换不会引起进程的切换
1.1 为什么要有进程
为什么会有 “进程” 呢?说白了还是为了合理压榨 CPU 的性能和分配运行的时间片,不能 “闲着“。
在计算机中,其计算核心是 CPU,负责所有计算相关的工作和资源。单个 CPU 一次只能运行一个任务。如果一个进程跑着,就把唯一一个 CPU 给完全占住,那是非常不合理的。
那为什么要压榨 CPU 的性能?因为 CPU 实在是太快,太快,太快了,寄存器仅仅能够追的上他的脚步,RAM 和别的挂在各总线上的设备则更是望尘莫及。
1.2 多进程
如果总是在运行一个进程上的任务,就会出现一个现象。就是任务不一定总是在执行 ”计算型“ 的任务,会有很大可能是在执行网络调用,阻塞了,CPU 岂不就浪费了?
1.3 线程
有了多进程,想必在操作系统上可以同时运行多个进程。那么为什么有了进程,还要线程呢?
原因如下:
- 进程间的信息难以共享数据,父子进程并未共享内存,需要通过进程间通信(IPC),在进程间进行信息交换,性能开销较大。
- 创建进程(一般是调用
fork方法)的性能开销较大。
大家又把目光转向了进程内,能不能在进程里做点什么呢?
一个进程可以由多个称为线程的执行单元组成。每个线程都运行在进程的上下文中,共享着同样的代码和全局数据。
多个进程,就可以有更多的线程。多线程比多进程之间更容易共享数据,在上下文切换中线程一般比进程更高效。
原因如下:
- 线程之间能够非常方便、快速地共享数据。
- 只需将数据复制到进程中的共享区域就可以了,但需要注意避免多个线程修改同一份内存。
- 创建线程比创建进程要快 10 倍甚至更多。
- 线程都是同一个进程下自家的孩子,像是内存页、页表等就不需要了。
2、协程goroutine
多进程、多线程已经提高了系统的并发能力,大量的进程/线程出现了新的问题:
- 高内存占用(进程虚拟内存会占用8GB,而线程的栈也要大约8MB)
- 调度的高消耗CPU
其实一个线程分为“内核态“线程和”用户态“线程。一个“用户态线程”必须要绑定一个“内核态线程”,但是CPU并不知道有“用户态线程”的存在,它只知道它运行的是一个“内核态线程”
细化去分类一下,内核线程依然叫“线程(thread)”,用户线程叫“协程(co-routine)"。既然一个协程(co-routine)可以绑定一个线程(thread),那么能不能多个协程(co-routine)绑定一个或者多个线程(thread)上呢。
N:1关系
N个协程绑定1个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1个进程的所有协程都绑定在1个线程上。
缺点:
- 某个程序用不了硬件的多核加速能力
- 一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。
1:1 关系
1个协程绑定1个线程,这种最容易实现。协程的调度都由CPU完成了,不存在N:1缺点,
缺点:
- 协程的创建、删除和切换的代价都由CPU完成,有点略显昂贵了。
M:N关系
M个协程绑定1个线程,是N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂。
GM模型
介绍GM模型之前先来介绍几个概念:
为了实现对goroutine的调度,需要引入一个数据结构来保存CPU寄存器的值以及goroutine的其它一些状态信息,在Go语言调度器源代码中,这个数据结构是一个名叫g的结构体,它保存了goroutine的所有信息,该结构体的每一个实例对象都代表了一个goroutine,调度器代码可以通过g对象来对goroutine进行调度,当goroutine被调离CPU时,调度器代码负责把CPU寄存器的值保存在g对象的成员变量之中,当goroutine被调度起来运行时,调度器代码又负责把g对象的成员变量所保存的寄存器的值恢复到CPU的寄存器。
要实现对goroutine的调度,仅仅有g结构体对象是不够的,至少还需要一个存放所有(可运行)goroutine的容器,便于工作线程寻找需要被调度起来运行的goroutine,于是Go调度器又引入了schedt结构体,一方面用来保存调度器自身的状态信息,另一方面它还拥有一个用来保存goroutine的运行队列。因为每个Go程序只有一个调度器,所以在每个Go程序中schedt结构体只有一个实例对象,该实例对象在源代码中被定义成了一个共享的全局变量,这样每个工作线程都可以访问它以及它所拥有的goroutine运行队列,我们称这个运行队列为全局运行队列。
除此之外,Go调度器源代码中还有一个用来代表工作线程的m结构体,每个工作线程都有唯一的一个m结构体的实例对象与之对应。
golang最早使用的调度器模型是GM模型,但由于自身的缺陷问题,目前已经废弃掉了。下面来简单看一下废弃掉的GM是怎么运行的。
伪代码如下:
// 程序启动时的初始化代码
......
for i := 0; i < N; i++ { // 创建N个操作系统线程执行schedule函数
create_os_thread(schedule) // 创建一个操作系统线程执行schedule函数
}
//schedule函数实现调度逻辑
func schedule() {
for { //调度循环
// 根据某种算法从M个goroutine中找出一个需要运行的goroutine
g := find_a_runnable_goroutine_from_M_goroutines()
run_g(g) // CPU运行该goroutine,直到需要调度其它goroutine才返回
save_status_of_g(g) // 保存goroutine的状态,主要是寄存器的值
}
}
M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。
老调度器有几个缺点:
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
- M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M'。
GMP模型
因为全局运行队列是每个工作线程都可以读写的,因此访问它需要加锁,然而在一个繁忙的系统中,加锁会导致严重的性能问题。于是,调度器又为每个工作线程引入了一个私有的局部goroutine运行队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这大大减少了锁冲突,提高了工作线程的并发性。在Go调度器源代码中,局部运行队列被包含在p结构体的实例对象之中,每一个运行着go代码的工作线程都会与一个p结构体的实例对象关联在一起。
我们进一步丰富下伪代码:
// 程序启动时的初始化代码
......
for i := 0; i < N; i++ { // 创建N个操作系统线程执行schedule函数
create_os_thread(schedule) // 创建一个操作系统线程执行schedule函数
}
// 定义一个线程私有全局变量,注意它是一个指向m结构体对象的指针
// ThreadLocal用来定义线程私有全局变量
ThreadLocal self *m
//schedule函数实现调度逻辑
func schedule() {
// 创建和初始化m结构体对象,并赋值给私有全局变量self
self = initm()
for { //调度循环
if (self.p.runqueue is empty) {
// 根据某种算法从全局运行队列中找出一个需要运行的goroutine
g := find_a_runnable_goroutine_from_global_runqueue()
} else {
// 根据某种算法从私有的局部运行队列中找出一个需要运行的goroutine
g := find_a_runnable_goroutine_from_local_runqueue()
}
run_g(g) // CPU运行该goroutine,直到需要调度其它goroutine才返回
save_status_of_g(g) // 保存goroutine的状态,主要是寄存器的值
}
}
在简单的介绍了Go语言调度器以及它所需要的数据结构之后,下面我们来看一下Go的调度代码中对上述的几个结构体的定义,为了节省篇幅,下面各结构体的定义略去了跟调度器无关的成员。另外,这些结构体的定义全部位于Go语言的源代码路径下的runtime/runtime2.go文件之中。
g结构体
g结构体用于代表一个goroutine,该结构体保存了goroutine的所有信息,包括栈,gobuf结构体和其它的一些状态信息:
// 前文所说的g结构体,它代表了一个goroutine
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
// stackguard1 is the stack pointer compared in the C stack growth prologue.
// It is stack.lo+StackGuard on g0 and gsignal stacks.
// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
// 记录该goroutine使用的栈
stack stack // offset known to runtime/cgo
// 下面两个成员用于栈溢出检查,实现栈的自动伸缩,抢占调度也会用到stackguard0
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
......
// 此goroutine正在被哪个工作线程执行
m *m // current m; offset known to arm liblink
// 保存调度信息,主要是几个寄存器的值
sched gobuf
......
// schedlink字段指向全局运行队列中的下一个g,
//所有位于全局运行队列中的g形成一个链表
schedlink guintptr
......
// 抢占调度标志,如果需要抢占调度,设置preempt为true
preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
......
}
stack结构体
stack结构体主要用来记录goroutine所使用的栈的信息,包括栈顶和栈底位置。
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
//用于记录goroutine使用的栈的起始和结束位置
type stack struct {
lo uintptr // 栈顶,指向内存低地址
hi uintptr // 栈底,指向内存高地址
}
gobuf结构体
gobuf结构体用于保存goroutine的调度信息,主要包括CPU的几个寄存器的值:
type gobuf struct {
// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
//
// ctxt is unusual with respect to GC: it may be a
// heap-allocated funcval, so GC needs to track it, but it
// needs to be set and cleared from assembly, where it's
// difficult to have write barriers. However, ctxt is really a
// saved, live register, and we only ever exchange it between
// the real register and the gobuf. Hence, we treat it as a
// root during stack scanning, which means assembly that saves
// and restores it doesn't need write barriers. It's still
// typed as a pointer so that any other writes from Go get
// write barriers.
sp uintptr // 保存CPU的rsp寄存器的值
pc uintptr // 保存CPU的rip寄存器的值
g guintptr // 记录当前这个gobuf对象属于哪个goroutine
ctxt unsafe.Pointer
// 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占,
// 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统调用的返回值。
ret sys.Uintreg
lr uintptr
// 保存CPU的rip寄存器的值
bp uintptr // for GOEXPERIMENT=framepointer
}
m结构体
m结构体用来代表工作线程,它保存了m自身使用的栈信息,当前正在运行的goroutine以及与m绑定的p等信息,详见下面定义中的注释:
type m struct {
// g0主要用来记录工作线程使用的栈信息,在执行调度代码时需要使用这个栈
// 执行用户goroutine代码时,使用用户goroutine自己的栈,调度时会发生栈的切换
g0 *g // goroutine with scheduling stack
// 通过TLS实现m结构体对象与工作线程之间的绑定
tls [6]uintptr // thread-local storage (for x86 extern register)
mstartfn func()
// 指向工作线程正在运行的goroutine的g结构体对象
curg *g // current running goroutine
// 记录与当前工作线程绑定的p结构体对象
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
oldp puintptr // the p that was attached before executing a syscall
// spinning状态:表示当前工作线程正在试图从其它工作线程的本地运行队列偷取goroutine
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
// 没有goroutine需要运行时,工作线程睡眠在这个park成员上,
// 其它线程通过这个park唤醒该工作线程
park note
// 记录所有工作线程的一个链表
alllink *m // on allm
schedlink muintptr
// Linux平台thread的值就是操作系统线程ID
thread uintptr // thread handle
freelink *m // on sched.freem
......
}
p结构体
p结构体用于保存工作线程执行go代码时所必需的资源,比如goroutine的运行队列,内存分配用到的缓存等等。
type p struct {
lock mutex
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr // back-link to associated m (nil if idle)
......
// Queue of runnable goroutines. Accessed without lock.
//本地goroutine运行队列
runqhead uint32 // 队列头
runqtail uint32 // 队列尾
runq [256]guintptr //使用数组实现的循环队列
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice. If a set of goroutines is locked in a
// communicate-and-wait pattern, this schedules that set as a
// unit and eliminates the (potentially large) scheduling
// latency that otherwise arises from adding the ready'd
// goroutines to the end of the run queue.
runnext guintptr
// Available G's (status == Gdead)
gFree struct {
gList
n int32
}
......
}
schedt结构体
schedt结构体用来保存调度器的状态信息和goroutine的全局运行队列。
type schedt struct {
// accessed atomically. keep at top to ensure alignment on 32-bit systems.
goidgen uint64
lastpoll uint64
lock mutex
// When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
// sure to call checkdead().
// 由空闲的工作线程组成链表
midle muintptr // idle m's waiting for work
// 空闲的工作线程的数量
nmidle int32 // number of idle m's waiting for work
nmidlelocked int32 // number of locked m's waiting for work
mnext int64 // number of m's that have been created and next M ID
// 最多只能创建maxmcount个工作线程
maxmcount int32 // maximum number of m's allowed (or die)
nmsys int32 // number of system m's not counted for deadlock
nmfreed int64 // cumulative number of freed m's
ngsys uint32 // number of system goroutines; updated atomically
// 由空闲的p结构体对象组成的链表
pidle puintptr // idle p's
// 空闲的p结构体对象的数量
npidle uint32
nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.
// Global runnable queue.
// goroutine全局运行队列
runq gQueue
runqsize int32
......
// Global cache of dead G's.
// gFree是所有已经退出的goroutine对应的g结构体对象组成的链表
// 用于缓存g结构体对象,避免每次创建goroutine时都重新分配内存
gFree struct {
lock mutex
stack gList // Gs with stacks
noStack gList // Gs without stacks
n int32
}
......
}
重要的全局变量
allgs []*g // 保存所有的g
allm *m // 所有的m构成的一个链表,包括下面的m0
allp []*p // 保存所有的p,len(allp) == gomaxprocs
ncpu int32 // 系统中cpu核的数量,程序启动时由runtime代码初始化
gomaxprocs int32 // p的最大值,默认等于ncpu,但可以通过GOMAXPROCS修改
sched schedt // 调度器结构体对象,记录了调度器的工作状态
m0 m // 代表进程的主线程
g0 g // m0的g0,也就是m0.g0 = &g0
在程序初始化时,这些全变量都会被初始化为0值,指针会被初始化为nil指针,切片初始化为nil切片,int被初始化为数字0,结构体的所有成员变量按其本类型初始化为其类型的0值。所以程序刚启动时allgs,allm和allp都不包含任何g,m和p。
二、主协程的创建到退出
本部分内容将以Hello World程序为例,通过跟踪其从创建到退出这一完整的运行流程来分析Go语言调度器的初始化、goroutine的创建与退出、工作线程的调度循环以及goroutine的切换等重要内容。以下代码golang版本为1.16.3,操作系统为MacOS。
package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
使用go build 编译hello.go,得到可执行程序hello,然后使用gdb调试。
go build -gcflags "-N -l -E" hello.go
gdb hello
使用命令info files查看程序入口为0x1065620。打个断点b *0x1065620
在入口处打个断点,并查看断点信息。可以看到程序的入口是_rt0_amd64_darwin。
同样可以通过run来进入断点查看
接下来可以继续使用gdb调试,但在这里为了方便代码阅读,我直接贴相关代码。
runtime/rt0_darwin_amd64.s,line7
TEXT _rt0_amd64_darwin(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
上面第一行代码定义了_rt0_amd64_linux这个符号,并不是真正的CPU指令,第二行的JMP指令才是主线程的第一条指令,这条指令简单的跳转到(相当于go语言或c中的goto)_rt0_amd64 这个符号处继续执行。
_rt0_amd64 这个符号的定义在runtime/asm_amd64.s 文件中,line14:
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)
前两行指令把操作系统内核传递过来的参数argc和argv数组的地址分别放在DI和SI寄存器中,第三行指令跳转到 rt0_go 去执行。
rt0_go函数完成了go程序启动时的所有初始化工作,因此这个函数比较长,也比较繁杂,所以下面我们分段来看:
runtime/asm_amd64.s ,line89:
TEXT runtime·rt0_go<ABIInternal>(SB),NOSPLIT,$0
// copy arguments forward on an even stack
MOVQ DI, AX // argc
MOVQ SI, BX // argv
SUBQ $(4*8+7), SP // 2args 2auto
ANDQ $~15, SP //调整栈顶寄存器使其按16字节对齐
MOVQ AX, 16(SP) //argc放在SP + 16字节处
MOVQ BX, 24(SP) //argv放在SP + 24字节处
上面的第4条指令用于调整栈顶寄存器的值使其按16字节对齐,也就是让栈顶寄存器SP指向的内存的地址为16的倍数,之所以要按16字节对齐,是因为CPU有一组SSE指令,这些指令中出现的内存地址必须是16的倍数,最后两条指令把argc和argv搬到新的位置。
下面开始初始化全局变量g0。g0的主要作用是提供一个栈供runtime代码执行,因此这里主要对g0的几个与栈有关的成员进行了初始化,从这里可以看出g0的栈大约有64K,地址范围为 SP - 64*1024 + 104 ~ SP
runtime/asm_amd64.s ,line100:
MOVQ $runtime·g0(SB), DI //g0的地址放入DI寄存器
LEAQ (-64*1024+104)(SP), BX//BX = SP - 64*1024 + 104
MOVQ BX, g_stackguard0(DI)//g0.stackguard0 = SP - 64*1024 + 104
MOVQ BX, g_stackguard1(DI)//g0.stackguard1 = SP - 64*1024 + 104
MOVQ BX, (g_stack+stack_lo)(DI)//g0.stack.lo = SP - 64*1024 + 104
MOVQ SP, (g_stack+stack_hi)(DI)//g0.stack.hi = SP
设置好了g0栈,接下来是CPU型号检查以及cgo初始化相关的代码,我们跳过去,直接从189行继续分析。
runtime/asm_amd64.s ,line189:
LEAQ runtime·m0+m_tls(SB), DI //DI = &m0.tls,取m0的tls成员的地址到DI寄存器
CALL runtime·settls(SB) //调用settls设置线程本地存储,settls函数的参数在DI寄存器中
// store through it, to make sure it works
get_tls(BX) //获取fs段基地址并放入BX寄存器,其实就是m0.tls[1]的地址
MOVQ $0x123, g(BX) //把整型常量0x123拷贝到fs段基地址偏移-8的内存位置,也就是m0.tls[0] = 0x123
MOVQ runtime·m0+m_tls(SB), AX//AX = m0.tls[0]
CMPQ AX, $0x123//检查m0.tls[0]的值是否是通过线程本地存储存入的0x123来验证tls功能是否正常
JEQ 2(PC)
CALL runtime·abort(SB)
go_tls.h,line 9:
#ifdef GOARCH_amd64
#define get_tls(r) MOVQ TLS, r
#define g(r) 0(r)(TLS*1)
#endif
这段代码首先调用settls函数初始化主线程的线程本地存储(TLS),目的是把m0与g0关联在一起。设置了线程本地存储之后接下来的几条指令在于验证TLS功能是否正常,如果不正常则直接abort退出程序。
runtime/asm_amd64.s ,line199:
ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX)//获取fs段基址到BX寄存器
LEAQ runtime·g0(SB), CX //CX = g0的地址
MOVQ CX, g(BX)//把g0的地址保存在线程本地存储里面,也就是m0.tls[0]=&g0
LEAQ runtime·m0(SB), AX //AX = m0的地址
// save m->g0 = g0
MOVQ CX, m_g0(AX)
// save m0 to g0->m
MOVQ AX, g_m(CX)
上面的代码首先把g0的地址放入主线程的线程本地存储中,然后把m0和g0绑定在一起,这样,之后在主线程中通过get_tls可以获取到g0,通过g0的m成员又可以找到m0,于是这里就实现了m0和g0与主线程之间的关联。此时,主线程,m0,g0以及g0的栈之间的关系如下图所示:
runtime/asm_amd64.s ,line214:
MOVL 16(SP), AX // copy argc
MOVL AX, 0(SP) //argc放在栈顶
MOVQ 24(SP), AX // copy argv
MOVQ AX, 8(SP) // argv放在SP + 8的位置
CALL runtime·args(SB)//处理操作系统传递过来的参数和env,不需要关心
//osinit唯一功能就是获取CPU的核数并放在global变量ncpu中,
//调度器初始化时需要知道当前系统有多少CPU核
CALL runtime·osinit(SB) //执行的结果是全局变量 ncpu = CPU核数
CALL runtime·schedinit(SB)//调度系统初始化
来看下调度系统是如何初始化的。
runtime/proc.go ,line600:
func schedinit() {
...
// raceinit must be the first call to race detector.
// In particular, it must be done before mallocinit below calls racemapshadow.
_g_ := getg() //_g_ = &g0
if raceenabled {
_g_.racectx, raceprocctx0 = raceinit()
}
//设置最多启动10000个操作系统线程,也是最多10000个M
sched.maxmcount = 10000
...
mcommoninit(_g_.m, -1)//初始化m0,m0放入全局链表allm
...
lock(&sched.lock)
sched.lastpoll = uint64(nanotime())
procs := ncpu//系统中有多少核,就创建和初始化多少个p结构体对象
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n//如果环境变量指定了GOMAXPROCS,则创建指定数量的p
}
if procresize(procs) != nil {//创建和初始化全局变量allp
throw("unknown runnable goroutine during bootstrap")
}
...
}
getg函数是编译器实现的,从线程本地存储中获取当前正在运行的g,这里获取出来的是g0,然后调用mcommoninit函数对m0(g0.m)进行必要的初始化,对m0初始化完成之后调用procresize初始化系统需要用到的p结构体对象,按照go语言官方的说法,p就是processor的意思,它的数量决定了最多可以有多少个goroutine同时并行运行。schedinit函数除了初始化m0和p,还设置了全局变量sched的maxmcount成员为10000,限制最多可以创建10000个操作系统线程出来工作。
这里我们需要重点关注一下mcommoninit如何初始化m0以及procresize函数如何创建和初始化p结构体对象。首先我们深入到mcommoninit函数中一探究竟。这里需要注意的是不只是初始化的时候会执行该函数,在程序运行过程中如果创建了工作线程,也会执行它,所以我们会在函数中看到加锁和检查线程数量是否已经超过最大值等相关的代码。
runtime/proc.go ,line722:
func mcommoninit(mp *m, id int64) {
_g_ := getg() //初始化过程中_g_ = g0
// g0 stack won't make sense for user (and is not necessary unwindable).
if _g_ != _g_.m.g0 {
callers(1, mp.createstack[:])
}
lock(&sched.lock)
if id >= 0 {
mp.id = id
} else {
mp.id = mReserveID()//id=sched.mnext,并检查已创建系统线程是否超过了数量限制(10000)
}
mp.fastrand[0] = uint32(int64Hash(uint64(mp.id), fastrandseed))
mp.fastrand[1] = uint32(int64Hash(uint64(cputicks()), ^fastrandseed))
if mp.fastrand[0]|mp.fastrand[1] == 0 {
mp.fastrand[1] = 1
}
//创建用于信号处理的gsignal,只是简单的从堆上分配一个g结构体对象,然后把栈设置好就返回了
mpreinit(mp)
if mp.gsignal != nil {
mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
}
// Add to allm so garbage collector doesn't free g->m
// when it is just in a register or thread-local storage.
// 把m挂入全局链表allm之中
mp.alllink = allm
...
}
从这个函数的源代码可以看出,这里并未对m0做什么关于调度相关的初始化,所以可以简单的认为这个函数只是把m0放入全局链表allm之中就返回了。
接下来看下初始化p的代码
runtime/proc.go ,line4776:
func procresize(nprocs int32) *p {
assertLockHeld(&sched.lock)
assertWorldStopped()
old := gomaxprocs //初始化时gomaxprocs=0
if old < 0 || nprocs <= 0 {
throw("procresize: invalid arg")
}
···
// Grow allp if necessary.
if nprocs > int32(len(allp)) {//初始化时len(allp) == 0
// Synchronize with retake, which could be running
// concurrently since it doesn't run on a P.
lock(&allpLock)
if nprocs <= int32(cap(allp)) {
allp = allp[:nprocs]
} else {//初始化时进入此分支,创建allp 切片
nallp := make([]*p, nprocs)
// Copy everything up to allp's cap so we
// never lose old allocated Ps.
copy(nallp, allp[:cap(allp)])
allp = nallp
}
...
unlock(&allpLock)
}
// initialize new P's
//循环创建nprocs个p并完成基本初始化
for i := old; i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p)
}
pp.init(i)
atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
}
_g_ := getg()
if _g_.m.p != 0 && _g_.m.p.ptr().id < nprocs {
//初始化时m0->p还未初始化,所以不会执行这个分支
// continue to use the current P
_g_.m.p.ptr().status = _Prunning
_g_.m.p.ptr().mcache.prepareForSweep()
} else {
// release the current P and acquire allp[0].
//
// We must do this before destroying our current P
// because p.destroy itself has write barriers, so we
// need to do that from a valid P.
if _g_.m.p != 0 {//初始化时不执行这里
if trace.enabled {
// Pretend that we were descheduled
// and then scheduled again to keep
// the trace sane.
traceGoSched()
traceProcStop(_g_.m.p.ptr())
}
_g_.m.p.ptr().m = 0
}
_g_.m.p = 0
p := allp[0]//获取p队列中第一个p
p.m = 0
p.status = _Pidle//修改p的状态
acquirep(p)//把p和m0关联起来,其实是这两个strct的成员相互赋值,并且把p的状态改为 _Prunning
if trace.enabled {
traceGoStart()
}
}
// g.m.p is now set, so we no longer need mcache0 for bootstrapping.
mcache0 = nil
// release resources from unused P's
for i := nprocs; i < old; i++ {
p := allp[i]
p.destroy()
// can't free P itself because it can be referenced by an M in syscall
}
// Trim allp.
if int32(len(allp)) != nprocs {
lock(&allpLock)
allp = allp[:nprocs]
idlepMask = idlepMask[:maskWords]
timerpMask = timerpMask[:maskWords]
unlock(&allpLock)
}
var runnablePs *p
//下面这个for 循环把所有空闲的p放入空闲链表
for i := nprocs - 1; i >= 0; i-- {
p := allp[i]
if _g_.m.p.ptr() == p {//allp[0]跟m0关联了,所以是不能放任
continue
}
p.status = _Pidle
if runqempty(p) {//初始化时除了allp[0]其它p全部执行这个分支,放入空闲链表
pidleput(p)
} else {
p.m.set(mget())
p.link.set(runnablePs)
runnablePs = p
}
}
...
return runnablePs
}
这个函数代码比较长,但并不复杂,这里总结一下这个函数的主要流程:
- 使用make([]*p, nprocs)初始化全局变量allp,即allp = make([]*p, nprocs)
- 循环创建并初始化nprocs个p结构体对象并依次保存在allp切片之中
- 把m0和allp[0]绑定在一起,即m0.p = allp[0], allp[0].m = m0
- 把除了allp[0]之外的所有p放入到全局变量sched的pidle空闲队列之中
procresize函数执行完后,调度器相关的初始化工作就基本结束了,这时整个调度器相关的各组成部分之间的联系如下图所示:
schedinit到这里就结束了,接下来继续看之后的代码:
runtime/asm_amd64.s ,line223:
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry,mainPC是runtime.main
//newproc的第二个参数入栈,也就是新的goroutine需要执行的函数
PUSHQ AX //AX = &funcval{runtime·main},
//newproc的第一个参数入栈,该参数表示runtime.main函数需要的参数大小,因为runtime.main没有参数,所以这里是0
PUSHQ $0 // arg size
CALL runtime·newproc(SB)//创建main goroutine
POPQ AX
POPQ AX
runtime/asm_amd64.s ,line244:
DATA runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL runtime·mainPC(SB),RODATA,$8
newproc函数用于创建新的goroutine,它有两个参数,先说第二个参数fn,新创建出来的goroutine将从fn这个函数开始执行,而这个fn函数可能也会有参数,newproc的第一个参数正是fn函数的参数以字节为单位的大小。接下来看下源码:
runtime/proc.go ,line3974:
func newproc(siz int32, fn *funcval) {
//注意:argp指向fn函数的第一个参数,而不是newproc函数的参数
//参数fn在栈上的地址+8的位置存放的是fn函数的第一个参数
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
<span data-word-id="785" class="abbreviate-word">gp</span> := getg()//获取正在运行的g,初始化时是m0.g0
//getcallerpc()返回一个地址,也就是调用newproc时由call指令压栈的函数返回地址,
//对于我们现在这个场景来说,pc就是CALLruntime·newproc(SB)指令后面的POPQ AX这条指令的地址
pc := getcallerpc()
//systemstack的作用是切换到g0栈执行作为参数的函数
//我们这个场景现在本身就在g0栈,因此什么也不做,直接调用作为参数的函数
systemstack(func() {
newg := newproc1(fn, argp, siz, gp, pc)
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true)//把newg放入_p_的运行队列,初始化的时候一定是p的本地运行队列,其它时候可能因为本地队列满了而放入全局队列
if mainStarted {//初始化时不会执行
wakep()
}
})
}
newproc1比较长,分段分析:
runtime/proc.go ,line3974:
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
_g_ := getg()//_g_ = g0
...
_p_ := _g_.m.p.ptr()//初始化时_p_ = g0.m.p,其实就是allp[0]
newg := gfget(_p_)//从p的本地缓冲里获取一个没有使用的g,初始化时没有,返回nil
if newg == nil {
//new一个g结构体对象,然后从堆上为其分配栈,并设置g的stack成员和两个stackgard成员
newg = malg( _StackMin)// _StackMin=2048,设置了新建的goroutine栈为约2kb
//初始化g的状态为_Gdead
casgstatus(newg, _Gidle, _Gdead)
//放入全局变量allgs切片中
allgadd(newg) //publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
if newg.stack.hi == 0 {
throw("newproc1: newg missing stack")
}
if readgstatus(newg) != _Gdead {
throw("newproc1: new g is not Gdead")
}
//调整g的栈顶置针,无需关注
totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame
totalSize += -totalSize & (sys.SpAlign - 1) // align to spAlign
sp := newg.stack.hi - totalSize
spArg := sp
if usesLR {
// caller's <span data-word-id="535" class="abbreviate-word">LR</span>
*(*uintptr)(unsafe.Pointer(sp)) = 0
prepGoExitFrame(sp)
spArg += sys.MinFrameSize
}
if narg > 0 {
//把参数从执行newproc函数的栈(初始化时是g0栈)拷贝到新g的栈
memmove(unsafe.Pointer(spArg), argp, uintptr(narg))
// This is a stack-to-stack copy. If write barriers
// are enabled and the source stack is grey (the
// destination is always black), then perform a
// barrier copy. We do this *after* the memmove
// because the destination stack may have garbage on
// it.
if writeBarrier.needed && !_g_.m.curg.gcscandone {
f := findfunc(fn.fn)
stkmap := (*stackmap)(funcdata(f, _FUNCDATA_ArgsPointerMaps))
if stkmap.nbit > 0 {
// We're in the prologue, so it's always stack map index 0.
<span data-word-id="34286484" class="abbreviate-word">bv</span> := stackmapdata(stkmap, 0)
bulkBarrierBitmap(spArg, spArg, uintptr(bv.n)*sys.PtrSize, 0, bv.bytedata)
}
}
}
}
这段代码主要从堆上分配一个g结构体对象并为这个newg分配一个大小为2048字节的栈,并设置好newg的stack成员,然后把newg需要执行的函数的参数从执行newproc函数的栈(初始化时是g0栈)拷贝到newg的栈,完成这些事情之后newg的状态如下图所示:
我们可以看到,经过前面的代码之后,程序中多了一个我们称之为newg的g结构体对象,该对象也已经获得了从堆上分配而来的2k大小的栈空间,newg的stack.hi和stack.lo分别指向了其栈空间的起止位置。
接下来继续分析newproc1函数。
runtime/proc.go ,line4062:
//把newg.sched结构体成员的所有成员设置为0
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
//设置newg的sched成员,调度器需要依靠这些字段才能把goroutine调度到CPU上运行。
newg.sched.sp = sp
newg.stktopsp = sp
//newg.sched.pc表示当newg被调度起来运行时从这个地址开始执行指令
//把pc设置成了goexit这个函数偏移1(sys.PCQuantum等于1)的位置,
//至于为什么要这么做需要等到分析完gostartcallfn函数才知道
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)//调整sched成员和newg的栈
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp)
//设置newg的startpc为fn.fn,该成员主要用于函数调用栈的traceback和栈收缩
//newg真正从哪里开始执行并不依赖于这个成员,而是sched.pc
newg.startpc = fn.fn
...
//设置g的状态为_Grunnable,表示这个g代表的goroutine可以运行了
casgstatus(newg, _Gdead, _Grunnable)
...
return newg
newproc1函数最后这点代码比较直观,首先设置了几个与调度无关的成员变量,然后修改newg的状态为_Grunnable,并返回newproc函数,newproc中将newg放入了运行队列,到此程序中第一个真正意义上的goroutine已经创建完成。
这个图看起来比较复杂,因为表示指针的箭头实在是太多了,这里对其稍作一下解释。
- 首先,main goroutine对应的newg结构体对象的sched成员已经完成了初始化,图中只显示了pc和sp成员,pc成员指向了runtime.main函数的第一条指令,sp成员指向了newg的栈顶内存单元,该内存单元保存了runtime.main函数执行完成之后的返回地址,也就是runtime.goexit函数的第二条指令,预期runtime.main函数执行完返回之后就会去执行runtime.exit函数的CALL runtime.goexit1(SB)这条指令;
- 其次,newg已经放入与当前主线程绑定的p结构体对象的本地运行队列,因为它是第一个真正意义上的goroutine,还没有其它goroutine,所以它被放在了本地运行队列的头部;
- 最后,newg的m成员为nil,因为它还没有被调度起来运行,也就没有跟任何m进行绑定。
好了,最后回到runtime·rt0_go中看最后一部分的汇编代码:
runtime/asm_amd64.s ,line231:
// start this M
CALL runtime·mstart(SB) //主线程进入调度循环,运行刚刚创建的goroutine
CALL runtime·abort(SB) // mstart should never return
RET
最后一部分其实就是进行了调度,运行刚刚创建的goroutine。核心部分就是runtime·mstart,接下来就来看下runtime·mstart到底做了什么。
runtime/proc.go ,line1246:
func mstart() {
_g_ := getg()//_g_ = g0
osStack := _g_.stack.lo == 0//对于启动过程来说,g0的stack.lo早已完成初始化,所以onStack = false
if osStack {//初始化时不会执行这里
...
}
// Initialize stack guard so that we can start calling regular
// Go code.
_g_.stackguard0 = _g_.stack.lo + _StackGuard
// This is the g0, so we can also call go:systemstack
// functions, which check stackguard1.
_g_.stackguard1 = _g_.stackguard0
mstart1()
// Exit this thread.
if mStackIsSystemAllocated() {
// Windows, Solaris, illumos, Darwin, AIX and Plan 9 always system-allocate
// the stack, but put it in _g_.stack before mstart,
// so the logic above hasn't set osStack yet.
osStack = true
}
mexit(osStack)
}
mstart函数本身没做什么,只是设置了stackguard0和stackguard1,它继续调用mstart1函数。
runtime/proc.go ,line1284:
func mstart1() {
_g_ := getg()//_g_ = g0
if _g_ != _g_.m.g0 {
throw("bad runtime·mstart")
}
// Record the caller for use as the top of stack in mcall and
// for terminating the thread.
// We're never coming back to mstart1 after we call schedule,
// so other calls can reuse the current frame.
//getcallerpc()获取mstart1执行完的返回地址
//getcallersp()获取调用mstart1时的栈顶地址
save(getcallerpc(), getcallersp())
...
// Install signal handlers; after minit so that minit can
// prepare the thread to be able to handle the <span data-word-id="34809598" class="abbreviate-word">signals</span>.
if _g_.m == &m0 {//启动时_g_.m是m0,所以会执行下面的mstartm0函数
mstartm0()//信号相关的初始化
}
if fn := _g_.m.mstartfn; fn != nil {//初始化过程中fn == nil,不执行
fn()
}
if _g_.m != &m0 {// m0已经绑定了allp[0],不是m0的话还没有p,所以需要获取一个p
acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
}
schedule()//执行调度
}
mstart1首先调用save函数来保存g0的调度信息,save这一行代码非常重要,是我们理解调度循环的关键点之一。这里首先需要注意的是代码中的getcallerpc()返回的是mstart调用mstart1时被call指令压栈的返回地址,getcallersp()函数返回的是调用mstart1函数之前mstart函数的栈顶地址,其次需要看看save函数到底做了哪些重要工作。
runtime/proc.go ,line3445:
func save(pc, sp uintptr) {
_g_ := getg() //_g_ = g0
_g_.sched.pc = pc //再次运行时的指令地址
_g_.sched.sp = sp //再次运行时的栈顶
_g_.sched.lr = 0
_g_.sched.ret = 0
_g_.sched.g = guintptr(unsafe.Pointer(_g_))
// We need to ensure ctxt is zero, but can't have a write
// barrier here. However, it should always already be zero.
// Assert that.
if _g_.sched.ctxt != nil {
badctxt()
}
}
可以看到,save函数保存了调度相关的所有信息,包括最为重要的当前正在运行的g的下一条指令的地址和栈顶地址,不管是对g0还是其它goroutine来说这些信息在调度过程中都是必不可少的,我们会在后面的调度分析中看到调度器是如何利用这些信息来完成调度的。代码执行完save函数之后g0的状态如下图所示:
为什么g0已经执行到mstart1这个函数了而且还会继续调用其它函数,但g0的调度信息中的pc和sp却要设置在mstart函数中?难道下次切换到g0时要从mstart函数中的 if 语句继续执行?可是从mstart函数可以看到,if语句之后就要退出线程了!这看起来很奇怪,不过随着分析的进行,我们会看到这里为什么要这么做。
继续分析代码,save函数执行完成后,返回到mstart1继续其它跟m相关的一些初始化,完成这些初始化后则调用调度系统的核心函数schedule()完成goroutine的调度,之所以说它是核心,原因在于每次调度goroutine都是从schedule函数开始的。schedule函数很长,我们只看些核心的代码。
runtime/proc.go ,line3051:
func schedule() {
_g_ := getg() //_g_ = g0
...
var gp *g//创建一个g指针
...
//接下来开始找到要调度执行的g
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.
//为了保证调度的公平性,每进行61次调度就需要优先从全局运行队列中获取goroutine,
//因为如果只调度本地队列中的g,那么全局运行队列中的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 {
//从与m关联的p的本地运行队列中获取goroutine
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 {
//如果从本地运行队列和全局运行队列都没有找到需要运行的goroutine,
//则调用findrunnable函数从其它工作线程的运行队列中偷取,如果偷取不到,则当前工作线程进入睡眠,
//直到获取到需要运行的goroutine之后findrunnable函数才会返回。
gp, inheritTime = findrunnable() // blocks until work is available
}
...
//当前运行的是runtime的代码,函数调用栈使用的是g0的栈空间
//调用execte切换到gp的代码和栈空间去运行
execute(gp, inheritTime)
}
schedule函数通过调用globrunqget()和runqget()函数分别从全局运行队列和当前工作线程的本地运行队列中选取下一个需要运行的goroutine,如果这两个队列都没有需要运行的goroutine则通过findrunnalbe()函数从其它p的运行队列中盗取goroutine。关于这几个函数在分析调度部分会有详细介绍。
在初始化的时候,前面的启动流程已经创建好第一个goroutine并放入了当前工作线程的本地运行队列,所以这里会通过runqget把目前唯一的一个goroutine取出来。一旦找到下一个需要运行的goroutine,则调用excute函数从g0切换到该goroutine去运行。接下来看一下excute函数。
runtime/proc.go ,line2519:
func execute(gp *g, inheritTime bool) {
_g_ := getg()//_g_ = g0
// Assign gp.m before entering _Grunning so running Gs have an
// M.
//把m和待运行的g绑定
_g_.m.curg = gp
gp.m = _g_.m
//修改待运行g的状态为_Grunning
casgstatus(gp, _Grunnable, _Grunning)
...
gogo(&gp.sched)//gogo完成从g0到gp真正的切换
}
execute函数的第一个参数gp即是需要调度起来运行的goroutine,这里首先把gp和m关联起来,这样通过m就可以找到当前工作线程正在执行哪个goroutine,反之亦然。然后把gp的状态从_Grunnable修改为_Grunning。
完成gp运行前的准备工作之后,execute调用gogo函数完成从g0到gp的的切换:CPU执行权的转让以及栈的切换。
gogo函数是通过汇编语言编写的,这里之所以需要使用汇编,是因为goroutine的调度涉及不同执行流之间的切换,前面我们在讨论操作系统切换线程时已经看到过,执行流的切换从本质上来说就是CPU寄存器以及函数调用栈的切换,然而不管是go还是c这种高级语言都无法精确控制CPU寄存器的修改,因而高级语言在这里也就无能为力了,只能依靠汇编指令来达成目的。
runtime/asm_amd64.s ,line281:
TEXT runtime·gogo(SB), NOSPLIT, $16-8
MOVQ buf+0(FP), BX // gobuf,buf = &gp.sched
MOVQ gobuf_g(BX), DX //DX = gp.sched.g
//检查gp.sched.g是否是nil,如果是nil进程会crash死掉
MOVQ 0(DX), CX // make sure g != nil
get_tls(CX)
//把要运行的g的指针放入线程本地存储,这样后面的代码就可以通过线程本地存储
//获取到当前正在执行的goroutine的g结构体对象,从而找到与之关联的m和p
MOVQ DX, g(CX)
#把CPU的SP寄存器设置为sched.sp,完成了栈的切换
MOVQ gobuf_sp(BX), SP // restore SP
#设置恢复调度上下文时需要的寄存器
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
#清空sched的值,因为我们已把相关值放入CPU对应的寄存器了,不再需要,这样做可以少gc的工作量
MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
//把sched.pc值放入BX寄存器
MOVQ gobuf_pc(BX), BX
//JMP把BX寄存器的包含的地址值放入CPU的IP寄存器,于是,CPU跳转到该地址继续执行指令
JMP BX
gogo其实做了两件重要的事情:
- 把gp.sched的成员恢复到CPU的寄存器完成状态以及栈的切换;
- 跳转到gp.sched.pc所指的指令地址(runtime.main)处执行。
现在已经从g0切换到了gp这个goroutine,对于我们这个场景来说,gp还是第一次被调度起来运行,它的入口函数是runtime.main,所以接下来CPU就开始执行runtime.main函数。runtime.main函数很长,我们只选取少量关键部分分析。
runtime/proc.go ,line115:
func main() {
g := getg()// 在此之前已经完成了g的切换,不再是g0了
// Racectx of m0->g0 is used only as the parent of the main goroutine.
// It must not be used for anything else.
g.m.g0.racectx = 0
// Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
// Using decimal instead of binary GB and MB because
// they look nicer in the stack overflow failure message.
if sys.PtrSize == 8 {
maxstacksize = 1000000000//64位系统上每个goroutine的栈最大可达1GB
} else {
maxstacksize = 250000000//32位系统上每个goroutine的栈最大可达250 MB
}
// An upper limit for max stack size. Used to avoid random crashes
// after calling SetMaxStack and trying to allocate a stack that is too big,
// since stackalloc works with 32-bit sizes.
maxstackceiling = 2 * maxstacksize
// Allow newproc to start new Ms.
mainStarted = true //设置main已启动的标识,newproc函数就能够启动新的m
if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
// For runtime_syscall_doAllThreadsSyscall, we
// register sysmon is not ready for the world to be
// stopped.
atomic.Store(&sched.sysmonStarting, 1)
//创建监控线程sysmon,该线程独立于调度器,不需要跟p关联即可运行
systemstack(func() {
newm(sysmon, nil, -1)
})
}
...
//调用runtime包的初始化函数,由编译器实现
doInit(&runtime_inittask) // Must be before defer.
// Defer unlock so that runtime.Goexit during init does the unlock too.
needUnlock := true
defer func() {
if needUnlock {
unlockOSThread()
}
}()
gcenable()//开启gc
...
//main 包的初始化函数,也是由编译器实现,会递归的调用我们import进来的包的初始化函数
doInit(&main_inittask)
...
//调用main.main函数
fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn()
...
//进入系统调用,退出进程,可以看出main goroutine并未返回,而是直接进入系统调用退出进程了
exit(0)
//保护性代码,如果exit意外返回,下面的代码也会让该进程crash死掉
for {
var x *int32
*x = 0
}
}
runtime.main函数主要工作流程如下:
- 启动一个sysmon系统监控线程,该线程负责整个程序的gc、抢占调度以及netpoll等功能的监控,在后续分析抢占调度时我们再详细分析;
- 执行runtime包的初始化;
- 执行main包以及main包import的所有包的初始化;
- 执行main.main函数;
- 从main.main函数返回后调用exit系统调用退出进程;
好了,到这里主协程从创建到退出就分析完了。来大概整理下到底发生了什么。
三、非主协程的创建到退出
1、goroutine的创建
首先通过一个简单的代码来看下普通goroutine是如何创建的。
package main
import (
"fmt"
)
func main() {
go test()
fmt.Println("Hello World!")
}
func test(){
}
直接看上面的汇编代码
以上便是第8行代码的汇编码,可以看到其实就是调用了runtime.newproc。runtime.newproc之前已经分析过了,这一次再来看看newproc函数执行的过程
runtime/proc.go ,line3974:
func newproc(siz int32, fn *funcval) {
//注意:argp指向fn函数的第一个参数,而不是newproc函数的参数
//参数fn在栈上的地址+8的位置存放的是fn函数的第一个参数
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()//获取正在运行的g,这里现在就不是g0了
//getcallerpc()返回一个地址,也就是调用newproc时由call指令压栈的函数返回地址,
//对于我们现在这个场景来说,pc就是下一行的XORPS X0, X0
pc := getcallerpc()
//systemstack的作用是切换到g0栈执行作为参数的函数
systemstack(func() {
newg := newproc1(fn, argp, siz, gp, pc)
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true)//把newg放入_p_的运行队列,初始化的时候一定是p的本地运行队列,其它时候可能因为本地队列满了而放入全局队列
if mainStarted {//初始化时不会执行,执行runtime.main之后的所有调用都会执行这部分代码了
wakep()
}
})
}
可以看到其实和创建主协程时差不多,只是会多执行wakep()函数。执行newproc1函数时会切回g0栈,和之前分析的差不多,这里就分析。下面来看看wakep函数。
runtime/proc.go ,line2425:
// Tries to add one more P to execute G's.
// Called when a G is made runnable (newproc, ready).
func wakep() {
//如果没有空闲的p直接返回
if atomic.Load(&sched.npidle) == 0 {
return
}
// be conservative about spinning threads
//如果有自旋线程,根本无需唤醒休眠的m
if atomic.Load(&sched.nmspinning) != 0 || !atomic.Cas(&sched.nmspinning, 0, 1) {
return
}
startm(nil, true)
}
wakep检查了当前是否有空闲的p,如果没有那么直接返回。如果有,那么再判断是否存在自旋的m,如果有,那么也直接返回。自旋的m会去获取空闲的p,不需要我们再去申请新的m了。如果有空闲的p且不存在自旋的m,那么就调用startm去唤醒或者新建m。
runtime/proc.go ,line2278:
func startm(_p_ *p, spinning bool) {
mp := acquirem()
lock(&sched.lock)
if _p_ == nil {//没有指定p的话需要从p的空闲队列中获取一个p
_p_ = pidleget() //从p的空闲队列中获取空闲p
if _p_ == nil {
unlock(&sched.lock)
if spinning {
// The caller incremented nmspinning, but there are no idle Ps,
// so it's okay to just undo the increment and give up.
//spinning为true表示进入这个函数之前已经对sched.nmspinning加了1,需要还原
if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
throw("startm: negative nmspinning")
}
}
releasem(mp)
return
}
}
nmp := mget()//从m空闲队列中获取正处于睡眠之中的工作线程,所有处于睡眠状态的m都在此队列中
if nmp == nil { //没有处于睡眠状态的工作线程
id := mReserveID()
unlock(&sched.lock)
var fn func()
if spinning {
// The caller incremented nmspinning, so set m.spinning in the new M.
fn = mspinning
}
newm(fn, _p_, id)//创建新的工作线程
// Ownership transfer of _p_ committed by start in newm.
// Preemption is now safe.
releasem(mp)
return
}
unlock(&sched.lock)
if nmp.spinning {
throw("startm: m is spinning")
}
if nmp.nextp != 0 {
throw("startm: m has p")
}
if spinning && !runqempty(_p_) {
throw("startm: p has runnable gs")
}
// The caller incremented nmspinning, so set m.spinning in the new M.
nmp.spinning = spinning
nmp.nextp.set(_p_)
//唤醒处于休眠状态的工作线程
notewakeup(&nmp.park)
// Ownership transfer of _p_ committed by wakeup. Preemption is now
// safe.
releasem(mp)
}
startm函数首先判断是否有空闲的p结构体对象,如果没有则直接返回,如果有则需要创建或唤醒一个工作线程出来与之绑定,从这里可以看出所谓的唤醒p,其实就是把空闲的p利用起来。
在确保有可以绑定的p对象之后,startm函数首先尝试从m的空闲队列中查找正处于休眠状态的工作线程,如果找到则通过notewakeup函数唤醒它(唤醒部分代码不展开分析了,有兴趣的同学可以自行阅读),否则调用newm函数创建一个新的工作线程出来。
我们主要看一下创建工作线程的代码
runtime/proc.go ,line2073:
func newm(fn func(), _p_ *p, id int64) {
mp := allocm(_p_, fn, id)//堆上分配一个m结构体对象
mp.doesPark = (_p_ != nil)
mp.nextp.set(_p_)//设置p
...
newm1(mp)
}
newm从堆上分配一个m结构体对象,然后调用newm1函数。
runtime/proc.go ,line2106:
func newm1(mp *m) {
//cgo的代码
...
execLock.rlock() // Prevent process clone.
newosproc(mp)
execLock.runlock()
}
newm1继续调用newosproc函数,newosproc的主要任务是创建一个系统线程,而新建的这个系统线程将从mstart函数开始运行。
runtime/os_darwin.go ,line191:
func newosproc(mp *m) {
stk := unsafe.Pointer(mp.g0.stack.hi)
...
// Finally, create the thread. It starts at mstart_stub, which does some low-level
// setup and then calls mstart.
var oset sigset
sigprocmask( _SIG_SETMASK, &sigset_all, &oset)
err = pthread_create(&attr, funcPC(mstart_stub), unsafe.Pointer(mp))
sigprocmask( _SIG_SETMASK, &oset, nil)
if err != 0 {
write(2, unsafe.Pointer(&failthreadcreate[0]), int32(len(failthreadcreate)))
exit(1)
}
}
newosproc的主要任务是创建一个系统线程,而新建的这个系统线程将从mstart函数开始运行。
mstart函数在之前已经分析过了,一直到执行完这个goroutine。
无法复制加载中的内容
2、goroutine的退出
还记得这张图吗?这是执行完newproc1之后的协程状态图。这里可以看到其实对于每个协程都设置了返回地址为goexit+1。当协程执行结束后,g中的sp寄存器的值就会弹到PC中来执行退出操作。
现在来思考下为什么主协程设置了没有执行goexit+1处的退出代码呢。其实在runtime.main代码中,main goroutine 直接调用 exit(0) 退出了进程。
普通 goroutine 则调用设置好的退出函数来退出。其实我们也可以通过程序来看下函数调用栈。还是第三章开始处的代码
package main
import (
"fmt"
)
func main() {
go test()
fmt.Println("Hello World!")
}
func test(){
}
我们用gdb调试,在test处打个断点。
由此我们也可以看出goroutine执行test之后会返回到runtime.goexit中。
接下来我们看一下goexit+1出的代码到底是什么:
runtime/asm_amd64.s ,line840:
TEXT runtime·goexit(SB),NOSPLIT|NOFRAME|TOPFRAME,$0-0
MOVW R0, R0 // NOP
BL runtime·goexit1(SB) // does not return
// traceback from goexit1 must hit code range of goexit
MOVW R0, R0 // NOP
goexit+1其实就是BL runtime·goexit1(SB)这一行,调用了runtime·goexit1。
runtime/proc.go ,line3365:
func goexit1() {
if raceenabled {//与竞态检查有关,不关注
racegoend()
}
if trace.enabled { //与backtrace有关,不关注
traceGoEnd()
}
mcall(goexit0)
}
goexit1函数通过调用mcall从当前运行的g2 goroutine切换到g0,然后在g0栈上调用和执行goexit0这个函数。
mcall函数主要有两个功能:
- 首先从当前运行的g(我们这个场景是g2)切换到g0,这一步包括保存当前g的调度信息,把g0设置到tls中,修改CPU的rsp寄存器使其指向g0的栈;
- 以当前运行的g(我们这个场景是g2)为参数调用fn函数(此处为goexit0)。
mcall部分的代码这里就不详细分析了。有兴趣的同学可以自己去阅读下。
runtime/proc.go ,line3376:
func goexit0(gp *g) {
_g_ := getg()
casgstatus(gp, _Grunning, _Gdead)//修改g的状态为 _Gdead
if isSystemGoroutine(gp, false) {
atomic.Xadd(&sched.ngsys, -1)
}
//清空g保存的一些信息
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
_g_.m.lockedg = 0
gp.preemptStop = false
gp.paniconfault = false
gp._defer = nil // should be true already but just in case.
gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
gp.writebuf = nil
gp.waitreason = 0
gp.param = nil
gp.labels = nil
gp.timer = nil
...
//g->m = nil, m->currg = nil 解绑g和m之关系
dropg()
...
//g放入p的freeg队列,方便下次重用,免得再去申请内存,提高效率
gfput(_g_.m.p.ptr(), gp)
...
//新一轮的调度schedule
schedule()
}
到此为止g2的生命周期就结束了,工作线程再次调用了schedule函数进入新一轮的调度循环。
四、goroutine调度
所谓的goroutine调度,是指程序代码按照一定的算法在适当的时候挑选出合适的goroutine并放到CPU上去运行的过程。 这句话揭示了调度系统需要解决的三大核心问题:
- 调度时机:什么时候会发生调度?
- 调度策略:使用什么策略来挑选下一个进入运行的goroutine?
- 切换机制:如何把挑选出来的goroutine放到CPU上运行?(这部分前面已经提过了,这里就不分析了)
1、调度策略
我们回到schedule函数,来分析下goroutine的调度策略。
runtime/proc.go ,line3051:
func schedule() {
_g_ := getg() //_g_ = g0
...
var gp *g//创建一个g指针
...
//接下来开始找到要调度执行的g
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.
//为了保证调度的公平性,每进行61次调度就需要优先从全局运行队列中获取goroutine,
//因为如果只调度本地队列中的g,那么全局运行队列中的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 {
//从与m关联的p的本地运行队列中获取goroutine
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 {
//如果从本地运行队列和全局运行队列都没有找到需要运行的goroutine,
//则调用findrunnable函数从其它工作线程的运行队列中偷取,如果偷取不到,则当前工作线程进入睡眠,
//直到获取到需要运行的goroutine之后findrunnable函数才会返回。
gp, inheritTime = findrunnable() // blocks until work is available
}
...
//当前运行的是runtime的代码,函数调用栈使用的是g0的栈空间
//调用execte切换到gp的代码和栈空间去运行
execute(gp, inheritTime)
}
schedule函数分三步分别从各运行队列中寻找可运行的goroutine:
第一步,从全局运行队列中寻找goroutine。为了保证调度的公平性,每个工作线程每经过61次调度就需要优先尝试从全局运行队列中找出一个goroutine来运行,这样才能保证位于全局运行队列中的goroutine得到调度的机会。全局运行队列是所有工作线程都可以访问的,所以在访问它之前需要加锁。
第二步,从工作线程本地运行队列中寻找goroutine。如果不需要或不能从全局运行队列中获取到goroutine则从本地运行队列中获取。
第三步,从其它工作线程的运行队列中偷取goroutine。如果上一步也没有找到需要运行的goroutine,则调用findrunnable从其他工作线程的运行队列中偷取goroutine,findrunnable函数在偷取之前会再次尝试从全局运行队列和当前线程的本地运行队列中查找需要运行的goroutine。
1.1从全局运行队列中获取goroutine
runtime/proc.go ,line5571:
func globrunqget(_p_ *p, max int32) *g {
assertLockHeld(&sched.lock)
if sched.runqsize == 0 {//全局运行队列为空
return nil
}
//根据p的数量平分全局运行队列中的goroutines
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize { //上面计算n的方法可能导致n大于全局运行队列中的goroutine数量
n = sched.runqsize
}
if max > 0 && n > max { //最多取max个goroutine
n = max
}
if n > int32(len(_p_.runq))/2 {
n = int32(len(_p_.runq)) / 2//最多只能取本地队列容量的一半
}
sched.runqsize -= n
//直接通过函数返回gp,其它的goroutines通过runqput放入本地运行队列
gp := sched.runq.pop()
n--
for ; n > 0; n-- {//从全局运行队列中取出goroutine,放入到本地运行队列
gp1 := sched.runq.pop()
runqput(_p_, gp1, false)
}
return gp
}
globrunqget函数首先会根据全局运行队列中goroutine的数量,函数参数max以及_p_的本地队列的容量计算出到底应该拿多少个goroutine,然后把第一个g结构体对象通过返回值的方式返回给调用函数,其它的则通过runqput函数放入当前工作线程的本地运行队列。这段代码值得一提的是,计算应该从全局运行队列中拿走多少个goroutine时根据p的数量(gomaxprocs)做了负载均衡。
如果没有从全局运行队列中获取到goroutine,那么接下来就在工作线程的本地运行队列中寻找需要运行的goroutine。
1.2从工作线程本地运行队列中获取goroutine
工作线程M的本地运行队列其实分为两个部分,一部分是由p的runq、runqhead和runqtail这三个成员组成的一个无锁队列,该队列最多可包含256个goroutine;另一部分是p的runnext成员,它是一个指向g结构体对象的指针,它最多只包含一个goroutine。
runtime/proc.go ,line5851:
func runqget(_p_ *p) (gp *g, inheritTime bool) {
// If there's a runnext, it's the next G to run.
for {//从runnext成员中获取goroutine
next := _p_.runnext
if next == 0 {
break
}
if _p_.runnext.cas(next, 0) {
return next.ptr(), true
}
}
//从队列中获取goroutine
for {
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers
t := _p_.runqtail
if t == h {
return nil, false
}
gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()
if atomic.CasRel(&_p_.runqhead, h, h+1) { // cas-release, commits consume
return gp, false
}
}
}
1.3 从其它工作线程的本地运行队列中偷取goroutine
runtime/proc.go ,line2554:
func findrunnable() (gp *g, inheritTime bool) {
_g_ := getg()
// The conditions here and in handoffp must agree: if
// findrunnable would return a G to run, handoffp must start
// an M.
top:
_p_ := _g_.m.p.ptr()
...
// local runq
//再次看一下本地运行队列是否有需要运行的goroutine
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
}
// global runq
//再看看全局运行队列是否有需要运行的goroutine
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false
}
}
...
// Steal work from other P's.
procs := uint32(gomaxprocs)
ranTimer := false
// If number of spinning M's >= number of busy P's, block.
// This is necessary to prevent excessive CPU consumption
// when GOMAXPROCS>>1 but the program parallelism is low.
// 这个判断主要是为了防止因为寻找可运行的goroutine而消耗太多的CPU。
// 因为已经有足够多的工作线程正在寻找可运行的goroutine,让他们去找就好了,自己偷个懒去睡觉
if !_g_.m.spinning && 2*atomic.Load(&sched.nmspinning) >= procs-atomic.Load(&sched.npidle) {
goto stop
}
if !_g_.m.spinning {
//设置m的状态为spinning
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)//处于spinning状态的m数量加一
}
//从其它p的本地运行队列盗取goroutine
const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if sched.gcwaiting != 0 {
goto top
}
p2 := allp[enum.position()]
if _p_ == p2 {
continue
}
// Steal timers from p2. This call to checkTimers is the only place
// where we might hold a lock on a different P's timers. We do this
// once on the last pass before checking runnext because stealing
// from the other P's runnext should be the last resort, so if there
// are timers to steal do that first.
//
// We only check timers on one of the stealing iterations because
// the time stored in now doesn't change in this loop and checking
// the timers for each P more than once with the same value of now
// is probably a waste of time.
//
// timerpMask tells us whether the P may have timers at all. If it
// can't, no need to check at all.
if stealTimersOrRunNextG && timerpMask.read(enum.position()) {
tnow, w, ran := checkTimers(p2, now)
now = tnow
if w != 0 && (pollUntil == 0 || w < pollUntil) {
pollUntil = w
}
if ran {
// Running the timers may have
// made an arbitrary number of G's
// ready and added them to this P's
// local run queue. That invalidates
// the assumption of runqsteal
// that is always has room to add
// stolen G's. So check now if there
// is a local G to run.
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
}
ranTimer = true
}
}
// Don't bother to attempt to steal if p2 is idle.
//从其它非空闲的p的本地运行队列偷取goroutine
if !idlepMask.read(enum.position()) {
if gp := runqsteal(_p_, p2, stealTimersOrRunNextG); gp != nil {
return gp, false
}
}
}
}
if ranTimer {
// Running a timer may have made some goroutine ready.
goto top
}
stop:
...
allpSnapshot := allp
...
if sched.runqsize != 0 {
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
return gp, false
}
if releasep() != _p_ {// 当前工作线程解除与p之间的绑定,准备去休眠
throw("findrunnable: wrong p")
}
pidleput(_p_) //把p放入空闲队列
unlock(&sched.lock)
// Delicate dance: thread transitions from spinning to non-spinning state,
// potentially concurrently with submission of new goroutines. We must
// drop nmspinning first and then check all per-P queues again (with
// #StoreLoad memory barrier in between). If we do it the other way around,
// another thread can submit a goroutine after we've checked all run queues
// but before we drop nmspinning; as a result nobody will unpark a thread
// to run the goroutine.
// If we discover new work below, we need to restore m.spinning as a signal
// for resetspinning to unpark a new worker thread (because there can be more
// than one starving goroutine). However, if after discovering new work
// we also observe no idle Ps, it is OK to just park the current thread:
// the system is fully loaded so no spinning threads are required.
// Also see "Worker thread parking/unparking" comment at the top of the file.
wasSpinning := _g_.m.spinning
if _g_.m.spinning {
//m即将睡眠,状态不再是spinning
_g_.m.spinning = false
if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
throw("findrunnable: negative nmspinning")
}
}
// check all runqueues once again
// 休眠之前再看一下是否有工作要做
for id, _p_ := range allpSnapshot {
if !idlepMaskSnapshot.read(uint32(id)) && !runqempty(_p_) {
lock(&sched.lock)
_p_ = pidleget()
unlock(&sched.lock)
if _p_ != nil {
acquirep(_p_)
if wasSpinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}
goto top
}
break
}
}
...
//休眠
stopm()
goto top
}
findrunnable并不是一进来就从其他队列里面去偷取goroutine。首先会再次去p的本地队列和全局队列中去找可执行的goroutine,找不到的话还会查看当前是否已经有足够多的工作线程在自旋(2*当前自旋的m的数量>工作的p的数量),如果已有足够多的m在自旋,当前的m就去休眠。
从上面的代码可以看到,工作线程在放弃寻找可运行的goroutine而进入睡眠之前,会反复尝试从各个运行队列寻找需要运行的goroutine,可谓是尽心尽力了。这个函数需要重点注意以下两点:
第一点,工作线程M的自旋状态(spinning) 。工作线程在从其它工作线程的本地运行队列中盗取goroutine时的状态称为自旋状态。从上面代码可以看到,当前M在去其它p的运行队列盗取goroutine之前把spinning标志设置成了true,同时增加处于自旋状态的M的数量,而盗取结束之后则把spinning标志还原为false,同时减少处于自旋状态的M的数量,从后面的分析我们可以看到,当有空闲P又有goroutine需要运行的时候,这个处于自旋状态的M的数量决定了是否需要唤醒或者创建新的工作线程。
第二点,偷取算法。盗取过程用了两个嵌套for循环。内层循环实现了盗取逻辑,从代码可以看出盗取的实质就是遍历allp中的所有p,查看其运行队列是否有goroutine,如果有,则取其一半到当前工作线程的运行队列,然后从findrunnable返回,如果没有则继续遍历下一个p。但这里为了保证公平性,遍历allp时并不是固定的从allp[0]即第一个p开始,而是从随机位置上的p开始,而且遍历的顺序也随机化了,并不是现在访问了第i个p下一次就访问第i+1个p,而是使用了一种伪随机的方式遍历allp中的每个p,防止每次遍历时使用同样的顺序访问allp中的元素。
下面来看下偷取函数runqsteal:
runtime/proc.go ,line5935:
func runqsteal(_p_, p2 *p, stealRunNextG bool) *g {
t := _p_.runqtail
n := runqgrab(p2, &_p_.runq, t, stealRunNextG)
if n == 0 {
return nil
}
n--
gp := _p_.runq[(t+n)%uint32(len(_p_.runq))].ptr()
if n == 0 {
return gp
}
//判断本地队列是否溢出
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
if t-h+n >= uint32(len(_p_.runq)) {
throw("runqsteal: runq overflow")
}
//调整队头位置
atomic.StoreRel(&_p_.runqtail, t+n) // store-release, makes the item available for consumption
return gp
}
runqsteal调用runqgrab函数去偷取goroutine,之后将其加入到当前p的本地队列中,并取出队首的g返回。
runtime/proc.go ,line5880:
func runqgrab(_p_ *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
for {
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers
t := atomic.LoadAcq(&_p_.runqtail) // load-acquire, synchronize with the producer
n := t - h //计算队列中有多少个goroutine
n = n - n/2//取队列中goroutine个数的一半
if n == 0 {
...
}
//所以n的最大值也就是len(_p_.runq)/2,那为什么需要这个判断呢?
if n > uint32(len(_p_.runq)/2) { // read inconsistent h and t
continue
}
//从_p_中偷取n个goroutine
for i := uint32(0); i < n; i++ {
g := _p_.runq[(h+i)%uint32(len(_p_.runq))]
batch[(batchHead+i)%uint32(len(batch))] = g
}
if atomic.CasRel(&_p_.runqhead, h, h+n) { // cas-release, commits consume
return n
}
}
}
代码中n的计算很简单,从计算过程来看n应该是runq队列中goroutine数量的一半,它的最大值不会超过队列容量的一半,但为什么这里的代码却偏偏要去判断n是否大于队列容量的一半呢?这里关键点在于读取runqhead和runqtail是两个操作而非一个原子操作,当我们读取runqhead之后但还未读取runqtail之前,如果有其它线程快速的在增加(这是完全有可能的,其它偷取者从队列中偷取goroutine会增加runqhead,而队列的所有者往队列中添加goroutine会增加runqtail)这两个值,则会导致我们读取出来的runqtail已经远远大于我们之前读取出来放在局部变量h里面的runqhead了,也就是代码注释中所说的h和t已经不一致了,所以这里需要这个if判断来检测异常情况。
2、调度时机
从调度机制上来讲,操作系统的调度可以分为协作式和抢占式。
2.1 协作式调度
协作式调度一般分为两种情形:
- 被动调度:goroutine执行某个操作因条件不满足需要等待而发生的调度(如等待锁、等待时间、等待IO资源就绪等);
- 主动调度:goroutine主动调用Gosched()函数让出CPU而发生的调度;
2.1.1 主动调度
运行中的goroutine可以通过runtime.Gosched()操作主动让出其占用的内核级工作线程,让工作线程执行其他的goroutine。
runtime/proc.go ,line267:
func Gosched() {
checkTimeouts()
mcall(gosched_m)//切换到g0栈,执行gosched_m
}
runtime/proc.go ,line2635:
func gosched_m(gp *g) {//gp是调用Gosched的g
if trace.enabled {
traceGoSched()
}
goschedImpl(gp)
}
runtime/proc.go ,line2619:
func goschedImpl(gp *g) {
status := readgstatus(gp)
if status&^ _Gscan != _Grunning {//当前的g为 _Grunning状态才能释放
dumpgstatus(gp)
throw("bad g status")
}
casgstatus(gp, _Grunning, _Grunnable)//修改g的运行状态
dropg() //设置当前m.curg = nil, gp.m = nil
lock(&sched.lock)
globrunqput(gp)//把gp放入sched的全局运行队列runq
unlock(&sched.lock)
schedule()//进入新一轮调度
}
goschedImpl函数有一个g指针类型的形参,传递给它的实参是调用Gosched的g,goschedImpl函数首先把g的状态从_Grunning设置为_Grunnable,并通过dropg函数解除当前工作线程m和g之间的关系(把m.curg设置成nil,把g.m设置成nil),然后g放入全局运行队列之中。之后调用schedule() 进入新一轮调度。至此g通过自己主动调用Gosched()函数自愿放弃了执行权,达到了调度的目的。
2.1.2 被动调度
我们还是来看下Hello World代码:
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello World!")
}
我们编译一下来看看
看汇编代码我们可以看到在main执行的时候会比较g.stackguard0和sp的值是否相等,不等的话就跳转执行runtime.morestack_noctxt。接下来看看runtime.morestack_noctxt函数:
runtime/asm_amd64.s,line463:
TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0
MOVL $0, DX
JMP runtime·morestack(SB)
runtime.morestack_noctxt调用了runtime·morestack:
runtime/asm_amd64.s,line416:
TEXT runtime·morestack(SB),NOSPLIT,$0-0
...
CALL runtime·newstack(SB)
CALL runtime·abort(SB) // crash if newstack returns
RET
runtime/stack.go,line938:
func newstack() {
thisg := getg()
...
//判断stackguard0是否为stackPreempt
preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
...
if preempt {
...
// Act like goroutine called runtime.Gosched.
gopreempt_m(gp)// never return
}
...
}
runtime/proc.go,line3313:
func gopreempt_m(gp *g) {
if trace.enabled {
traceGoPreempt()
}
goschedImpl(gp)
}
goschedImpl在前面已经介绍过了,可以实现goroutine的调度,这里就不重复讲了。
由此我们可以看出其实只要修改g的stackguard0来触发扩栈操作,并且将stackguard0设置为stackPreempt,就能在函数调用的时候实现goroutine的调度了。
2.2 抢占式调度
由于golang协程本质上是一种用户级的线程,golang调度器在运行时并不具备操作系统内核级的一些能力,如硬件中断、内核线程切换等等,但其通过在程序启动时的启动一个系统监控线程来执行与抢占式条件的判断、并通过栈扩张、信号等机制实现抢占逻辑。
在之前分析的runtime.main中有提到启动时创建了一个系统监控线程来执行sysmon,用于定期的垃圾回收和抢占调度。sysmon是独立执行的,作为golang的runtime系统检测器,sysmon可进行forcegc、netpoll、retake等操作。拿抢占功能来说,如sysmon放到pmg调度模型里,每个p上面的goroutine恰好阻塞了,就无法执行抢占了。接下来看下sysmon函数。
runtime/proc.go ,line5099:
func sysmon() {
lock(&sched.lock)
sched.nmsys++
checkdead()
unlock(&sched.lock)
// For syscall_runtime_doAllThreadsSyscall, sysmon is
// sufficiently up to participate in fixups.
atomic.Store(&sched.sysmonStarting, 0)
lasttrace := int64(0)
idle := 0 // how many cycles in succession we had not wokeup somebody
delay := uint32(0)
for {
if idle == 0 { // start with 20us sleep...
delay = 20
} else if idle > 50 { // start doubling the sleep after 1ms...
delay *= 2//大于1ms(50*20us)后每次翻倍
}
if delay > 10*1000 { // up to 10ms
delay = 10 * 1000// 大于10ms后稳定在10ms
}
usleep(delay)
...
// retake P's blocked in syscalls
// and preempt long running G's
if retake(now) != 0 {//retake执行抢占逻辑
idle = 0
} else {
idle++
}
...
}
}
sysmon 执行一个无限循环,一开始每次循环休眠 20us,之后(1ms 后)每次休眠时间倍增,最终每一轮都会休眠 10ms;每次循环会调用retake去进行抢占。
runtime/proc.go ,line5263:
func retake(now int64) uint32 {
n := 0
// Prevent allp slice changes. This lock will be completely
// uncontended unless we're already stopping the world.
lock(&allpLock)
// We can't use a range loop over allp because we may
// temporarily drop the allpLock. Hence, we need to re-fetch
// allp each time around the loop.
for i := 0; i < len(allp); i++ {//遍历所有p,然后根据p的状态进行抢占
_p_ := allp[i]
if _p_ == nil {
// This can happen if procresize has grown
// allp but not yet created new Ps.
continue
}
pd := &_p_.sysmontick
s := _p_.status
sysretake := false
if s == _Prunning || s == _Psyscall {
t := int64(_p_.schedtick)
if int64(pd.schedtick) != t {
//pd.schedtick != t 说明(pd.schedwhen ~ now)这段时间发生过调度
//重置跟sysmon相关的schedtick和schedwhen变量
pd.schedtick = uint32(t)
pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
//连续运行超过10毫秒了,设置抢占请求
preemptone(_p_)
// In case of syscall, preemptone() doesn't
// work, because there is no M wired to P.
sysretake = true
}
}
if s == _Psyscall {//系统调用抢占处理
// Retake P from syscall if it's there for more than 1 sysmon tick (at least 20us).
t := int64(_p_.syscalltick)
if !sysretake && int64(pd.syscalltick) != t {//判断是否已发生过调度
pd.syscalltick = uint32(t)
pd.syscallwhen = now
continue
}
// On the one hand we don't want to retake Ps if there is no other work to do,
// but on the other hand we want to retake them eventually
// because they can prevent the sysmon thread from deep sleep.
// 1. 本地P中无等待运行的goroutine
// 2. 有空闲的m、p
// 3. 从上一次监控线程观察到 p 对应的 m 处于系统调用之中到现在小于 10 毫秒
if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
continue
}
// Drop allpLock so we can take sched.lock.
unlock(&allpLock)
// Need to decrement number of idle locked M's
// (pretending that one more is running) before the CAS.
// Otherwise the M from which we retake can exit the syscall,
// increment nmidle and report deadlock.
incidlelocked(-1)
if atomic.Cas(&_p_.status, s, _Pidle) {//cas修改p的状态为 _Pidle
if trace.enabled {
traceGoSysBlock(_p_)
traceProcStop(_p_)
}
n++
_p_.syscalltick++
handoffp(_p_)//寻找一个新的m出来接管P
}
incidlelocked(1)
lock(&allpLock)
}
}
unlock(&allpLock)
return uint32(n)
}
retake函数所做的主要事情就在遍历所有的p,并根据每个p的状态以及处于该状态的时长来决定是否需要发起抢占。从代码可以看出只有当p处于 _Prunning 或 _Psyscall 状态时才会进行抢占。
2.2.1 超时抢占
runtime/proc.go ,line5360:
func preemptone(_p_ *p) bool {
mp := _p_.m.ptr()
if mp == nil || mp == getg().m {
return false
}
gp := mp.curg
if gp == nil || gp == mp.g0 {
return false
}
gp.preempt = true//设置抢占标志
//goroutine中每次调用都会通过比较当前栈指针和 gp.stackgard0来检查栈是否溢出。
//设置stackguard0使被抢占的goroutine去处理抢占请求
gp.stackguard0 = stackPreempt
// Request an async preemption of this P.
//1.14 之后新增的信号抢占
if preemptMSupported && debug.asyncpreemptoff == 0 {
_p_.preempt = true
preemptM(mp)//发送抢占信号到当前p绑定的m
}
return true
}
Preemptone其实做了两件事情,将g.stackguard0改为 stackPreempt,此外还向m发送了一个抢占信号。
runtime/signal_unix.go ,line352:
func preemptM(mp *m) {
// On Darwin, don't try to preempt threads during exec.
// Issue #41702.
if GOOS == "darwin" || GOOS == "ios" {
execLock.rlock()
}
if atomic.Cas(&mp.signalPending, 0, 1) {
if GOOS == "darwin" || GOOS == "ios" {
atomic.Xadd(&pendingPreemptSignals, 1)
}
//发送抢占信号给m
signalM(mp, sigPreempt)
}
if GOOS == "darwin" || GOOS == "ios" {
execLock.runlock()
}
}
在1.14之前是没有基于信号的抢占的,sysmon设置g.stackguard0改为 stackPreempt,goroutine进行newstack、morestack、syscall时会检查当前栈指针和 g.stackgard0来检查栈是否溢出,进而现实抢占。看一个经典的例子:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
var x int
t:=runtime.GOMAXPROCS(0)
for i := 0; i < t; i++ {
go func() {
for{
x++
}
}()
}
time.Sleep(time.Second)
fmt.Println("Hello World!")
}
以上这段代码在1.13会陷入死循环的。1.14之前是没有信号抢占的,所以使用之前版本的需要注意下别让程序陷入这样的死循环中。
接下来我们看下基于信号的抢占是如何实现的。
其实在goroutine创建的时候会调用mstart1函数(忘记的同学翻翻前面的分析),在mstart1中调用了mstartm0函数。之前跳过了这部分,现在我们来一起看看这边做了什么:
runtime/signal_unix.go ,line1322:
func mstartm0() {
// Create an extra M for callbacks on threads not created by Go.
// An extra M is also needed on Windows for callbacks created by
// syscall.NewCallback. See issue #6751 for details.
if (iscgo || GOOS == "windows") && !cgoHasExtraM {
cgoHasExtraM = true
newextram()
}
initsig(false)// 注册信号
}
runtime/signal_unix.go ,line111:
func initsig(preinit bool) {
...
for i := uint32(0); i < _NSIG; i++ {
...
setsig(i, funcPC(sighandler))// 注册信号对应的回调方法
}
}
runtime/signal_unix.go ,line536:
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
_g_ := getg()
c := &sigctxt{info, ctxt}
...
if sig == sigPreempt && debug.asyncpreemptoff == 0 {//如果是抢占信号
// Might be a preemption signal.
doSigPreempt(gp, c)
// Even if this was definitely a preemption signal, it
// may have been coalesced with another signal, so we
// still let it through to the application.
}
...
}
runtime/signal_unix.go ,line325:
func doSigPreempt(gp *g, ctxt *sigctxt) {
// Check if this G wants to be preempted and is safe to
// preempt.
if wantAsyncPreempt(gp) {// 检查 goroutine 是否需要被抢占、抢占是否安全
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
ctxt.pushCall(funcPC(asyncPreempt), newpc)// 插入抢占调用
}
}
// Acknowledge the preemption.
atomic.Xadd(&gp.m.preemptGen, 1)
atomic.Store(&gp.m.signalPending, 0)
if GOOS == "darwin" || GOOS == "ios" {
atomic.Xadd(&pendingPreemptSignals, -1)
}
}
runtime/preempt.go ,line299:
func asyncPreempt()
//go:nosplit
func asyncPreempt2() {
gp := getg()
gp.asyncSafePoint = true
if gp.preemptStop {
mcall(preemptPark)
} else {
mcall(gopreempt_m)
}
gp.asyncSafePoint = false
}
asyncPreempt内部会调用asyncPreempt2,在asyncPreempt2中则会根据 preemptPark 或者 gopreempt_m 重新切换回调度循环,从而打断goroutine的继续执行。
runtime/proc.go ,line3277:
func gopreempt_m(gp *g) {
if trace.enabled {
traceGoPreempt()
}
goschedImpl(gp)
}
goschedImpl前面已经分析过了,它会把g的状态从_Grunning设置为_Grunnable,并通过dropg函数解除当前工作线程m和g之间的关系(把m.curg设置成nil,把g.m设置成nil),然后g放入全局运行队列之中。之后调用schedule() 进入新一轮调度。至此g通过自己主动调用Gosched()函数自愿放弃了执行权,达到了调度的目的。
2.2.2 系统调用抢占
对于处于_Psyscall状态的p,除了检测是否超时外,只要满足下面三个条件中的任意一个就需要对其进行抢占:
- p的运行队列里面有等待运行的goroutine。这用来保证当前p的本地运行队列中的goroutine得到及时的调度,因为该p对应的工作线程正处于系统调用之中,无法调度队列中goroutine,所以需要寻找另外一个工作线程来接管这个p从而达到调度这些goroutine的目的;
- 没有空闲的p。表示其它所有的p都已经与工作线程绑定且正忙于执行go代码,这说明系统比较繁忙,所以需要抢占当前正处于系统调用之中而实际上系统调用并不需要的这个p并把它分配给其它工作线程去调度其它goroutine。
- 从上一次监控线程观察到p对应的m处于系统调用之中到现在已经超过10毫秒。这表示只要系统调用超时,就对其抢占,而不管是否真的有goroutine需要调度,这样保证sysmon线程不至于觉得无事可做(sysmon线程会判断retake函数的返回值,如果为0,表示retake并未做任何抢占,所以会觉得没啥事情做)而休眠太长时间最终会降低sysmon监控的实时性。至于如何计算某一次系统调用时长可以参考上面代码及注释。
来看下抢占部分的代码:
runtime/proc.go ,line2362:
func handoffp(_p_ *p) {
// handoffp must start an M in any situation where
// findrunnable would return a G to run on _p_.
// if it has local work, start it straight away
//运行队列不为空,需要启动m来接管
if !runqempty(_p_) || sched.runqsize != 0 {
startm(_p_, false)
return
}
// if it has GC work, start it straight away
//有垃圾回收工作需要做,也需要启动m来接管
if gcBlackenEnabled != 0 && gcMarkWorkAvailable(_p_) {
startm(_p_, false)
return
}
// no local work, check that there are no spinning/idle M's,
// otherwise our help is not required
//所有其它p都在运行goroutine,说明系统比较忙,需要启动m
if atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) == 0 && atomic.Cas(&sched.nmspinning, 0, 1) { // TODO: fast atomic
startm(_p_, true)
return
}
lock(&sched.lock)
if sched.gcwaiting != 0 {//如果gc正在等待Stop The World
_p_.status = _Pgcstop
sched.stopwait--
if sched.stopwait == 0 {
notewakeup(&sched.stopnote)
}
unlock(&sched.lock)
return
}
...
if sched.runqsize != 0 {//全局运行队列有工作要做,需要启动m
unlock(&sched.lock)
startm(_p_, false)
return
}
// If this is the last running P and nobody is polling network,
// need to wakeup another M to poll network.
if sched.npidle == uint32(gomaxprocs-1) && atomic.Load64(&sched.lastpoll) != 0 {
unlock(&sched.lock)
startm(_p_, false)
return
}
// The scheduler lock cannot be held when calling wakeNetPoller below
// because wakeNetPoller may call wakep which may call startm.
when := nobarrierWakeTime(_p_)
pidleput(_p_)//无事可做,把p放入全局空闲队列
unlock(&sched.lock)
if when != 0 {
wakeNetPoller(when)
}
}
从handoffp的代码可以看出,在如下几种情况下则需要调用我们已经分析过的startm函数启动新的工作线程出来接管_p_:
- _p_的本地运行队列或全局运行队列里面有待运行的goroutine;
- 需要帮助gc完成标记工作;
- 系统比较忙,所有其它_p_都在运行goroutine,需要帮忙;
- 所有其它P都已经处于空闲状态,如果需要监控网络连接读写事件,则需要启动新的m来poll网络连接。
到此,sysmon监控线程对处于系统调用之中的p的抢占就已经完成。
2.3 小结
通过以上的分析,我们大概可以总结一下goroutine的调度时机:
附一下G 的状态流转:
P的状态流转: