Go 中 chan 的底层实现:从源码到原理

309 阅读5分钟

引言

在 Go 里,chan(通道)是并发编程的灵魂。它让 goroutine 之间传递数据变得简单、自然,避免了锁和共享内存的复杂性。无缓冲的同步通信,有缓冲的异步操作,甚至关闭 chan 的优雅处理——这些特性背后,是 Go 运行时精心设计的数据结构和逻辑。

这篇文章将深入 chan 的底层实现,基于 Go 的源码(主要是 runtime/chan.go),剖析它的核心机制。我们会从 chan 的数据结构讲起,逐步拆解发送、接收和关闭的操作流程,用直白的语言和例子把技术细节讲清楚。目标是既体现深度,又好懂。

chan 的本质

先简单梳理一下 chan 的基本行为:

  • 无缓冲 chan:发送(ch <- data)和接收(<-ch)必须同时发生,任何一方没准备好,另一方就得等着。
  • 有缓冲 chan:发送者可以先把数据塞进缓冲区,只要没满就不用等;接收者从缓冲区取数据,只要不空就能立刻拿到。
  • 关闭 chanclose(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 才用得上。数据按顺序写进去,再按顺序读出来,靠 sendxrecvx 两个索引控制。
  • recvqsendq:等待队列,用来存被阻塞的 goroutine。每个队列是个双向链表,里面装着 goroutine 和它要发送或接收的数据。
  • lock:互斥锁,保证同一时刻只有一个 goroutine 能操作这个 chan。
  • closed:关闭标志,0 是未关闭,非 0 是已关闭。

这些字段是 chan 功能的基石,发送、接收和关闭的操作都围绕它们展开。

发送是怎么实现的

来看看发送操作(ch <- data)的底层逻辑,主要由 runtime.chansend 函数实现。流程大概是这样:

  1. 锁住 chan:先抢到 lock,避免并发冲突。
  2. 检查是否关闭:如果 closed 不为 0,直接 panic,因为不能往已关闭的 chan 塞数据。
  3. 分情况处理
    • 无缓冲 chan
      • 看看 recvq 里有没有等着接收的 goroutine。如果有,直接把数据交给它,唤醒对方,发送就算完成了。
      • 如果没有接收者,当前 goroutine 把自己塞进 sendq,然后调用 gopark 挂起,等着被唤醒。
    • 有缓冲 chan
      • 检查缓冲区是否满了(qcount < dataqsiz)。如果没满,把数据写到 bufsendx 位置,sendx 往后挪一格(循环到头就回 0),qcount 加 1,发送结束。
      • 如果满了,当前 goroutine 加入 sendq,挂起。
  4. 解锁:释放 lock

无缓冲 chan 像是个接力赛,必须有人接棒才能跑;有缓冲 chan 则像个仓库,空间够就先存着。

接收是怎么实现的

接收操作(data := <-ch)的逻辑在 runtime.chanrecv 函数里,流程跟发送差不多,但方向相反:

  1. 锁住 chan:同样先拿到 lock
  2. 分情况处理
    • 无缓冲 chan
      • 检查 sendq 里有没有等着发送的 goroutine。如果有,直接从它那儿拿数据,唤醒对方,接收完成。
      • 如果没发送者,当前 goroutine 加入 recvq,挂起。
    • 有缓冲 chan
      • 如果缓冲区有数据(qcount > 0),从 bufrecvx 位置取出来,recvx 后移,qcount 减 1,接收搞定。
      • 如果缓冲区空了,当前 goroutine 加入 recvq,挂起。
  3. 处理关闭情况
    • 如果 chan 已关闭(closed != 0)且缓冲区空了,返回元素的零值和 false
    • 如果关闭了但缓冲区还有数据,继续读缓冲区。
  4. 解锁:释放 lock

接收操作的核心是找到数据来源——要么是缓冲区,要么是发送者。

关闭 chan 的细节

关闭 chan(close(ch))由 runtime.closechan 处理,步骤很简单但影响深远:

  1. 锁住 chan:拿到 lock
  2. 检查重复关闭:如果 closed 已经不是 0,说明重复关闭,直接 panic。
  3. 标记关闭:把 closed 设为 1。
  4. 清场
    • recvq 里的所有 goroutine 唤醒,它们会收到零值和 false
    • sendq 里的所有 goroutine 唤醒,它们会因为发送失败而 panic。
  5. 解锁:释放 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 的并发设计真是妙,简单却不失强大。