自旋锁仅适用于极短关键路径:临界区必须稳定在几十纳秒级、纯计算、指令数可预测,且不调用任何可能调度或阻塞的操作,否则将耗尽cpu。

能用,但必须满足“极短关键路径”这个前提——临界区执行时间必须稳定在几十纳秒级,且 goroutine 数量可控。否则自旋锁会把 CPU 吃满,反而拖垮整个服务。
自旋锁只适合临界区 Lock 到 Unlock 之间干完三件事的场景
自旋锁不是互斥锁的“更快替代品”,它是对特定时序敏感路径的窄带优化。它的价值只在以下条件全部成立时才兑现:
- 临界区内不调用任何可能调度或阻塞的操作:比如
time.Sleep、runtime.Gosched()、channel操作、net.Conn.Read、fmt.Sprintf(字符串拼接易触发 GC)、json.Marshal - 临界区纯计算,且指令数可预测:例如原子计数器递增
atomic.AddInt64(&x, 1)、指针替换atomic.StorePointer(&p, unsafe.Pointer(newObj))、位运算标记atomic.OrUint64(&flags, 1 - 单次执行耗时
(实测建议用 <code>go test -benchmem -benchtime=5s+pprof火焰图确认) - goroutine 并发数 ≤ P 的数量(即不超过 GOMAXPROCS),避免跨 P 自旋失效
SpinLock 实现里最常踩的两个坑
手写自旋锁看似简单,但生产环境出问题往往就栽在这两处:
-
Unlock()必须用atomic.StoreUint32(&l.state, 0),不能用atomic.CompareAndSwapUint32(&l.state, 1, 0)—— 后者在锁已被他人释放后再次调用会失败,导致解锁丢失,死锁静默发生 - 绝对不要在自旋循环里加
time.Sleep(1)或runtime.nanosleep做退避:Go 没有用户态纳秒级 sleep,这些调用会陷入内核,彻底失去“自旋”意义,还引入额外调度开销 - 如果临界区可能 panic(比如访问了 nil 指针),
defer l.Unlock()是安全的,但要确保Unlock()本身不 panic(上面的StoreUint32实现是安全的)
什么时候该换回 sync.Mutex?看这三点信号
一旦出现以下任一现象,说明自旋锁正在反向伤害系统:
-
go tool pprof -cpu显示runtime.mcall或runtime.gopark占比异常升高 —— 这代表大量 goroutine 因自旋失败被迫进入阻塞队列,已失去自旋初衷 - CPU 使用率持续 > 90%,但 QPS 不升反降,火焰图里
SpinLock.Lock函数自身占比超过 30% —— 说明自旋在空转,没换来实际执行 - 临界区里出现了哪怕一次
mallocgc调用(可通过go tool pprof -alloc_space验证)—— 内存分配器压力会让自旋成功率断崖下跌
高频读场景下,别直接套 SpinLock,先看 sync.RWMutex 是否够用
很多开发者想用自旋锁保护缓存读取,其实走错了方向:
-
sync.RWMutex的读锁RLock()在竞争不激烈时会自动触发短暂自旋(无需配置),且读操作本身不修改状态,天然比写锁更适配自旋逻辑 - 真正需要自旋的,往往是写路径中的“原子切换”动作,比如:
atomic.StorePointer(&cache.current, newMap)—— 此时用自旋锁包裹这一行即可,而不是给整个cache.Set()函数加锁 - 若读操作本身带副作用(如记录访问时间戳),那它就不是纯读,
RWMutex无法覆盖,这时才考虑为写侧单独实现轻量自旋
自旋锁的边界非常锋利:它不是“更快的锁”,而是“更窄的锁”。越试图把它通用化,越容易掉进 CPU 空转、goroutine 饥饿、GC 干扰的组合陷阱里。真正有效的优化,永远始于对临界区执行时间的精确测量,而不是对锁类型的主观偏好。











