从线程池到 goroutine:并发思维的转变
写 Go 半年了,今天想聊聊我理解中客户端和 Go 在并发上最本质的差别。标题有点大,内容是务实的。 做移动端的时候,并发是个"危险操作":线程宝贵,要线程池;主线程神圣不可侵犯;到处想的是怎么避免并发。到了 Go 这边,第一课就是反转:goroutine 很便宜,并发是常态,要担心的是共享状态。 举一个我实际改过的例子。刚入职时我写了一个聚合接口,习惯性地写成了串行调用:先查用户信息,再查订单,再查优惠券,一个 200ms 的接口被我写成了 600ms。mentor 看了一眼说,这三个没依赖关系,用 errgroup 并发去。改完的代码大概长这样: g, ctx := errgroup.WithContext(ctx) var user User g.Go(func() error { var err error user, err = userRepo.Get(ctx, uid) return err }) var orders []Order g.Go(func() error { var err error orders, err = orderRepo.List(ctx, uid) return err }) if err := g.Wait(); err != nil { return nil, err } 这段代码现在看稀松平常,但当时给我震撼挺大的:在 OC 里做同样的事要 dispatch_group 加一堆样板,而 Go 里就几行。而且 WithContext 把"一个下游失败就取消其他请求"这件事也顺带做了。 但便宜是有代价的。 goroutine 开起来太容易,泄漏也就太容易。我见过最隐蔽的一个 bug:一个 goroutine 往无缓冲 channel 写数据,下游超时提前返回了,这个 goroutine 就永远阻塞在那里。流量一上来,内存曲线肉眼可见地往上爬。排查工具是 pprof 的 goroutine profile,看到几千个阻塞在同一个位置的 goroutine 时,那个画面我忘不了。 所以现在我的习惯是:每个 goroutine 都要有明确的退出路径。要么由 ctx 控制生命周期,要么用带缓冲的 channel 并接受可能丢数据,绝不裸开。这大概是我从线程池时代带过来、但用新方式表达的旧智慧:资源永远是有限的。