Go-协程原理

Go 语言在并发编程方面有强大的能力,谈到 Go 语言调度器,绕不开的是操作系统、进程与线程这些概念,线程是操作系统调度时的最基本单元,而 Linux 在调度器并不区分进程和线程的调度,它们在不同操作系统上也有不同的实现,但是在大多数的实现中线程都属于进程。

多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量。虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1M 以上的内存空间,在切换线程时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁资源,每一次线程上下文的切换都需要消耗 ~1us 左右的时间1,但是 Go 调度器对 Goroutine 的上下文切换约为 ~0.2us,减少了 80% 的额外开销。

设计原理
单线程
0.x 版本调度器只包含表示 Goroutine 的 G 和表示线程的 M 两种结构,全局也只有一个线程。

该函数会遵循如下的过程调度 Goroutine:

获取调度器的全局锁;
调用 runtime.gosave:9682400 保存栈寄存器和程序计数器;
调用 runtime.nextgandunlock:9682400 获取下一个需要运行的 Goroutine 并解锁调度器;
修改全局线程 m 上要执行的 Goroutine;
调用 runtime.gogo:9682400 函数运行最新的 Goroutine
虽然这个单线程调度器的唯一优点就是能运行,但是这次提交已经包含了 G 和 M 两个重要的数据结构,也建立了 Go 语言调度器的框架。

多线程
Go 语言在 1.0 版本正式发布时就支持了多线程的调度器。多线程调度器的主要问题是调度时的锁竞争会严重浪费资源

任务窃取式
2012 年 Google 的工程师 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了现有多线程调度器的问题并在多线程调度器上提出了两个改进的手段:

在当前的 G-M 模型中引入了处理器 P,增加中间层;
在处理器 P 的基础上实现基于工作窃取的调度器;
这就是沿用至今的GMP模型,他大致的调度如下:

如果当前运行时在等待垃圾回收,调用 runtime.gcstopm:779c45a 函数;
调用 runtime.runqget:779c45a 和 runtime.findrunnable:779c45a 从本地或者全局的运行队列中获取待执行的 Goroutine;
调用 runtime.execute:779c45a 在当前线程 M 上运行 Goroutine;
处理器持有一个由可运行的 Goroutine 组成的环形的运行队列 runq,还反向持有一个线程。调度器在调度时会从处理器的队列中选择队列头的 Goroutine 放到线程 M 上执行。如下所示的图片展示了 Go 语言中的线程 M、处理器 P 和 Goroutine 的关系

基于工作窃取的多线程调度器将每一个线程绑定到了独立的 CPU 上,这些线程会被不同处理器管理(因此我们通常将maxP设置为我们的cpu数量,一个线程绑定一个CPU,多了也没用),不同的处理器通过工作窃取对任务进行再分配实现任务的平衡,也能提升调度器和 Go 语言程序的整体性能。

抢占式
对 Go 语言并发模型的修改提升了调度器的性能,但是 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。Go 语言的调度器在 1.2 版本中引入基于协作的抢占式调度解决下面的问题:

某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作;
GMP模型
G
G表示我们常说的Goroutine,Goroutine 在 Go 语言运行时使用私有结构体runtime.g表示。这个私有结构体非常复杂,总共包含 40 多个用于表示各种状态的成员变量,这里也不会介绍所有的字段,仅会挑选其中的一部分:

type g struct {
stack stack //当前 Goroutine 的栈内存范围 [stack.lo, stack.hi)
stackguard0 uintptr //用于调度器抢占式调度
preempt bool // 抢占信号
preemptStop bool // 抢占时将状态修改成 _Gpreempted
preemptShrink bool // 在同步安全点收缩栈
m *m //当前 Goroutine 占用的线程,可能为空;
sched gobuf //— 存储 Goroutine 的调度相关的数据;
atomicstatus uint32 // Goroutine 的状态;
goid int64 // Goroutine 的 ID;

}
结构体 runtime.g 的 atomicstatus 字段存储了当前 Goroutine 的状态。除了几个已经不被使用的以及与 GC 相关的状态之外,Goroutine 可能处于以下 9 种状态:

状态 描述
_Gidle 刚刚被分配并且还没有被初始化
_Grunnable 没有执行代码,没有栈的所有权,存储在运行队列中
_Grunning 可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
_Gsyscall 正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
_Gwaiting 由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
_Gdead 没有被使用,没有执行代码,可能有分配的栈
_Gcopystack 栈正在被拷贝,没有执行代码,不在运行队列上
_Gpreempted 由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
_Gscan GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在
虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:

等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting、_Gsyscall 和 _Gpreempted 几个状态;
可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable;
运行中:Goroutine 正在某个线程上运行,即 _Grunning;

M
Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有GOMAXPROCS个活跃线程能够正常运行。GOMAXPROCS 默认是我们的CPU的核数,也可以通过以下代码设置。

runtime.GOMAXPROCS(n)

不过设置多了也没用,因为会一个CPU同一时刻是能有一个线程运行,因此使用默认值基本上就可以了。在默认情况下,一个四核机器会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销。

Go 语言会使用私有结构体 runtime.m 表示操作系统线程,这个结构体也包含了几十个字段,这里先来了解几个与 Goroutine 相关的字段:

type m struct {
g0 *g //是持有调度栈的 Goroutine
curg *g //在当前线程上运行的用户 Goroutine
p puintptr //正在运行代码的处理器
nextp puintptr //暂存的处理器
oldp puintptr //执行系统调用之前使用线程的处理器

}

P
调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。调度器的主要字段如下:

type p struct {
//处理器对应的县城
m muintptr
//runhead、runqtail 和 runq 三个字段表示处理器持有的运行队列
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr //线程下一个需要执行的 Goroutine。

}
处理器主要有以下几种状态:

状态 描述
_Pidle 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning 被线程 M 持有,并且正在执行用户代码或者调度器
_Psyscall 没有执行用户代码,当前线程陷入系统调用
_Pgcstop 被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead 当前处理器已经不被使用
调度过程
下面以一个简单的程序说明协程调度的过程

func main() {
go func() {
fmt.Println(“hello world”)
}()
time.Sleep(time.Second)
}

程序一开始,Go语言程序的入口并不是我们熟悉的main.main,而是会创建一个main.goroutine协程,当main.goroutine协程创建起来之后,main.main的代码才会被调用。再来看看数据段:

数据段中有几个重要的全局变量,g0就是我们主线程持有的携程,他持有着调度栈,并且栈空间相对其他协程也要大很多。主线程则对应着m0,在m0中记录着g0的指针,在g0中也记录着m0的指针,从而两者联系了起来,p为一个本地队列,为g和m的中间层。。而allgs和allm,allp记录着所有的g,p,m。

在没有P之前,M都需要在全局队列中和众多的M竞争,从而造成频繁加锁解锁,竞争严重。在有了P之后,每个M只需要在本地的P,获取runnext下一个要执行的线程即可。

但是,全局队列依然是存在的,全局队列存放在调度器中。如果本地的P已满了,那么就会将g存放到全局的p中。那么全局队列的G什么时候被调用呢?首先,M首先会从关联的P中获取待执行的G,如果关联的P中没有G了,这个时候就会从全局队列中获取待执行的G。另外,如果全局队列中也没有G了,这时候就会从其他的P中获取G。这就是抢占式的调度器,主要是为了防止某些G运行时间太久而造成其他G的饥饿。

这时候,可以来看看我们的程序了,首先是只有一个P的情况。

一开始main.goroutine g0创建,初始化完成后,G(main)进入P队列,然后m0就调用G(main)协程,在G(main)中,我们创建了一个协程,假设为hello,由于只有一个P,此时G(hello)会进入P(0)等待main执行完毕。

如果没有time.sleep的话,G(main)执行完毕会执行exit,整个main栈包括G(hello)都会一起销毁掉,G(Hello)是没有办法执行的,因此需要调用time.sleep,main线程进入waiting状态,进入timer等待并让出线程,此时我们的G(hello)就可以占有m0线程并打印出hello world了。如果sleep的时间过了,G(main)同样会在p0队列中等待G(Hello)执行完毕。最后是main的执行,整个程序就执行完毕了。

如果是有多个P的情况呢?

G(hello)就会在另外一个P中被调用。同样需要G(main)等待。否则的话,G(hello)很有可能执行到一半就被回收了。如以下代码,G(hello)协程可能执行完毕,也可能无法执行,这要取决于G(main)什么时候执行完毕。

func main() {
go func() {
fmt.Println(“hello world”)
}()
for i := 0; i < 100000; i++ {

}

}

因此呢,通常我们会使用chan,waitGroup,time.sleep甚至是for{}防止Go协程的意外被终止。

整个Go协程一个简单的执行流程就如上所示了。

参考:
https://www.zhihu.com/zvideo/1308438116354207744

https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/#65-%E8%B0%83%E5%BA%A6%E5%99%A8

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值