把一个 Go 服务的 goroutine 数打到十万,监控面板通常仍然很好看:CPU 不高,内存平稳,QPS 也正常。于是很容易得出一个结论:Go 天生高并发,并发问题已经被语言解决了。
这个结论只说对了一半。
Go 确实把“启动一个并发任务”的成本降到了近乎语法糖的程度。但并发的表达成本和并发的正确性成本是两件事。前者由语言承担,后者仍然由写代码的人承担。goroutine 便宜,不代表共享状态会自己变得安全;channel 好用,不代表队列会自己产生消费者;context 是标准库,不代表取消会自己传播到每一个阻塞点。
Go 没有消除并发问题,它只是把并发问题从运行时搬进了设计。
本文想回答的就是标题里的问题:既然 Go 已经“原生高并发”,到底还要不要处理并发问题?答案是:要,但处理的对象变了。
1. “原生高并发”到底原生了什么
Go 的并发能力并不是一句营销口号,它由几个具体机制组成。
goroutine 的初始栈只有 2 KB,按需增长,创建和销毁在用户态完成;运行时用 GMP 模型把大量 goroutine 复用到少量 OS 线程上;网络 I/O 交给 netpoller,阻塞的 goroutine 不会长期占用内核线程;channel 和 select 是语言级原语,不需要引入额外的库。
查看图表源码
flowchart LR subgraph G["G:Goroutine"] G1["G1"] G2["G2"] G3["G3"] end subgraph P["P:逻辑处理器"] LRQ["本地运行队列"] end subgraph M["M:OS 线程"] T["内核线程"] end CPU[("CPU 核")] GRQ["全局运行队列"] NET["netpoller"] G1 --> LRQ G2 --> LRQ G3 --> GRQ LRQ --> T T --> CPU GRQ -. 工作窃取 .-> LRQ NET -. 唤醒 G .-> LRQ与传统的线程模型相比,差距是数量级上的:
| 维度 | OS 线程 | goroutine |
|---|---|---|
| 初始栈 | 1–8 MB | 2 KB,按需增长 |
| 创建与销毁 | 系统调用级 | 用户态,微秒级 |
| 调度 | 内核抢占 | runtime 的 M:N 调度 |
| 网络阻塞 | 占用内核线程 | 交给 netpoller |
| 常见数量级 | 千 | 十万到百万 |
| 隔离性 | 较强 | 共享地址空间,没有隔离 |
所以下面这段代码可以轻松跑起来:
for i := 0; i < 100000; i++ { go func(id int) { handle(id) }(i)}它能跑,甚至可能跑得不错。但它同时创建了十万个生命周期未知、并发度未知、失败结果未知的任务。语言不会拦你,编译器不会报错,运行时也不会提醒你——这正是“原生高并发”的真实含义:它降低了启动并发的门槛,没有降低并发推理的门槛。
2. 四个被混为一谈的词
讨论 Go 并发时,至少有四个概念经常被当成同一个词使用:
| 概念 | 回答的问题 | Go 提供什么 | 谁负责 |
|---|---|---|---|
| 并发表达 | 怎么同时处理多件事 | goroutine、channel、select | 语言 |
| 并行执行 | 是否真的在多个核上同时跑 | GOMAXPROCS、GMP 调度 | runtime |
| 并发正确 | 结果是否在任何调度下都正确 | happens-before 规则、同步原语 | 开发者 |
| 高并发容量 | 能扛住多少流量 | 轻量调度、netpoller | 架构与资源设计 |
查看图表源码
flowchart TD A["业务目标:10 万连接 / 1 万 QPS"] --> B["并发表达<br/>Concurrency"] A --> E["容量<br/>Capacity"] B --> C["并行执行<br/>Parallelism"] B --> D["并发正确<br/>Correctness"] C --> R["Go runtime:GMP / netpoller"] D --> S["同步语义:happens-before / 锁 / channel"] E --> T["资源与背压:连接池 / 队列 / 下游配额"]当有人说“Go 高并发”时,通常只指第一层便宜、第二层高效。第三层和第四层,语言从来没有接管过。
一个服务能开十万个 goroutine,说明它的表达上限很高;但如果有两千个 goroutine 同时打向一个只有二十个连接的数据库连接池,系统依然会雪崩。goroutine 数量从来不是容量的证明,它只是并发度的计量单位。
3. Go 替你保证的,与不替你保证的
Go 的内存模型给出了一组明确的 happens-before 关系:channel 的发送发生在对应接收完成之前;channel 的关闭发生在“因关闭而返回零值”的接收之前;互斥锁的解锁发生在下一次加锁之前;sync.Once 中 f 的返回发生在后续 Do 返回之前;原子操作之间保持顺序一致。
这些保证足以让你在不额外加锁的情况下,安全地在 goroutine 之间传递数据。但它们只覆盖通过同步原语建立的关系,不覆盖裸共享内存。
var counter intvar wg sync.WaitGroupfor i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() counter++ // 数据竞争 }()}wg.Wait()这段代码不是“可能少加几次”,而是未定义行为。go test -race 会报,但线上通常不会带 -race;最终结果可能是 987,也可能是别的数字,甚至可能在某个架构上表现出更奇怪的行为。
把边界整理成一张表:
| 层面 | Go 保证 | Go 不保证 |
|---|---|---|
| 调度 | goroutine 最终会被调度 | 何时执行、执行多久、是否先执行 |
| 内存 | 同步原语建立 happens-before | 没有同步的共享读写 |
| 生命周期 | 几乎没有强约束 | 谁负责让 goroutine 退出 |
| 通信 | channel 收发与关闭的语义 | 谁是消费者、队列满了怎么办 |
| 取消 | context 提供统一信号 | 每个阻塞点都响应取消 |
| 资源 | GC 管理堆内存 | 连接、文件、goroutine 数量的上限 |
一句话概括:go f() 只保证 f 会被调度,不保证它何时结束、是否结束、是否安全。
4. 语言不会替你处理的五类问题
4.1 数据竞争
最常见也最容易被低估的一类。Go 的 map 对并发写是零容忍的:
m := map[string]int{}for i := 0; i < 100; i++ { go func(i int) { m[fmt.Sprint(i)] = i // fatal error: concurrent map writes }(i)}这个错误由 runtime 直接抛出,recover 接不住,进程会退出。加锁、改用 sync.Map、或者让单个 goroutine 独占 map,都是可行方案;唯一不可行的是“先跑起来看看”。
另一个隐蔽变体是把原子操作当成“复合条件”的保护:
var inflight atomic.Int64if inflight.Load() < limit { inflight.Add(1) // 检查与自增之间不是原子的 defer inflight.Add(-1) handle()}inflight 本身是原子的,但 Load 与 Add 组合起来不是。两个 goroutine 可能同时看到 99,然后一起加到 101。原子操作保护单个操作,不保护由多个操作组成的检查和修改。这种场景需要 CAS 循环,或者干脆退回互斥锁。
4.2 goroutine 泄漏
goroutine 没有父子的强生命周期约束。创建它的函数返回了,它仍然可以活着:
func watch(ch <-chan Event) { go func() { for e := range ch { handle(e) } }()}如果 ch 永远不关闭,这个 goroutine 永远不会退出。阻塞在无缓冲 channel 的发送端、等待永远不会到来的锁、循环里创建 time.After,都是常见的泄漏形态:
for { select { case <-time.After(time.Second): // 每次循环都新建一个 Timer;高频场景下分配与调度开销不可忽略 case <-ctx.Done(): return }}Go 1.23 之前,未被停止的 Timer 会一直保留到触发;Go 1.23 起运行时允许回收不再被引用的 Timer,但这不意味着循环里反复创建没有成本。高频路径应该复用 time.Ticker,或者用 time.NewTimer 并在不需要时 Stop。泄漏的可怕之处在于它很安静:内存缓慢上涨,goroutine 数缓慢上涨,直到某一天压测或线上流量高峰才暴露。
4.3 无界扇出
for _, id := range ids { go fetch(id)}如果 ids 有一万个,而下游连接池只有二十个连接,那么九千九百八十个 goroutine 都在等连接。它们不占 CPU,却占内存、占调度队列,并且让超时和重试机制同时失效。
并发度应该由资源决定,而不是由输入规模决定:
g, ctx := errgroup.WithContext(ctx)g.SetLimit(8)for _, id := range ids { id := id g.Go(func() error { return fetch(ctx, id) })}if err := g.Wait(); err != nil { return err}SetLimit 不是性能优化,它是保护下游的边界。
4.4 背压缺失
比无界扇出更隐蔽的是无界队列。生产者速度超过消费者时,队列会一直涨,直到内存耗尽。队列只是把 OOM 推迟,并没有消除它。
有界队列必须配合明确的拒绝策略:
var rejected atomic.Int64func (q *Queue) Submit(ctx context.Context, task Task) error { select { case q.ch <- task: return nil case <-ctx.Done(): return ctx.Err() default: rejected.Add(1) return ErrQueueFull }}非阻塞发送加上 default 分支是合法的背压设计,但它有一个前提:丢弃必须可观测。如果没有计数、没有日志、没有告警,那么“队列满”这个事实就只存在于代码里。
4.5 取消传播断裂
context 是约定,不是强制。只要有一个阻塞点没有接收 ctx.Done(),取消就会在这里断掉:
func (s *Service) Run() { ctx := context.Background() go s.worker(ctx)}func (s *Service) Close() { s.conn.Close() // worker 仍在重连,ctx 从未取消}正确的做法是把 cancel 保存下来,由 Close 调用,并用 errgroup 或 WaitGroup 等待后台任务真正退出。取消不是“发一个信号”,而是“发信号并等待对方确认收到”。
5. 一个真实的撤回案例:通知是怎么静默消失的
下面的案例的 SDK 需要在 WebSocket 读协程与业务回调之间传递状态变更事件。最初的设计是标准的生产者—队列—消费者:
// 生产者:唯一的 WebSocket 读协程select {case c.notify <- event:case <-c.ctx.Done():default: slog.Warn("通知队列已满,丢弃事件")}// 消费者:独立 goroutine,串行执行业务回调func (c *Client) runNotifications() { for { select { case <-c.ctx.Done(): return case event := <-c.notify: c.onEvent(event) } }}这个设计同时解决两个问题:业务回调不会阻塞唯一的读协程;回调按入队顺序串行执行,不会并发重入。队列容量是 64,满了就丢弃并记日志,属于显式的背压策略。
后来发生了一次合理的重构:SDK 的 NewClient 原本会阻塞等待首次连接成功,十秒连不上就返回错误;但子进程通常晚于主进程启动,这个同步屏障会把正常的启动顺序变成启动失败。于是 NewClient 改成后台连接、立即返回。
问题在于,同一次提交里,消费者也被删掉了:
- go client.runNotifications()- func (c *Client) runNotifications() { ... }生产端还在,队列还在,非阻塞写入还在,但已经没有 goroutine 从队列里取数据了。事件会先填满 64 个缓冲位,之后每一条都走进 default 分支,被安静地丢弃。OnStreamNotify 从此不会再被调用。
这个缺陷为什么没被发现?因为仓库里有一个 waitForStreamNotification 测试辅助函数,写得很完整,却从未被任何用例调用。没有断言,删除消费者不会让任何测试变红。
查看图表源码
sequenceDiagram participant P as 生产者(读协程) participant Q as notify 队列(容量 64) participant C as 消费者(已删除) participant B as 业务回调 P->>Q: 非阻塞写入 alt 队列未满 Q-->>P: 入队成功 else 队列已满 P->>P: default 分支,丢弃并记日志 end Note over C,B: 消费者不存在,队列只进不出用三个问题复盘这次删除:
| 问题 | 答案 | 结论 |
|---|---|---|
| 被保护的共享状态还在吗 | 在,notify 队列仍在接收事件 | 不是死代码 |
| 删除后有等价机制吗 | 没有,没有其它消费者 | 保障缺失 |
| 有测试能证明行为不变吗 | 没有,辅助函数从未被调用 | 无回归保护 |
三个问题有两个不成立,这次删除就是回归。但要注意:同一个提交里,第一件事是对的,第二件事是错的。 判断删除是否合理,要按“改动单元”看,而不是按提交看。把产品取舍和顺手清理混在一起,是 review 最难发现的一类风险。
删除一个并发不变量时,编译器不会提醒你。它只会让程序继续运行,然后安静地丢掉一些东西。
6. 语法成本低,不代表推理成本低
Go 让并发代码看起来很短,但需要想清楚的状态并没有变少。几个经典陷阱,代码都只有几行。
循环变量捕获。Go 1.22 之前,所有闭包共享同一个循环变量:
for _, item := range items { go func() { process(item) // Go 1.22 之前,可能全部处理最后一个元素 }()}Go 1.22 起每次迭代都会创建新变量,这个问题在语言层面被修掉了。但它说明了一件事:并发缺陷往往不是“写错了 API”,而是“作用域与生命周期没有对齐”。
WaitGroup 的 Add 时机:
var wg sync.WaitGroupfor i := 0; i < 10; i++ { go func() { wg.Add(1) // 错误:Add 与 Wait 之间没有同步 defer wg.Done() }()}wg.Wait()正确写法是在启动 goroutine 之前 Add。WaitGroup 一旦开始使用就不能复制,作为函数参数时应该传指针。
锁的粒度与复制:
type Counter struct { mu sync.Mutex n int}func (c Counter) Inc() { // 值接收者复制了锁 c.mu.Lock() c.n++ c.mu.Unlock()}这种代码可以通过编译,go vet 也会提示,但在 review 里仍然经常出现。语法上没有一行是错的,错的是“锁保护的对象”和“被修改的对象”不是同一个。
这些例子共同说明:Go 降低的是打字量,不是状态空间。 并发正确性取决于你对“谁在什么时候读写什么”的推理,而不是取决于用了多少 channel。
7. 真正需要设计的四件事
如果语言不负责正确性和容量,那么设计阶段至少要覆盖四个维度。
7.1 并发度
并发度应该由下游资源决定:连接池大小、下游 QPS 配额、CPU 核数、内存上限。最常用的是 errgroup 的 SetLimit:
func fetchAll(ctx context.Context, ids []string) error { g, ctx := errgroup.WithContext(ctx) g.SetLimit(workerCount) for _, id := range ids { id := id g.Go(func() error { return fetch(ctx, id) }) } return g.Wait()}如果限流需要跨函数、跨阶段,用 semaphore.Weighted;如果是持续消费队列,用固定数量的 worker goroutine。三种方式形态不同,约束相同:goroutine 的数量是设计出来的,不是输入规模推导出来的。
7.2 背压
背压的核心不是“加一个队列”,而是“当系统处理不过来时,明确地拒绝或降级”。有界队列、非阻塞发送、拒绝计数、队列深度指标,四者缺一不可。
type Metrics struct { QueueDepth prometheus.Gauge Rejected prometheus.Counter}func (q *Queue) Submit(ctx context.Context, task Task) error { select { case q.ch <- task: q.metrics.QueueDepth.Set(float64(len(q.ch))) return nil case <-ctx.Done(): return ctx.Err() default: q.metrics.Rejected.Inc() return ErrQueueFull }}如果队列是无界的,那么背压就不存在,只是把故障时间往后推迟。
7.3 生命周期
每一个 goroutine 都应该有明确的退出路径,每一个 context.WithCancel 都应该有对应的 cancel 调用。启动与关闭最好成对出现:
func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() g, ctx := errgroup.WithContext(ctx) g.Go(func() error { return server.Run(ctx) }) g.Go(func() error { return worker.Run(ctx) }) if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) { slog.Error("服务退出", "error", err) }}需要注意,http.Server.Shutdown 会等待普通请求结束,但不会等待被 hijack 的连接,例如 WebSocket。这类连接需要单独关闭,否则“优雅退出”会变成“等满超时”。
7.4 可观测性
并发问题最难的不是修,而是发现。至少要有四类观测手段:
| 手段 | 能看到什么 | 局限 |
|---|---|---|
go test -race | 实际发生的竞争 | 只能覆盖跑到的路径,开销数倍 |
pprof goroutine | 当前 goroutine 的调用栈分布 | 是快照,不是历史 |
runtime/trace | 调度、阻塞、GC 的时间线 | 短时间窗口,适合定位而非长期监控 |
runtime/metrics | goroutine 数、堆、GC、调度延迟 | 需要接入监控和告警 |
runtime.NumGoroutine() 适合做最粗的哨兵:如果它随流量线性上涨且不回落,基本可以确定存在泄漏。再配合队列深度、拒绝计数、下游超时率,才能把“并发问题”从感觉变成指标。
8. 单机不是终点:瓶颈会转移
很多团队把“goroutine 很便宜”当成容量结论,忽略了 Little's Law:
L = λ × W并发请求数等于到达率乘以平均停留时间。1000 QPS、平均 200 ms,系统里同时存在的请求就是 200 个。每个请求即使开 3 个 goroutine,也只有 600 个,对 Go 来说毫无压力。真正的瓶颈会出现在有配额的地方:
| 层次 | 典型瓶颈 | 常用手段 |
|---|---|---|
| 接入层 | 连接数、文件描述符 | 连接复用、限流、超时 |
| 业务层 | 锁竞争、序列化 | 减小临界区、分片、无锁快照 |
| 数据层 | 连接池、慢查询 | 池化、索引、批量、读写分离 |
| 下游 | QPS 配额、第三方限流 | 退避、熔断、singleflight |
| 跨实例 | 分布式竞态 | 幂等键、分布式锁、最终一致 |
单机内的 mutex 保护不了跨实例的共享状态。两个副本同时处理同一个设备的请求,各自加锁都成功,冲突依然存在。这类问题需要幂等键、唯一约束或分布式锁,语言层面的原语帮不上忙。
对于读多写少、回源昂贵的场景,singleflight 可以把并发的相同请求合并成一次:
v, err, shared := group.Do(key, func() (any, error) { return loadFromUpstream(key)})shared 表示这次结果是否被多个调用方共享。它解决的是“并发回源”,不解决“缓存一致性”;后者仍然需要失效策略。
9. 一份可执行的 Go 并发检查清单
为了避免“知道很多道理,仍然写出竞态”,可以把检查项固定下来。
MUST
- 每个 goroutine 都必须有明确的退出路径,并在关闭时被等待。
- 每个 channel 都必须有明确的所有者,关闭操作只能由发送方执行。
- 每个
context.WithCancel都必须调用对应的cancel。 - 并发度必须由下游资源决定,并有明确上限。
- 有界队列必须定义拒绝策略,并记录拒绝次数与队列深度。
- 共享 map 必须加锁、使用
sync.Map,或由单个 goroutine 独占。 - 删除 channel 的消费者时,必须同时删除生产端,或补上回归用例。
- 关闭流程必须等待后台任务退出,而不是只关闭连接。
MUST NOT
- 不得用裸共享内存配合“应该不会同时写”的假设。
- 不得在循环里无条件
go func()扇出到输入规模。 - 不得用无界队列假装实现了背压。
- 不得把
sync.Mutex、sync.WaitGroup作为值复制。 - 不得在持有锁时执行网络 I/O 或阻塞调用。
- 不得只依赖
-race通过就认为没有并发问题。 - 不得在删除并发防护时,只凭“看起来没用到”做判断。
SHOULD
- 在 CI 与预发环境固定运行
go test -race ./...。 - 为关键路径增加 goroutine 数、队列深度、拒绝计数、下游超时率指标。
- 用
errgroup.WithContext收敛错误与取消,而不是散落的WaitGroup。 - 热路径复用
time.Ticker或time.Timer,避免反复创建定时器。 - 对跨实例的写操作使用幂等键或唯一约束。
- 定期用
pprof与runtime/trace做一次并发体检。
10. 结语
回到标题:高并发原生的 Go,到底要不要处理并发问题?
要。但“处理”的含义需要分层:
语法层:Go 负责 goroutine 便宜、channel 好用、调度高效语义层:开发者负责 共享状态、happens-before、生命周期、取消传播容量层:架构负责 并发度、背压、连接池、下游配额、跨实例一致性Go 把并发的门槛降到了语法层,但把并发的责任留在了设计层。
一个 Go 服务可以轻松跑起十万个 goroutine,这确实值得骄傲;但真正决定它是否稳定的,是那十万个 goroutine 是否都有退出路径,它们共享的状态是否有同步保护,它们打向下游的流量是否有上限,以及当其中一个队列写满时,是否有人会收到告警。
< Back to blog listGo 让并发变得便宜,但便宜不等于免费。你省下的是线程管理的成本,付出的仍然是对状态与资源的思考。