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】协议,转载请保留原文出处及本版权声明。








 
    
评论