2592 字
约 8 分钟
16
S09-X10 · channel 与并发模式
2024-12-31
2026-11-09

服务跑了一周,goroutine 数稳定在 20 万,CPU 却只有 5%,pprof 显示几乎所有 goroutine 都停在 chan receive。这类"并发很高、吞吐很低"的现场,八成是把 channel 当队列用,而消费端根本没起来,或者 select 少了超时分支。

channel 的语义很小,但每条都有尖锐的边界。按 Go 1.22 起的运行时行为拆。


1. 两种 channel 与容量语义

类型 发送行为 接收行为 适用
无缓冲 make(chan T) 阻塞直到有接收方就绪 阻塞直到有发送方就绪 交接(handoff)、同步信号
有缓冲 make(chan T, n) 缓冲未满时立即返回,满了阻塞 缓冲非空时立即返回,空了阻塞 解耦生产/消费速率、限流
ch := make(chan int)        // 无缓冲:发送与接收必须"碰面"
buf := make(chan int, 8)    // 有缓冲:可先存 8 个

无缓冲 channel 是同步点,不只是数据通道:发送完成即意味着对方已接收。这让它天然适合"等待某个事件发生",也让它在没有接收方时变成永久阻塞。


2. 三种操作 × 两种状态

操作 channel 为 nil channel 已关闭
发送 ch <- v 永久阻塞 panic: send on closed channel
接收 v := <-ch 永久阻塞 立即返回零值,ok == false
关闭 close(ch) panic: close of nil channel panic: close of closed channel
len/cap 0 / 0 剩余缓冲数 / 容量
v, ok := <-ch      // ok=false 表示已关闭且缓冲已空
for v := range ch  // 关闭后自动退出循环 —— 这是唯一的"正常退出"写法

记住三条:向已关闭的 channel 发送会 panic;关闭已关闭的会 panic;nil channel 上的收发永久阻塞。 第三条常被当作技巧用(见第 6 节),前两条是事故来源。


3. "通过通信共享内存"到底在说什么

// 共享内存 + 锁
mu.Lock(); counter++; mu.Unlock()

// 通信传递所有权
ch <- &task{...}    // 发送后本地不再持有该指针

这句话的原意是**"数据的写入权在同一时刻只属于一个 goroutine"**,不是"禁止使用锁"。判断标准是所有权:

需求 用 channel 用 mutex
传递数据所有权 是,天然表达 需要额外约定"谁还能用"
保护一个共享结构的内部状态 别扭,需要额外请求-响应协议 是,最直接
等待事件/信号 是 需要 condition variable
计数器、缓存、配置 过度设计 是

把计数器改成 channel 请求-响应,是把简单问题复杂化;用 mutex 保护一个需要跨阶段传递的对象,则容易忘记同步。选型看的是"这块数据归谁",不是"哪种更 Go"。


4. select 的语义与三个陷阱

select {
case v := <-a:
    handle(v)
case b <- 1:
case <-time.After(100 * time.Millisecond):
    return errTimeout
default:
    // 非阻塞分支
}
陷阱 表现 修法
多个分支同时就绪 随机选一个,不保证优先级 需要优先级时先做非阻塞探测,再进阻塞 select
只有 default 立刻返回,形成忙轮询,CPU 打满 去掉 default,或用 time.Sleep/ticker
无 default 也无超时 所有 case 都不就绪时永久阻塞 加 <-ctx.Done() 或 time.After

select 的随机性是故意的:它避免固定优先级造成饿死。如果你的逻辑依赖"channel A 优先于 B",必须显式写两次 select(先非阻塞试 A,再阻塞等 A 和 B)。

time.After 在循环里会不断创建 Timer,到期前不回收——循环内用 time.NewTicker 并 defer Stop()。


5. context 与 channel 的配合

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()                        // 必须调用,否则 Timer 与子 context 泄漏

select {
case v := <-ch:
    return v, nil
case <-ctx.Done():
    return nil, ctx.Err()             // context.DeadlineExceeded / Canceled
}
概念 作用 常见误用
ctx.Done() 返回一个关闭即代表取消的 channel 每次调用都新建,导致无法统一取消
WithCancel/WithTimeout 产生可取消的子 context 忘记 cancel(),父链上的 Timer 不释放
取消传播 父取消 → 所有子取消 以为取消会杀掉 goroutine(它只是通知)

取消是协作式的:goroutine 必须在 select 里监听 Done 才会退出。cancel 不是"停止",是"通知该停了"。


6. 五种常用模式

模式 结构 适用 代价
管道(pipeline) 每级一个 channel,逐级处理 流式转换 级数越多,调度与缓冲开销越大
扇出/扇入 多个 worker 消费同一 channel,再合并结果 提高吞吐 结果顺序不保证
worker pool 固定 worker 数 + 任务 channel 限并发、复用资源 队列积压需监控
errgroup 一组 goroutine + 首个错误取消 有错误语义的并发 需 Go 官方扩展包 x/sync
semaphore 带缓冲 channel 或 x/sync/semaphore 限流、限制外部调用并发 获取失败要正确释放
// worker pool
jobs := make(chan Job, 100)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for j := range jobs {   // range 在 close 后自动退出
            process(j)
        }
    }()
}
for _, j := range all { jobs <- j }
close(jobs)      // 由发送方关闭
wg.Wait()
// 信号量限流:缓冲 channel 版
sem := make(chan struct{}, 4)
for _, u := range urls {
    sem <- struct{}{}
    go func(u string) {
        defer func() { <-sem }()
        fetch(u)
    }(u)
}
// 扇出扇入 + 取消
func fanIn(ctx context.Context, chs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, c := range chs {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for {
                select {
                case v, ok := <-c:
                    if !ok { return }
                    select { case out <- v: case <-ctx.Done(): return }
                case <-ctx.Done():
                    return
                }
            }
        }(c)
    }
    go func() { wg.Wait(); close(out) }()   // 关闭责任集中在一处
    return out
}

7. nil channel 与关闭约定

var ch chan int          // nil
<-ch                     // 永久阻塞——可用于"关闭某个 select 分支"

// 典型用法:动态禁用分支
var out chan int
if enabled { out = realCh }
select {
case out <- v:           // enabled=false 时该分支永不就绪
case <-ctx.Done():
}

把分支置 nil 可以让 select "忽略"它,这是标准技巧;代价是如果所有分支都 nil,select 永久阻塞,且这种阻塞在 pprof 里表现为 chan receive (nil chan),很容易被当成正常等待。

关闭的三条约定:

约定 理由
由发送方关闭 接收方无法知道还有没有其他发送者
不要重复关闭 第二次 close 直接 panic
不要关闭只读 channel 编译期就报错(<-chan T 不可 close)

多个发送者时,标准做法是:用 sync.Once 或让一个协调者 goroutine 负责关闭,其余发送者只发不关。


8. 缓冲大小不是背压

缓冲设置 效果 风险
0(无缓冲) 强背压,生产与消费严格同步 消费稍慢就阻塞生产
小缓冲(几个) 削峰,抵抗调度抖动 缓冲一满立刻退化为无缓冲
大缓冲(上千) 掩盖消费不足 队列积压变成内存占用,故障延迟暴露
按峰值估算 更长的容忍时间 峰值持续时仍然阻塞,只是来得更晚

缓冲的本质是"把阻塞推迟",不是"消除阻塞"。把缓冲当背压调节手段,等于把问题从"调用变慢"改成"内存上涨 + 延迟变大"。 真正的背压需要监控队列长度并拒绝新请求(如 select + default 返回 429)。


9. 排错表:常见坑

坑 表现 修法
向已关闭 channel 发送 panic: send on closed channel 关闭权集中;发送前用 select + ok 保护
多个发送者都 close 偶发 panic sync.Once 包装关闭
select 无 default 无超时 永久阻塞,goroutine 泄漏 加 ctx.Done()
缓冲开太大 内存涨、延迟高、故障晚发现 小缓冲 + 队列长度指标 + 拒绝策略
range 前忘记 close 消费 goroutine 永不退出 发送方 defer close(ch)
用 channel 保护计数器 吞吐下降、代码复杂 用 sync.Mutex 或 atomic

10. 边界表

场景 选择 理由
传递任务/所有权 channel 语义即所有权转移
保护共享结构 sync.Mutex 避免请求-响应开销
等待一组任务结束 WaitGroup / errgroup channel 需要额外的完成计数协议
限流 信号量(带缓冲 channel) 语义清晰,容量即并发上限
事件通知 close(ch) + <-ch 关闭是广播,比发 N 条消息便宜
需要优先级 多 channel + 显式两级 select select 本身随机

最后一个值得展开:close 是广播。要通知 100 个 goroutine,关闭一个 channel 就够了,不需要发 100 条消息;接收方从已关闭 channel 立刻得到零值,这正是"取消信号"的实现原理。


11. 自测

  1. 无缓冲 channel 的"发送成功"意味着什么?这使它适合表达哪类语义?
  2. 向 nil channel 发送、向已关闭 channel 发送,分别是什么行为?
  3. select 多个分支同时就绪时按什么规则选?如何实现优先级?
  4. select 里只写 default 会带来什么问题?
  5. 为什么关闭 channel 的责任要放在发送方?多个发送者时怎么办?
  6. 缓冲大小为什么不能当作背压手段?真正的背压需要什么?
S09-X10 · channel 与并发模式
https://www.clxhxhhr.top/posts/5989/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。