Go语言修炼(七):协程的本质与线程循环模型
前言
协程是Go语言中一个非常重要的概念,Go语言能够天然支持高并发应用的开发所依赖的就是协程。本文将深入探讨协程的本质以及早期Go语言的线程循环模型,揭开协程的神秘面纱。
协程的概念
首先我们回顾一下《操作系统》这门课的知识。操作系统的处理机管理中,一个非常重要的模块就是进程和线程的管理。
- 进程:是资源分配的基本单位,进程占用一定的内存空间。
- 线程:是CPU调度的基本单元,占用CPU资源,共享进程的空间。
操作系统的正常工作有赖于进程和线程的调度,然而,传统的线程调度存在如下问题:
- 线程占用资源大。
- 线程操作开销大。
- 内核态和用户态切换开销大。
协程这是在这一背景下提出来的。协程可以复用线程,节约CPU多个线程之间调度的开销。
协程的本质是将一段程序的运行状态打包,可以在线程之间调度。协程不取代线程,协程在线程上运行,线程是协程的资源。使用协程进行并发具有如下的优势:
- 资源利用率高
- 支持快速调度(内核感知不到)
- 支持超高并发
Go语言协程的底层结构
Go语言对协程的抽象存放在runtime/runtime2.go
下面的g
结构体。其部分代码如下:
type g struct {
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
m *m // current m; offset known to arm liblink
sched gobuf
syscallsp uintptr
atomicstatus atomic.Uint32
stackLock uint32 // sigprof/scang lock; TODO: fold in to atomicstatus
goid uint64
schedlink guintptr
waitsince int64 // approx time when the g become blocked waitreason waitReason // if status==Gwaiting
preempt bool
preemptStop bool
preemptShrink bool // shrink stack at synchronous safe point
lockedm muintptr
sig uint32
writebuf []byte
sigcode0 uintptr
sigcode1 uintptr
sigpc uintptr
gopc uintptr
ancestors *[]ancestorInfo
startpc uintptr // pc of goroutine function
racectx uintptr
waiting *sudog
labels unsafe.Pointer // profiler labels
timer *timer // cached timer for time.Sleep
selectDone atomic.Uint32 // are we participating in a select and did someone win the race?
gcAssistBytes int64
}
g结构体具有下面几个最主要的字段:
stack
:堆栈地址。包含lo、hi两个指针,为协程栈的上下限。sched
:目前程序运行现场。gobuf类型的结构体- sp:栈原始指针
- pc:记录当前运行到哪一行
atomicstatus
:协程状态goid
:协程ID
我们可以用一张图来表示g结构体:
Go语言对线程的抽象
协程运行在线程上,接下来我们来看看Go语言是如何抽象线程的。
runtime包将操作系统的线程抽象为m
结构体,其重要字段如下:
g0
:用于启动其它协程的g0协程。curg
:指向正在运行的协程gmOS
:记录每种OS对线程的额外描述信息
协程如何执行
搞清楚协程在Go语言的底层表示后,我们来看看Go语言早期的协程执行模型。
单线程循环模型(Go 0.x)
在Go语言0.x版本中采用的是单线程循环模型,可以概括为下图:
我们逐一解释一下每个模块:
g0 stack
:记录线程调用的方法。g stack
:协程栈,里面是协程的调用栈,另外还存有一些局部变量和函数调用信息,后面的讲内存的文章再细讲。- 线程循环调用
schedule
、execute
、gogo
、业务方法、goexit
。 - 有一个全局队列可以获取能运行的协程。
从源码角度查看每个方法的大致执行流程
schedule
位于runtime/proc.go
中:它是线程运行的第一个方法,大致流程如下:
- 定义
gp
,指向即将要运行的协程。 - 从协程队列中拿到协程
- 执行
execute
方法
execute
方法:
- 给gp字段赋值
- 调用
gogo
方法,用汇编实现,在asm_amd64.s
中,传入gobuf
,即栈地址和PC。
gogo
方法:
- 拿到
gobuf
中协程栈g stack
。 - 将
goexit()
插入到Go的协程栈。 - 跳到
gobuf
的PC
,即转到协程运行到的代码,此后开始运行业务代码。每个协程都记录自己运行到哪里。
goexit
方法:
- 使用
mcall
调用goexit0
,mcall调用方法会切换栈,此时会从协程栈切换到g0 stack
。 goexit0
设置g的参数后,调用schedule
方法,继续循环。
整个单线程模型可以抽象为:线程M
不断从队列拿到G
执行。
多线程循环(Go 1.0)
多线程循环和单线程循环类似,不过在多线程并发获取全局G
队列的时候需要加锁。
小结
- 操作系统不知道go协程存在。
- 操作系统线程执行一个调度循环,顺序执行go协程。
- 调度循环非常类似于线程池。
然而,目前的线程调度存在下面的问题:
- 第一,目前协程是顺序执行的,无法大规模并发
- 第二,多线程并发会抢夺协程队列的锁,造成锁并发问题,影响效率。
下篇文章我们将讲述大名鼎鼎的G-M-P
调度模型,这也是Go语言现在所采用的协程调度模型,它将很好地解决上述的问题。
- 感谢你赐予我前进的力量
赞赏者名单
因为你们的支持让我意识到写文章的价值🙏
本文是原创文章,采用 CC BY-NC-ND 4.0 协议,完整转载请注明来自 AjaxZhan
评论
匿名评论
隐私政策
你无需删除空行,直接评论以获取最佳展示效果