【Go笔记】通道与协程间通信

CSP并发编程

  • CSP (Communicating Sequential Process,通信顺序进程) - 用于描述鬓发系统中交互模式的形式化语言,通过通道来传递信息
  • 进程之间通过消息传递来进行通信,而不是通过共享内存,各进程独立运行,只有在通信点,也就是channel上进行同步和数据交换
  • 通信通常是同步的,只有发送方和接收方都做好准备了才会进行数据交换,因此会堵塞
  • 避免了共享内存的数据竞争和锁问题
  • 天然支持高并发和分布式
  • 方便设计和推理验证工程设计

通道的基本使用方式

  • 使用箭头<-代表数据流通方向,可以流入通道也可以从通道流出
  • 使用var {name} chan T来定义通道及其传输的数据类型
  • 通道的表示形式有三种,chan T, chan <- T, <- chan T,分别代表可读写,只写和只读
  • 未被初始化的通道是nil,使用make()来初始化通道
  • 对于无缓冲通道,能够向通道写入数据的前提是有另外一个协程在读取通道,否则当前协程会进入休眠状态,直到成功写入,即同步堵塞
  • 读取也是一样,如果一个通道没有数据流出,就会将当前协程堵塞,进入休眠状态,直到通道有数据出来。有两种方式data := <-cdata, ok := <-c,后者表示通道读取完毕后是否被关闭
  • 关闭通道用内置的close(c)函数就可以了,可以向未初始化的通道写入数据,虽然无效;但是不能向已经关闭了的通道写入数据。
  • 通道关闭的时候,堵塞在读取通道处的语句会自动解除堵塞并获取该通道的默认零值
  • 不能重复关闭通道,也不能关闭未被初始化的通道。
  • 事实上,也并不需要关心是否所有通道都被关闭了,gc会自动关闭回收未被引用的通道
  • 通道是Go中的引用类型而不是值类型,因此传递到其他协程中的通道实际引用了同一个通道
  • 如果使用for n := c {...}这样的循环来遍历通道的话,for循环会等到c关闭才会退出
  • 普通通道可以隐形转换为单向通道,反之不可

select多路复用

  • 当多个通道同时准备好的时候,select会随机选择一个case来执行
  • 如果没有任何一个通道准备好,select就会堵塞,永远陷入等待。此时可以加上default来应对所有通道都没有准备好而堵塞的情况,也可以使用time.After(...),这会定时发送一个信号,以此完成超时控制
  • 可以将for与select结合来重复执行select,再使用time.Tick(...)来设定一个间隔任务
  • 由于nil通道无论如何都会被堵塞,所以该case永远不会被执行,但最好不要把通道设置为nil,因为这是不可逆操作

通道底层原理

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
type hchan struct {
qcount uint // 通道队列数据个数
dataqsiz uint // 数据大小
buf unsafe.Pointer // 存放实际数据的指针
elemsize uint16 // 通道数据类型大小
closed uint32 // 是否关闭
elemtype *_type // 通道类型
sendx uint // 发送者在buf中的编号
recvx uint // 接收者在buf中的编号
sendq waitq // 写入的阻塞协程队列
recvq waitq // 读取的堵塞协程队列
lock mutex // 锁
}
  • buf中的数据虽然是线性的结构,但是通过数组和需要recvx, sendx可以模拟出环形结构以重复利用空间
  • 初始化时,分配大小为0时,只分配hchan结构体大小;元素不包含指针时,连续分配hchansize元素大小;包含指针时,单独分配内存空间,以进行正常gc

写入

  • 会调用runtime/chan.go里面的chansend执行
  • 分为三种情况:有正在等待的读取协程,缓冲区有空余,缓冲区无空余
  • 有正在等待的读取协程 - 准备获取的协程会被放在recvq的队列里面,直接取出第一个然后将元素复制到对应的协程中,然后再唤醒
  • 缓冲区有空余 - 如果没有整下等待的协程,但是有缓冲区且没有满,那就写到缓冲区buf里面
  • 缓冲区无空余 - 如果无缓冲区或者满了,那就把发送的协程丢到sendq的末尾然后让它休眠,等待唤醒

读取

  • 调用了chanrecv函数,与写入的原理非常类似,依然是三种情况
  • 有正在等待的写入协程 - 直接从sendq里面获取第一个写入协程,然后将正在写入的元素直接复制到当前协程中,当前协程不需要休眠
  • 缓冲区有元素 - 如果没有写入协程,但是有缓冲区且有数据,那就直接读取缓冲区的数据
  • 缓冲区无数据 - 如果没有缓冲区或者里头没有数据,那就直接丢到recvq末尾等待被唤醒

select底层原理

  • 只有一个通道的时候,编译器会直接优化成普通等待操作
  • 每一个case都对应一个结构体
1
2
3
4
5
6
type scase struct {
c *hchan // 通道
elem unsafe.Pointer // 通道元素类型
kind uint16 // scase的类型
...
}
  • scase有四种类型,分别为caseNil, caseRecv, caseSendcaseDefault,分别代表分支通道为nil,分支从通道接受信息,分支从通道发送信息,default分支,每种通道会有不同的行为
  • 首先通过pollorderlockorder处理序列,前者类似于洗牌算法,保证随机性;后者按照地址大小对通道地址排序,便于对通道一次加锁,避免多个协程并发加锁的时候的死锁问题
  • 随后进入一轮循环,主要找出是否有准备好的分支,有就执行,没有就看有咩有default,有就执行
  • 当一轮循环完成且不能退出时,意味着需要进入休眠状态并等待通道。此时进入二轮循环,读取和写入通道都要创建一个新的sudog并放到对应通道的等待队列,然后开始休眠。当有任意通道不堵塞了,协程将被唤醒。需要将sudog结构体在其他通道的等待队列中出栈,因为已经被唤醒了。

【Go笔记】通道与协程间通信
https://study.0x535a.cn/go-note/go-channel/
Author
Stephen Zeng
Posted on
August 31, 2025
Licensed under