2026-10-31·von 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 是为了看懂框架在干什么、出了问题定位到哪一层,不是每个项目重写一套。

über diesen eintrag

Einer von sijie's Wiki-Einträgen. Die KI auf dieser Site ist im selben Corpus verankert und antwortet in sijie's Stimme mit Zitaten zurück auf Einträge wie diesen — das Beantworten kostet sijie Geld, daher wartet sie hinter einem Code: zugriffscode eingeben →

Channel — Sijie Wang