GO 运行时内部原理
Go channel 内部原理
channel 常被简单地理解成"线程安全的队列",但它实际管理的是三样东西:一段环形缓冲区、两条等待队列(卡住的发送者/接收者),以及一套"能直接交接就不进缓冲区"的优化路径。这篇文章配了一个和真实 go run 输出逐项比对过的调度模拟场景,专门还原一个大多数人不知道的细节:缓冲区满了、又有发送者在排队时,一次接收到底做了几件事。
01 · channel 不只是一个队列
三样东西:缓冲区、等待队列、直接交接
一个 chan T 在运行时对应一个 hchan:
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
timer *timer // timer feeding this chan
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
bubble *synctestBubble
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
type waitq struct {
first *sudog
last *sudog
}
buf/dataqsiz/sendx/recvx/qcount 这几个字段就是一个标准的环形缓冲区(circular buffer):make(chan T, n) 里的 n 就是 dataqsiz,无缓冲 channel 的 dataqsiz 就是 0。sendq/recvq 是两条等待队列,挂着因为"缓冲区帮不上忙"而被挂起的 goroutine(用 sudog 包一层)。整个结构体靠一把 mutex 保护——channel 操作在底层其实都是"加锁、判断、可能唤醒别人、解锁"。
02 · 三个核心概念
缓冲区 / 等待队列 / 直接交接
发送时,只要 qcount < dataqsiz 就直接把值拷进 buf[sendx],不涉及任何 goroutine 调度——这是最快的路径,和普通队列没什么区别。
缓冲区满了(发送)或空了(接收)、又没有直接交接的机会时,goroutine 把自己包成一个 sudog 挂到 sendq/recvq,然后真正休眠,把 CPU 让给别的 goroutine。
发送时如果 recvq 里已经有人在等,值会被直接拷贝到那个接收者的内存里,完全跳过缓冲区——哪怕这个 channel 是有缓冲的。这是无缓冲 channel"握手"语义的根基,也是整个设计里最容易被忽略的优化。
03 · 现场直播
缓冲区满了、又有人排队发送时,接收做了什么
下面这段场景先用 go run(配合真实的两个 goroutine 和 -race 检测)跑出真实结果,再逐项比对生成的轨迹:一个容量为 2 的 channel,连续发送三次、接收三次、关闭、再收两次、最后再发一次。
04 · 发送时的优先级
chansend 先问"有没有人在等"
抛开非阻塞快速路径和各种检测代码,chansend 的核心判断顺序是:channel 关了就 panic;recvq 里有等待的接收者就直接交给它(跳过缓冲区);缓冲区还有空位就塞进去;都不行、又允许阻塞,才真的把自己挂起来:
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
if sg := c.recvq.dequeue(); sg != nil {
// Found a waiting receiver. We pass the value we want to send
// directly to the receiver, bypassing the channel buffer (if any).
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
if c.qcount < c.dataqsiz {
// Space is available in the channel buffer. Enqueue the element to send.
qp := chanbuf(c, c.sendx)
if raceenabled {
racenotify(c, c.sendx, nil)
}
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}
if !block {
unlock(&c.lock)
return false
}
注意"有等待的接收者"这个判断排在"缓冲区还有空位"前面——即使是有缓冲的 channel,只要恰好有人已经在 recvq 里等着,新的发送也会跳过缓冲区,直接交给那个接收者。这不只是抄近路,更是为了保证 FIFO 语义:如果 A 先在等,B 后发送的值理应先给 A,而不是排到缓冲区队尾。
05 · 无缓冲 channel 的"握手"
一个 goroutine 直接写进另一个的栈
"直接交给它"具体是怎么交的?sendDirect 会把值直接 memmove 到接收者那个 goroutine 的栈内存里——这也是 Go 运行时里少数几个"一个正在运行的 goroutine 会直接写另一个 goroutine 栈"的地方,需要特殊的写屏障处理配合 GC:
// Sends and receives on unbuffered or empty-buffered channels are the
// only operations where one running goroutine writes to the stack of
// another running goroutine. The GC assumes that stack writes only
// happen when the goroutine is running and are only done by that
// goroutine. Using a write barrier is sufficient to make up for
// violating that assumption, but the write barrier has to work.
// typedmemmove will call bulkBarrierPreWrite, but the target bytes
// are not in the heap, so that will not help. We arrange to call
// memmove and typeBitsBulkBarrier instead.
func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) {
// src is on our stack, dst is a slot on another stack.
// Once we read sg.elem out of sg, it will no longer
// be updated if the destination's stack gets copied (shrunk).
// So make sure that no preemption points can happen between read & use.
dst := sg.elem.get()
typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.Size_)
// No need for cgo write barrier checks because dst is always
// Go memory.
memmove(dst, src, t.Size_)
}
func recvDirect(t *_type, sg *sudog, dst unsafe.Pointer) {
// dst is on our stack or the heap, src is on another stack.
// The channel is locked, so src will not move during this
// operation.
src := sg.elem.get()
typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.Size_)
memmove(dst, src, t.Size_)
}
这正是无缓冲 channel 常被称为"同步/握手"的原因:发送方和接收方必须同时到场,数据才能从一个栈"跳"到另一个栈,中间没有任何缓冲区可以让二者错开时间。模拟器场景二演示了这个过程——发送方先到,自己挂起等待;接收方一到,值被直接搬走,发送方立刻被唤醒继续执行,缓冲区自始至终没有被用到。
06 · 一个鲜为人知的细节
缓冲区满了、又有人排队发送时,接收会做两件事
这是整篇文章里最值得记住的一段。设想缓冲区已经满了,还有一个发送者因此被挂在 sendq 里排队。这时候来一次接收,会发生什么?直觉上大概是"从缓冲区拿一个,然后单独去唤醒那个排队的发送者,让它下次自己把值塞进缓冲区"——但真实实现更聪明:
lock(&c.lock)
if c.closed != 0 {
if c.qcount == 0 {
if raceenabled {
raceacquire(c.raceaddr())
}
unlock(&c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
// The channel has been closed, but the channel's buffer have data.
} else {
// Just found waiting sender with not closed.
if sg := c.sendq.dequeue(); sg != nil {
// Found a waiting sender. If buffer is size 0, receive value
// directly from sender. Otherwise, receive from head of queue
// and add sender's value to the tail of the queue (both map to
// the same buffer slot because the queue is full).
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
}
if c.qcount > 0 {
// Receive directly from queue
qp := chanbuf(c, c.recvx)
if raceenabled {
racenotify(c, c.recvx, nil)
}
if ep != nil {
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
}
关键在那句注释:"receive from head of queue and add sender's value to the tail of the queue"。接收操作会:①从缓冲区头部(recvx)取走一个值给自己;②把排队发送者手里那个值,直接搬进缓冲区刚空出来的那个槽位(也就是新的队尾);③唤醒那个发送者,告诉它"你的值已经放进去了,可以走了"。全程只有一次数据搬动被"存"进了缓冲区,发送者不需要真的醒来再抢锁塞值——这就是模拟器场景一里 G2 接收时,G1 立刻被唤醒、且它的值(3)紧接着出现在缓冲区里的原因。
07 · close 做了什么
唤醒所有人,但唤醒的方式不一样
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
if raceenabled {
callerpc := sys.GetCallerPC()
racewritepc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(closechan))
racerelease(c.raceaddr())
}
c.closed = 1
var glist gList
// release all readers
for {
sg := c.recvq.dequeue()
if sg == nil {
break
}
if sg.elem.get() != nil {
typedmemclr(c.elemtype, sg.elem.get())
sg.elem.set(nil)
}
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
if raceenabled {
raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}
// release all writers (they will panic)
for {
sg := c.sendq.dequeue()
if sg == nil {
break
}
sg.elem.set(nil)
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
if raceenabled {
raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}
两条等待队列都会被清空、对应的 goroutine 都会被唤醒,但唤醒之后的命运不同:recvq 里的接收者会拿到零值(sg.elem 被清空)和 ok=false;sendq 里的发送者被唤醒后会检查 sg.success,发现是 false,直接 panic("send on closed channel")——close 唤醒了它们,不是为了让发送成功,而是为了让它们"体面地"崩溃,而不是永远卡在那里。缓冲区里已经存在的数据不会被清空,后续的接收还能正常读到,直到缓冲区变空才会开始返回零值——这正是模拟器场景一里 close 之后 G4 还能收到 3、G5 才收到零值的原因。
08 · select 怎么保证公平
先打乱顺序,再挨个问一遍
当 select 里有多个 case 同时就绪,Go 不会按代码里写的顺序挑,也不会有任何优先级——它会先把所有 case 的检查顺序随机打乱:
// generate permuted order
norder := 0
allSynctest := true
for i := range scases {
cas := &scases[i]
// Omit cases without channels from the poll and lock orders.
if cas.c == nil {
cas.elem = nil // allow GC
continue
}
if cas.c.bubble != nil {
if getg().bubble != cas.c.bubble {
fatal("select on synctest channel from outside bubble")
}
} else {
allSynctest = false
}
if cas.c.timer != nil {
cas.c.timer.maybeRunChan(cas.c)
}
j := cheaprandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)
norder++
}
pollorder = pollorder[:norder]
然后严格按这个打乱后的顺序(pollorder)挨个检查,第一个发现已经就绪的 case 获胜:
var casi int
var cas *scase
var caseSuccess bool
var caseReleaseTime int64 = -1
var recvOK bool
for _, casei := range pollorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
if casi >= nsends {
sg = c.sendq.dequeue()
if sg != nil {
goto recv
}
if c.qcount > 0 {
goto bufrecv
}
if c.closed != 0 {
goto rclose
}
} else {
if raceenabled {
racereadpc(c.raceaddr(), casePC(casi), chansendpc)
}
if c.closed != 0 {
goto sclose
}
sg = c.recvq.dequeue()
if sg != nil {
goto send
}
if c.qcount < c.dataqsiz {
goto bufsend
}
}
}
实测验证一下:两个已经就绪的 channel 各跑一万次 select,统计各自被选中的次数(go run,Go 1.26):
countA: 4984 countB: 5016
接近 50/50,符合"公平随机挑一个"的说法。这也是为什么不能指望 select 在多个 case 同时就绪时有稳定的优先级——想要优先级,得自己在外面用嵌套 select 或者显式的信号量来实现。
09 · 三个经典的坑
顺手提三个常见问题
- 对
nilchannel 发送或接收会永远阻塞,而不是 panic。chansend/chanrecv一开头就检查c == nil,直接gopark让这个 goroutine 永久休眠——常见于结构体里的 channel 字段忘了初始化,代码能编译、能跑,但相关的 goroutine 悄无声息地永远卡住,连报错都没有。 - 关闭一个已经关闭的 channel 会 panic,给已关闭的 channel 发送也会 panic,但从已关闭的 channel 接收永远不会 panic。 接收是"安全"的那一侧:缓冲区有数据先给数据,没数据就给零值 +
ok=false——这也是为什么for v := range ch能在 channel 被关闭且清空后自动、干净地退出循环,不需要额外判断。 - 该由谁来关闭 channel? 只有发送方应该关闭 channel,而且要确保没有其它 goroutine 还在往里发送——多个发送者共用同一个 channel 时,"谁关闭"要么提前约定好只由一方负责,要么额外用一个协调机制(比如
sync.Once),否则就会撞上"关闭已关闭的 channel"或者"向已关闭的 channel 发送"这两个 panic 中的一个。
10 · 参考与说明
引用与这个模拟器做了哪些简化
- 本文所有源码引用均来自 golang/go,commit
72aa6db7943024b48c4d41c1fbc32b57b9fa036e,文件src/runtime/chan.go与src/runtime/select.go,遵循 Go 项目的 BSD-3-Clause 协议(© The Go Authors)。每段引用都标了具体文件、行号,并附了指向该 commit 的永久链接。 - 模拟器场景、以及第 08 节的一万次 select 统计,都是真实跑过的(
go run,部分加了-race,Go 1.26)——第 06 节那个"接收顺带帮排队发送者腾位置"的行为,专门用两个真实 goroutine + 显式同步(而不是猜测)复现并核对过。 - 模拟器省略了
race/msan/asan检测分支、timerchannel 的特殊路径,以及synctestbubble 相关的检查——它们都不影响缓冲区/等待队列/直接交接这条主线逻辑。 sudog(挂在等待队列里、代表一个等待中 goroutine 的结构体)本身没有单独展开,模拟器里直接用一个"goroutine 名字 + 待发送的值"简化表示。