Golang channel 底层是如何实现的?(深度好文)
Hi 你好,我是 k 哥。大厂搬砖 6 年的后端程序员。
我们知道,Go 语言为了方便使用者,提供了简单、安全的协程数据同步和通信机制,channel。那我们知道 channel 底层是如何实现的吗?今天 k 哥就来聊聊 channel 的底层实现原理。同时,为了验证我们是否掌握了 channel 的实现原理,本文也收集了 channel 的高频面试题,理解了原理,面试题自然不在话下。
1 原理
默认情况下,读写未就绪的 channel(读没有数据的 channel,或者写缓冲区已满的 channel)时,协程会被阻塞。
但是当读写 channel 操作和 select 搭配使用时,即使 channel 未就绪,也可以执行其它分支,当前协程不会被阻塞。
本文主要介绍 channel 的阻塞模式,和 select 搭配使用的非阻塞模式,后续会另起一篇介绍。
1.1 数据结构
channel 涉及到的核心数据结构包含 3 个。
hchan
hchan 是 channel 底层的数据结构,其核心是由数组实现的一个环形缓冲区:
qcount 通道中数据个数
dataqsiz 数组长度
buf 指向数组的指针,数组中存储往 channel 发送的数据
sendx 发送元素到数组的 index
recvx 从数组中接收元素的 index
elemsize channel 中元素类型的大小
elemtype channel 中的元素类型
closed 通道关闭标志
recvq 因读取 channel 而陷入阻塞的协程等待队列
sendq 因发送 channel 而陷入阻塞的协程等待队列
lock 锁
waitq
waitq 是因读写 channel 而陷入阻塞的协程等待队列。
first 队列头部
last 队列尾部
sudog
sudog 是协程等待队列的节点:
g 因读写而陷入阻塞的协程
next 等待队列下一个节点
prev 等待队列前一个节点
elem 对于写 channel,表示需要发送到 channel 的数据指针;对于读 channel,表示需要被赋值的数据指针。
success 标记协程被唤醒是因为数据传递(true)还是 channel 被关闭(false)
c 指向 channel 的指针
1.2 通道创建
通道创建主要是分配内存并构建 hchan 对象。
1.3 通道写入
3 种异常情况处理
对 nil channel 写入,会死锁
对被关闭的 channel 写入,会 panic
对因写入而陷入阻塞的协程,如果 channel 被关闭,阻塞协程会被唤醒并 panic
写时有阻塞读协程
加锁
从阻塞读协程队列取出 sudog 节点
在 send 方法中,调用 memmove 方法将数据拷贝给 sudog.elem 指向的变量。
goready 方法唤醒接收到数据的阻塞读协程 g,将其放入协程可运行队列中等待调度
解锁
写时无阻塞读协程但环形缓冲区仍有空间
加锁
将数据放入环形缓冲区
解锁
写时无阻塞读协程且环形缓冲区无空间
加锁。
将当前协程 gp 封装成 sudog 节点,并加入 channel 的阻塞写队列 sendq。
调用 gopark 将当前协程设置为等待状态并解锁,触发调度其它协程运行。
因数据被读或者 channel 被关闭,协程从 park 中被唤醒,清理 sudog 结构。
因 channel 被关闭导致协程唤醒,panic
返回
整体写流程
channel 为 nil 检查。为空则死锁。
加锁
如果 channel 已关闭,直接 panic。
当存在阻塞读协程,直接把数据发送给读协程,唤醒并将其放入协程可运行队列中等待调度运行。
当缓冲区未满时,将当前发送的数据拷贝到缓冲区。
当既没有阻塞读协程,缓冲区也没有剩余空间时,将协程加入阻塞写队列 sendq。
调用 gopark 将当前协程设置为等待状态,进入休眠等待被唤醒,触发协程调度。
被唤醒之后执行清理工作并释放 sudog 结构体
唤醒之后检查,因 channel 被关闭导致协程唤醒则 panic。
返回。
1.4 通道读
2 种异常情况处理
channel 未初始化,读操作会死锁
channel 已关闭且缓冲区无数据,给读变量赋零值。
读时有阻塞写协程
加锁
从阻塞写队列取出 sudog 节点
假如 channel 为无缓冲区通道,则直接读取 sudog 对应写协程数据,唤醒写协程。
假如 channel 为缓冲区通道,从 channel 缓冲区头部(recvx)读数据,将 sudog 对应写协程数据,写入缓冲区尾部(sendx),唤醒写协程。
解锁
读时无阻塞写协程且缓冲区有数据
加锁
从环形缓冲区读数据。在 channel 已关闭的情况下,缓冲区有数据依然可以被读。
解锁
读时无阻塞写协程且缓冲区无数据
加锁。
将当前协程 gp 封装成 sudog 节点,加入 channel 的阻塞读队列 recvq。
调用 gopark 将当前协程设置为等待状态并解锁,触发调度其它协程运行。
因读到数据或者 channel 被关闭,协程从 park 中被唤醒,清理 sudog 结构。
返回
整体读流程
通道读流程如下:
channel 为 nil 检查。空则死锁。
加锁。
如果 channel 已关闭,并且缓冲区无数据,读变量赋零值,返回。
当存在阻塞写协程,如果缓冲区已满,则直接从 sender 接收数据;否则,从环形缓冲区头部接收数据,并把 sender 的数据加到环形缓冲区尾部。唤醒 sender,将其放入协程可运行队列中等待调度运行,返回。
如果缓冲区中有数据,直接从缓冲区拷贝数据到当前协程,返回。
当既没有阻塞写协程,缓冲区也没有数据时,将协程加入阻塞读队列 recvq。
调用 gopark 将当前协程设置为等待状态,进入休眠等待被唤醒,触发协程调度。
因通道关闭或者可读被唤醒。
返回。
1.5 通道关闭
channel 为 nil 检查。为空则 panic
已关闭 channel 再次被关闭,panic
将 sendq 和 recvq 所有 Goroutine 的状态置为_Grunnable,放入协程调度队列等待调度器调度
2 高频面试题
channel 的底层实现原理 (数据结构)
nil、关闭的 channel、有数据的 channel,再进行读、写、关闭会怎么样?(各类变种题型)
有缓冲 channel 和无缓冲 channel 的区别
版权声明: 本文为 InfoQ 作者【golang架构师k哥】的原创文章。
原文链接:【http://xie.infoq.cn/article/b13af51186ab387549fedd20b】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论