go之goroutine协程

概念

goroutine在go里面是叫协程的,一般情况下进程里处理并发任务时都会开启多个线程来处理,线程的生命周期是通过操作系统控制的,而协程就相当于go在线程的基础上,又往下写了一套线程的架构,然后不给操作系统管理,自己写一套调度策略来使用,有进程-线程-协程这种关系。

协程的话,可以算做一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

在go中每一个协程是一个独立的执行单元,相较于每个线程固定分配 2M 内存的模式,协程的栈采取了动态扩容方式, 初始时仅为2KB,随着任务执行按需增长,最大可达 1GB(64 位机器最大是 1G,32 位机器最大是 256M),且完全由 go自己的调度器来调度。此外,go的gc还会周期性地将不再使用的内存回收,收缩栈空间,因此,go程序可以同时并发成千上万个 协程是得益于它强劲的调度器和高效的内存模型。

下面详细讲解协程调度的细节。

调度模型

协程调度通过 GPM 模型+其他协作的数据模型实现。

G

goroutine, 代表一个协程,包含自己的栈、程序计数器等信息。

G的状态

  • 空闲中(_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扫描)

M

machine,代表线程,一个 M 对应一个线程。

  • go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
  • runtime/debug 中的 setMaxThreads 函数,设置 M 的最大数量
  • 一个 M 阻塞了,会创建新的 M。

M的状态

M并没有像G和P一样的状态标记, 但可以认为一个M有以下的状态:

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

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

P

processor,指处理器,主要用途是用来执行协程,维护一个全局运行队列和gFree空闲协程列表。
P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量默认为cpu核数,也可由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。GOMAXPROCS可通过2种方式设置:

  • 环境变量GOMAXPROCS=数量
  • 代码里控制runtime.GOMAXPROCS(数量)

P的状态

  • 空闲中(_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会变为此状态

sched

调度器,专门调度协程执行。维护一个全局运行队列和gFree空闲协程列表。

本地运行队列

在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会重新变为运行中的状态。

创建协程过程

我们在创建协程只需通过关键字go即可,编译器会自动帮我们关联到相应的方法中。下面是通过runtime.newproc方法作为入口总结出的关键步骤:

  1. 尝试从当前P跟sched的gFree获取到协程,获取不到就创建一个新的协程,并更新状态_Grunnable。
  2. 根据当前P的情况,直接将该协程设置为下次执行的任务,或者是放到某个队列中。
  3. 如果当前P的队列没满就把协程放到队尾,满了就把队列一半的协程跟现在的协程移动到sched的全局队列中。
  4. 最后再判断是否满足相应条件调用 runtime.wakep 执行协程。

调度过程

go程序是通过循环调用runtime.schedule方法去实现调度,关键步骤:

  1. 从当前_P_的队列中取待执行协程,没就下一步
  2. 从全局队列中取取待执行协程,没就下一步
  3. 调用netpoll()取异步调用结束的_协程_,该次调用为非阻塞调用,直接返回;
  4. 调用runqsteal()从其他_P_的队列中“偷”待执行协程。
  5. 获取到协程后,调用execute()函数执行该协程,如果获取不到就停止当前M的执行。

sysmon方法

runtime在go程序启动时,会自动创建一个线程,运行sysmon方法。 sysmon方法在整个程序生命周期中一直执行,负责监视各个协程的状态、判断是否要进行垃圾回收等, 默认是每个协程最多只能执行10毫秒便得把执行权交给下一个协程了。

Channel通道

如果说协程是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个协程发送特定值到另一个协程的通信机制。
go语言中的channel是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。

Context上下文

Go 1.7 标准库引入 context,中文译作“上下文”,准确说它是协程的上下文,包含协程的运行状态、环境、现场等信息,用途是在于控制协程的生命周期。

你可能感兴趣的:(golang)