用过 go 的都知道 channel,无需多言,直接开整!

1 核心数据结构

1.1 hchan

hchan: channel 数据结构

(1)qcount:当前 channel 中存在多少个元素;

(2)dataqsize: 当前 channel 能存放的元素容量;

(3)buf:channel 中用于存放元素的环形缓冲区;

(4)elemsize:channel 元素类型的大小;

(5)closed:标识 channel 是否关闭;

(6)elemtype:channel 元素类型;

(7)sendx:发送元素进入环形缓冲区的 index;

(8)recvx:接收元素所处的环形缓冲区的 index;

(9)recvq:因接收而陷入阻塞的协程队列;

(10)sendq:因发送而陷入阻塞的协程队列;

1.2 waitq

waitq:阻塞的协程队列

(1)first:队列头部

(2)last:队列尾部

1.3 sudog

sudog:用于包装协程的节点

(1)g:goroutine,协程;

(2)next:队列中的下一个节点;

(3)prev:队列中的前一个节点;

(4)elem: 读取/写入 channel 的数据的容器;

(5)c:标识与当前 sudog 交互的 chan.

2 构造器函数

(1)判断申请内存空间大小是否越界,mem 大小为 element 类型大小与 element 个数相乘后得到,仅当无缓冲型 channel 时,因个数为 0 导致大小为 0;

(2)根据类型,初始 channel,分为 无缓冲型、有缓冲元素为 struct 型、有缓冲元素为 pointer 型 channel;

(3)倘若为无缓冲型,则仅申请一个大小为默认值 96 的空间;

(4)如若有缓冲的 struct 型,则一次性分配好 96 + mem 大小的空间,并且调整 chan 的 buf 指向 mem 的起始位置;

(5)倘若为有缓冲的 pointer 型,则分别申请 chan 和 buf 的空间,两者无需连续;

(6)对 channel 的其余字段进行初始化,包括元素类型大小、元素类型、容量以及锁的初始化.

3 写流程

3.1 两类异常情况处理

(1)对于未初始化的 chan,写入操作会引发死锁;

(2)对于已关闭的 chan,写入操作会引发 panic.

3.2 case1:写时存在阻塞读协程

(1)加锁;

(2)从阻塞度协程队列中取出一个 goroutine 的封装对象 sudog;

(3)在 send 方法中,会基于 memmove 方法,直接将元素拷贝交给 sudog 对应的 goroutine;

(4)在 send 方法中会完成解锁动作.

3.3 case2:写时无阻塞读协程但环形缓冲区仍有空间

(1)加锁;

(2)将当前元素添加到环形缓冲区 sendx 对应的位置;

(3)sendx++;

(4)qcount++;

(4)解锁,返回.

3.4 case3:写时无阻塞读协程且环形缓冲区无空间

(1)加锁;

(2)构造封装当前 goroutine 的 sudog 对象;

(3)完成指针指向,建立 sudog、goroutine、channel 之间的指向关系;

(4)把 sudog 添加到当前 channel 的阻塞写协程队列中;

(5)park 当前协程;

(6)倘若协程从 park 中被唤醒,则回收 sudog(sudog能被唤醒,其对应的元素必然已经被读协程取走);

(7)解锁,返回

3.5 写流程整体串联


4 读流程

4.1 异常 case1:读空 channel

(1)park 挂起,引起死锁;

4.2 异常 case2:channel 已关闭且内部无元素

(1)直接解锁返回即可

4.3 case3:读时有阻塞的写协程

(1)加锁;

(2)从阻塞写协程队列中获取到一个写协程;

(3)倘若 channel 无缓冲区,则直接读取写协程元素,并唤醒写协程;

(4)倘若 channel 有缓冲区,则读取缓冲区头部元素,并将写协程元素写入缓冲区尾部后唤醒写写成;

(5)解锁,返回.

4.4 case4:读时无阻塞写协程且缓冲区有元素

(1)加锁;

(2)获取到 recvx 对应位置的元素;

(3)recvx++

(4)qcount--

(5)解锁,返回

4.5 case5:读时无阻塞写协程且缓冲区无元素

(1)加锁;

(2)构造封装当前 goroutine 的 sudog 对象;

(3)完成指针指向,建立 sudog、goroutine、channel 之间的指向关系;

(4)把 sudog 添加到当前 channel 的阻塞读协程队列中;

(5)park 当前协程;

(6)倘若协程从 park 中被唤醒,则回收 sudog(sudog能被唤醒,其对应的元素必然已经被写入);

(7)解锁,返回

4.6 读流程整体串联


5 阻塞与非阻塞模式

在上述源码分析流程中,均是以阻塞模式为主线进行讲述,忽略非阻塞模式的有关处理逻辑.

此处阐明两个问题:

(1)非阻塞模式下,流程逻辑有何区别?

(2)何时会进入非阻塞模式?

5.1 非阻塞模式逻辑区别

非阻塞模式下,读/写 channel 方法通过一个 bool 型的响应参数,用以标识是否读取/写入成功.

(1)所有需要使得当前 goroutine 被挂起的操作,在非阻塞模式下都会返回 false;

(2)所有是的当前 goroutine 会进入死锁的操作,在非阻塞模式下都会返回 false;

(3)所有能立即完成读取/写入操作的条件下,非阻塞模式下会返回 true.

5.2 何时进入非阻塞模式

默认情况下,读/写 channel 都是阻塞模式,只有在 select 语句组成的多路复用分支中,与 channel 的交互会变成非阻塞模式:

5.3 代码一览

在 select 语句包裹的多路复用分支中,读和写 channel 操作会被汇编为 selectnbrecv 和 selectnbsend 方法,底层同样复用 chanrecv 和 chansend 方法,但此时由于第三个入参 block 被设置为 false,导致后续会走进非阻塞的处理分支.


6 两种读 channel 的协议

读取 channel 时,可以根据第二个 bool 型的返回值用以判断当前 channel 是否已处于关闭状态:

实现上述功能的原因是,两种格式下,读 channel 操作会被汇编成不同的方法:

7 关闭



(1)关闭未初始化过的 channel 会 panic;

(2)加锁;

(3)重复关闭 channel 会 panic;

(4)将阻塞读协程队列中的协程节点统一添加到 glist;

(5)将阻塞写协程队列中的协程节点统一添加到 glist;

(6)唤醒 glist 当中的所有协程.

8 一道考题

要求实现一个 map:

(1)面向高并发;

(2)只存在插入和查询操作 O(1);

(3)查询时,若 key 存在,直接返回 val;若 key 不存在,阻塞直到 key val 对被放入后,获取 val 返回; 等待指定时长仍未放入,返回超时错误;

(4)写出真实代码,不能有死锁或者 panic 风险.




文末小广告:

欢迎老板们关注我的个人公众号:小徐先生的编程世界

我会不定期更新个人纯原创的编程技术博客,技术栈以 go 语言为主,让我们一起点亮更多的编程技能树吧!