活锁是goroutine持续“醒着”却无效重试导致cpu满载的服务停滞现象,需用指数退避、channel或超时机制替代自旋,而非runtime.gosched()。
活锁不是死锁,但cpu会先爆掉
活锁在go里不会报 fatal error: all goroutines are asleep - deadlock!,它更隐蔽:所有goroutine都“醒着”,却在反复尝试、失败、重试,把cpu吃满到100%+,服务响应停滞。典型场景是分布式锁重试、自旋等待资源、或多个goroutine用非阻塞方式争抢同一资源(比如反复 atomic.compareandswap 失败后立即重试)。
for {} + runtime.Gosched() 不是解药,而是慢性毒药
很多人以为加个 runtime.Gosched() 就能缓解活锁,其实不然。它只是让出当前M的执行权,不释放OS线程,也不触发调度器重新分配P——尤其在GOMAXPROCS=1时,等于原地打转。更糟的是,高频调用 runtime.Gosched() 本身会放大调度开销,加剧CPU震荡。
- 别写
for { if !tryLock() { runtime.Gosched() } else { break } } - 真正要的是退避(backoff),不是让出(yield)
- 哪怕只退1微秒,也比空转百万次强
用带退避的CAS或channel替代自旋
活锁本质是“无等待策略”的自旋竞争。解决思路不是让它转得更“礼貌”,而是让它等得有节奏。
- 对共享状态用
atomic.LoadUint64+ 条件判断,避免在锁未就绪时进入循环 - 重试逻辑必须含指数退避:
time.Sleep(time.Duration(rand.Int63n(int64(base)))),base从1ms起跳 - 优先用
select配合time.After或带超时的context.WithTimeout,把“忙等”转为“挂起等待” - 例如分布式锁续期失败时,不要
for { renew() },而应select { case
pprof里一眼识别活锁热点
活锁在 go tool pprof 的CPU profile中特征极明显:顶部函数几乎全是你的重试逻辑(比如 tryAcquireLock、spinWait),且调用栈极浅、无系统调用、无GC标记痕迹。此时看 Goroutine 数量可能正常,但每秒调度次数(sched sweeps)会飙升到10万+/秒——这是调度器在疯狂轮询唤醒被你“假唤醒”的goroutine。
真正难察觉的是:这种问题在线上压测初期不爆发,等到QPS突破某个阈值、退避来不及生效时,CPU才突然拉满。所以退避参数不能硬编码,得随负载动态调整。










