Go 基础体系 · 第 18/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go channel 完整基础:发送、接收、缓冲、关闭与所有权

本文所有语义与示例均以 Go 1.26.4 为基准。channel 是 Go 用来在 goroutine 之间传递值并建立同步关系的类型化通信原语。它可以承载任务、结果、事件和生命周期信号,但不是“线程安全队列”的简单别名,也不能自动消除经由指针、slice 或 map 产生的共享内存竞争。

真正完整的 channel 设计必须回答六个问题:谁创建、谁发送、谁接收、谁关闭、阻塞怎样被取消、错误怎样返回。只会写 ch <- value<-ch,还不足以保证并发程序能够结束、不会泄漏并且在过载时行为可控。

1. Channel 类型、方向、创建与零值

chan T 表示可发送也可接收的 channel,chan<- T 只能发送,<-chan T 只能接收。方向是编译期能力约束:双向 channel 可以隐式转换为单向 channel,单向 channel 不能反向恢复成双向类型。

func produce(out chan<- int) {
    out <- 42
}

func consume(in <-chan int) int {
    return <-in
}

func main() {
    values := make(chan int)
    go produce(values)
    fmt.Println(consume(values))
}

make(chan T) 创建无缓冲 channel;make(chan T, n) 创建容量为 n 的缓冲 channel。容量不能为负,运行时会为 channel 本身以及可能存在的缓冲区分配空间。

channel 变量保存的是对运行时 channel 对象的引用。把 channel 赋值给另一个变量或作为参数传递,只复制引用,不会复制缓冲区。channel 可与 nil 以及相同元素类型的 channel 比较;只有引用同一个运行时对象时才相等。

channel 的零值是 nil。对 nil channel 发送或接收会永久阻塞,关闭 nil channel 会 panic。它与“已创建但没有数据”的空 channel 不是同一状态。

var missing chan int
fmt.Println(missing == nil) // true

ready := make(chan int)
fmt.Println(ready == nil)   // false

len(ch) 返回采样瞬间缓冲区中的元素数,cap(ch) 返回固定容量。另一个 goroutine 可以在下一纳秒改变长度,因此不能写成“先判断 len,再决定是否发送或接收”的正确性协议。len 适合观测,不适合加锁式判断。

2. 运行时模型与数据流:缓冲区、发送等待队列和接收等待队列

从语义上理解 channel 时,可以把一个非 nil channel 看成四部分:元素类型信息、可选的环形缓冲区、等待发送的 goroutine 队列、等待接收的 goroutine 队列。运行时实现通常用 hchan 一类内部结构维护这些状态,并用锁保护结构本身;这些实现细节不是公开 API,业务代码不能依赖字段布局或等待队列的具体策略。

发送发生时,运行时大致按以下顺序寻找完成条件:

  1. 如果已有接收者等待,值直接交给该接收者;
  2. 否则如果缓冲区还有空位,把值复制进缓冲区;
  3. 否则把当前 goroutine 挂入发送等待队列并让出执行权。

接收则是对称流程:

  1. 如果已有发送者等待,完成一次交接;
  2. 否则如果缓冲区非空,取出队头元素;
  3. 否则在 channel 尚未关闭时挂入接收等待队列;
  4. 若 channel 已关闭且缓冲耗尽,立即返回零值与 ok=false

“阻塞”不等于占住操作系统线程空转。goroutine 会进入等待态,调度器可让其他 goroutine 继续运行。不过等待中的 goroutine 仍保留栈、引用和调度元数据;如果协议永远不再满足,它就是泄漏。

3. 发送语义:先求值,再等待通信完成

发送语句 ch <- expression 会先求值 channel 操作数和右侧表达式,然后尝试通信。如果通信暂时不能完成,已经求出的值会随等待中的发送操作保留。

func build() int {
    fmt.Println("build once")
    return 7
}

values := make(chan int)
go func() {
    time.Sleep(10 * time.Millisecond)
    fmt.Println(<-values)
}()
values <- build()

一次发送会复制 channel 元素值。发送整数、结构体时复制完整值;发送指针时复制地址;发送 slice、map、函数或接口时复制描述符。复制描述符不等于复制底层数据,所以“通过 channel 发送过”不能证明底层对象可被两端并发修改。

发送到已关闭 channel 会 panic,关闭状态无法通过一个无竞态的 isClosed 检查提前规避。即使某个检查函数在此刻返回未关闭,另一个 goroutine 也可能紧接着关闭。因此正确方案是设计唯一且可证明的关闭所有者,而不是检查后发送或用 recover 掩盖协议错误。

4. 接收语义:三种写法和 comma-ok

接收表达式可以丢弃结果、接收一个值,或同时接收值和状态:

<-events
value := <-values
value, ok := <-values

ok=true 表示收到的是某次发送产生的值;ok=false 表示 channel 已关闭且缓冲数据已经取完。元素零值可以是合法数据,所以不能只看 value == 0value == ""value == nil 判断关闭。

values := make(chan int, 2)
values <- 0
values <- 8
close(values)

for {
    value, ok := <-values
    if !ok {
        break
    }
    fmt.Println(value) // 合法数据 0 和 8
}

for value := range values 会持续接收,直到 channel 关闭并被排空。它不会因为 channel 暂时为空而结束。反过来,如果生产者永远不关闭,而消费者又依赖 range 结束,消费者就会永久等待。

for value := range values {
    fmt.Println(value)
}

是否使用 range 取决于协议:当“关闭输出”明确表示数据流结束时,它很合适;当消费者知道精确数量,或生命周期完全由 context 控制时,不一定需要关闭后再 range。

5. 无缓冲 channel:发送与接收的一次会合

无缓冲 channel 没有存放元素的槽位。发送必须等某个接收操作准备完成,接收也必须等发送者出现。它建立的是一次 rendezvous,也就是发送方和接收方在某个通信点会合,而不是把值先放进普通队列。

ready := make(chan struct{})

go func() {
    prepareResource()
    close(ready)
}()

<-ready
useResource()

无缓冲并不意味着两个 goroutine 会同时继续。通信完成后,哪一个先获得 CPU 仍由调度器决定。业务只能依赖通信建立的先后关系,不能依赖日志看起来总是谁先打印。

同一个 goroutine 向无缓冲 channel 发送,然后在后续语句接收,会在发送处阻塞,后面的接收永远无法执行:

func deadlock() {
    ch := make(chan int)
    ch <- 1
    fmt.Println(<-ch) // 永远到不了
}

修复方式不是随意加一个大缓冲,而是明确另一个并发参与者,或者承认这里根本不需要 channel。

6. Channel 与 Go 内存模型的 happens-before

channel 不只是传值,还建立内存可见性关系。Go 内存模型规定:

  • 某次发送完成,发生在对应接收完成之前;
  • 关闭 channel,发生在因该关闭而返回零值的接收完成之前;
  • 对容量为 C 的 channel,第 k 次接收发生在第 k+C 次发送完成之前。

因此,可以在发送或关闭之前写入普通变量,再在完成对应接收后读取:

done := make(chan struct{})
var message string

go func() {
    message = "ready"
    close(done)
}()

<-done
fmt.Println(message)

这里的可见性来自 close(done)<-done 的同步关系。若读取发生在接收之前,或另一个 goroutine 在同步完成后继续写 message,仍可能出现数据竞争。

channel 传递指针时尤其容易误判:发送之前完成的对象写入能被接收方看到,但如果发送方在发送之后继续修改同一对象,而接收方也在访问,就需要额外同步。常见安全约定是“发送即转移所有权”,发送者之后不再访问可变对象。

7. 缓冲 channel:有限解耦,不是无限队列

缓冲 channel 在未满时允许发送立即完成,在非空时允许接收立即完成。容量是“允许多少项已接纳但尚未消费的工作”,不是 worker 数量,也不是吞吐量。

queue := make(chan string, 2)
queue <- "A"
queue <- "B"

fmt.Println(len(queue), cap(queue)) // 2 2
fmt.Println(<-queue)
fmt.Println(<-queue)

容量为 1 常用于只返回一次结果的异步操作:即使调用方因超时不再接收,worker 仍能完成一次发送并退出。它不能取消外部 I/O,真正的网络或数据库调用仍要接收 context。

容量大于 1 可以吸收短时调度抖动或上下游速率波动,但消费者永久退出后,任何有限缓冲最终都会写满。单纯扩大容量通常只是把“立即阻塞”变成“更晚阻塞”,同时增加内存与排队延迟。

8. 容量设计与背压:从预算反推,不靠猜

确定 channel 容量时,应从业务预算反推:允许多少任务等待、每项占多少内存、可接受的最大排队时间、峰值输入速率、稳定处理速率,以及队列满时采取什么策略。

假设峰值每秒进入 1,000 项,稳定消费每秒 800 项,峰值持续 5 秒,期间会积压约 1,000 项。若每项连同引用对象占 20 KiB,仅队列引用的存活集合就可能接近 20 MiB;如果每项还持有请求体或图片,成本会更高。容量选择必须与延迟和内存预算一起评审。

常见过载策略有三类:

  • 阻塞生产者,把压力沿调用链向上游传播;
  • 非阻塞拒绝,立即返回“系统繁忙”并计数;
  • 有业务依据地丢弃旧值或低优先级值。
select {
case queue <- job:
    return nil
default:
    return ErrQueueFull
}

default 会把发送变成非阻塞尝试,但它也可能在极短暂拥塞时拒绝任务。是否采用必须由产品语义决定,不能为了“防阻塞”随处添加。

9. 关闭语义:关闭的是发送阶段,不是对象

close(ch) 表示“以后不会再有新的发送”。它不是释放内存、清空缓冲或强制取消发送者。channel 对象会像其他可达对象一样由垃圾回收器管理,不需要为了 GC 而关闭。

关闭后的行为可以归纳为下表:

操作 nil channel 开放且可立即通信 开放但暂不可通信 已关闭
发送 永久阻塞 成功 阻塞 panic
接收 永久阻塞 得到发送值 阻塞 先排空缓冲,再返回零值、ok=false
关闭 panic 成功 成功并唤醒接收者 panic

关闭时,缓冲区中的值不会丢失;阻塞接收者会被唤醒;阻塞发送者不会被当作成功发送,而会因向已关闭 channel 发送而 panic。因此绝不能把“接收方关闭输入”当作粗暴取消手段。

values := make(chan int, 2)
values <- 7
values <- 9
close(values)

fmt.Println(<-values)       // 7
fmt.Println(<-values)       // 9
value, ok := <-values
fmt.Println(value, ok)      // 0 false

10. 所有权原则:由能证明“不再发送”的一方关闭

关闭权不简单等于“谁创建谁关闭”,而属于能证明所有发送已经结束的一方。单生产者最直接:生产 goroutine 在退出时关闭自己的输出。

func generate(ctx context.Context, values []int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, value := range values {
            select {
            case out <- value:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

多生产者共享输出时,任何一个生产者都无法单独证明其他人已经停止,因此生产者本身不应各自关闭。由协调者等待全部生产者退出,再执行一次关闭:

func mergeWorkers(ctx context.Context, workers int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    for id := range workers {
        wg.Go(func() {
            for n := range 3 {
                select {
                case out <- id*10 + n:
                case <-ctx.Done():
                    return
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

Go 1.26.4 的 WaitGroup.Go 会把任务加入组并在函数返回时完成计数,传入函数不应 panic。使用传统 Add/Done 时,必须在启动 goroutine 前完成 Add,避免 Wait 与新增任务发生错误交错。

11. 取消与关闭必须分工

数据 channel 的关闭表示数据流结束;context.Context 表示调用链取消或 deadline;error 表示失败原因。三者职责不同。

下游不再消费时,不能关闭上游正在发送的 channel。它应该取消 context,让发送者在阻塞发送时也能退出:

select {
case results <- result:
    return nil
case <-ctx.Done():
    return ctx.Err()
}

只在循环入口调用一次 ctx.Err() 不够,因为 goroutine 可能随后永久阻塞在 channel 操作。任何可能持续等待的发送、接收或外部 I/O,都应有可达的取消分支。

广播“一次性完成”可关闭 chan struct{},因为所有等待接收的 goroutine 都会被唤醒。但现代请求链路通常应优先使用 context,它能携带 deadline 和取消原因,并可沿调用树传播。

12. nil channel 在 select 中用于动态禁用分支

普通代码中遇到 nil channel 往往是初始化错误;在 select 中,nil channel 对应 case 永远不会就绪,因此可以通过把局部 channel 变量设为 nil 动态启用或禁用分支。

func relay(input <-chan int, output chan<- int) {
    var pending []int
    for input != nil || len(pending) > 0 {
        var send chan<- int
        var next int
        if len(pending) > 0 {
            send = output
            next = pending[0]
        }

        select {
        case value, ok := <-input:
            if !ok {
                input = nil
                continue
            }
            pending = append(pending, value)
        case send <- next:
            pending = pending[1:]
        }
    }
}

如果已关闭输入仍保留在 select 中,它会持续立即返回零值,形成高 CPU 循环并压制其他 case。处理关闭后应将该局部 channel 设为 nil 或直接退出。若所有 case 都因 nil 被禁用且没有 default,当前 goroutine会永久阻塞。

13. 方向化 API 让通信能力可见

函数参数和返回值使用单向 channel,可以把“此函数只发送”或“调用方只能接收”变成编译器可检查的约束。

func sendAll(ctx context.Context, out chan<- string, values []string) error {
    for _, value := range values {
        select {
        case out <- value:
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return nil
}

func first(in <-chan string) (string, bool) {
    value, ok := <-in
    return value, ok
}

方向并不表达唯一发送者,也不会阻止另一个仍持有双向引用的代码关闭。API 文档仍需说明:谁关闭、调用方提前停止消费时如何通知生产者、错误从哪里返回、值的所有权是否转移。

返回只读 channel 的函数通常已经启动 goroutine。调用方必须知道如何终止它,否则一个看似简单的返回值会隐藏生命周期成本。若操作本质上是同步一次调用,直接返回 (T, error) 往往更清晰。

14. 多发送者、多接收者与顺序边界

多个 goroutine 可以并发向同一 channel 发送或从中接收,运行时会保护 channel 内部状态。这个“操作安全”不等于业务顺序确定。

单个发送者按其发送操作实际完成的顺序提交值;多个发送者之间的全局顺序由调度、阻塞和 select 就绪情况决定。多个接收者中,某个值只交给一个接收操作,channel 不会自动广播同一个值。

运行时不会承诺可供业务依赖的严格公平调度。即使等待队列实现通常倾向按排队顺序唤醒,也可能受 select、抢占和重新调度影响。需要按用户、分区或序列号有序处理时,应显式分片到独立 channel,或由单一所有者排序,而不是依赖“测试时看起来很公平”。

15. 值所有权:channel 安全不等于载荷安全

以下代码对 channel 的操作本身安全,但对 map 的并发访问不安全:

updates := make(chan map[string]int, 1)
state := map[string]int{"count": 1}
updates <- state

go func() {
    received := <-updates
    received["count"]++
}()
state["other"] = 2 // 与上面的 map 写入可能竞争

常用载荷规则有三种:

  1. 发送后转移所有权,发送方不再读写;
  2. 发送不可变快照,例如复制 map 或 slice 的底层数据;
  3. 两端共享,但对象内部另有 Mutex、atomic 或线程安全抽象。

传大结构体可避免共享,却可能增加复制成本;传指针减少复制,却可能增加逃逸、GC 扫描和所有权复杂度。应先选正确语义,再用 benchmark 与 profile 决定是否优化。

16. Channel、Mutex、Cond 与 atomic 的选型边界

channel 适合传递任务、结果、事件和对象所有权,或让单个 goroutine 串行拥有复杂状态。Mutex 适合多个 goroutine 对内存中的短临界区做同步访问。atomic 适合少量、语义明确的独立状态;Cond 适合在持锁条件变化时唤醒等待者。

如果缓存核心 API 是同步 Get/Set,一把 RWMutex 通常比“启动常驻 goroutine,再为每次读取构造请求和响应 channel”更直接。如果状态变化天然是一串命令,并且串行顺序本身就是领域约束,单一拥有者加命令 channel 可能更清晰。

不要用“一切都通过 channel”或“一切都加锁”的口号代替建模。判断标准是:数据由谁拥有、调用是同步还是异步、是否需要背压、取消如何传播、临界区是否足够短。

17. Pipeline、fan-out 与 fan-in 的闭合协议

流水线中,每个 stage 通常读取输入、生成输出,并只关闭自己创建的输出。fan-out 让多个 worker 消费同一个输入;fan-in 把多个输出汇合为一个 channel。

func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for value := range in {
            select {
            case out <- value * value:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

func merge(ctx context.Context, inputs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, input := range inputs {
        wg.Go(func() {
            for value := range input {
                select {
                case out <- value:
                case <-ctx.Done():
                    return
                }
            }
        })
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

关键不是函数名,而是闭合关系:每个输出只有一个关闭者;下游提前退出时 context 能到达所有上游;协调者等待全部转发 goroutine 后才关闭汇总输出。缺少任意一项,都可能在异常路径泄漏。

18. 常见 panic、死锁与泄漏陷阱

channel 故障通常来自协议不闭合,而非运行时随机失效:

  • 同一 goroutine 先向无缓冲 channel 发送、后接收,发送处自锁;
  • 消费者 range 等待关闭,生产者所有返回路径却没有关闭;
  • 下游只读取第一个结果就退出,上游仍无取消地发送;
  • 多个发送者竞争关闭,出现 close of closed channelsend on closed channel
  • 接收方关闭输入来“通知停止”,并发发送者随即 panic;
  • len(ch) < cap(ch) 判断可发送,判断后状态变化,仍然阻塞;
  • 锁内执行可能阻塞的 channel 操作,而另一端需要同一把锁;
  • select 持续读取已关闭 channel,产生零值忙循环;
  • goroutine 捕获大对象后阻塞在 channel,使对象长期不能回收。

运行时只会在所有 goroutine 都无法继续且没有网络轮询等可唤醒事件时报告 fatal error: all goroutines are asleep - deadlock!。服务器中即使已经泄漏成千上万个 goroutine,只要仍有其他事件,进程通常不会自动报错。

19. 故障诊断:从现象还原通信协议

请求不返回、goroutine 数持续增长、队列长期接近容量或内存不下降时,先保留证据:

curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine
go test -race ./...

goroutine dump 中的 [chan send][chan receive][select] 指出等待点,但等待点不一定是根因。沿调用栈向上检查创建者和所有者:另一端是否已经返回、关闭责任是否遗漏、context 是否真正传入、队列为何满、消费者是否被更早的错误终止。

对比多个时间点的 dump:如果同一调用栈数量持续增长,通常是稳定泄漏。block profile 可观察 channel 和锁的累计阻塞时间,但需要提前设置采样率;go tool trace 可看到 goroutine 状态转换和调度延迟;竞态检测器能发现共享载荷的竞争,却不能证明协议不会死锁。

20. 测试与验证:用事件推进,不用 Sleep 猜调度

并发测试应验证可观察协议:输出最终关闭、取消后 goroutine 退出、满队列执行预定策略、多生产者只关闭一次、合法零值不会被误判为结束。不要用 time.Sleep 猜“另一个 goroutine 应该已经运行了”。

func TestGenerateStopsAfterCancel(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    out := generate(ctx, []int{1, 2, 3, 4})

    if got := <-out; got != 1 {
        t.Fatalf("first value = %d", got)
    }
    cancel()

    deadline := time.After(time.Second)
    for {
        select {
        case _, ok := <-out:
            if !ok {
                return
            }
        case <-deadline:
            t.Fatal("producer did not close output after cancellation")
        }
    }
}

测试本身要有超时,使错误协议能够收敛。再执行 go test -race -count=100 ./... 扩大调度交错覆盖。高次数通过不能数学证明并发正确,但能捕获部分偶发错误。需要精确顺序时,使用测试专用 barrier channel 明确推进步骤。

还可以在测试前后记录 runtime.NumGoroutine 发现明显泄漏,但该值受测试框架和后台任务影响,只能作为辅助。更可靠的方法是让被测组件暴露 Wait/Close 生命周期,并断言它在 deadline 内完成。

21. 性能基准:比较正确的方案,不只测每次操作

channel 操作包含同步、可能的元素复制、队列维护和调度。无竞争时的纳秒数字不能代表真实系统;基准应覆盖无缓冲交接、不同容量、不同生产者/消费者数量、载荷大小和取消路径。

func BenchmarkBufferedChannel(b *testing.B) {
    ch := make(chan int, 256)
    done := make(chan struct{})
    go func() {
        defer close(done)
        for range ch {
        }
    }()

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ch <- i
    }
    close(ch)
    <-done
}

批处理可以摊薄通信成本,但会增加单项等待时间,并扩大取消后可能废弃的工作。大结构体改为指针可能降低复制,却可能引入堆分配和共享竞争。优化前同时查看 benchstat、CPU profile、内存 profile、阻塞 profile 和端到端延迟分位数。

22. 生产部署、观测与运维

生产中的 channel 通常是进程内队列,进程退出后内容不会持久化,也无法天然跨实例协调。要求任务不丢、可重放或跨服务消费时,应使用具备持久化和确认语义的消息系统,而不是把 channel 包装成“内存 MQ”。

重要队列至少观测以下指标:

  • 当前长度与容量,以及接近满载的持续时间;
  • 入队等待、队列等待和实际处理耗时;
  • 接纳、拒绝、丢弃、重试和失败数量;
  • 活跃 worker、goroutine 总数和取消原因;
  • 服务关闭时剩余任务与 drain 用时。

len(ch) 可作为近似 gauge,但采样值不是一致快照。指标标签不能携带任务 ID、用户 ID 等高基数数据。告警应关注持续拥塞、处理速率下降和 goroutine 趋势,而不是单次短峰值。

优雅关闭时通常先停止接纳新任务,再取消或等待生产者,随后由所有者关闭输入,worker 排空或按 deadline 退出,最后关闭结果并等待协调者。顺序必须与业务的“允许丢弃还是必须处理完”一致。

23. 可运行综合示例:有界、可取消、单一关闭者

下面程序把核心原则放在一起:输入容量有界;生产者关闭 jobs;worker 不关闭共享 results;协调者等待全部 worker 后关闭 results;所有可能阻塞的通信都观察 context。

package main

import (
    "context"
    "errors"
    "fmt"
    "strconv"
    "sync"
)

type Result struct {
    Job   int
    Value int
}

func run(ctx context.Context, workers int, input []int) <-chan Result {
    jobs := make(chan int, workers)
    results := make(chan Result, workers)

    go func() {
        defer close(jobs)
        for _, job := range input {
            select {
            case jobs <- job:
            case <-ctx.Done():
                return
            }
        }
    }()

    var wg sync.WaitGroup
    for range workers {
        wg.Go(func() {
            for job := range jobs {
                result := Result{Job: job, Value: job * job}
                select {
                case results <- result:
                case <-ctx.Done():
                    return
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(results)
    }()
    return results
}

func parse(args []string) ([]int, error) {
    values := make([]int, 0, len(args))
    for _, arg := range args {
        value, err := strconv.Atoi(arg)
        if err != nil {
            return nil, fmt.Errorf("parse %q: %w", arg, err)
        }
        values = append(values, value)
    }
    if len(values) == 0 {
        return nil, errors.New("provide at least one integer")
    }
    return values, nil
}

func main() {
    values, err := parse([]string{"2", "3", "4", "5"})
    if err != nil {
        panic(err)
    }

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    for result := range run(ctx, 3, values) {
        fmt.Printf("%d -> %d\n", result.Job, result.Value)
    }
}

若真实处理函数可能失败,可让 worker 返回 struct { Result Result; Err error },由拥有者记录首错并取消 context,再等待所有 worker 退出。不要在收到首错后直接放弃接收,而让其余 worker 阻塞在结果发送上。

24. 设计审查清单与结论

审查每个 channel 时,应能明确写出以下答案:

  1. channel 在哪里创建,容量依据是什么;
  2. 发送者有哪些,是否可能在接收者退出后继续发送;
  3. 谁是唯一关闭者,它如何证明所有发送已经结束;
  4. 消费者通过关闭、固定数量还是 context 判断结束;
  5. 每个阻塞点能否在取消或 deadline 后退出;
  6. 错误是否会取消同组任务,谁负责等待它们结束;
  7. 载荷是复制、不可变共享还是发生所有权转移;
  8. 过载时是阻塞、拒绝还是丢弃,是否有指标;
  9. 测试是否覆盖关闭、取消、零值、满队列和多生产者;
  10. 进程关闭时,队列是排空还是允许放弃。

channel 的可靠性最终来自完整、可证明的通信协议,而不是语法上的箭头。先确定生命周期和所有权,再选择无缓冲或有缓冲;先保证所有路径能结束,再讨论吞吐和微观性能。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。