Go 最吸引人的特点之一,就是并发写起来相对直接。goroutine 很轻,channel 很方便,标准库也提供了不少同步原语。这些能力让很多人一开始就对 Go 的并发印象很好。但真正把它们放进后端服务时,问题马上就不再是“会不会用”,而是“什么时候该并发、什么时候不该并发、并发之后怎么收得住”。

后端工程化里的并发控制,重点从来不是把代码写得更花,而是让服务在提高吞吐的同时,仍然保持正确性、资源可控性和可排查性。

为什么会并发语法不等于会并发工程

因为服务里的并发不是独立小实验,而是和请求生命周期、资源限制、下游能力、超时控制强相关的。

你可以很容易起很多 goroutine,但如果没有收敛策略,它们可能在请求结束后还继续跑;你可以用 channel 传递结果,但如果没人消费或者提前返回,就可能阻塞;你可以用锁保护共享状态,但如果边界不清,又会引入争用甚至死锁风险。

所以并发能力一旦进入服务,就必须从“语法层面会用”升级到“工程层面知道怎么控制”。

后端服务里为什么需要并发

因为很多场景天然存在等待。

例如同时请求多个下游、批量处理多个任务、并行拉取多个资源、后台异步写日志或做非关键通知。如果这些工作完全串行,吞吐和响应时间都会受影响。

但并发不是默认答案。因为并发一旦引入,也会带来共享状态、上下文取消、错误聚合、资源竞争这些新的问题。

goroutine 最容易被误用在哪

最常见的误用就是“看到能异步就随手开一个”。

比如在请求处理里直接起后台 goroutine 做额外工作,却没有绑定 context、没有控制数量、没有处理错误、也没有确认请求结束后是否还应该继续。这类代码短期看起来很方便,长期却很容易制造资源泄漏和行为失控。

所以一个很重要的意识是:每起一个 goroutine,都要问自己三件事。

  • 谁负责结束它。
  • 谁负责接收结果。
  • 谁负责处理错误。

channel 适合解决什么问题

channel 很适合表达“并发协作”和“结果传递”,而不是替代所有共享数据结构。

如果你的场景本质上是多个并发任务之间交换结果、同步完成状态,那么 channel 很自然;但如果只是为了共享缓存、共享计数或共享状态,很多时候锁或者更明确的数据结构会更直观。

也就是说,channel 不是并发的万能答案,它更适合描述通信,而不是掩盖设计不清。

锁为什么依然重要

很多初学者学了 channel 以后,会误以为锁是不是就不重要了。其实并不是。

在服务里,只要存在共享内存和共享状态,锁依然是非常基础而且直接的工具。特别是一些缓存、计数器、连接池元数据、本地状态管理场景,锁往往比绕一圈 channel 更清楚。

工程里的关键从来不是“只用哪一种工具”,而是让同步方式和问题模型匹配。

并发控制和 context 要一起看

后端服务里的并发控制,几乎不能脱离 context 单独讨论。

因为请求级并发任务通常应该跟随请求生命周期一起结束。如果客户端取消、上游超时或服务退出,并发任务还在后台继续跑,就可能浪费资源,甚至写出错误结果。

所以并发不只是“同时做几件事”,而是“在正确的生命周期里同时做几件事,并且能及时停下”。

一个常见反例

在 handler 里直接起多个 goroutine 去请求下游,但没有超时、没有错误聚合、没有等待机制。这类代码最开始看起来吞吐更高,实际上问题非常多:结果顺序不可控、失败难以统一处理、请求结束后任务还可能悬挂。

更合理的思路是什么

更合理的思路是:只有在确实存在收益时才并发;并发后要有明确收敛点;必要时结合 context、等待组、错误汇聚和并发数量控制一起设计。

这时并发就不再是“写起来酷”的技巧,而是服务吞吐与稳定性之间的平衡手段。

一个值得自己做的小练习

为你的服务设计一个接口,需要并发查询两个模拟下游,再把结果合并返回。

要求你同时考虑。

  • 如果其中一个超时怎么办。
  • 如果客户端提前取消怎么办。
  • 如果两个请求都失败,错误怎么返回。

只要你做过这个练习,就会明白:服务里的并发控制真正难的不是开线程,而是收线程、控资源和保边界。

小结

Go 的并发模型非常强,但工程化服务真正需要的不是“能并发”,而是“能可控地并发”。goroutinechannel 和锁都只是工具,真正重要的是你能不能让并发任务和请求生命周期对齐、和资源限制对齐、和错误处理对齐。只要这几个维度想清楚了,并发才会成为服务能力的放大器,而不是问题的放大器。