阅读 723

Go协程管理

Go协程管理

go语言最大的特点就是高并发,实现方式就是协程。今天就来说说go协程那些事儿。

什么是协程

一句话概括,协程是go语言自己定义的一个轻量级的、可独立运行的代码块。类比操作系统层面的线程,我们可用把一个协程看作是一个线程,平时我们是如何开启一个线程?一般是调用语言提供的开启线程方法,传入一个方法,它内部实现就是调用系统接口,去执行这个方法(代码块),协程也一样,也是执行一个方法(代码块),因此可用理解为一个协程就是一个线程,没什么区别。

为什么要协程

既然一个协程就是一个线程,那为什么还要协程呢?线程不就已经足够了吗?

我们先来看看线程的缺点。假设有个机器,4个CPU,当在它上运行一个程序的时候,最大可同时运行4个线程。当4个线程都运行到阻塞点的时候,都会挂起,这个挂起需要操作系统接入。操作系统将阻塞的线程挂起,然后运行其他还未开始执行的线程或者已经不阻塞的线程。涉及到内核态和用户态的切换,非常消耗时间,而且由于cpu是轮询时间片形式调度线程的,当一个线程被阻塞但是它所占用的时间片还未到期时,这个时间是一直阻塞的、被浪费掉的。这里产生了两个问题:
1.操作系统切换线程非常耗时
2.被阻塞的线程时间片还未用完时,cpu时间片被浪费了
(注意:问题2好像是我理解的有问题。问题2可能并没有解决。当一个协程被阻塞,会将对应的P与G和M解锁,让P去选择其他的M继续执行对应队列中其他的G,从而实现高并发。待讨论)

Go设计者为了解决这两个问题,提出了协程的概念。跟线程一样,协程也是执行一段代码块,但是协程是go语言层面实现的一个东西,底层操作系统无感知。即协程都是用户态的。可以理解为一个线程包含多个协程(上文说一个协程就是一个线程是为了容易理解协程,准确概念看这里)。
协程就是go在语言层面定义了一个结构体,对应一个代码块,结构体中包含代码块的pc指针、栈等少量的信息,比线程包含的信息更少。
问题1解决:协程是go语言自己定义的一个东西,因此它是用户态的,所有协程都在用户态运行,对操作系统来说就只感知到一个线程,线程内部做什么它不管。协程的管理、切换都是go自己用代码控制的,跟操作系统无关,因此协程之间切换速度非常快,不涉及操作系统的cpu、内存等资源。
问题2解决:当一个协程被阻塞时,go语言的协程管理器会将这个协程挂起,继续执行其他还未运行的协程或者不阻塞的协程,对操作系统来说一直是一个线程在运行,go语言的这个骚操作充分利用了这个线程被阻塞而时间片未到期的这段时间。

如何管理协程

上面说到go语言自己定义一个结构体,叫协程。自己在用户态控制多个协程(结构体)的调度和执行,那它是怎么实现的呢?
go引入了P(Processor)的概念。一个P表示一个逻辑处理器,用于调度G。称之为逻辑处理器,一般与物理处理器对应
M(Machine),可以理解成一个线程,真正执行P的线程。
G、P、M之间的关系如下图:
每一个P都有一个对应的G队列,P绑定了线程M0正在执行协程G0,当遇到阻塞事件的时候,runtime会为P绑定一个新的线程M1,执行新的新的线程

数据结构

G的结构

重要成员

  • stack: 当前g使用的栈空间, 有lo和hi两个成员
  • stackguard0: 检查栈空间是否足够的值, 低于这个值会扩张栈, 0是go代码使用的
  • stackguard1: 检查栈空间是否足够的值, 低于这个值会扩张栈, 1是原生代码使用的
  • m: 当前g对应的m
  • sched: g的调度数据, 当g中断时会保存当前的pc和rsp等值到这里, 恢复运行时会使用这里的值
  • atomicstatus: g的当前状态
  • schedlink: 下一个g, 当g在链表结构中会使用
  • preempt: g是否被抢占中
  • lockedm: g是否要求要回到这个M执行, 有的时候g中断了恢复会要求使用原来的M执行

状态

  • 空闲中(_Gidle): 表示G刚刚新建, 仍未初始化
  • 待运行(_Grunnable): 表示G在运行队列中, 等待M取出并运行
  • 运行中(_Grunning): 表示M正在运行这个G, 这时候M会拥有一个P
  • 系统调用中(_Gsyscall): 表示M正在运行这个G发起的系统调用, 这时候M并不拥有P
  • 等待中(_Gwaiting): 表示G在等待某些条件完成, 这时候G不在运行也不在运行队列中(可能在channel的等待队列中)
  • 已中止(_Gdead): 表示G未被使用, 可能已执行完毕(并在freelist中等待下次复用)
  • 栈复制中(_Gcopystack): 表示G正在获取一个新的栈空间并把原来的内容复制过去(用于防止GC扫描)

P的结构

重要成员

  • status: p的当前状态
  • link: 下一个p, 当p在链表结构中会使用
  • m: 拥有这个P的M
  • mcache: 分配内存时使用的本地分配器
  • runqhead: 本地运行队列的出队序号
  • runqtail: 本地运行队列的入队序号
  • runq: 本地运行队列的数组, 可以保存256个G
  • gfree: G的自由列表, 保存变为_Gdead后可以复用的G实例
  • gcBgMarkWorker: 后台GC的worker函数, 如果它存在M会优先执行它
  • gcw: GC的本地工作队列, 详细将在下一篇(GC篇)分析

状态

  • 空闲中(_Pidle): 当M发现无待运行的G时会进入休眠, 这时M拥有的P会变为空闲并加到空闲P链表中
  • 运行中(_Prunning): 当M拥有了一个P后, 这个P的状态就会变为运行中, M运行G会使用这个P中的资源
  • 系统调用中(_Psyscall): 当go调用原生代码, 原生代码又反过来调用go代码时, 使用的P会变为此状态
  • GC停止中(_Pgcstop): 当gc停止了整个世界(STW)时, P会变为此状态
  • 已中止(_Pdead): 当P的数量在运行时改变, 且数量减少时多余的P会变为此状态

M的结构

重要成员

  • g0: 用于调度的特殊g, 调度和执行系统调用时会切换到这个g
  • curg: 当前运行的g
  • p: 当前拥有的P
  • nextp: 唤醒M时, M会拥有这个P
  • park: M休眠时使用的信号量, 唤醒M时会通过它唤醒
  • schedlink: 下一个m, 当m在链表结构中会使用
  • mcache: 分配内存时使用的本地分配器, 和p.mcache一样(拥有P时会复制过来)
  • lockedg: lockedm的对应值

状态

  • 自旋中(spinning): M正在从运行队列获取G, 这时候M会拥有一个P
  • 执行go代码中: M正在执行go代码, 这时候M会拥有一个P
  • 执行原生代码中: M正在执行原生代码或者阻塞的syscall, 这时M并不拥有P
  • 休眠中: M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 这时M并不拥有P

自旋中(spinning)这个状态非常重要, 是否需要唤醒或者创建新的M取决于当前自旋中的M的数量.

本地运行队列

在go中有多个运行队列可以保存待运行(_Grunnable)的G, 它们分别是各个P中的本地运行队列和全局运行队列. 入队待运行的G时会优先加到当前P的本地运行队列, M获取待运行的G时也会优先从拥有的P的本地运行队列获取, 本地运行队列入队和出队不需要使用线程锁.

本地运行队列有数量限制, 当数量达到256个时会入队到全局运行队列. 本地运行队列的数据结构是环形队列, 由一个256长度的数组和两个序号(head, tail)组成.

当M从P的本地运行队列获取G时, 如果发现本地队列为空会尝试从其他P盗取一半的G过来, 这个机制叫做Work Stealing

全局运行队列

全局运行队列保存在全局变量sched中, 全局运行队列入队和出队需要使用线程锁. 全局运行队列的数据结构是链表, 由两个指针(head, tail)组成.

空闲M链表

当M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 空闲M链表保存在全局变量sched. 进入休眠的M会等待一个信号量(m.park), 唤醒休眠的M会使用这个信号量.

go需要保证有足够的M可以运行G, 是通过这样的机制实现的:

  • 入队待运行的G后, 如果当前无自旋的M但是有空闲的P, 就唤醒或者新建一个M
  • 当M离开自旋状态并准备运行出队的G时, 如果当前无自旋的M但是有空闲的P, 就唤醒或者新建一个M
  • 当M离开自旋状态并准备休眠时, 会在离开自旋状态后再次检查所有运行队列, 如果有待运行的G则重新进入自旋状态

因为"入队待运行的G"和"M离开自旋状态"会同时进行, go会使用这样的检查顺序:

入队待运行的G => 内存屏障 => 检查当前自旋的M数量 => 唤醒或者新建一个M 减少当前自旋的M数量 => 内存屏障 => 检查所有运行队列是否有待运行的G => 休眠

这样可以保证不会出现待运行的G入队了, 也有空闲的资源P, 但无M去执行的情况.

空闲P链表

当P的本地运行队列中的所有G都运行完毕, 又不能从其他地方拿到G时, 拥有P的M会释放P并进入休眠状态, 释放的P会变为空闲状态并加到空闲P链表中, 空闲P链表保存在全局变量sched
下次待运行的G入队时如果发现有空闲的P, 但是又没有自旋中的M时会唤醒或者新建一个M, M会拥有这个P, P会重新变为运行中的状态.

协程之间如何通信

通过channel实现协程间通信

channel数据结构

  • qcount: 当前队列中的元素数量
  • dataqsiz: 队列可以容纳的元素数量, 如果为0表示这个channel无缓冲区
  • buf: 队列的缓冲区, 结构是环形队列
  • elemsize: 元素的大小
  • closed: 是否已关闭
  • elemtype: 元素的类型, 判断是否调用写屏障时使用
  • sendx: 发送元素的序号
  • recvx: 接收元素的序号
  • recvq: 当前等待从channel接收数据的G的链表(实际类型是sudog的链表)
  • sendq: 当前等待发送数据到channel的G的链表(实际类型是sudog的链表)
  • lock: 操作channel时使用的线程锁

向channel写数据流程

发送数据到channel实际调用的是runtime.chansend1函数, chansend1函数调用了chansend函数, 流程是:

  • 检查channel.recvq是否有等待中的接收者的G
    • 如果有, 表示channel无缓冲区或者缓冲区为空
    • 调用send函数
      • 如果sudog.elem不等于nil, 调用sendDirect函数从发送者直接复制元素
      • 等待接收的sudog.elem是指向接收目标的内存的指针, 如果是接收目标是_则elem是nil, 可以省略复制
      • 等待发送的sudog.elem是指向来源目标的内存的指针
      • 复制后调用goready恢复发送者的G
        • 切换到g0调用ready函数, 调用完切换回来
          • 把G的状态由等待中(_Gwaiting)改为待运行(_Grunnable)
          • 把G放到P的本地运行队列
          • 如果当前有空闲的P, 但是无自旋的M(nmspinning等于0), 则唤醒或新建一个M
    • 从发送者拿到数据并唤醒了G后, 就可以从chansend返回了
  • 判断是否可以把元素放到缓冲区中
    • 如果缓冲区有空余的空间, 则把元素放到缓冲区并从chansend返回
  • 无缓冲区或缓冲区已经写满, 发送者的G需要等待
    • 获取当前的g
    • 新建一个sudog
    • 设置sudog.elem = 指向发送内存的指针
    • 设置sudog.g = g
    • 设置sudog.c = channel
    • 设置g.waiting = sudog
    • 把sudog放入channel.sendq
    • 调用goparkunlock函数
      • 调用gopark函数
        • 通过mcall函数调用park_m函数
          • mcall函数和上面说明的一样, 会把当前的状态保存到g.sched, 然后切换到g0和g0的栈空间并执行指定的函数
          • park_m函数首先把G的状态从运行中(_Grunning)改为等待中(_Gwaiting)
          • 然后调用dropg函数解除M和G之间的关联
          • 再调用传入的解锁函数, 这里的解锁函数会对解除channel.lock的锁定
          • 最后调用schedule函数继续调度
  • 从这里恢复表示已经成功发送或者channel已关闭
    • 检查sudog.param是否为nil, 如果为nil表示channel已关闭, 抛出panic
    • 否则释放sudog然后返回

从channel接收数据流程

从channel接收数据实际调用的是runtime.chanrecv1函数, chanrecv1函数调用了chanrecv函数, 流程是:

  • 检查channel.sendq中是否有等待中的发送者的G
    • 如果有, 表示channel无缓冲区或者缓冲区已满, 这两种情况需要分别处理(为了保证入出队顺序一致)
    • 调用recv函数
      • 如果无缓冲区, 调用recvDirect函数把元素直接复制给接收者
      • 如果有缓冲区代表缓冲区已满
        • 把队列中下一个要出队的元素直接复制给接收者
        • 把发送的元素复制到队列中刚才出队的位置
        • 这时候缓冲区仍然是满的, 但是发送序号和接收序号都会增加1
      • 复制后调用goready恢复接收者的G, 处理同上
    • 把数据交给接收者并唤醒了G后, 就可以从chanrecv返回了
  • 判断是否可以从缓冲区获取元素
    • 如果缓冲区有元素, 则直接取出该元素并从chanrecv返回
  • 无缓冲区或缓冲区无元素, 接收者的G需要等待
    • 获取当前的g
    • 新建一个sudog
    • 设置sudog.elem = 指向接收内存的指针
    • 设置sudog.g = g
    • 设置sudog.c = channel
    • 设置g.waiting = sudog
    • 把sudog放入channel.recvq
    • 调用goparkunlock函数, 处理同上
  • 从这里恢复表示已经成功接收或者channel已关闭
    • 检查sudog.param是否为nil, 如果为nil表示channel已关闭
    • 和发送不一样的是接收不会抛panic, 会通过返回值通知channel已关闭
    • 释放sudog然后返回

关闭channel流程

  • 关闭channel实际调用的是closechan函数, 流程是:
  • 设置channel.closed = 1
  • 枚举channel.recvq, 清零它们sudog.elem, 设置sudog.param = nil
  • 枚举channel.sendq, 设置sudog.elem = nil, 设置sudog.param = nil
  • 调用goready函数恢复所有接收者和发送者的G

可以看到如果G需要等待资源时, 会记录G的运行状态到g.sched, 然后把状态改为等待中(_Gwaiting), 再让当前的M继续运行其他G. 等待中的G保存在哪里, 什么时候恢复是等待的资源决定的, 上面对channel的等待会让G放到channel中的链表.

对网络资源的等待可以看netpoll相关的处理, netpoll在不同系统中的处理都不一样, 有兴趣的可以自己看看.

本文主要参考【1】文档,感谢作者

参考

【1】Golang源码探索(二) 协程的实现原理
【2】Golang协程调度二:协程切换原理
【3】什么是协程(goroutine),它们是怎样工作的呢?
【4】Go 协程简介

文章分类
后端
文章标签