从操作系统角度浅谈 goroutine 和 channel

一篇笔记主要从操作系统的角度去思考 goroutine 和 channel
简单说说我对 goroutine 和 channel 原理的理解

goroutine

我们知道 Go 语言在语言层面提供了并发编程的高级特性,一个很好的例子是 goroutine

goroutine 有以下几个特点:

  • 它是一种用户态线程,不需要操作系统来进行抢占式调用,而是由Go runtime 管理
  • 它是 Go 语言轻量级的线程实现,它不由系统而由应用程序创建和管理,因此使用 goroutine 的开销非常低

关于上下文切换

关于线程的上下文切换:

  • 我们知道线程的调度是由操作系统管理的,线程的切换可能发生在任何时机:主动的线程调度、抢占式调度

  • 内核无法区分出当前哪些寄存器正在使用,因此需要将所有寄存器的状态全都保存下来,开销很高

goroutine 的上下文切换:

  • goroutine 由 Go runtime 管理,goroutine 的调度是协作式的。Go 在很多地方增加了 hook,例如标准库、函数调用等等

  • goroutine 调度的时机是确定的,编译器知道在调度时执行到了哪里,能自动将寄存器的状态保存下来

  • 实际上,需要保存和恢复的寄存器有三个:PC、SP、DX,相比于线程切换,开销小很多

简单说说调度器

对于同一个内核上的 goroutine,我们需要一个调度器来维护他们。调度器的主要有4个重要部分,分别是 M、G、P、Sched,前三个定义在 runtime 中,Sched 定义在 proc.c 中:

  • M (work thread) 代表了系统线程OS Thread,由操作系统管理

  • P (processor) 衔接 M 和 G 的调度上下文,它负责将等待执行的 G 与 M 对接。P的数量可以通过 GOMAXPROCS() 来设置,它其实也就代表了真正的并发度,即有多少个 goroutine 可以同时运行

  • G (goroutine) goroutine 的实体,包括了调用栈,重要的调度信息,例如 channel等

调度器的工作原理这里先不说,我理解的还不深,等我真正看懂了再补一篇完整的笔记,顺带加上 goroutine 的生老病死

Channel

我们想要协调并发单元之间的工作,需要让它们相互通信

并发通信的方式通常有两种类型:共享内存和消息传递

基于共享内存的并发:

  • 利用文件映射:内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率

  • 由于多个进程共享一段内存,因此也需要依靠某种同步机制(锁)

基于消息传递的并发:

  • 利用一种类似队列的机制作为通信手段

  • 根据实现方式的不同,这个队列可能是同步的,也可能是异步的

Go 的并发通信机制使采用了消息传递的方式,而消息传递的载体就是 channel

channel 内部的实现原理

channel 数据结构的定义:

type hchan struct {
    qcount   uint // 队列缓冲区的数据个数
    dataqsiz uint // 队列缓冲区的大小
    buf      unsafe.Pointer // 指向队列缓冲区的指针
    elemsize uint16
    closed   uint32
    elemtype *_type
    sendx    uint // send index
    recvx    uint // receive index
    recvq    waitq // 指向等待接收数据的 goroutine 链表的指针
    sendq    waitq // 指向等待发送数据的 goroutine 链表的指针
    lock     mutex // 用于保护对 channel 访问的互斥锁
}

我们可以定义两种 channel:

  • 无缓冲区的 chan

    • ch := make(chan int)
    • 它的 buf == nil 且 dataqsiz == 0,Go 不会为其分配数据缓冲区,这种 channel 是同步的:消息被接收前,发送方将阻塞
  • 带缓冲区的 chan

    • ch := make(chan int, 10)
    • Go 会为其分配数据缓冲区,这种 channel 是异步的:发送方将数据发送至队列缓冲区后,继续执行

goroutine 搭配 channel 的通信过程

让我们想一下,goroutine 之间是如何通过 chan 进行通信的?

首先,发送数据的 goroutine 会对 channel 做一系列检查,通过 recvq 发现链表中有另外一个 goroutine 在等待接受数据

于是发送方 goroutine 将之前阻塞的 goroutine 从链表中移除,把需要发送的数据写入到接收方的栈空间,并将其唤醒

接收方 goroutine 也会对 channle 做一些检查,比如

  • 这个 chan 是否是 closed
  • 有没有 buf
  • 通过 sendq 检查出是否有 goroutine 在等待发送数据

如果这个 channel 没有 close,也没有 buf,也没有其他 goroutine 在等待发送数据,那么当前 goroutine 会把自己添加到 recvq 链表中,并阻塞