写在文章开头
go语言
的精华就在于协程的设计,只有理解协程的设计思想和工作机制,才能确保我们能够完全的利用协程编写强大的并发程序。
Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
详解协程工作机制和实现
协程示例
正式介绍底层之前,我们给出一段协程的代码示例,可以看到笔者开启一个协程进行函数内部调用:
func foo1() {
fmt.Println("foo1 调用 foo2")
foo2()
}
func foo2() {
fmt.Println("foo2调用foo3")
foo3()
}
func foo3() {
fmt.Println("foo3 执行了")
}
func main() {
//设置WaitGroup等待协程结束
var wg sync.WaitGroup
wg.Add(1)
go func() {
foo1()
defer wg.Done()
}()
//等待上述协程运行结束
wg.Wait()
}
运行结果如下:
foo1 调用 foo2
foo2调用foo3
foo3 执行了
结合debug我们可以看到当前协程的调用栈帧,在函数调用前插入一个goexit
的东西,结合这一点我们开始对协程的深入剖析:
协程实现结构
在go语言
的协程结构为:
- 通过一个
stack
记录其高地址和低地址。 - 通过
sched
的sp(即stackpointer)栈帧的指针
和程序计数器pc(指向下一条运行的指令)
. - 采用
goid
生成唯一标识。 - 然后再用
atomicstatus
记录其执行状态。
基于这几点我们结合上述的代码给出协程的底层结构,如下图所示,当前协程的stack
记录整个foo1函数的高低地址,假设我们当前的协程go
来到foo2
函数准备调用foo3
函数,我们的sched
中的sp即stackpointer
记录foo2的指针,同时因为foo2
内部会调用foo3
所以程序计数器pc
记录着调用foo3
的指令。
最后因为协程都是由线程调度的,所以协程的内部也有一个变量记录着当前线程的指针m:
到此我们了解了协程核心结构,同时我们也在runtime2.go
这一文件中即给出上述所说的核心变量:
type g struct {
//记录栈帧的高地址和低地址
stack stack // offset known to runtime/cgo
//......
m *m //执行当前协程的线程指针
//记录当前堆栈的指针以及下一条指令的运行地址
sched gobuf
atomicstatus atomic.Uint32
goid uint64
//......
}
步入stack
可以看到lo
和hi
两个专门记录栈帧高低地址的指针:
type stack struct {
lo uintptr
hi uintptr
}
对应的我们也给出sched
的类型gobuf
,可以看到sp
和pc
两个核心指针变量:
type gobuf struct {
sp uintptr
pc uintptr
//......
}
谈谈go语言对于线程的抽象
上文我们提出线程的用m
指针记录,如下源码所示,我们都知道在go语言中每个线程都会从一个协程队列中获取协程执行,所以执行时它会用curg
记录当前运行的协程,然后通过id对自己进行唯一标识,而mOS
则是及记录当前操作系统信息,这其中最核心的就是g0
它就是每一个线程的操作调度器:
type m struct {
g0 *g // goroutine with scheduling stack
id int64
curg *g // current running goroutine
mOS
}
了解整体结构之后我们再来聊聊go语言线程的g0栈是如何工作的,如下图所示,每一个g0栈都会通过schedule开始工作:
- 通过execute从协程队列中获取任务。
- 调用gogo方法在协程调用前插入
go exit
指针它记录g0栈帧,这个指针就是用于协程执行退出或者挂起是可以通过这个指针跳回g0
栈。 - 然后就是执行当前协程。
- 协程执行完成切换回
g0
栈,重新调用schedule方法再次从步骤1开始执行,由此构成一个循环。
这里我们也给出asm_amd64.s
中关于gogo
的汇编代码,可以看到gobuf_sp方法它会记录当前stack pointer
也就是我们上文针对g0
所说的g0
栈地址:
TEXT gogo<>(SB), NOSPLIT, $0
get_tls(CX)
MOVQ DX, g(CX)
MOVQ DX, R14 // set the g register
//记录g0栈地址
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
小结
自此我们从go语言底层实现的角度完整的剖析的协程与线程的关系和实现,希望对你有帮助。
我是 sharkchili ,CSDN Java 领域博客专家,开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
参考
程序计数器(PC)、堆栈指针(SP)与函数调用过程:https://www.cnblogs.com/uestcliming666/p/11488782.html