0%

channel 与 select 的一些记录

channel 与 select 的一些记录

本篇文章用于记录本人学习到的与 channel 与 select 有关的一些知识。

CSP 并发模型

CSP 并发模型是指“不要通过共享内存来通信,而应该通过通信来共享内存”。go 语言的并发模型是基于 CSP 模型的,这得益于完备的 go、channel、select 体系。这一模型的核心思想是尽量降低程序模块之间的耦合度,尽可能地将每一个模块封装为状态机的形式,在不同的模块之间通过数据或标志位的传递来改变状态机的行为,并尽可能降低不同模块之间的临界竞争。

在 C++程序中,线程之间的通信大多数场景也是遵循这一思想的。大部分线程之间会选择使用队列、原子标志位、期望值来进行同步,而不是多个线程同时去并发访问同一数据结构。毕竟无锁数据结构的设计非常复杂,并且可以设计为无锁形式的数据结构非常有限;而采取加锁的形式来保证线程安全可能会因为竞争而带来比较严重的性能问题。

C++的并发编程中,简单的控制同步可以直接使用原子量来完成,数据类型的传递可以使用队列来完成。但是,如果状态机中的某个状态具有多个分支,可能就需要使用多个原子量、或者使用多态类型的队列,在实际编程过程中可能会比较繁琐。而 go 语言中的 select 语句可以很好地实现信号的多路复用,使得一个程序的模块可以更加轻松地使用复杂的状态机。这也是为什么 goroutine 并没有提供外界退出的方式,而是必须从自身的逻辑中退出。go 语言的编程逻辑中,更希望每一个 goroutine 中封装的是一个完成的状态机,每一个 goroutine 都是一个独立的模块,从而实现程序不同模块间的低耦合。

如果进程内的所有线程/协程之间传递数据都使用FIFO 队列,可能会出现较多的内存拷贝。一种更好的方式是针对大对象,通过指针的方式来转移对象的所属权,使程序的不同模块用流水线的方式工作。

CSP 并发模型,更多地还是在描述程序不同模块之间如何去进行信息交换。在程序模块的内部,为了高性能可以尝试使用一些更加底层的方法来进行更加复杂的操作,但是最好不要将这种复杂度传递给模块之外,否则程序将会变得难以维护。

hchan

go 语言中 channel 底层使用的是 hchan 结构体,它本质上是一个有锁的环形缓冲区,hchan 数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type hchan struct {

// 有缓冲时,存储相关信息
qcount uint // 循环数组中的元素数量
dataqsiz uint // 循环数组的长度
buf unsafe.Pointer // 指向底层循环数组的指针
elemsize uint16 //能够收发元素的大小


closed uint32 //channel是否关闭的标志
elemtype *_type //channel中的元素类型


// 读写游标
sendx uint // 下一次发送数据的下标位置
recvx uint // 下一次读取数据的下标位置

// 用于处理读写阻塞
recvq waitq // 读等待队列
sendq waitq // 写等待队列


lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}

如果 hchan 中的 ring_buffer 缓冲区有数据,那么读写操作无特殊之处。若 hchan 中缓冲区为空,并且接收到一个读操作,或者 hchan 中缓冲区已满,但是接收到一个写操作;那么就会将该操作注册到 waitq 结构体中,waitq 结构体底层是一个 sudog 链表,存储了阻塞 goroutine 的相关调度信息。

1
2
3
4
5
6
7
8
9
10
11
12
type sudog struct {

// 原始G结构。相当于g_id
g *g

// 等待链表上的指针
next *sudog
prev *sudog

// 所属的channel,相当于channel_id
c *hchan
}

sudog 是注册在一个具体的 hchan 上的,这是因为一个 goroutine 可能会因为不同的 channel 阻塞,因此每一个 hchan 都必须要保留能够查询到阻塞 goroutine 的指针。

通过上述结构体的分析,goroutine 因为 channel 而陷入阻塞,是会主动让出执行权的。而唤醒操作则由另外一个操作 channel 的 goroutine 负责。

channel 的并发读写

一般的互斥量性能都会随着并发量的增长而迅速下降;channel 虽然是一个有锁的数据结构,但是因为底层做了特殊的优化,即使由大量的 goroutine 进行并发操作也不会造成性能的严重下降,只会发生 ns 级别的改变。

另外,由于涉及到 GC 的问题,所有数据结构在经过 channel 进行传递时都会发生一次复制。因此,可以尽量尝试使用指针来传递比较复杂的数据结构,防止内存复制损耗过多时间。

select 的阻塞与唤醒

在早期的版本中,select 语句是由后台 goroutine 轮询来实现的,当分支过多的情况下性能会比较差。在最新版本中,已经针对 select 语句进行了优化,采取了被动调度的方式进行唤醒。

select 语句的执行逻辑大致可以分为三步:

  • 判断当前是否有就绪 channel,并随机选择一个分支执行操作
  • 若无就绪 channel,将 goroutine 信息注册到各个 channel 分支,进入阻塞
  • 被唤醒时检测就绪 channel,随机选择一个分支操作

这意味着,一个 for - select 循环在一直满足就绪条件下是可以不断执行的,直到运行被抢占。

当一个 goroutine 中的 select 语句中并没有可满足的条件时,runtime.selectgo 函数会根据不同分支的 poller 顺序来选择被唤醒时的轮询顺序(每一次完成选择后会重新将该顺序打乱,保证随机选择分支);还需要根据 channel 的内存指针顺序来依次加锁,以防止死锁。完成上述操作后,goroutine 会进入阻塞状态,等待自身的 G 结构被其他 goroutine 因发送 channel 而状态改变为 _Grunnable,也就是进入 select 阻塞后的 goroutine 只能够被 goroutine 调度器唤醒,而不会主动尝试运行。当 goroutine 被唤醒时,会将所有的 channel 加锁,并且释放所有的 sudog 结构体。

由于 select 语句在进入阻塞和被唤醒时都需要对所有 channel 加锁,这里会有一定的性能损耗。

select 语句是一个多分支语句,只能够随机选择一个 channel 运行。但是 go 语言中,sudog 的节点的移出是由解除阻塞的一方负责的,如果不加以控制,就可能会出现多个分支注册的 sudog 同时被多个协程删除的情况,这样就会造成channel 消息的丢失。为了防止这一情况,select 语句注册的 sudog 会做出特殊的标记处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// runtime/chan.go
func (q *waitq) dequeue() *sudog {
for {
// 这里是链表删除的逻辑,进入函数前已经对 hchan 加锁
...

// if a goroutine was put on this queue because of a
// select, there is a small window between the goroutine
// being woken up by a different case and it grabbing the
// channel locks. Once it has the lock
// it removes itself from the queue, so we won't see it after that.
// We use a flag in the G struct to tell us when someone
// else has won the race to signal this goroutine but the goroutine
// hasn't removed itself from the queue yet.
if sgp.isSelect && !atomic.Cas(&sgp.g.selectDone, 0, 1) {
continue
}

return sgp
}
}

可以看到,如果发现取出的 sudog 结构体是由 select 语句注册的,那么会使用 CAS 语句将 selectDone 标志位设置为 1。虽然在进入 dequeue 函数前已经对 hchan 加锁,但是这里的 selectDone 标志位也被注册到了其他 hchan 结构体中,可能会发生 concurrency unsafe。如果成功修改标志位,代表本次操作是第一个通知 select 的操作,此时才会进行数据拷贝;如果修改失败,那么会将忽略本次操作,并且再一次尝试取出 sudog。