为什么要有协程,线程不好吗
go
语言的协程和其他编程语言的协程有区别
- 进程
- 占据内存空间
- 进程是操作系统的最小单位(一个进程就是一个应用或者一个程序)
- 线程
- 占用
cpu
的处理能力(cpu
的时间) - 一个进程可以有多个线程
- 线程使用进程的内存,线程之间共享内存
- 线程的调度需要由系统进行,开销比较大
- 缺点:
- 线程本身占用资源大
- 线程操作开销大(状态转换)
- 线程切换开销大(在不同线程之间切换)
- 占用
- 协程
- 将一段程序的运行状态打包,在线程上调度(单线程)
- 协程也要在线程上运行
- 线程时协程的资源,协程使用线程这个资源
- 优势
- 快速调度(线程调度需要操作系统,协程调度不需要)
- 超高并发
他们之间的关系大概可以理解为:
- 内存 -> 园区
- 进程 -> 工厂的厂房
- 线程 -> 工厂的生产线
本来生产多种产品需要多条生产线,但是协程的作用是在一条生成线上生产多种产品
协程的本质是什么
协程在 go
中本质是一个 g
结构体,在 runtime/runtime2.go
中定义
go
type g struct {
stack stack
sched gobuf
atomicstatus atomic.Uint32
goid uint64
// ...
}
-
stack
是协程的协程栈-
类型是一个
stack
的结构体 -
lo
是低指针 -
hi
是高指针gotype stack struct { lo uintptr hi uintptr }
-
-
sched
- 类型是
gobuf
-
sp
:StackPointer
,栈指针,它记录的是当前栈执行到地址(指向方法或者变量的栈帧) -
pc
:ProgramCounter
,程序计数器,它记录是程序运行到了哪一行代码gotype gobuf struct { sp uintptr pc uintptr // ... }
-
- 类型是
-
atomicstatus
表示协程的状态 -
goid
协程的id
协程底层原理如图所示:
由上面可以知道:
runtime
中,协程本质是一个g
结构体stack
:堆栈地址gobuf
:目前程序运行现场atomicstatus
:协程状态
go
中线程 m
结构体,是用来描述操作系统中的线程,go
操作不了,在 runtime/runtime2.go
中定义
go
type m struct {
g0 *g // goroutine with scheduling stack
curg *g // current running goroutine
id int64
mOS
}
g0
是go
启动时的第一个协程,操作调度器curg
是现在正在运行的协程id
是线程的id
mOS
是用来记录每种操作系统对于线程描述的额外信息
协程是如何执行的
go
中每个线程都在执行 schedule()
-> execute
-> gogo
-> 业务方法
-> goexit()
-> schedule()
这样一个循环
通过这几个方法调用业务方法,业务方法是代码中协程需要跑的方法,如图所示:
go
中每个线程是从 schedule
开始调度,schedule
开始是在 g0 stack
中执行的
g0 stack
是给 g0
协程在栈空间中分配的内存,用来记录函数跳转的信息
为什么不用普通协程的栈记录呢?
- 普通协程的栈只能记录业务方法的函数调用
- 当线程还没有拿到协程时,是不知道普通协程栈的
schedule
schedule
方法在 runtime/proc.go
中定义
schedule
方法是在全局的协程的队列中拿到一个可以执行的协程
找到后调用 execute
方法
go
func schedule() {
// ...
top:
// 即将要运行的协程
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
if sched.disable.user && !schedEnabled(gp) {
// ...
}
if gp.lockedm != 0 {
// ..
}
execute(gp, inheritTime)
}
execute
execute
方法在 runtime/proc.go
中定义
exectue
方法给要执行的协程赋了一些值
最后调用 gogo
方法
go
func execute(gp *g, inheritTime bool) {
mp := getg().m
mp.curg = gp
gp.m = mp
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + stackGuard
// ...
gogo(&gp.sched)
}
gogo
gogo
只有一个函数声明,在 runtime/stubs.go
文件中,说明这是一个由汇编实现的方法
我们找到 asm_amd64.s
文件中的 gogo
方法
asm_amd64.s
文件是 windows
和 linux
下的 go
代码的汇编文件,m3
芯片的 mac
电脑是 asm_arm64.s
文件
我们可以看到它的入参是 gobuf
结构体的指针
下面代码最核心的两句是:
MOVQ gobuf_sp(BX), SP
- 从
gobuf
中取出sp
赋值给SP
寄存器(作用是往协程栈中插入了一个栈帧,这个栈帧是goexit
方法) goexit
方法的栈帧不是调用的,而是人为的插入进来,为的是协程退出时,能够正常的退到goexit
方法
- 从
MOVQ gobuf_pc(BX), BX
、JMP BX
- 跳转线程正在执行的计数器
gobuf_pc
记录的是协程执行到的位置,JMP
指令是跳转到这个位置- 执行的时候用的是协程自己的协程栈(作用是记录自己协程的相关信息)
s
// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $0-8
MOVQ buf+0(FP), BX // gobuf
MOVQ gobuf_g(BX), DX
MOVQ 0(DX), CX // make sure g != nil
JMP gogo<>(SB)
TEXT gogo<>(SB), NOSPLIT, $0
get_tls(CX)
MOVQ DX, g(CX)
MOVQ DX, R14 // set the g register
// 往协程栈中插入了一个栈帧,这个栈帧是 goexit 方法
MOVQ gobuf_sp(BX), SP // restore SP
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
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)
// 跳转线程正在执行的计数器
MOVQ gobuf_pc(BX), BX
JMP BX
goexit
goexit
只有一个函数声明,在 runtime/stubs.go
文件中,说明这是一个由汇编实现的方法
我们找到 asm_amd64.s
文件中的 goexit
方法
这个函数最核心的一段是 CALL runtime·goexit1(SB)
,调用 go runtime
中的 goexit1
方法
s
// The top-most function running on a goroutine
// returns to goexit+PCQuantum.
TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME|NOFRAME,$0-0
BYTE $0x90 // NOP
CALL runtime·goexit1(SB) // does not return
// traceback from goexit1 must hit code range of goexit
BYTE $0x90 // NOP
goexit1
方法在 runtime/proc.go
中定义
goexit1
方法调用了 mcall
方法
go
func goexit1() {
// ...
mcall(goexit0)
}
mcall
方法也只有一个函数声明,它的作用是用 mcall
调用一个方法时,会切换栈,就会从 g stack
切换到 g0 stack
中
go
// mcall switches from the g to the g0 stack and invokes fn(g),
// where g is the goroutine that made the call.
// mcall saves g's current PC/SP in g->sched so that it can be restored later.
// It is up to fn to arrange for that later execution, typically by recording
// g in a data structure, causing something to call ready(g) later.
// mcall returns to the original goroutine g later, when g has been rescheduled.
// fn must not return at all; typically it ends by calling schedule, to let the m
// run other goroutines.
//
// mcall can only be called from g stacks (not g0, not gsignal).
//
// This must NOT be go:noescape: if fn is a stack-allocated closure,
// fn puts g on a run queue, and g executes before fn returns, the
// closure will be invalidated while it is still executing.
func mcall(fn func(*g))
goexit0
函数的作用是将刚刚退出的协程相关状态修改下,最后调用 schedule
方法,循环继续
go
func goexit0(gp *g) {
mp := getg().m
pp := mp.p.ptr()
casgstatus(gp, _Grunning, _Gdead)
gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))
if isSystemGoroutine(gp, false) {
sched.ngsys.Add(-1)
}
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
mp.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 = waitReasonZero
gp.param = nil
gp.labels = nil
gp.timer = nil
schedule()
}
线程循环
单线程循环在 go 0.x
时就已经有了
M
表示系统线程,用三角形,G
表示协程,用圆形
多线程循环是多个线程执行一模一样的线程循环逻辑,他们不断的在协程队列中找可以执行的协程,然后执行
如果有多个线程在做这件事时,协程队列就要保证一个协程只能被一个线程执行,所以全局协程队列需要加锁
- 操作系统不知道
goroutine
的存在 - 操作系统线程执行一个调度循环,顺序执行
goroutine
- 调度循环是由
go
或者汇编代码组成的循环 - 执行
goroutine
时,强行跳转到goroutine
- 调度循环是由
- 调度循环非常向线程池
GMP 调度模型
多线程并发时,会抢夺协程队列的协程锁,造成锁的冲突和锁的等待,影像了性能
解决这个问题的方法是引入本地队列,每个线程都有一个本地队列,每次从全局的协程队列中拿多个协程过来,放到本地队列中
如图所示
p
是一个线程的本地队列,它是一个结构体,定义在 runtime/runtime2.go
中
go
type p struct {
m muintptr // back-link to associated m (nil if idle)
// Queue of runnable goroutines. Accessed without lock.
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.
//
// Note that while other P's may atomically CAS this to zero,
// only the owner P can CAS it to a valid G.
runnext guintptr
}
m
:原始指针,内存中的物理地址- 队列:
runqhead
:队列的头序号runqtail
:队列的尾序号runq
:队列,256
长度的指针
runnext
:下一个可用的协程的指针
每一个 p
服务于一个 m
,p
的职责是构建一个本地队列
p
的作用是:
m
与g
之间的中介p
持有一些g
,使得每次获取g
时,不需要从全局获取- 减少了并发冲突的问题
从本地队列 p
中获取 g
的方法是 runqget
,定义在 runtime/proc.go
中
schedule
方法中调用 findRunnable
方法
findRunable
方法中调用分别调用了 runqget
、globrunqget
、steakWork
方法:
runqget
方法找到一个只可以执行的g
globrunqget
方法从全局协程队列中找到一批协程放到本地队列p
中stealWork
方法的作用是从其他的协程队列中投一些协程过来,因为本地队列和全局队列都没有可以执行的协程了,但是其他协程队列中有可以执行的协程,就去偷一些过来执行
go
func schedule() {
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
}
runqget
方法的作用是从本地队列中获取一个 g
go
// Get g from local runnable queue.
// If inheritTime is true, gp should inherit the remaining time in the
// current time slice. Otherwise, it should start a new time slice.
// Executed only by the owner P.
func runqget(pp *p) (gp *g, inheritTime bool) {
// If there's a runnext, it's the next G to run.
next := pp.runnext
// If the runnext is non-0 and the CAS fails, it could only have been stolen by another P,
// because other Ps can race to set runnext to 0, but only the current P can set it to non-0.
// Hence, there's no need to retry this CAS if it fails.
if next != 0 && pp.runnext.cas(next, 0) {
return next.ptr(), true
}
// ...
}
globrunqget
方法的作用是从全局协程队列中拿一批协程放到本地队列 p
中
go
// Try get a batch of G's from the global runnable queue.
// sched.lock must be held.
func globrunqget(pp *p, max int32) *g {
// ...
}
stealWork
方法的作用是从其他的协程队列中投一些协程过来,因为本地队列和全局队列都没有可以执行的协程了,但是其他协程队列中有可以执行的协程,就去偷一些过来执行,增加线程的利用率
go
// stealWork attempts to steal a runnable goroutine or timer from any P.
//
// If newWork is true, new work may have been readied.
//
// If now is not 0 it is the current time. stealWork returns the passed time or
// the current time if now was passed as 0.
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {}
新建协程时,会随机找一个本地队列,然后将新的协程放入 p
的 runnext
(插队),如果本地队列都满了,才会放入全局的协程队列中
go
// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
gp := getg()
pc := getcallerpc()
systemstack(func() {
// 新建一个协程
newg := newproc1(fn, gp, pc)
pp := getg().m.p.ptr()
// 随机找一个本地队列
runqput(pp, newg, true)
if mainStarted {
wakep()
}
})
}
如何实现协程并发
如果一个协程执行时间过长,就需要一种轮换机制,让其他协程也有机会执行
在执行到一半时,需要暂停当前协程的执行,暂停执行当前协程的执行需要做一些保存现场的事情,比如执行栈中执行到哪个程序计数器,栈指针指向哪里,这些都需要保存下来
将这些信息保存在协程中,放到协程队列中(本地或者全局),然后跳出当前协程,重新执行 schedule
方法
本地队列的小循环,加上一定几率的全局队列大循环
这个操作在函数 findRunnable
中运行,每执行 61
次线程循环,会去全局队列中找一次协程放入本地队列中
go
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// 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 次线程循环,会去全局队列中找一次协程放入本地队列中
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 1)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
}
切换时机
任务在执行时,很难被打断,所以有两种切换时机:
- 主动挂起(
runtime.gopark
)-
业务方法中主动调用
runtime.gopark
方法,将当前协程挂起(因为gopark
方法会调用mcall
方法,切换栈)go// Puts the current goroutine into a waiting state and calls unlockf on the // system stack. // // If unlockf returns false, the goroutine is resumed. // // unlockf must not access this G's stack, as it may be moved between // the call to gopark and the call to unlockf. // // Note that because unlockf is called after putting the G into a waiting // state, the G may have already been readied by the time unlockf is called // unless there is external synchronization preventing the G from being // readied. If unlockf returns false, it must guarantee that the G cannot be // externally readied. // // Reason explains why the goroutine has been parked. It is displayed in stack // traces and heap dumps. Reasons should be unique and descriptive. Do not // re-use reasons, add new ones. func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) { // ... mcall(park_m) }
-
gopark
函数开发者无法调用,但是我们开发中使用的time.Sleep()
函数内部会调用gopark
-
- 系统调用完成时
go
在运行时,会有一些系统调用,一旦涉及到系统的底层调用时,在调用结束后,会调用exitsyscall()
抢占式调度
一个协程如果不主动挂起,或者不走系统调用,那么它就会一直执行,不会被打断
go
在函数调用函数时,会先调用 runtime.morestack
方法,这个方法会检查当前协程的栈是否足够,如果不够就会扩容
将下面的代码运行 go build -gcflags -S main.go
,就可以看到编译后的汇编代码
go
func do1() {
do2()
}
func do2() {
do3()
go do1()
}
func do3() {
fmt.Println("do3")
}
通过编译出来的汇编,我们可以看到 do1
和 do2
方法中都调用了 runtime.morestack_noctxt
s
# command-line-arguments
main.do1 STEXT size=24 args=0x0 locals=0x8 funcid=0x0 align=0x0
0x0011 00017 (/Astak/project/tools/limit-go/main.go:16) CALL runtime.morestack_noctxt(SB)
0x0016 00022 (/Astak/project/tools/limit-go/main.go:16) PCDATA $0, $-1
0x0016 00022 (/Astak/project/tools/limit-go/main.go:16) JMP 0
main.do2 STEXT size=103 args=0x0 locals=0x40 funcid=0x0 align=0x0
0x0060 00096 (/Astak/project/tools/limit-go/main.go:20) CALL runtime.morestack_noctxt(SB)
0x0065 00101 (/Astak/project/tools/limit-go/main.go:20) PCDATA $0, $-1
0x0065 00101 (/Astak/project/tools/limit-go/main.go:20) JMP 0
在编译时 go
会将 runtime.morestack()
方法插入到函数调用的前面
go
func do1() {
runtime.morestack() // 在编译时 go 会将 runtime.morestack() 方法插入到函数调用的前面
do2()
}
morestack
方法本意是检查协程栈是否有足够的空间,既然在每个方法调用前都会调用 morestack
,那么就给他设置一个标记抢占的钩子
标记抢占钩子:当系统监控到 goroutine
运行超过 10ms
,会将 g.stackguard0
设置为 stackPreempt
,对应的十六进制数是 0xfffffade
抢占:执行 morestack
时判断协程是否被抢占了,如果被抢占了,就会调用 runtime.preemptone
方法,这个方法会将协程的状态设置为 Gpreempted
,然后调用 schedule
方法,重新调度
runtime.morestack
方法在 runtime/stubs.go
文件中定义,但只是一个函数声明,实际的实现在 runtime/asm_amd64.s
文件中
汇编语言实现了 runtime.morestack
方法,它的作用是检查协程栈是否有足够的空间,如果没有就扩容,然后在调用 go
中的 newstack
方法
s
// Called during function prolog when more stack is needed.
//
// The traceback routines see morestack on a g0 as being
// the top of a stack (for example, morestack calling newstack
// calling the scheduler calling newm calling gc), so we must
// record an argument size. For that purpose, it has no arguments.
TEXT runtime·morestack(SB),NOSPLIT|NOFRAME,$0-0
// Cannot grow scheduler stack (m->g0).
// ...
// 调用了 runtime·newstack(SB) 方法
CALL runtime·newstack(SB)
CALL runtime·abort(SB) // crash if newstack returns
RET
runtime.newstack
方法在 runtime/stack.go
文件中定义
go
// Called from runtime·morestack when more stack is needed.
// Allocate larger stack and relocate to new stack.
// Stack growth is multiplicative, for constant amortized cost.
//
// g->atomicstatus will be Grunning or Gscanrunning upon entry.
// If the scheduler is trying to stop this g, then it will set preemptStop.
//
// This must be nowritebarrierrec because it can be called as part of
// stack growth from other nowritebarrierrec functions, but the
// compiler doesn't check this.
//
//go:nowritebarrierrec
func newstack() {
// Be conservative about where we preempt.
// We are interested in preempting user Go code, not runtime code.
// If we're holding locks, mallocing, or preemption is disabled, don't
// preempt.
// This check is very early in newstack so that even the status change
// from Grunning to Gwaiting and back doesn't happen in this case.
// That status change by itself can be viewed as a small preemption,
// because the GC might change Gwaiting to Gscanwaiting, and then
// this goroutine has to wait for the GC to finish before continuing.
// If the GC is in some way dependent on this goroutine (for example,
// it needs a lock held by the goroutine), that small preemption turns
// into a real deadlock.
// 判断是否被抢占
preempt := stackguard0 == stackPreempt
// 如果被抢占了调用 gopreempt_m -> goschedImpl -> schedule
if preempt {
// Act like goroutine called runtime.Gosched.
gopreempt_m(gp) // never return
}
}
这种还有个问题就是如果在一个函数中运行了类似死循环的代码,go
是不会调用 morestack
方法的,所以这种情况下,go
会一直执行下去,不会被抢占
这就有了基于信号的抢占式调度
基于信号的抢占式调度
这个信号是线程信号,因为在操作系统,有很多是基于信号的底层通信方式
比如 SIGPIPE
、SIGURG
、SIGHUP
,线程可以注册对应的信号处理函数,如果线程收到了信号,就可以自动的跳转到信号处理函数中执行
这个信号就是 SIGURG
紧急信号
当 GC
工作时,向目标线程发送信号,因为在 GC
时,很多线程都已经停止了,适合做抢占,在 GC
会向线程发送抢占信号,然后线程收到信号后触发调度
我们注册的信号处理函数是 doSigPreempt
,当 GC
向线程发送 SIGURG
抢占信号时,在执行业务方法的协程会立即跳到 doSigPreempt
方法中,然后重新调度循环
doSigPreempt
方法在 runtime/signal_unix.go
文件中定义
go
// doSigPreempt handles a preemption signal on gp.
func doSigPreempt(gp *g, ctxt *sigctxt) {
// Check if this G wants to be preempted and is safe to
// preempt.
if wantAsyncPreempt(gp) {
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}
}
// Acknowledge the preemption.
gp.m.preemptGen.Add(1)
gp.m.signalPending.Store(0)
if GOOS == "darwin" || GOOS == "ios" {
pendingPreemptSignals.Add(-1)
}
}
这个方法会调用 asyncPreempt
,asyncPreempt
方法在 runtime/preempt.go
文件中定义,但这个方法只有一个函数声明,再由汇编调用 asyncPreempt2
方法
go
//go:nosplit
func asyncPreempt2() {
gp := getg()
gp.asyncSafePoint = true
if gp.preemptStop {
mcall(preemptPark)
} else {
mcall(gopreempt_m)
}
gp.asyncSafePoint = false
}
总结
- 基于系统调用和主动挂起,协程可能无法调度
- 基于协作的抢占式调度:业务主动调用
morestack
方法 - 基于信号的抢占式调度:强制线程调用
doSigPreempt
方法
协程太多有什么问题
如果协程太多 go
会报错:too many concurrent operations on a single file or socket
这个报错的原因大概是系统资源耗尽,系统资源耗尽有好几种情况:
- 文件打开数限制
- 内存限制
- 调度开销过大
- 调度开销过大的意思是在调度协程上花费的时间比执行协程的时间还要长
解决方法是:
- 优化业务逻辑
- 利用
channel
的缓存区,可以看:channel 限流- 启动协程时,向
channel
中发送一个空结构体 - 协程结束,取出空结构体
- 启动协程时,向
- 协程池(慎用)
go
语言的线程,已经相当于池化了- 二级池化会增加系统复杂度
go
语言的初衷是希望协程即用即毁,不要池化
- 调整系统资源