2026-10-31·by Sijie Wang

Channel

Channel:用通信共享内存

动机:不要用共享内存通信,要用通信共享内存。 互斥锁守着一个共享变量,但两个 goroutine 还是得约好是哪个变量、哪把锁、谁什么时候碰。channel 把这份约定收进一个对象:一边发,一边收,交接本身就是同步。通信不是共享状态之上的附加物,它取代共享状态。

数据库类比,以及它在哪里断掉。 channel 很像数据库里的一张消息队列表:生产者插行,消费者按顺序读。两处差别要紧。第一,channel 不落盘,程序一退,队里剩的东西就没了。第二,无缓冲 channel 根本没有数据库等价物:没有表、没有行,只有面对面的交接,发送方等到接收方来,值直接从一方内存拷到另一方。

无缓冲:纯交接。 make(chan T) 容量是 0,没有缓冲区。发送方带着值睡进等待队列,接收方来了,直接从发送方内存里把值拷走。发送在拷贝加唤醒入队的那一刻就完成了,接收方那时未必已经跑起来。等待队列里排的是 goroutine,不是值。

有缓冲:有界队列。 make(chan T, N) 有 N 个格子。发送在值拷进空格子的那一刻完成,接收在值被取走的那一刻完成。满了发送方停,空了接收方停。容量在创建时定死,之后永远不长:跟 slice 不一样,slice 追加满了会翻倍重建,channel 的缓冲区没有扩容这条路。满了就反压,不扩容,反压本身就是目的。

值语义,以及引用类型的危险。 channel 拷的是值。值类型过去,接收方拿到的是独立的副本。引用类型(slice、map、指针)过去,拷的只是引用,底层数据还是共享的。发送方发完再碰发过去的 slice 或 map,就是 data race,map 并发写更是直接 fatal。“发送方发完就不再碰”只是约定,不是机制强制的,真正检查的是 race detector,不是 channel。

一把锁,只持几十纳秒。 每个 channel 共用一把互斥锁,守着缓冲区和等待队列。听起来粗,但临界区极小:锁、指针算术、一次值拷贝、一次唤醒入队,里面没有系统调用、没有 I/O、没有睡眠,所以锁只持几十纳秒。对比数据库行锁,动不动跨磁盘 I/O,微秒到毫秒。正是这个纳秒量级,一把锁才够用。

close:生产者的活,而且是广播。 只有发送方关,接收方不关,解铃还须系铃人。close 是“没有更多数据了”的广播,不是删除:已经缓冲的值还会按顺序流干,关了又取空的接收会拿到零值加 ok 为 false。两处 panic 是故意设的硬 guard:关了再发 panic,关两次也 panic。range 只有在关加流干时才结束,不关就永远等。close 也不负责释放内存,那是 GC 的活。

panic 不是 error。 panic 是编程 bug 引发的崩溃,带栈信息,整进程死掉,除非 deferred 函数里的 recover 接住。error 是预料之中、可以处理的失败。正常代码不能拿 panic 当 error 用;上面 close 的两处 panic 是针对违约的 fail-fast,不是 error 值。

select:同时站在几扇门前。 普通接收一次只等一个 channel,但一个 goroutine 常常同时等好几件事:活儿、超时、关闭信号。select 一次等多个 channel 操作,哪个先能走就走哪个。多个同时能走,随机挑,不按书写顺序,这样才不会有一路永远抢不到。写了 default 就是非阻塞:试一次,当时哪个都走不通就立刻走 default。对 nil channel 的 case 永远不会就绪,所以把一个 channel 变量置成 nil,就等于临时关掉这个 case,不用改结构。

done:不传数据的 channel。 done 不是关键字,也不是特殊类型,就是一个普通 channel,惯例写成 chan struct{},空结构体占 0 字节,因为里面永远不发值。唯一的动作就是 close(done),而关是广播:所有在等 <-done 的 goroutine 同时被叫醒。发一个值只能叫醒一个,广播不了,所以关闭、不是发送,才是停的信号。标准库 context.Context 的 Done() 就是这个模式的官方版。

单向 channel:一扇门,两张通行证。 chan T 是双向,谁拿到都能发能收。chan<- T 只发,<-chan T 只收。底下还是同一个 channel,变的是手里通行证写了什么,编译器按通行证卡。双向传给单向参数会自动收窄,但单向永远变不回双向。只发的这边还能关,只收的这边不能关。价值两点:签名上就写明谁发谁收;编译器提前拦住发错收错,不用等到运行时死锁或者 panic 才发现。

死锁:全睡着。泄漏:悄悄活着。 死锁是所有 goroutine 都睡着了,没人能叫醒别人:无缓冲没人接、两个人互相等。runtime 只有在全睡着时才报得出来,只要还有一个 goroutine 能跑,程序就悄悄挂在那儿,不报错也不动。泄漏是反过来的形状:goroutine 没退出路径,一直卡在一个永远没人发也没人关的 channel 上。程序还活着,但睡着的栈和它引用的内存越攒越多。runtime 从来不报泄漏,查法是 pprof 的 goroutine profile,或者测试里的 goleak:测试结束时抓一张所有 goroutine 的栈快照,划掉 runtime 自己的和白名单里的,剩下没走的就是漏的。防的办法是设计,不是机制:每个 go 起来的地方必须一眼看得到退出路径(done、close、超时),谁开谁负责停。

hchan:channel 本体。 make 分配的就是一个 runtime 结构体 hchan,你手里的 channel 变量是指向它的指针。前面所有行为,都是这个结构体里的字段在动:

发送先抢锁,有等收的人就直接交过去,缓冲区有空位就写进 sendx 那格,都没有就自己睡进 sendq。接收是镜像。关就是把 closed 置 1,再把两个队列里的人全叫醒。结构体长的字段正好就是行为需要的,不多不少。

环形数组:有限内存,无限数据流。 缓冲区是一整块连续的固定数组,不是链表:链表每个元素单独分配节点、带指针,内存散、GC 压力大、缓存也不友好。所谓“环”只是下标算术,不是 Go 数组的属性:Go 的数组默认是直的,越界就 panic。hchan 管两个指针,走到头就拨回 0,指针转圈,数据不动。qcount 另记着里面有几个元素,因为光看指针重合分不清是空还是满。队列在空间上有界(同一时刻最多 N 个在排队),在时间上无界(格子循环复用,收发可以永远进行下去)。这就是环形的好处:用有限的内存,接无限的数据流。

等待队列没有上限,但两个不会同时堆。 sendq 和 recvq 都是链表,不设 cap。能这么说是因为一个不变式:同一时刻最多一个队列在堆,因为有接收者在等,发送者来了就直接交接,不进队。堆起来的代价是 goroutine 本身,每个都占着栈,这正是泄漏的形状。拦住它的是你的设计(池子大小、done channel、信号量),不是 hchan。

四个模式,各有场景。 这些都是跨语言的通用模式,不是 Go 的发明,Go 的特别之处在于一个原语把四个都搭得很顺手。

  • worker pool。 场景:任务一波波来,每件耗时不短,来一件起一个 goroutine 会把下游打爆。形状:N 个固定 worker 循环从 jobs channel 取活,main 发完任务关 jobs,worker 取空退出。并发数就是 worker 数。
  • fan-out / fan-in。 场景:同时问几个后端拼一个页面,或者并行跑一批评测。形状:fan-out 把一个输入分给多个 worker 一起干;fan-in 是多个 worker 往同一个结果 channel 发,接收方汇总。结果 channel 必须等所有 worker 干完再关,不然接收方提前收尾。
  • pipeline。 场景:日志处理、ETL,数据过读、解析、处理、写出好几道,每道耗时不一样,串行太慢。形状:每一道是一个 stage(goroutine 加 channel),前一道的输出就是后一道的输入,各道并行流动。每道干完关自己的输出 channel,下游 range 自然结束;哪道慢了,channel 一满就反压到上游,不会无限堆。
  • 有缓冲 channel 当信号量。 场景:同时最多 10 个请求打到脆弱的下游,但任务本身不用常驻 worker。形状:sem := make(chan struct{}, 10),干活前往里塞一个占位(满了就等),干完取出来放位。卡的就是“同时在干的数”。

生产定位:普通 web 服务不手搓。 日常 CRUD 的请求处理层,手写 channel 就是重写框架:HTTP server 本来就一个请求一个 goroutine,中间件、超时、限流都配好了。channel 活在框架内部,以及几个关键处:聚合、限流、关闭、超时,还有 SSE/WebSocket 的 hub,一个中心 goroutine 加每个客户端一个 channel,这就是标准写法。errgroup 就是框架化了的 fan-out,带限流和错误收集,配好上限让它跑。学 channel 是为了看懂框架在干什么、出了问题定位到哪一层,不是每个项目重写一套。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →