GO 运行时内部原理

Go channel 内部原理

channel 常被简单地理解成"线程安全的队列",但它实际管理的是三样东西:一段环形缓冲区、两条等待队列(卡住的发送者/接收者),以及一套"能直接交接就不进缓冲区"的优化路径。这篇文章配了一个和真实 go run 输出逐项比对过的调度模拟场景,专门还原一个大多数人不知道的细节:缓冲区满了、又有发送者在排队时,一次接收到底做了几件事。

· 引用 Go 源码 commit 72aa6db7,文件 src/runtime/chan.goselect.go · 源码遵循 BSD-3-Clause 协议,版权归 The Go Authors 所有,下文均为简短引用并附原文链接
缓冲区 · 一段环形数组,发送先攒在这里 等待队列 · 缓冲区帮不上忙时,goroutine 在这里排队 直接交接 · 有对家在等,直接跳过缓冲区

01 · channel 不只是一个队列

三样东西:缓冲区、等待队列、直接交接

一个 chan T 在运行时对应一个 hchan:

chan.go · L34-L60在 GitHub 上查看 ↗
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,连续发送三次、接收三次、关闭、再收两次、最后再发一次。

🔒 channel 已关闭
缓冲区 buf
sendq(等待发送)
recvq(等待接收)
点击"下一步"或"播放"开始。
0 / 0
缓冲区里已占用的格子 排队等待的 goroutine 这一步刚发生变化的格子

04 · 发送时的优先级

chansend 先问"有没有人在等"

抛开非阻塞快速路径和各种检测代码,chansend 的核心判断顺序是:channel 关了就 panic;recvq 里有等待的接收者就直接交给它(跳过缓冲区);缓冲区还有空位就塞进去;都不行、又允许阻塞,才真的把自己挂起来:

chan.go · L214-L247(chansend 节选)在 GitHub 上查看 ↗
	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:

chan.go · L374-L404在 GitHub 上查看 ↗
// 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 里排队。这时候来一次接收,会发生什么?直觉上大概是"从缓冲区拿一个,然后单独去唤醒那个排队的发送者,让它下次自己把值塞进缓冲区"——但真实实现更聪明:

chan.go · L580-L621(chanrecv 节选)在 GitHub 上查看 ↗
	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 做了什么

唤醒所有人,但唤醒的方式不一样

chan.go · L414-L469(closechan 节选)在 GitHub 上查看 ↗
	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 的检查顺序随机打乱:

select.go · L167-L196(生成随机顺序)在 GitHub 上查看 ↗
	// 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 获胜:

select.go · L265-L301(按随机顺序轮询)在 GitHub 上查看 ↗
	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):

go run 真实输出已验证
countA: 4984 countB: 5016

接近 50/50,符合"公平随机挑一个"的说法。这也是为什么不能指望 select 在多个 case 同时就绪时有稳定的优先级——想要优先级,得自己在外面用嵌套 select 或者显式的信号量来实现。

09 · 三个经典的坑

顺手提三个常见问题

10 · 参考与说明

引用与这个模拟器做了哪些简化

☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电