最近在设计编写一个企业级Agent OS,很多场景会直接编写go func(){...}(...)代码。匿名go func(){}()本质上是“fire-and-forget"的并发原理:没有返回值、没有取消信号、没有汇合点、没有名字。在大型的工程中,多人协作的项目里,坏处会被无限放大。下面按“稳定性、正确性、可观测性、性能分类”:

1. 声明周期时空 -- goroutine泄漏

假设代码:

for _,id := range ids {
    go func(){process(id)}() //谁等它,它卡住了如何取消
}

以上代码至少存在如下问题:

  • 没有join点:函数返回了,goroutine还在跑,调用方以为任务已经结束了
  • 没有取消通道:若下游卡在http.Get、ch<-x、mu.Lock等上,永远卡住
  • 没有超时:一个阻塞的goroutine占2KB起始栈+引用的所有对象,GC永远无法回收
  • 典型泄漏源:向无缓冲/已满channel发送、读取永不关闭的channel、未设置超超时的网络调用、等待一把已被return前忘记Unlock的锁

哪怕泄漏率为0.1%,长期运行的服务几个月后goroutine数就会达到十万,内存曲线单调上生。

2.错误与panic无法传播

  • 错误被吞掉:go func()里的error只能log,或者干脆丢弃。上层无法重试、无法回滚、无法返回给调用者。
  • panic直接杀进程:goroutine内部没有recover,任何一个空指针/越界都会让整个进程退出 -- 不是只挂掉那个任务。这与并发任务失败的预期完全不符。
  • 没有原子性和顺序保证:多个goroutine各执行各的,部分成功、部分失败,留下脏数据。

3.并发无界 -- 打垮自己或下游

这点恐怕是项目里最现实的问题。

  • 后果链条:假设代码中有上千行go func(){}(),如果在routine内访问数据库或下游的http服务,并发数量无限制,很容易击穿下游的链接池,导致应用本身进入重试风暴,导致雪崩。
  • 没有背压:生产速度不受消费速度的制约,内存里堆积未处理的数据结构。
  • 缺少限流/信号量/worker pool这类显式的并发上限。

4.上下文丢失

  • context没有传递进去,取消/超时链路trace全部断开。
  • 请求已经被客户端断开了,后台goroutine还在继续执行。
  • trace断链后,线上排查时只能看到一段没有parent的孤立的span。

性能并不免费

  • goroutine有成本:起始栈2KB、栈增长需copy(延迟抖动),GC扫描每个goroutine的栈时,调读取要维护runqueue。
  • 闭包捕获导致变量逃逸到堆,多一分分配+GC压力。
  • 大量goroutine争抢GOMAXPROCS个P,调度延迟和尾延迟(p99)明显劣化--吞吐可能不升反降。
  • 频繁创建销毁带来额外的开销。

针对以上的问题,改造的建议是:下一篇。

Logo

更多推荐