前言
此篇笔记主要以自己的浅薄见解去讨论 go channel 数据结构的底层设计, 目前包括: 数据结构, makechan操作, send操作, recv操作, close操作, 并伴随着代码走读形式进行记录. 在过程中会跳过一些跟文章主题相关的关系不大代码,着重核心代码的解释, 故在后面的源码展示中出现// ... [explaination]格式的注释, 表示这里有被忽略的代码, 并可能会附上这段代码的一些解释
golang倡导 通过通信共享内存, 而不是通过共享内存进行通信.
通过共享内存进行通信 在形式上表现为两个协程通过共享一个外部变量(如全局变量)来实现信息的共享
通过通信共享内存 便是此篇笔记的主角, channel
数据结构
channel分为非缓冲channel和缓冲channel
创建如下:
// 必须要通过make创建, 使用一个nil channel 会panic
noCacheChannel := make(chan int) // 非缓冲channel
cacheChannel := make(chan int, 3) // 缓冲channel
两者区别为非缓冲channel遵守一进一出原则, 如果没有立即接收数据, sender端则会block, 反之, 没有立即发送数据, receiver端会block. 而缓冲channel则有个buffer 区域临时堆放数据, 就算receiver端没有及时接收数据, 数据会放在buffer里, sender端不会block, 直到buffer区域堆满为止, 而receiver则会不断从buffer读取数据直到buffer为空.
数据结构:
type hchan struct {
qcount uint // 当前buffer数据大小
dataqsiz uint // buffer数据容量
buf unsafe.Pointer // buffer内存指针
elemsize uint16 // 数据类型占用内存大小
closed uint32 // channel关闭状态
elemtype *_type // 数据类型
sendx uint // buffer 头指针(发送端数据从头部插入)
recvx uint // buffer 尾指针(接收端数据从尾部插入)
// 等待队列, 存储对应休眠的routine上下文, waitq 本质是一个双向链表
recvq waitq // 接收端等待队列, 先进先出原则
sendq waitq // 发送端等待队列, 先进先出原则
lock mutex // 锁
}
其中buffer本身是一个环形链表, 数据从头部进列, 尾部出列, 即发送和接收也是按照先进先出进行的, 当sendx == recvx 时, 意味着buffer当前处于是full或者empty
如下图为一个 存储了3个数据的 4-buffer channel:
makechan解读
即通过make标识符进行通道创建触发的底层代码:
func makechan(t *chantype, size int) *hchan {
elem := t.Elem
// ... 编译器检查代码
var c *hchan
switch {
case mem == 0: // mem 代表需要额外分配的buffer内存, 进入case说明是 no-buffer-channel
c = (*hchan)(mallocgc(hchanSize, nil, true)) // 仅分配 hchan自身所需要的内存
c.buf = c.raceaddr() // 没有缓冲区但为了应付竞态检测, 该值没有意义
case elem.PtrBytes == 0: // 需要额外分配的buffer没有包含指针变量时进入该case
c = (*hchan)(mallocgc(hchanSize+mem, nil, true)) // hchan和buffer内存一起分配
c.buf = add(unsafe.Pointer(c), hchanSize) // 标记对应内存空间首指针
default: // 进入此case说明buffer区域含有指针变量
c = new(hchan)
c.buf = mallocgc(mem, elem, true) // 此时buffer需要独立分配, 因为mallogc方法需要带类型, gc才会进行扫描
}
// 参数初始化
c.elemsize = uint16(elem.Size_)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)
}
return c
}
首先我们先看下定义,该方法接收一个类型(*type)和容量(size), 返回对应的实例(hchan)
而代码核心在中间switch条件判断那里:
-
第一个case说明当前是一个无缓冲channel, 故只需分配channel自身的内存即可
不过值得关注的是mallocgc的定义func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer第二个参数是类型, 如果这个参数为nil, 说明channel自身是不需要gc扫描的, 也就是说里面是没有引用变量的, 下面引用一段官方注释, 解释了这种情况:
// Hchan does not contain pointers interesting for GC when elements stored in buf do not contain pointers. // buf points into the same allocation, elemtype is persistent.也就是说只要buf中的elem不包含指针, 实际上hchan是不需要扫描的, 直接回收即可(没有引用到其他内存), 而唯一的指针*_type是随着程序的启动永久放在内存中的, 不需要gc回收.
-
第二个case是一个优化, 当buffer里面没有包含指针变量时, 只需分配一次内存即可, buffer会跟着hchan一起被回收
这里的没有包含指针变量是指elem本身不是指针或elem里没有包含指针, 如果一个结构体里有指针成员则会被识别带有指针, 这里感兴趣的可以通过debug观察hchan.elemtype.PtrBytes是不是为0来判断
- 第三个case, 当buffer包含指针变量时, 需要被gc扫描, 所以buffer需要单独分配内存, 此时需要进行两次内存分配
send 解读
有两种调用方式:
// ---one 直接使用
ch := make(chan int)
ch <- 1 // 这里接收端没有及时接收会block
// ---two 配合select使用
select {
case ch <- 1:
default:
fmt.Println("no receiver, but no block") // 这里case分支走不通时总能跑到default, 故不会block
}
核心源码:
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// -------section 1. nil channel
if c == nil {
if !block { // 如果channel未初始化, no-block mode下, 则不会panic
return false
}
// 丢出panic
gopark(nil, nil, waitReasonChanSendNilChan, traceBlockForever, 2)
throw("unreachable")
}
// ...
// -------section 2 fast path
if !block && c.closed == 0 && full(c) {
return false
} // no-block mode下, 通道已满, 返回nil
// ...
lock(&c.lock)
// -------section 3 check closed
// 如果通道已经close了, panic
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
// -------section 4 exist reciver
// 存在等待中的接收端, 将数据传递过去, 唤醒该g
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
// -------section 5 buffer is no full
// buffer有空闲的空间, 将数据放进buffer
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
}
// -------section 6 g need to block
// 跑到这里表示数据没有归处, 这里需要block了
if !block { // no-block mode 下, 直接结束
unlock(&c.lock)
return false
}
gp := getg()
mysg := acquireSudog()
// ... 将当前g和上下文封装进mysg(sudog), 包括待发送的值指针, 放在mysg.elem
c.sendq.enqueue(mysg) // 进入发送端等待队列
// 当前g 休眠
gp.parkingOnChan.Store(true)
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceBlockChanSend, 2)
// -------section 7 g wake up
// 当前g 唤醒
// ... 回收sudog
//醒来发现通道已close的处理
if closed {
if c.closed == 0 {
throw("chansend: spurious wakeup")
}
panic(plainError("send on closed channel"))
}
return true
}
老规矩, 先看定义 func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) (selected bool), 这里参数依次为 channel自身(c), 需传递的值(cp), 是否为no-block mode(block), 调用者的pc指针(callerpc), callerpc跟主流程关系不大, 不做讨论.
返回值适用于select情景, 表示该case分支是否block
定义这边需要着重探讨的是参数block, 如果为false, 表示在一些情况下, 将不会panic/block, 而是直接结束流程, 我这里把这种场景称为no-block mode
那什么时候需要no-block mode, 什么时候不需要呢? 这个问题就需要看其入口了:
- 类似
c<-x用法时, block->true
// entry point for c <- x from compiled code.
//
//go:nosplit
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
- 通过配合select使用时, block -> false, 此时是否block将由select进行控制
// compiler implements
//
// select {
// case c <- v:
// ... foo
// default:
// ... bar
// }
//
// as
//
// if selectnbsend(c, v) {
// ... foo
// } else {
// ... bar
// }
func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) {
return chansend(c, elem, false, getcallerpc())
}
接下来是核心源码的讨论, 这里我们按照从上到下划分的区域进行讨论:
- section 1, channel未初始化, no-block mode: 直接返回, 否则, panic. 这里引出了channel的一个特性, 往nil channel 写入会panic, 而在select-case中则不会, 所处的case将不会执行
- section 2, 这里是一个no-block mode 场景下的快速判断, 在no-block mode下, 如果通道没有接收端且buffer已满, 则直接结束. 因为后面的逻辑要加锁, 所以这里其实是一个优化
func full(c *hchan) bool {
if c.dataqsiz == 0 {
return c.recvq.first == nil // 没有等待中的接收端
}
return c.qcount == c.dataqsiz // full buffer
}
- section 3, 往一个已关闭的channel写数据, panic
- section 4, 如果有等待中的接收端, 唤醒, 并将值传递给接收端, success, 这里sg(sudog)是g的一个封装, 带着上下文信息
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
// ...
if sg.elem != nil { // 有可能接收端没有接收方, 如: <-ch
sendDirect(c.elemtype, sg, ep) //直接将值拷贝给接收方
sg.elem = nil
}
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
// sg 上下文更新
sg.success = true
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
goready(gp, skip+1) // 唤醒 g
}
func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) {
dst := sg.elem
// 这里是直接进行两个g之间的栈拷贝, 需要对对应的接收方(dst)进行写屏障处理, 不然gc不会扫描dst
typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.Size_)
memmove(dst, src, t.Size_) // 内存拷贝
}
- section 5, 有空闲的buffer, 将值暂时放到buffer, success
func chanbuf(c *hchan, i uint) unsafe.Pointer {
return add(c.buf, uintptr(i)*uintptr(c.elemsize)) // 以随机地址的方式获取第i元素的地址
}
- section 6, 数据无处可去 no-block mode下, 这里直接结束, 否则, 将当前g封装, 放入发送端等待队列并休眠, 直到被接收端唤醒
- section 7, 被唤醒, 回收sudog, 如果此时发现channel已经close, 表示是因为close操作导致的唤醒, 直接panic, 否则, 便是被接收端唤醒, success
recv解读
有以下几种调用方式:
ch := make(chan int)
// 如果没有发送端, 会block
v := <-ch // 有接收值
<-ch // 无接收值
v, ok := <-ch // 不会block, 如果通道close, 返回 nil, false, 不会panic
// 配合select使用
select {
case <-ch:
case v = <-ch:
case v, ok = <-ch:
default:
fmt.Println("no sender, but no block") // 这里case分支走不通时总能跑到default, 故不会block
核心源码:
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// ...
// section 1: nil channel
if c == nil {
if !block {
return
}
gopark(nil, nil, waitReasonChanReceiveNilChan, traceBlockForever, 2)
throw("unreachable")
}
// section 2: fast path
if !block && empty(c) {
if atomic.Load(&c.closed) == 0 {
return
}
if empty(c) {
// ...
if ep != nil {
typedmemclr(c.elemtype, ep) // 将ep置为零值
}
return true, false
}
}
// ...
lock(&c.lock)
if c.closed != 0 {
// section 3: channel closed and no unhandled data
if c.qcount == 0 {
// ...
unlock(&c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep) // 将ep置为零值
}
return true, false
}
} else {
// section 4: exist waiting sender
if sg := c.sendq.dequeue(); sg != nil {
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
}
// section 5: exist buffer data
if c.qcount > 0 {
qp := chanbuf(c, c.recvx)
// ...
if ep != nil { // // 有可能接收端没有接收方, 如: <-ch
typedmemmove(c.elemtype, ep, qp) // 值拷贝
}
typedmemclr(c.elemtype, qp) // ? 这里不知道为什么要多出这一步
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
unlock(&c.lock)
return true, true
}
// section 6: asleep
if !block {
unlock(&c.lock)
return false, false
}
// 没有数据且通道活跃, 此时休眠
gp := getg()
mysg := acquireSudog()
// ... 将当前g和上下文封装进mysg(sudog), 包括待接收的值指针, 放在mysg.elem
c.recvq.enqueue(mysg) // 接收端等待队列入队
gp.parkingOnChan.Store(true)
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceBlockChanRecv, 2) // 休眠
// section 7: wakeup
// ... check & 回收sudog
return true, success
}
定义: func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool), 基本跟chansend大差不差, 不过多赘述, 明显的区别是返回多了个received, 这里指明了接收端的另一种语法:
v, ok := <-ch // ok 即是返回值received
接下来跟着核心源码讨论流程:
-
section 1: nil channel 场景, 往nil channel读数据会panic, no-block mode下, selected 返回false
-
section 2: no-block mode 的fastpath 场景, 这里可能有疑惑的点是三次判断:
isEmpty -> isClose -> isEmpty
如果反过来这样判断:isClose -> isEmpty, 在判断close时, channel是open, 而在判断empty时, channel是close,此时程序认为channel状态为open, empty, 而实际上应该是close, empty
而按照源码的顺序的话, 在第二次判断时, 如果channel是open, 那就代表在第一次判断中channel应该是empty, open 状态, 而第三次判断时为了处理channel关闭时, 是否还有数据的情况. 而如果系统认为是非empty, 而实际上是empty的情况, 在后面的代码也有做二次处理. -
section 3: 再次检查channel状态是否close, empty, 是直接结束
-
section 4: 是否有休眠中的发送端, 有的话唤醒, 如果此时存在buffer, buffer一定是full, 此时优先从buffer取值, 并将唤醒的发送端的值入列buffer, 如果是unbuffered channel, 则直接从发送端取值
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if c.dataqsiz == 0 { // buffer没有数据再从等待中的发送端获取
// ...
if ep != nil { // 有可能接收端没有接收方, 如: <-ch
recvDirect(c.elemtype, sg, ep) // 直接从发送端获取
}
} else { // buffer 存在数据优先从buffer获取
qp := chanbuf(c, c.recvx) // 从buffer中取出一个值
// ...
if ep != nil { // 有可能接收端没有接收方, 如: <-ch
typedmemmove(c.elemtype, ep, qp) // 值拷贝
}
// 将发送端的值拷贝到buffer
typedmemmove(c.elemtype, qp, sg.elem)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.sendx = c.recvx // 当queue为full时, sendx == recvx
}
//更新sg上下文并唤醒
sg.elem = nil
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
sg.success = true
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
goready(gp, skip+1) // 唤醒
}
func recvDirect(t *_type, sg *sudog, dst unsafe.Pointer) {
src := sg.elem
// 写屏障处理
typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.Size_)
memmove(dst, src, t.Size_) // 值拷贝
}
-
section 5: buffer有值, 从buffer获取
-
section 6, 没有数据 no-block mode下, 这里直接结束, 否则, 将当前g封装, 放入接收端等待队列并休眠, 直到被发送端唤醒
-
section 7, 被唤醒, 回收sudog, success
close 解读
通过close关键字关闭channel触发:
close(ch)
channel close后, 所有等待中的发送端Panic, 所有等待中的接收端收到零值
核心源码:
func closechan(c *hchan) {
if c == nil { // close未初始化的channel, panic
panic(plainError("close of nil channel"))
}
lock(&c.lock)
if c.closed != 0 { // close已经关闭的channel, panic
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
// ...
c.closed = 1
var glist gList
// 预处理所有的接收端
// release all readers
for {
sg := c.recvq.dequeue()
if sg == nil {
break
}
if sg.elem != nil {
typedmemclr(c.elemtype, sg.elem) // 所有接收方值置0
sg.elem = nil
}
// ...
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
// ...
glist.push(gp)
}
// 预处理所有的发送端
// release all writers (they will panic)
for {
sg := c.sendq.dequeue()
if sg == nil {
break
}
sg.elem = nil
//...
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
// ...
glist.push(gp)
}
unlock(&c.lock)
// 唤醒所有的发送端和接收端
// Ready all Gs now that we've dropped the channel lock.
for !glist.empty() {
gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
}
这里源码相对比较简单,
首先检查, 如果关闭一个未初始化或者已关闭的channel, 将会panic; 预处理所有等待中的发送端和接收端, 然后进行唤醒
如果发送端因此被唤醒, 将会panic, 这部分逻辑写在chanshend里
后记
该笔记有些代码涉及到竞态检测(race), 垃圾回收, 协程模型, 内存模型等相关的内容, 这部分内容未来可能会在本账号的其他文章有所体现, 也可能不会.
初稿编辑于 2026.3.2