引言
在 Go 里,chan(通道)是并发编程的灵魂。它让 goroutine 之间传递数据变得简单、自然,避免了锁和共享内存的复杂性。无缓冲的同步通信,有缓冲的异步操作,甚至关闭 chan 的优雅处理——这些特性背后,是 Go 运行时精心设计的数据结构和逻辑。
这篇文章将深入 chan 的底层实现,基于 Go 的源码(主要是 runtime/chan.go),剖析它的核心机制。我们会从 chan 的数据结构讲起,逐步拆解发送、接收和关闭的操作流程,用直白的语言和例子把技术细节讲清楚。目标是既体现深度,又好懂。
chan 的本质
先简单梳理一下 chan 的基本行为:
- 无缓冲 chan:发送(
ch <- data)和接收(<-ch)必须同时发生,任何一方没准备好,另一方就得等着。 - 有缓冲 chan:发送者可以先把数据塞进缓冲区,只要没满就不用等;接收者从缓冲区取数据,只要不空就能立刻拿到。
- 关闭 chan:
close(ch)后,不能再发送,但还能读完缓冲区里的数据。
这些行为靠的是 chan 的底层实现,接下来我们直接切入正题。
chan 的数据结构
在 Go 运行时,chan 的核心是一个叫 hchan 的结构体,定义在 runtime/chan.go 里。它的字段长这样:
type hchan struct {
qcount uint // 当前缓冲区里的元素数量
dataqsiz uint // 缓冲区总大小
buf unsafe.Pointer // 指向环形缓冲区的指针
elemsize uint16 // 每个元素的大小
closed uint32 // 是否关闭,0 表示未关闭
elemtype *_type // 元素类型信息
sendx uint // 发送时的写入索引
recvx uint // 接收时的读取索引
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
lock mutex // 保护 chan 操作的锁
}
几个关键字段
buf:环形缓冲区,只有有缓冲 chan 才用得上。数据按顺序写进去,再按顺序读出来,靠sendx和recvx两个索引控制。recvq和sendq:等待队列,用来存被阻塞的 goroutine。每个队列是个双向链表,里面装着 goroutine 和它要发送或接收的数据。lock:互斥锁,保证同一时刻只有一个 goroutine 能操作这个 chan。closed:关闭标志,0 是未关闭,非 0 是已关闭。
这些字段是 chan 功能的基石,发送、接收和关闭的操作都围绕它们展开。
发送是怎么实现的
来看看发送操作(ch <- data)的底层逻辑,主要由 runtime.chansend 函数实现。流程大概是这样:
- 锁住 chan:先抢到
lock,避免并发冲突。 - 检查是否关闭:如果
closed不为 0,直接 panic,因为不能往已关闭的 chan 塞数据。 - 分情况处理:
- 无缓冲 chan:
- 看看
recvq里有没有等着接收的 goroutine。如果有,直接把数据交给它,唤醒对方,发送就算完成了。 - 如果没有接收者,当前 goroutine 把自己塞进
sendq,然后调用gopark挂起,等着被唤醒。
- 看看
- 有缓冲 chan:
- 检查缓冲区是否满了(
qcount < dataqsiz)。如果没满,把数据写到buf的sendx位置,sendx往后挪一格(循环到头就回 0),qcount加 1,发送结束。 - 如果满了,当前 goroutine 加入
sendq,挂起。
- 检查缓冲区是否满了(
- 无缓冲 chan:
- 解锁:释放
lock。
无缓冲 chan 像是个接力赛,必须有人接棒才能跑;有缓冲 chan 则像个仓库,空间够就先存着。
接收是怎么实现的
接收操作(data := <-ch)的逻辑在 runtime.chanrecv 函数里,流程跟发送差不多,但方向相反:
- 锁住 chan:同样先拿到
lock。 - 分情况处理:
- 无缓冲 chan:
- 检查
sendq里有没有等着发送的 goroutine。如果有,直接从它那儿拿数据,唤醒对方,接收完成。 - 如果没发送者,当前 goroutine 加入
recvq,挂起。
- 检查
- 有缓冲 chan:
- 如果缓冲区有数据(
qcount > 0),从buf的recvx位置取出来,recvx后移,qcount减 1,接收搞定。 - 如果缓冲区空了,当前 goroutine 加入
recvq,挂起。
- 如果缓冲区有数据(
- 无缓冲 chan:
- 处理关闭情况:
- 如果 chan 已关闭(
closed != 0)且缓冲区空了,返回元素的零值和false。 - 如果关闭了但缓冲区还有数据,继续读缓冲区。
- 如果 chan 已关闭(
- 解锁:释放
lock。
接收操作的核心是找到数据来源——要么是缓冲区,要么是发送者。
关闭 chan 的细节
关闭 chan(close(ch))由 runtime.closechan 处理,步骤很简单但影响深远:
- 锁住 chan:拿到
lock。 - 检查重复关闭:如果
closed已经不是 0,说明重复关闭,直接 panic。 - 标记关闭:把
closed设为 1。 - 清场:
- 把
recvq里的所有 goroutine 唤醒,它们会收到零值和false。 - 把
sendq里的所有 goroutine 唤醒,它们会因为发送失败而 panic。
- 把
- 解锁:释放
lock。
关闭后,缓冲区的数据还能读,但新数据进不来。
用例子看明白
下面通过几个例子,把 chan 的行为和底层逻辑对应起来。
无缓冲 chan 的同步
ch := make(chan int)
go func() {
ch <- 42 // 等着接收者
}()
data := <-ch // 唤醒发送者
fmt.Println(data) // 输出 42
发送者没见到接收者就进了 sendq,接收者来了直接拿数据,把发送者唤醒。
有缓冲 chan 的异步
ch := make(chan int, 2)
ch <- 1 // 写缓冲区,qcount=1
ch <- 2 // 写缓冲区,qcount=2
ch <- 3 // 缓冲区满,进 sendq 挂起
data := <-ch // 读缓冲区,qcount=1
fmt.Println(data) // 输出 1
缓冲区没满时,发送像写数组;满了就得等。
关闭 chan 的效果
ch := make(chan int, 1)
ch <- 1
close(ch)
data, ok := <-ch // 读缓冲区,ok=true
fmt.Println(data, ok) // 输出 1 true
data, ok = <-ch // 缓冲区空,ok=false
fmt.Println(data, ok) // 输出 0 false
关闭后,缓冲区的数据还能读完,之后就是零值。
写在最后
chan 的底层实现并不复杂:一个环形缓冲区,两个等待队列,再加一把锁,就撑起了 Go 并发的通信框架。无缓冲 chan 用同步保证精确传递,有缓冲 chan 用缓冲区提升灵活性,关闭机制则让资源清理更可控。
用 chan 时,记得根据场景选对类型,避免重复关闭或向已关闭的 chan 发送数据。只要理解了这些底层逻辑,写并发代码会更得心应手。Go 的并发设计真是妙,简单却不失强大。