跳到主要内容

Go 源码学习 --- chan

· 阅读需 8 分钟
ahKevinXy
作者

Channel 是 Go 语言最具标志性的特性之一,它以“不要通过共享内存来通信,而要通过通信来共享内存”的设计哲学,为并发编程提供了简洁、安全的解决方案。本文将从源码层面拆解 Channel 的底层实现,带你理解其核心结构、读写逻辑和关键特性。

一、Channel 核心数据结构

Channel 的底层实现全部封装在 Go 运行时(runtime)中,核心结构体是 hchan(定义在 src/runtime/chan.go),我们先看简化后的关键字段:

type hchan struct {
qcount uint // 队列中已有的元素数量
dataqsiz uint // 环形队列的容量(缓冲区大小)
buf unsafe.Pointer // 指向环形队列的指针
elemsize uint16 // 每个元素的大小
closed uint32 // 标记 channel 是否关闭(0:未关闭,1:已关闭)
elemtype *_type // 元素类型信息(用于类型检查、内存分配)
sendx uint // 发送操作的索引(下一个发送元素的位置)
recvx uint // 接收操作的索引(下一个接收元素的位置)
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
lock mutex // 保护 hchan 所有字段的互斥锁
}

// 等待队列的结构(存储阻塞的 goroutine)
type waitq struct {
first *sudog
last *sudog
}

关键字段解读

  1. 缓冲区相关buf 指向环形队列,dataqsiz 是缓冲区容量,sendx/recvx 分别标记发送/接收的位置,实现环形队列的“先进先出”;
  2. 阻塞队列recvqsendq 存储因读写阻塞的 goroutine(封装为 sudog 结构体),sudog 是 goroutine 与同步原语(如 channel、mutex)的关联载体;
  3. 基础属性elemsize/elemtype 保证类型安全,closed 标记关闭状态,lock 保证并发访问的原子性。

二、Channel 的创建:make 背后的逻辑

我们使用 make(chan T, size) 创建 Channel 时,底层调用的是 runtime.makechan 函数,核心逻辑分为三步:

1. 合法性检查

  • 元素大小超过 64KB 且缓冲区容量大于 0 时,直接 panic(避免大元素拷贝导致性能问题);
  • 缓冲区容量为负数时 panic。

2. 内存分配

  • 无缓冲 Channel(size=0):仅分配 hchan 结构体内存,无需分配缓冲区;
  • 有缓冲 Channel(size>0):先分配 hchan 结构体,再根据元素大小和容量分配环形队列内存。

3. 初始化字段

设置 elemsizeelemtypedataqsiz 等字段,初始化 recvq/sendq 为空队列,closed 标记为 0。

简化后的核心代码:

func makechan(t *chantype, size int) *hchan {
elem := t.elem
// 合法性检查
if elem.size >= 1<<16 {
throw("makechan: invalid channel element type")
}
if hchanSize%maxAlign != 0 || elem.align > maxAlign {
throw("makechan: bad alignment")
}
// 计算需要分配的总内存
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
if overflow || mem > maxAlloc-hchanSize || size < 0 {
panic(plainError("makechan: size out of range"))
}

var c *hchan
// 分配内存
if size == 0 || elem.size == 0 {
// 无缓冲或元素大小为0,仅分配hchan
c = (*hchan)(mallocgc(hchanSize, nil, true))
c.buf = c.raceaddr()
} else {
// 分配hchan + 缓冲区
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
}
// 初始化字段
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
return c
}

三、Channel 写操作:send 逻辑

当我们执行 ch <- val 时,底层调用 runtime.chansend 函数,核心逻辑可分为 4 个分支:

分支1:Channel 已关闭 → panic

首先检查 c.closed 标记,若已关闭,直接 panic(向关闭的 channel 发送数据是非法操作)。

分支2:有等待接收的 goroutine → 直接传递数据

recvq 中有阻塞的 goroutine(即有 goroutine 正在执行 <-ch 等待数据),则跳过缓冲区,直接将数据拷贝到目标 goroutine 的栈空间,然后唤醒该 goroutine,无需入队。

分支3:缓冲区未满 → 数据入队

若缓冲区有剩余空间(qcount < dataqsiz),则将数据拷贝到 bufsendx 指向的位置,更新 sendxqcount,完成发送。

分支4:无缓冲/缓冲区满 → 当前 goroutine 阻塞

若以上条件都不满足,当前 goroutine 会被封装为 sudog 加入 sendq,并陷入休眠,直到被唤醒(有 goroutine 接收数据或 channel 关闭)。

核心逻辑简化代码:

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// 空channel直接阻塞或返回
if c == nil {
if !block {
return false
}
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw("unreachable")
}

lock(&c.lock)
// 分支1:向关闭的channel发送数据 → panic
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}

// 分支2:有等待接收的goroutine,直接传递数据
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}

// 分支3:缓冲区未满,数据入队
if c.qcount < c.dataqsiz {
// 计算数据要放入的位置
qp := chanbuf(c, c.sendx)
// 拷贝数据到缓冲区
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}

// 分支4:无法发送,非阻塞模式返回false
if !block {
unlock(&c.lock)
return false
}

// 阻塞当前goroutine,加入sendq
gp := getg()
sg := acquireSudog()
sg.g = gp
sg.elem = ep
sg.c = c
gp.waiting = sg
c.sendq.enqueue(sg)
// 休眠goroutine
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)

// 被唤醒后清理
releaseSudog(sg)
return true
}

四、Channel 读操作:recv 逻辑

执行 val := <-chval, ok := <-ch 时,底层调用 runtime.chanrecv 函数,核心逻辑与写操作对称,分为 4 个分支:

分支1:有等待发送的 goroutine → 直接接收数据

  • 无缓冲 Channel:直接从发送 goroutine 拷贝数据,唤醒发送方;
  • 有缓冲但满:从缓冲区头部取出一个元素返回,将发送方的数据放入缓冲区尾部,唤醒发送方。

分支2:缓冲区有数据 → 从缓冲区取数据

bufrecvx 指向的位置拷贝数据,更新 recvxqcount,完成接收。

分支3:Channel 已关闭且无数据 → 返回零值+false

若 Channel 已关闭且缓冲区为空,返回元素类型的零值,ok 为 false(这是读取关闭 Channel 的合法场景)。

分支4:无数据且未关闭 → 当前 goroutine 阻塞

当前 goroutine 封装为 sudog 加入 recvq,陷入休眠,直到被唤醒(有 goroutine 发送数据或 channel 关闭)。

五、Channel 关闭:close 逻辑

执行 close(ch) 时,底层调用 runtime.closechan 函数,核心逻辑:

  1. 合法性检查:空 Channel 或已关闭的 Channel 直接 panic;
  2. 标记关闭:设置 c.closed = 1
  3. 唤醒所有阻塞的 goroutine
    • 唤醒 recvq 中所有等待接收的 goroutine(它们会收到零值+false);
    • 唤醒 sendq 中所有等待发送的 goroutine(它们会 panic,因为向关闭的 Channel 发送数据非法);
  4. 清理资源:释放相关内存,保证队列空指针安全。

六、Channel 关键特性总结

  1. 类型安全elemtypeelemsize 确保只有指定类型的数据能被发送/接收,编译期+运行期双重检查;
  2. 原子性lock 互斥锁保证所有对 hchan 字段的操作都是原子的,避免并发竞态;
  3. 阻塞唤醒机制:基于 sudog 和 goroutine 调度,实现“发送-接收”的高效匹配;
  4. 关闭语义:关闭 Channel 是“广播”操作,会唤醒所有阻塞的 goroutine,且关闭后的读取不会 panic(仅返回零值)。

七、常见使用误区与源码层面解释

  1. 向关闭的 Channel 发送数据 panicchansend 函数会先检查 c.closed,非 0 则直接 panic;
  2. 关闭 nil Channel panicclosechan 第一步检查 c == nil,直接 panic;
  3. 重复关闭 Channel panicclosechan 检查 c.closed 非 0 则 panic;
  4. 无缓冲 Channel 的“同步”特性:无缓冲时 dataqsiz=0,读写操作必须配对,直接通过 goroutine 间数据拷贝完成,本质是“同步通信”。

总结

  1. Channel 的核心是 hchan 结构体,包含缓冲区、阻塞队列、互斥锁等关键字段,是其实现的基础;
  2. Channel 读写逻辑遵循“优先直接传递、其次缓冲区、最后阻塞”的原则,保证了并发通信的高效性;
  3. Channel 的关闭操作是“广播式”唤醒,需注意关闭后的读写语义,避免 panic。

理解 Channel 的源码实现,不仅能让你写出更健壮的并发代码,更能深刻体会 Go 语言“简洁而不简单”的设计哲学——看似简单的 Channel 背后,是运行时对并发安全、性能的极致优化。