Go 协程的由来 与 GMP模型

232 阅读4分钟

1. 由 yield 函数 引来协程

我们先看一段python代码

def count():
   c = 1
   for i in range(5):
       print(c)
       c += 1

count()
print("###")

执行结果: image.png
先执行 count 函数 打印出 1-5, 最后再打印 ###

而这时我们在 count 函数内部加入 一个 yield 关键字

def count():
    c = 1
    for i in range(5):
        print(c)
        yield c
        c += 1

count()
print("###") 

再次执行: image.png

发现 count() 执行不了了。这是因为在Python中含有 yield关键字 的函数是一种特殊函数,称为 生成器函数,它调用返回将不再是执行到函数return,而是返回一个生成器对象(迭代器对象)。

生成器对象

  • 可以使用next函数驱动它执行,但执行到yield就暂停函数执行
  • 也可以使用for循环迭代它,相当于连续的next,直到不可迭代为止

我们试着去使用它吧:

 def count():
    c = 1
    for i in range(5):
        print(c)
        yield c
        c += 1

t = count()  # 迭代器对象
next(t)
print("###")

结果如下: image.png
输出结果为1,说明函数在第5行处暂停执行了(实际上这个函数没有执行完),且能继续向下执行到11 行,打印了3个#。

那如果有两个迭代器呢

import string

def count():
 c = 1
 for i in range(5):
     print(c)
     yield c
     c += 1

def chars():
 s = string.ascii_lowercase
 for c in s:
     print(c)
     yield c

t1 = count()  # 迭代器对象
t2 = chars()
next(t1)
next(t2)
next(t1)
next(t2)
print("###")

执行结果:

image.png

可以通过上面的代码看到2个任务交替进行,而这个函数的交替,完全是靠程序员的代码实现的,而不是 靠多线程的时间片用完操作系统强行切换,而且这种切换是在同一个线程中完成的。

交替执行任务是可以由程序员在一个线程内完成,这个任务被封装后就是协程。关键点是,在适当 的时候要暂停一个正在运行的任务,让出来执行另外一个任务。

2.GMP 模型

2.1 三大将

Go协程调度中,有三个重要角色: image.png

  • M:Machine,内核线程
    • 所有代码都要在系统线程上跑,协程最终也是代码,也不例外
  • G:Goroutine,Go协程。存储了协程的执行栈信息、状态和任务函数等。初始栈大小约为2~4k, 理论上开启百万个Goroutine不是问题
  • P:Processor,虚拟处理器
    • 代表 M 所需要的上下文环境,是处理用户级代码逻辑的处理器
    • 可以通过环境变量 GOMAXPROCSruntime.GOMAXPROCS()设置,默认为CPU核心数
    • P的数量决定着最大可并行的G的数量 P有自己的队列(长度256),里面放着待执行的G
    • M和P需要绑定在一起,这样P队列中的G才能正在有地方执行

2.2 调度模型

image.png

  1. 使用 go func 创建一个 Goroutine g1
  2. 当前 P 为p1,将 g1 放入当前 P 的本地队列,如果满了,就加入到 全局队列
  3. p1 和 m1 绑定,m1 从 p1 的 本地队列 中请求 G ,如果没有,就从 全局队列 中请求 G ,如果还没有,就从别的 P 的 本地队列中偷
  4. 假设 m1 拿到了 g1
  5. 让 g1 的代码在 m1 线程上运行,如果遇到了同步阻塞调用:
    • m1 和 p1 分离, m1 带着 g1 阻塞着。
    • 查看休眠线程队列有无空闲线程
      • 有:和 p1 绑定,并从 p1 队列中获取 G 来执行
      • 无:就创建一个线程提供给 p1 。
    • 如果 m1 阻塞结束,需要和一个空闲的 P 绑定
      • 优先和原来的 p1 绑定,其次是 空闲的 P
      • 如果没有空闲的 P,g1 会放到 全局队列 中,m1 加入到休眠线程队列中。
  6. 如果是异步阻塞调用:
    • g1绑定到network poller上,等到调用结束,回到P上,而M不被阻塞,可继续执行

2.3### 为什么要有P

GM直接绑定不就好了。

  1. M没有本地队列
    • 创建销毁调度G都需要和全局G队列打交道,使用全局锁。
    • 要求每个M都能执行新创建的G。创建的子协程最好和父协程在同一个M上执行,但M为了执行父协程只能把子协程交给全局队列让其他M执行。
    • 频繁的系统调用会使线程经常被阻塞和解阻塞

有了P后加入本地队列,减轻对全局队列的直接依赖,p还能从全局队列或其他P的本地队列拿取G,保证每个P的平衡

2.4为什么不直接在M上加本地队列和偷取算法?

  1. M的数量一般比P(默认CPU核心数)多,而只要系统调用阻塞M导致不够用就会一直增加M,这样本地队列的管理和偷取算法的性能会大规模下降。
  2. P和G都在用户空间,而M是内核线程,上下文交由P管理比M强