Chan实现原理

在 Go 语言中,Channel 是并发编程的核心,贯彻了 “不要通过共享内存来通信,而要通过通信来共享内存” 的设计哲学。

1. 核心数据结构:hchan

Channel 在运行时的表现是一个 *hchan 指针。其核心结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
type hchan struct {
qcount uint // 当前队列中剩余元素个数
dataqsiz uint // 环形队列长度,即缓冲区的大小(make(chan, N) 中的 N)
buf unsafe.Pointer // 指向环形队列的指针
elemsize uint16 // 每个元素的大小
closed uint32 // 标识关闭状态:0 未关闭,1 已关闭
elemtype *_type // 元素类型
sendx uint // 发送索引:后继写入元素在队列中的位置
recvx uint // 接收索引:后继读取元素在队列中的位置
recvq waitq // 等待读消息的 goroutine 队列(双向链表)
sendq waitq // 等待写消息的 goroutine 队列(双向链表)
lock mutex // 互斥锁,保障 channel 的线程安全
}

1.1 结构图解

可以将 hchan 想象成一个管理结构:

  • 中间是环形缓存(Ring Buffer):由 bufsendxrecvx 实现。
  • 两边是等待队列
    • recvq:装着等着拿数据的 G(Goroutine)。
    • sendq:装着等着送数据的 G。
  • 一把大锁lock 保护所有字段的修改。注意:Channel 并非无锁设计,而是基于 Mutex 实现的。

2. 发送流程 (Write to Channel)

当我们执行 ch <- data 时,底层发生了什么?

场景 A:直接发送(Recvq 不为空)

  • 条件:此时有 Goroutine 正在 recvq 中排队等待(说明缓冲区为空,或者没有缓冲区)。
  • 动作
    1. Go 运行时直接从 recvq 取出一个等待的 G (sudog)。
    2. 关键点:直接将数据从发送者的栈拷贝到接收者的栈中。
    3. 唤醒接收者 G。
  • 优势:绕过了缓冲区,完全不需要锁竞争缓冲区的操作,速度极快。

场景 B:放入缓冲区(Buffer 有空位)

  • 条件recvq 为空,且环形缓冲区(Ring Buffer)没有满。
  • 动作
    1. 获取 lock 锁。
    2. 将数据拷贝到 buf 指向的环形队列的 sendx 位置。
    3. sendx++qcount++
    4. 释放锁。
  • 结果:发送者 G 继续运行,不阻塞。

场景 C:阻塞等待(Buffer 已满或无缓冲)

  • 条件recvq 为空,且缓冲区已满(或无缓冲)。
  • 动作
    1. 发送者 G 挂起。
    2. 将当前 G 包装成一个 sudog 结构,放入 sendq 队列。
    3. 当前 G 进入 gopark 状态(睡眠),主动让出 CPU,等待被唤醒。
    4. 注意:此时发送的数据被保存在 sudog 结构中,并未写入 buffer。

3. 接收流程 (Read from Channel)

当我们执行 val := <- ch 时:

场景 A:直接接收(Sendq 不为空 & 无缓冲)

  • 条件sendq 不为空,且 Channel 是无缓冲的。
  • 动作
    1. 直接从 sendq 中取出发送者 G。
    2. 将数据从发送者 G 的栈拷贝到当前接收者 G 的栈
    3. 唤醒发送者 G。

场景 B:缓冲区满的接收(Sendq 不为空 & 有缓冲)

  • 条件sendq 不为空,且 Channel 有缓冲(说明此时缓冲区 buf 肯定是满的)。
  • 动作:此时并不是直接拿 sendq 里 G 的数据,为了保证 FIFO(先进先出)
    1. 从环形队列的头部(recvx)取出一个数据,返回给接收者。
    2. sendq 中那个等待的 G 的数据,写入到环形队列的尾部(sendx)。
    3. recvx++sendx++(环形推进)。
    4. 唤醒 sendq 中的发送者 G。

场景 C:正常从缓冲区接收

  • 条件sendq 为空,但缓冲区里有数据。
  • 动作
    1. 加锁。
    2. buf[recvx] 拷贝数据。
    3. recvx++qcount--
    4. 解锁。

场景 D:阻塞等待

  • 条件:缓冲区为空,且 sendq 也为空。
  • 动作
    1. 接收者 G 挂起。
    2. 包装成 sudog 放入 recvq
    3. 进入 gopark 休眠。

4. 关闭 Channel (Close)

执行 close(ch) 时,是一个极其暴力的过程:

  1. 加锁
  2. 设置 closed = 1
  3. 处理 recvq(读等待者)
    • recvq 中所有等待的 G 全部唤醒。
    • 这些 G 此时读取到的数据是该类型的零值,且第二个返回参数 okfalse
  4. 处理 sendq(写等待者)
    • sendq 中所有等待的 G 全部唤醒。
    • 关键:这些 G 会产生 panic(因为向已关闭的 channel 发送数据)。
  5. 解锁

4.1 必须记住的 Panic 场景

面试必问的三种 Panic:

  1. Close nil channel:关闭一个未初始化(nil)的 channel。
  2. Close closed channel:重复关闭同一个 channel。
  3. Send to closed channel:向已关闭的 channel 发送数据。

注意:从已关闭的 channel 读取数据是安全的(读到缓冲区剩余数据或零值),不会 panic。


5. 有缓冲 vs 无缓冲

这是设计并发模式时的关键选择。

5.1 无缓冲 Channel (make(chan T))

  • 同步通信:发送和接收必须同时准备好。
  • 握手性质:像是在跑道上接力棒,必须两个人面对面手递手,任何一方没来,另一方都要等。
  • 用途:强一致性的同步信号,保证执行顺序。

5.2 有缓冲 Channel (make(chan T, N))

  • 异步通信:发送方只管往缓冲区丢,只要不满就不阻塞;接收方只管拿,只要不空就不阻塞。
  • 解耦:像是投递信箱。邮递员(发送者)把信丢进去就走,不用等收信人(接收者)在场。
  • 用途:处理突发流量(削峰填谷),减少发送方等待时间。

6. 总结

  1. 结构:Channel 是一个带互斥锁的结构体,包含一个环形队列(buf)和两个等待队列(sendq/recvq)。
  2. 性能:Go 针对 Channel 做了内存优化,在特定场景下(如直接发送)可以直接跨栈拷贝数据,减少锁竞争。
  3. 顺序:严格遵循 FIFO 原则。即使缓冲区满了,新来的数据也是先去排队,让缓冲区头部的数据先被读走。
  4. 异常:“写关闭”和“重关闭”会导致 Panic,但“读关闭”是安全的(返回零值)。