深入理解 Golang: Goroutine 协程

进程用来分配内存空间,是操作系统分配资源的最小单位;线程用来分配 CPU 时间,多个线程共享内存空间,是操作系统或 CPU 调度的最小单位;协程用来精细利用线程。协程就是将一段程序的运行状态打包,可以在线程之间调度。或者说将一段生产流程打包,使流程不固定在生产线上。协程不是被操作系统内核所管理,而完全是由程序所控制。

Go 中协程的本质

协程在 Go 内部的表示如下:

type g struct {
    
    
 stack        stack   // 协程栈
 sched        gobuf   // 目前程序运行现场
 atomicstatus atomic.Uint32 // 协程状态
 goid         uint64 // 协程 id
 
 // 。。。省略一些其他属性
 
}

type stack struct {
    
    
 lo uintptr
 hi uintptr
}

type gobuf struct {
    
    
 sp   uintptr // 栈指针,指向当前协程运行到哪个地方
 pc   uintptr // 程序计数器,记录运行到了哪行代码
 g    guintptr
 ctxt unsafe.Pointer
 ret  uintptr
 lr   uintptr
 bp   uintptr // for framepointer-enabled architectures
}

在这里插入图片描述

而线程的描述为一个 m 结构体:

type m struct {
    
    
 g0      *g     // goroutine with scheduling stack
 curg    *g     // current running goroutine

 mOS
  // 。。。省略一些其他属性
}
  • runtime 中将操作系统线程抽象为 m 结构体
  • g0 协程,操作调度器
  • curg 记录当前运行的协程
  • mOS 记录操作系统线程信息

单线程循环 Go 0.x
首先进入 g0 stack,执行 schedule() 方法,在 schedule 方法中调用 execute() 方法,execute 中再调用 gogo() 方法,gogo 方法为汇编语言编写,针对不同平台提供不同处理方法,接着通过 gogo 方法,从全局队列 runnable queue 中获取任务协程 g,进入到用户自定义的业务方法中。此时使用业务协程 g 自己的栈记录调用、跳转关系、本地变量等信息。

业务逻辑方法执行完成后会回退到 goexit() 的栈帧,goexit 会进行栈的切换,切换到 g0 stack,继续执行 schedule 方法链,不停地将 runnable queue 中的业务方法协程 g 取出执行。

多线程循环 Go 1.0
在单线程的基础上,多个线程的标准调度循环同时从 runnable queue 中取出协程 g 执行。注意,为保证协程安全,runnable queue 需要加锁。

存在的问题

  1. 协程串行执行,无法并发,会有阻塞现象。
  2. 多线程并发时,会抢夺全局队列的全局锁。

G-M-P 调度模型

前面说到,多线程从全局队列中取协程出来执行时,需要对这个队列加锁,如果每个线程 m 每次获取锁后,只从中取一个 g,则会造成很大开销,存在极大的性能问题。一个朴素的思想就是,每次取多个,将这些协程维护在一个自己的本地队列 p 中,本地队列中的全部 g 执行完后,再去全局队列抓取一堆。
在这里插入图片描述
这个本地队列 p 在 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
 // 队列,存放 g
 runq     [256]guintptr
 // 下一个可用协程指针
 runnext guintptr
 // 。。。省略其他属性
}
  • P 作为 M 与 G 的中介,承担’送料’的作用。
  • P 持有一些 G,使得每次获取 G 不用去全局找。
  • P 大大减少了并发冲突状况。

新建协程
创建一个新协程时,会随机查找一个 P,将该协程放到 P 的 runnext(优先执行),如果本地队列已经满了,则将新协程放到全局队列。

协程阻塞-触发切换
P 中本地队列里存在长耗时任务时,会阻塞后续协程,造成饥饿现象,一种做法是内部调用 runtime.gopark() 方法,让当前大任务进入等待状态,回到 execute() 继续执行后续操作。另外也可以完成系统调时后挂起或主动挂起,这里的主动的含义是用户自己的方法去触发 runtime.gopark()。
在这里插入图片描述
在极端情况下,本地队列中的协程全部为耗时任务,则会造成全局队列 runnable queue 饥饿问题,那么此时需要同时调度本地队列和全局队列:
在这里插入图片描述
runtime 中的具体做法是:

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.
 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
  }
 }
 
 // 。。。
}

执行 61 次线程循环后,gp := globrunqget(pp, 1) 去全局协程队列里拿 1 个协程进本地队列。

猜你喜欢

转载自blog.csdn.net/by6671715/article/details/131445313