select 能在 Channel 上进行非阻塞的收发操作; select 在遇到多个 Channel 同时响应时,会随机执行一种情况;

default

go channel从非阻塞收发演变到阻塞收发

数据结构

select 在 Go 语言的源代码中不存在对应的结构体,但是我们使用 runtime.scase 结构体表示 select 控制结构中的 case:

1
2
3
4
type scase struct {
	c    *hchan         // chan
	elem unsafe.Pointer // data element
}

select 不存在任何的 case; select 只存在一个 case; select 存在两个 case,其中一个 case 是 default; select 存在多个 case;

空的 select 语句会直接阻塞当前 Goroutine,导致 Goroutine 进入无法被唤醒的永久休眠状态。

单一管道改写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
select {
case v, ok <-ch: // case ch <- v
    ...    
}

// 改写后
if ch == nil {
    block()
}
v, ok := <-ch // case ch <- v
...

非阻塞操作

当 select 中仅包含两个 case,并且其中一个是 default 时,Go 语言的编译器就会认为这是一次非阻塞的收发操作。cmd/compile/internal/gc.walkselectcases 会对这种情况单独处理。不过在正式优化之前,该函数会将 case 中的所有 Channel 都转换成指向 Channel 的地址,我们会分别介绍非阻塞发送和非阻塞接收时,编译器进行的不同优化。

在默认的情况下,编译器会使用如下的流程处理 select 语句:

将所有的 case 转换成包含 Channel 以及类型等信息的 runtime.scase 结构体;
调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体;
通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
selv := [3]scase{}
order := [6]uint16
for i, cas := range cases {
    c := scase{}
    c.kind = ...
    c.elem = ...
    c.c = ...
}
chosen, revcOK := selectgo(selv, order, 3)
if chosen == 0 {
    ...
    break
}
if chosen == 1 {
    ...
    break
}
if chosen == 2 {
    ...
    break
}

重点: runtime.selectgo

执行一些必要的初始化操作并确定 case 的处理顺序;
在循环中根据 case 的类型做出不同的处理;

初始化:

runtime.selectgo 函数首先会进行执行必要的初始化操作并决定处理 case 的两个顺序 — 轮询顺序 pollOrder 和加锁顺序 lockOrder:

pollorder:

将cases的顺序随机打乱

lockorder: 按照chan在堆区的地址顺序进行排序

循环:

当我们为 select 语句锁定了所有 Channel 之后就会进入 runtime.selectgo 函数的主循环,它会分三个阶段查找或者等待某个 Channel 准备就绪:

查找是否已经存在准备就绪的 Channel,即可以执行收发操作;
将当前 Goroutine 加入 Channel 对应的收发队列上并等待其他 Goroutine 的唤醒;
当前 Goroutine 被唤醒之后找到满足条件的 Channel 并进行处理;

由于当前的 select 结构找到了一个 case 执行,那么剩下 case 中没有被用到的 sudog 就会被忽略并且释放掉。为了不影响 Channel 的正常使用,我们还是需要将这些废弃的 sudog 从 Channel 中出队。

pass 1 - look for something already waiting 根据确定好的pollorder顺序,遍历,找到第一个ready的channel,执行对应逻辑

我们简单总结一下 select 结构的执行过程与实现原理,首先在编译期间,Go 语言会对 select 语句进行优化,它会根据 select 中 case 的不同选择不同的优化路径:

空的 select 语句会被转换成调用 runtime.block 直接挂起当前 Goroutine;
如果 select 语句中只包含一个 case,编译器会将其转换成 if ch == nil { block }; n; 表达式;
    首先判断操作的 Channel 是不是空的;
    然后执行 case 结构中的内容;
如果 select 语句中只包含两个 case 并且其中一个是 default,那么会使用 runtime.selectnbrecv 和 runtime.selectnbsend 非阻塞地执行收发操作;
在默认情况下会通过 runtime.selectgo 获取执行 case 的索引,并通过多个 if 语句执行对应 case 中的代码;

在编译器已经对 select 语句进行优化之后,Go 语言会在运行时执行编译期间展开的 runtime.selectgo 函数,该函数会按照以下的流程执行:

随机生成一个遍历的轮询顺序 pollOrder 并根据 Channel 地址生成锁定顺序 lockOrder;
根据 pollOrder 遍历所有的 case 查看是否有可以立刻处理的 Channel;
    如果存在,直接获取 case 对应的索引并返回;
    如果不存在,创建 runtime.sudog 结构体,将当前 Goroutine 加入到所有相关 Channel 的收发队列,并调用 runtime.gopark 挂起当前 Goroutine 等待调度器的唤醒;
当调度器唤醒当前 Goroutine 时,会再次按照 lockOrder 遍历所有的 case,从中查找需要被处理的 runtime.sudog 对应的索引;

单个channel select有可能会被阻塞