在 Go 语言的并发编程哲学中,有一句广为人知的名言:“不要通过共享内存来通信,而要通过通信来共享内存(Do not communicate by sharing memory; instead, share memory by communicating)”。而实现这一哲学的核心纽带就是Channel。
本文基于 Go 官方运行时源码(src/runtime/chan.go),深度剖析 Channel 的底层数据结构、内存分配方式以及发送、接收和关闭的核心运作机制。
一、核心数据结构:hchan 与 sudog
在 Go runtime 中,Channel 的底层实体是一个名为hchan的结构体。使用make(chan T, cap)创建的 channel 本质上是指向hchan结构体的指针。
1.hchan结构解析
type hchan struct { qcount uint // 当前环形队列中剩余的元素个数 (len) dataqsiz uint // 环形队列的容量大小 (cap) buf unsafe.Pointer // 指向环形缓冲区的底层数组指针(仅有缓冲 channel 使用) elemsize uint16 // 元素大小 closed uint32 // channel 关闭状态标记(0: 开启, 1: 已关闭) elemtype *_type // 元素类型元数据指针 sendx uint // 缓冲区发送索引,下次写入数据的下标 recvx uint // 缓冲区接收索引,下次读取数据的下标 recvq waitq // 等待接收的 goroutine 双向链表 (sudog 链表) sendq waitq // 等待发送的 goroutine 双向链表 (sudog 链表) lock mutex // 互斥锁,保护 hchan 中的所有字段操作 }从结构体可以看出,Channel 的底层实现主要由三部分组成:
环形缓冲区(Ring Buffer):由
buf、sendx、recvx配合实现,维护一个循环队列。等待队列(Wait Queue):
recvq和sendq,当读写发生阻塞时挂起 goroutine。互斥锁(Mutex):Channel并不是无锁结构,其并发安全完全依赖内部的互斥锁
lock。
2.sudog结构
当 goroutine 在 channel 上被阻塞时,不能直接把g结构体挂在队列里,因为一个 goroutine 可能会在多个 channel 上等待(例如select语句)。
运行时使用sudog结构体来封装处于等待状态的 goroutine 及其绑定的数据地址:
type waitq struct { first *sudog last *sudog }sudog中保存了当前的 goroutine 指针、要接收或发送的数据内存地址elem,以及前驱和后继指针。
二、Channel 的创建机制(makechan)
当我们执行ch := make(chan int, 5)时,底层调用的是makechan函数:
func makechan(t *chantype, size int) *hchanChannel 创建时的内存分配根据缓冲区大小和元素类型有三种情况:
无缓冲 channel(size == 0):只需为
hchan结构体本身分配一次内存。有缓冲且元素不包含指针:runtime 会将
hchan与其底层环形缓冲区buf所需的内存一次性连续分配(单次内存分配,有利于减少 GC 压力并提升局部缓存友好度)。有缓冲且元素包含指针:
hchan和buf会分别进行独立的堆内存分配。
三、发送流程(chansend)
执行ch <- x时,底层调用chansend,其执行流程按优先级分为以下几种情况:
+-----------------------+ | 发送数据: ch <- elem | +-----------------------+ | [ ch 是否为 nil ? ] / \ 是 否 | | 永久阻塞 获取 hchan.lock | [ ch 是否已关闭 ? ] / \ 是 否 | | 触发 panic [ recvq 是否有等待接收者 ? ] / \ 是 否 | | 直接将数据拷贝至该 G [ buf 缓冲区是否未满 ? ] 唤醒目标 G (goready) / \ 完成 是 否 | | 写入 buf[sendx] 封装为 sudog sendx 递增并取模 入队 sendq 完成 调用 gopark 挂起nil channel 检查:向未初始化的
nilchannel 发送数据,goroutine 会直接永久挂起阻塞。关闭状态检查:对已关闭的 channel 执行写入,会立即抛出
panic: send on closed channel。直接发送优化(Direct Send):
如果
recvq等待队列中有阻塞的接收者,说明此时缓冲区必定为空(若有缓冲)。运行时会从
recvq头部弹出一个sudog,跳过缓冲区,直接将数据拷贝到接收者 goroutine 的内存栈中。然后调用
goready(g)将被阻塞的接收 goroutine 状态置为可运行,等待调度。
写入环形缓冲区:
若
recvq为空,且环形缓冲区还有空余空间(qcount < dataqsiz),则将数据拷贝到buf[sendx]对应的位置。更新
sendx = (sendx + 1) % dataqsiz,且qcount++。
阻塞并挂起:
若无接收者且缓冲区已满(或为无缓冲 channel),当前 goroutine 会被包装为一个
sudog。将数据地址记录在
sudog.elem中,把sudog挂入sendq尾部。释放
hchan.lock,调用gopark让出 CPU 执行权,当前 goroutine 进入等待状态。
四、接收流程(chanrecv)
执行<-ch或x, ok := <-ch时,底层调用chanrecv,主要步骤如下:
nil channel 检查:从
nilchannel 读取,当前 goroutine 同样会永久阻塞。快速判断关闭状态:
若 channel 已关闭且缓冲区中已无数据(
qcount == 0),则将目标变量清理为对应类型的零值,返回false(表示读取失败/已关闭)。
从 sendq 中唤醒发送者:
若
sendq中有正在等待的发送者:无缓冲 channel:直接从发送者的
sudog.elem拷贝数据到接收者的内存地址。有缓冲 channel(此时缓冲区必定已满):先从
buf[recvx]读取数据给当前接收者;再将sendq头部的发送者数据写入刚才空出的buf[recvx]槽位,并推进recvx和sendx。调用
goready唤醒该发送者。
从环形缓冲区读取:
若
sendq为空,但缓冲区中有数据(qcount > 0),则直接从buf[recvx]拷贝数据给当前接收者。更新
recvx = (recvx + 1) % dataqsiz,qcount--。
阻塞并挂起:
若缓冲区为空且无等待发送者,将当前 goroutine 封装为
sudog挂入recvq,调用gopark挂起等待唤醒。
五、关闭流程(closechan)
执行close(ch)时,底层调用closechan:
合法性检查:
关闭
nilchannel:触发panic: close of nil channel。重复关闭已关闭的 channel:触发
panic: close of closed channel。
设置状态位:将
hchan.closed标记设为 1。批量唤醒等待队列:
取出
recvq中所有的接收者sudog,赋予零值并全部调用goready唤醒。取出
sendq中所有的发送者sudog,全部唤醒(这部分发送者被唤醒后检测到 channel 已关闭,会直接触发 panic)。
六、Channel 各种状态下的操作行为汇总
| 操作 | nil Channel | 正常开启状态(Open) | 已关闭状态(Closed) |
读(<-ch) | 永久阻塞 | 正常读取;无数据时阻塞 | 读取剩余缓冲数据;空了后返回零值与false |
写(ch <- x) | 永久阻塞 | 正常写入;缓冲区满时阻塞 | Panic(向已关闭 channel 写入) |
关闭(close(ch)) | Panic | 正常关闭 | Panic(重复关闭) |
七、关键设计总结
加锁但低开销:Channel 并不是纯粹的无锁队列,内部通过
hchan.lock保证操作的原子性。但由于临界区仅涉及内存拷贝和指针移动,耗时极短。零拷贝穿透(Direct Copy):在无缓冲场景或接收者先就绪的情况下,数据直接通过内存拷贝从一个 Goroutine 的栈转移到另一个 Goroutine 的栈,完全规避了环形缓冲区的二次搬迁。
M:N 调度协同:阻塞操作不占用系统线程,而是通过
gopark让出执行权,结合sudog挂入等待链表,待条件满足时通过goready重新加入运行队列,体现了与 Go 运行时调度器(GMP)的深度协同。