在学习jvm的时候便了解到了语言中的线程执行模型的区别,以及Go语言中的协程的执行模型。这次来总结一下Go中的协程设计模型,尽量不过多展开源码。之前在跟学长咨询关于编程语言的问题的时候,得到的回答都是语言差别不大,精通一门即可。当时不甚理解,但随着学习的深入,确实逐渐感觉到编程语言终究只是计算机科学中的一环罢了,语言更迭迅速,其背后的设计理念与原理才是万古长青的。

一、回顾OS中的用户线程与内核线程

在学习OS线程调度这一章的时候,用户级线程和内核级线程应该是重点内容了。

内核级线程与用户级线程

线程有三种实现方式:

  1. 用户线程,在用户空间实现,OS看不到,由应用程序的用户线程库来管理;POSIX Pthreads, Mach C-threads
  2. 内核线程,在内核中实现,OS管理的;Windows
  3. 轻量级进程,内核中实现,支持用户线程。Solaris, Linux

内核级线程的引出:操作系统无法感知到一个用户级线程的存在,因此多个用户级线程只能共享一个CPU执行权,同时,如果一个用户及现场在内核中阻塞,则这个进程的所有用户级线程都会阻塞,除非用户程序自己实现了线程调度(例如Go,Python协程的实现)。

语言中的实现

而在真正的编程语言中,该如何实现“线程”这一概念呢?答:主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现), 使用用户线程加轻量级进程混合实现(N:M实现)

下面摘取自《深入理解java虚拟机中》:

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。Java、Ruby等语言都曾经使 用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等,使得用户线程的使用率有所回升。

那么,作为今天的主角,GoLang又是怎么做到大获成功的高效协程并发的呢?且听下文分解。

二、GoLang中的协程调度模型

从上面对于线程模型的分析中,可以看到多用户线程到多内核线程的实现(N: M)中,存在两级调度

  1. 用户级线程到内核级线程的调度,由应用程序完成
  2. 内核级线程到CPU的调度,由OS完成
因此,所谓神神秘秘的“协程”,无非是“互相协作的用户级线程”罢了。

下面重点探讨Go是如何完成从用户级线程到内核级线程的调度的。

Kernel Schedule Entity

GMP 模型

G:表示 Goroutine。每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。当 Goroutine 被调离 CPU 时,调度器代码负责把 CPU 寄存器的值保存在 G 对象的成员变量之中,当 Goroutine 被调度起来运行时,调度器代码又负责把 G 对象的成员变量所保存的寄存器的值恢复到 CPU 的寄存器。

M:OS 底层线程的抽象,它本身就与一个内核线程进行绑定,每个工作线程都有唯一的一个 M 结构体的实例对象与之对应,它代表着真正执行计算的资源,由操作系统的调度器调度和管理。M 结构体对象除了记录着工作线程的诸如栈的起止位置、当前正在执行的 Goroutine 以及是否空闲等等状态信息之外,还通过指针维持着与 P 结构体的实例对象之间的绑定关系。

P:表示逻辑处理器。对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等。它维护一个局部 Goroutine 可运行 G 队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这可以大大减少锁冲突,提高工作线程的并发性,并且可以良好的运用程序的局部性原理。

三者的关系,来一张概念图:

一个 G 的执行需要 P 和 M 的支持。一个 M 在与一个 P 关联之后,就形成了一个有效的 G 运行环境(内核线程+上下文)。每个 P 都包含一个可运行的 G 的队列(runq)。该队列中的 G 会被依次传递给与本地 P 关联的 M,并获得运行时机。

M 与 KSE 之间总是一一对应的关系,一个 M 仅能代表一个内核线程。M 与 KSE 之间的关联非常稳固,一个 M 在其生命周期内,会且仅会与一个 KSE 产生关联,而 M 与 P、P 与 G 之间的关联都是可变的,M 与 P 也是一对一的关系,P 与 G 则是一对多的关系。

协程结构体G的状态

协程之于Go调度器好比内核线程之于OS调度器,也是有着多种状态的,但因为它不可见底层核心线程的状态,因此会有更细致和复杂的状态迁移图,如下图所示:

不同状态的描述如下图所示:

下面放一张OS进程状态图:

可以看到,Go协程状态图相比进程状态图多了收缩/扩展栈执行系统调用两个更加细分的状态,而且同时runnable和running状态和OS的状态不是一一对应的,其实用户态无法感知到OS的就绪态、运行态和阻塞态。

三、调度模型场景分析

由于GoLang的调度模型有多达11种场景,本文仅仅做简单总结,更多的内容可以参考一位GoLang大佬关于GMP的文章:[Golang三关-典藏版] Golang 调度器 GMP 原理与调度全分析

我们分别用三角形,矩形和圆形表示Machine、Processor和Goroutine。

正常情况下

所有的goroutine运行在同一个M系统线程中,每一个M系统线程维护一个Processor,任何时刻,一个Processor中只有一个goroutine,其他goroutine在runqueue中等待。一个goroutine运行完自己的时间片后,让出上下文,回到runqueue中。 多核处理器的场景下,为了运行goroutines,每个M系统线程会持有一个Processor。

协程创建与更替

go func()

协程阻塞

当正在运行的goroutine(G0)阻塞的时候,例如进行系统调用,会再创建一个系统线程(M1),当前的M0线程放弃了它的Processor(P),P转到新的线程中去运行。

全局队列与公平性

  • 由于在G1中创建的新协程会优先放到当前P上,因此容易发生P的runqueue满的情况,这种情况下会尝试将该P上的一部分G放到别的P上或者放到全局队列中
  • 为了保证公平,当全局运行队列中有待执行的Goroutine 时,通过schedtick 保证有一定几率(1/61)会从全局的运行队列中查找对应的Goroutine
  • 从处理器本地的运行队列中查找待执行的Goroutine
  • 如果前两种方法都没有找到Goroutine,会通过runtime.findrunnable 进行阻塞地查找Goroutine,寻找的路径有以下几种,包括:
    1. 从全局运行队列中查找。
    2. 从网络轮询器中查找是否有Goroutine 等待运行。
    3. 通过runtime.runqsteal 尝试从其他随机的处理器中窃取待运行的Goroutine。

最后上一张大佬的状态全解图:

四、聊聊channel

在学习进程间通信方式的时候,提到过7种通信方式,但由于线程通信的简便性,逐渐地很少人关心进程间通信了,但IPC本身描述了多种可行且高效的通信模式,由此衍生了多种框架或者中间件。

IPC的七种方式

  1. 管道(Pipe):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  2. 命名管道(FIFO):有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  3. 消息队列(MessageQueue):消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  4. 共享存储(Shared Memory):共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
  5. 信号量(Semaphore):信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  6. 套接字(Socket):套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
  7. 信号 ( signal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

作为经典的通信模型,IPC的通信设计理念在各种软件设计中被用到,例如:

  • shell中的管道概念,GoLang中的管道channel
  • 消息队列,Kafka、rabbitMQ
  • 共享存储,线程间共享变量
  • 信号量,“锁”概念的提出
  • 套接字,不用过多解释了
  • 信号,注册监听机制

channel怎么实现的?

Go 中经常被人提及的一个设计模式:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。Goroutine 之间会通过 channel 传递数据,作为 Go 语言的核心数据结构和 Goroutine 之间的通信方式,channel 是支撑 Go 语言高性能并发编程模型的重要结构。

GoLang的channel,听起来新颖,但其实实现起来很简单,如果用原始的线程+锁来实现,该如何设计呢?

a <- channelchannel <- b

是不是很简单!这便是channel的实现逻辑,代表着一种典型的进程/线程/协程之间利用管道进行通信的设计模式。

五、总结

本文联系了OS和linux相关计算机基础知识,分析了GoLang的协程设计理念,旨在呈现协程的来龙去脉,他并非很新颖的概念,但大部分的细节是摘自其他博客文章的。同时浅析了channel的管道通信设计理念,以及基于线程与锁的实现机制。以后会谈谈GoLang是怎么原生支持其他语言的机制,包括GC和IO多路复用。

六、参考文章