子兮子兮 子兮子兮

子兮子兮风兮寒,三江七泽情洄沿。

目录
Go 并发稳定性(1):select 使用不当引发的三类并发问题
/        

Go 并发稳定性(1):select 使用不当引发的三类并发问题

select 是 Go 并发中最强大的语法之一,同时也是生产事故的高发区之一。

很多并发问题并不是来自复杂算法,而是来自一些看起来完全合理的 select 写法
这些问题往往:

  • 本地测试完全正常
  • 压测难以复现
  • 只在生产环境逐渐恶化

这篇文章结合真实工程经验,分析三类最常见、最隐蔽的 select 使用问题。


一、为什么 select 是并发 bug 的重灾区?

select 的语义非常灵活:

  • 多路通信
  • 超时控制
  • 非阻塞逻辑
  • 协程调度

但问题在于:

select 本身不管理生命周期、不保证公平,也不保证退出路径。

换句话说:

  • select 只负责“选择”,
  • 不负责“收尾”。

而生产事故,往往就发生在“没有收尾”的地方。


二、问题一:timeout 分支导致 goroutine 泄露

这是生产中最常见的一类问题。

常见写法

func request() {
	ch := make(chan Result)

	go func() {
		ch <- callRemote()
	}()

	select {
	case res := <-ch:
		handle(res)
	case <-time.After(1 * time.Second):
		return
	}
}

看起来完全合理:

  • 发起异步请求
  • 最多等待 1 秒
  • 超时就退出

实际发生了什么?

当 timeout 触发:

  • select 直接 return
  • goroutine 仍然在运行
  • callRemote 执行完毕
  • 尝试发送到 ch

没有接收者了,于是:

goroutine 永远阻塞在发送操作上

每一次 timeout,都会留下一个无法退出的 goroutine。


正确设计方式

方式一:带缓冲 channel

ch := make(chan Result, 1)

避免发送阻塞。


方式二(推荐):context 控制生命周期

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

go func(ctx context.Context) {
	select {
	case <-ctx.Done():
		return
	default:
		result := callRemote()
		ch <- result
	}
}(ctx)

核心原则:

timeout 只是控制等待,不是控制 goroutine 生命周期。


三、问题二:default 分支导致 CPU 飙升

这是另一个非常常见的事故源。

典型代码

for {
	select {
	case msg := <-ch:
		process(msg)
	default:
	}
}

很多人以为:

  • 这样可以“非阻塞读取”
  • 没数据就继续循环

实际运行效果

这个循环会变成无限忙循环

CPU 使用率会迅速升高,因为:

  • default 立即执行
  • 没有任何阻塞点
  • runtime 无法让出调度

结果:

  • 单核 CPU 被打满
  • GC 压力增加
  • RT 开始抖动

正确方式

如果你想:

等待消息

msg := <-ch

定期执行任务

ticker := time.NewTicker(time.Second)

select {
case msg := <-ch:
case <-ticker.C:
}

必须使用 default(极少见)

必须配合:

runtime.Gosched()
time.Sleep()

否则就是一个隐形的 CPU 炸弹。


四、问题三:多个 channel 的伪公平问题

很多人误以为 select 是公平调度。

实际上:

select 是伪随机选择,并不保证公平。


示例场景

select {
case fast := <-fastCh:
	handleFast(fast)
case slow := <-slowCh:
	handleSlow(slow)
}

如果:

  • fastCh 高频
  • slowCh 低频

那么:

  • fastCh 会持续被命中
  • slowCh 可能长期得不到处理

这叫 channel 饥饿


生产后果

  • 某类任务延迟不断增加
  • 队列堆积
  • RT 偶发抖动

正确用法

方式一:拆分 worker

不要把不同优先级放在同一个 select 中。


方式二:使用调度层

例如:

  • 独立 goroutine 读取 slowCh
  • 合并到统一队列

方式三:使用带权队列

而不是依赖 select 的调度行为。


五、select 的三个核心设计原则

原则一:select 不负责生命周期

timeout ≠ goroutine 结束。


原则二:default 几乎不应该存在

99% 的 default 都可以被:

  • 阻塞等待
  • ticker
  • context

替代。


原则三:select 不是调度器

它:

  • 不保证公平
  • 不保证优先级
  • 不保证吞吐

复杂调度必须自己设计。


六、生产环境中的 select 使用检查清单

上线前可以自查:

  • 是否存在 timeout 分支?
  • timeout 是否可能留下发送阻塞?
  • 是否使用了 default
  • 是否存在无限循环?
  • channel 是否存在吞噬风险?

七、写在最后

select 是 Go 并发的核心工具之一,但它只提供了“选择”,没有提供“安全”。

生产环境中的并发稳定性,往往不是由复杂技术决定,而是由这些基础结构的细节决定。

如果你发现系统:

  • RT 偶发抖动
  • CPU 异常升高
  • goroutine 数量缓慢上涨

请第一时间检查 select