用过 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 语言为主,让我们一起点亮更多的编程技能树吧!