简介
在 .NET 里,只要你开始深入看并发控制,很快就会发现这样一个现象:
- 大多数业务代码都用
lock - 异步场景更常见的是
SemaphoreSlim/AsyncLock - 简单状态更新则更适合
Interlocked
但在更底层的高性能并发场景里,你还会遇到一个更"危险"的家伙:
csharp
SpinLock
一句话先说透:
SpinLock是一种忙等待锁。抢不到锁时,线程不会立刻阻塞挂起,而是先在 CPU 上自旋等待,反复尝试获取锁。
它之所以存在,不是因为 lock 不够用,而是因为某些极端场景下:
- 临界区极短;
- 锁持有时间非常短;
- 线程切换成本反而更贵;
这时候"先原地转几圈"可能比"马上挂起线程再唤醒"更划算。
但它也是典型的双刃剑。
用对了,它可能比普通阻塞锁更快。
用错了,它会让 CPU 白白空转,吞吐下降,代码也更难维护。
所以这篇文章重点不是只讲 API,而是讲清楚:
SpinLock到底是什么;- 它和
lock/Monitor、Interlocked有什么本质区别; - 为什么它是
struct,这会带来什么坑; - 什么场景适合它,什么场景绝对不适合;
- 实战里应该怎么做选择。
SpinLock 到底是什么?
可以先用一句最直白的话理解:
SpinLock是一个专门为"极短临界区"设计的低级同步原语。
它位于:
csharp
System.Threading
和 lock 最大的区别在于等待策略。
lock / Monitor 更像什么?
抢不到锁时,更倾向于:
- 进入等待;
- 由运行时和操作系统协调后续唤醒。
SpinLock 更像什么?
抢不到锁时,更倾向于:
- 先不睡;
- 先在用户态反复尝试;
- 期待锁很快就会被释放。
也就是说,它的核心思路不是:
- "先挂起线程,等别人叫醒我"
而是:
- "这把锁如果马上就放开了,那我先别切线程"
为什么会有 SpinLock?
因为线程阻塞和唤醒并不是免费的。
在普通业务代码里,这些开销通常可以接受。
但如果临界区小到只有几条指令,例如:
- 更新一个计数器;
- 修改一个极小的共享状态;
- 保护某个非常短的数据结构操作;
那么这时候真正贵的可能不是"拿锁"本身,而是:
- 线程挂起;
- 调度切换;
- 唤醒恢复。
这就是 SpinLock 的出发点:
- 如果锁持有时间极短;
- 那么先自旋几次,可能比切线程更便宜。
SpinLock 和 lock 的核心区别
先看这张表:
| 维度 | SpinLock |
lock / Monitor |
|---|---|---|
| 等待方式 | 自旋忙等 | 竞争后可阻塞等待 |
| CPU 占用 | 等待时可能更高 | 一般更平稳 |
| 使用难度 | 高 | 低 |
| 可重入 | 默认不适合重入 | 可重入 |
| 适用场景 | 极短临界区 | 绝大多数业务场景 |
| 容易误用 | 很高 | 相对低 |
这里最关键的不是"谁更高级",而是:
SpinLock是优化工具;lock是默认工具。
也就是说,大多数时候,你不该先想到 SpinLock。
它和 Interlocked 又是什么关系?
这是另一个很重要的边界。
如果你的操作只是:
- 单个原子增减;
- 单次交换;
- CAS 比较交换;
那第一选择通常不是 SpinLock,而是:
csharp
Interlocked
因为:
Interlocked直接针对单个原子操作;- 不需要真的"加锁";
- 更轻、更直接。
所以可以先这样记:
- 单个原子操作:
Interlocked - 普通互斥:
lock - 极短且明确证明值得优化的临界区:
SpinLock
SpinLock 的基本用法
最标准的模板是这样:
csharp
using System.Threading;
public sealed class Counter
{
private SpinLock _spinLock = new SpinLock(enableThreadOwnerTracking: false);
private int _value;
public void Increment()
{
bool lockTaken = false;
try
{
_spinLock.Enter(ref lockTaken);
_value++;
}
finally
{
if (lockTaken)
{
_spinLock.Exit();
}
}
}
}
这段代码里最重要的点有三个:
- 必须用
bool lockTaken - 必须
try/finally - 只有真的拿到锁才
Exit()
为什么一定要写 lockTaken?
因为 SpinLock 不会像 lock 语法糖那样帮你把整个获取和释放包好。
它要求你自己精确知道:
- 到底有没有成功拿到锁。
所以标准模板总是:
csharp
bool lockTaken = false;
try
{
spinLock.Enter(ref lockTaken);
}
finally
{
if (lockTaken) spinLock.Exit();
}
这是最基本的安全用法。
SpinLock 最危险的一个点:它是 struct
这是很多人第一次用时最容易忽略的坑。
SpinLock 不是引用类型,而是:
csharp
struct
这意味着什么?
这意味着:
- 它会被复制;
- 一旦你不小心按值传递,就可能操作的是副本;
- 结果不是"稍微慢一点",而是同步语义直接失效。
例如这种写法就是危险的:
csharp
void DoWork(SpinLock spinLock)
{
bool lockTaken = false;
spinLock.Enter(ref lockTaken);
try
{
// ...
}
finally
{
if (lockTaken) spinLock.Exit();
}
}
这里传进去的是副本,不是原锁。
更稳妥的方式是:
csharp
void DoWork(ref SpinLock spinLock)
{
bool lockTaken = false;
spinLock.Enter(ref lockTaken);
try
{
// ...
}
finally
{
if (lockTaken) spinLock.Exit();
}
}
所以 SpinLock 的一个核心使用原则就是:
不要随便复制它,不要按值传递它。
为什么不要把它当成普通字段随便玩?
同理,你也不该轻易把它:
- 暴露出去;
- 赋值拷贝给别的变量;
- 放进容易产生副本语义的位置。
SpinLock 最适合的姿势通常是:
- 作为私有字段;
- 在持有它的对象内部直接使用;
- 必要时只通过
ref传递。
它为什么经常被说"不支持重入"?
因为 SpinLock 不是为了可重入设计的。
也就是说,如果同一个线程:
- 还没释放第一次拿到的锁;
- 又再次进入同一个
SpinLock;
这通常不是好事。
在默认配置下,这种情况往往会让你很难看。
所以对 SpinLock 必须有一个很明确的认知:
它不是给递归调用、复杂锁层级和可重入业务代码准备的。
如果你的代码天然需要重入语义,通常更该考虑的是:
lockMonitor
而不是强行上 SpinLock。
enableThreadOwnerTracking 是干什么的?
构造函数里你会看到这个参数:
csharp
new SpinLock(enableThreadOwnerTracking: true)
它的作用可以先粗暴理解成:
- 帮你跟踪"当前持有锁的是哪个线程";
- 在调试错误用法时更容易发现问题。
但代价是:
- 额外开销更高;
- 性能更差一些。
所以实战里通常可以先这样记:
- 排查问题、调试阶段:可以考虑开
- 明确要追求性能的正式路径:通常关掉
SpinLock 真的是"纯死循环空转"吗?
不是。
虽然它叫"自旋锁",但内部不是简单的:
csharp
while (true) { }
它的等待策略会比这种粗暴死循环更聪明。
更准确地说,它会结合:
- 原子尝试;
- 自旋等待;
- 适度让步;
去平衡这些目标:
- 尽量快拿到锁;
- 尽量别过度烧 CPU;
- 尽量别太早进入更重的等待路径。
所以它是"忙等待",但不是"无脑死循环"。
从源码视角看,SpinLock 内部大致在做什么?
如果从运行时实现思路去理解,SpinLock 可以粗略看成三部分:
- 一个表示锁状态的内部字段;
- 一段基于原子操作的抢锁逻辑;
- 一段失败后的自旋与让步策略。
它的核心路线并不复杂:
- 先用类似
Interlocked.CompareExchange的方式尝试把锁从"未持有"改成"已持有"。 - 如果抢到了,直接进入临界区。
- 如果没抢到,就进入一段受控的自旋等待。
- 在持续失败时,不会一直傻转,而是逐步增加让步强度。
所以从源码思路上说,SpinLock 的本质不是"有一个神秘的新锁",而是:
用原子状态位 + 自旋等待策略,在用户态尽量把极短互斥做快。
这也是它和 Monitor 最大的思路差异。
Monitor 的目标是通用、稳妥、可维护。
SpinLock 的目标则更像:
- 临界区足够短;
- 我宁可先花一点 CPU;
- 也不想太早进入更重的等待路径。
SpinWait 在这里扮演什么角色?
很多人会把"自旋"理解成:
csharp
while (!success) { }
但 .NET 里真正成熟的自旋策略,通常会借助 SpinWait 这一类机制。
它的价值在于:
- 前几次失败时,先做非常轻量的等待;
- 随着失败次数增加,再逐步让出时间片;
- 避免把 CPU 长时间浪费在最粗暴的忙等上。
也就是说,SpinLock 的等待策略更接近:
- 先积极尝试;
- 再适度退让;
- 最终避免无限制地硬扛。
这也是为什么说它不是"纯死循环空转"。
更务实地说,SpinWait 这类机制解决的是一个平衡问题:
- 如果太早让线程睡下去,短锁会因为切换成本吃亏;
- 如果一直不让步,高争用时又会疯狂烧 CPU。
SpinLock 实际上就是在这个平衡点上做文章。
什么场景适合 SpinLock?
它真正适合的场景其实很窄。
一般要同时满足这些条件:
- 临界区极短;
- 锁持有时间稳定且很短;
- 争用存在,但不是夸张到把 CPU 打爆;
- 代码非常明确,不包含阻塞操作;
- 你已经用性能分析证明普通锁这里确实是瓶颈。
典型例子可能是:
- 极短的共享状态切换;
- 高频缓存槽位更新;
- 自定义并发数据结构里的局部保护;
- 某些底层基础组件的热点路径。
什么场景绝对不适合?
下面这些情况,基本都不该优先考虑 SpinLock:
- 临界区里有
await - 临界区里有
Thread.Sleep - 临界区里有
I/O - 临界区里会访问数据库、网络、文件
- 临界区代码不透明,可能调用未知外部逻辑
- 锁持有时间明显不短
- 高争用严重场景
一句话说透:
只要锁内代码可能慢,
SpinLock就很危险。
因为等待线程不会老老实实睡觉,而是在持续消耗 CPU。
在 async/await 代码里能不能用?
实战上基本可以直接记成:
- 不要用。
原因很简单:
SpinLock是同步自旋锁;- 它适合极短同步临界区;
- 异步代码的等待模型和它完全不匹配。
如果你在 async/await 场景里需要互斥,通常应该看的是:
SemaphoreSlimAsyncLock
而不是 SpinLock。
为什么很多时候 lock 反而更好?
因为 lock 的最大优点不是"绝对最快",而是:
- 默认就够好;
- 语义清晰;
- 可维护性高;
- 更不容易被误用。
而且要注意一点:
Monitor并不是一个"完全傻乎乎一竞争就睡眠"的原语。
它本身也有成熟的优化路径。
所以在很多你以为该用 SpinLock 的地方,现实里很可能:
lock已经够快;SpinLock只是让代码更复杂、更脆弱。
从运行时取舍看,为什么 Monitor 往往更稳?
这也是源码和面试里很常见的追问。
很多人会先入为主地觉得:
- 自旋是在用户态;
- 阻塞要走更重的路径;
- 所以
SpinLock天然应该更快。
这个判断只在一个前提下成立:
锁真的很快就会被释放。
一旦这个前提不成立,情况就会反过来。
因为 Monitor 的优势从来不只是"能加锁",而是它更擅长处理这些复杂现实:
- 竞争时间不确定;
- 线程数可能偏多;
- 临界区有时长有时短;
- 业务代码不总是那么干净可控。
这时候,SpinLock 的问题就会暴露出来:
- 等待线程持续消耗 CPU;
- 竞争越激烈,浪费越明显;
- 性能收益很容易被吃掉,甚至出现负优化。
所以把这两个原语放到一个更大的运行时视角下看,会更容易做判断:
SpinLock优化的是"短时间内别切线程";Monitor优化的是"通用互斥下的整体稳定性"。
也正因为如此,绝大多数业务代码最后还是会落回 lock。
一个非常实用的选择顺序
如果你在做并发控制,可以先按这个顺序判断:
- 能不能用
Interlocked解决? - 如果不能,普通
lock是否已经足够? - 如果
lock真的成了瓶颈,且临界区极短,再考虑SpinLock。
这个顺序很重要。
因为 SpinLock 从来不该是默认答案。
SpinLock 最常见的坑
1. 忘记 finally 释放
这是最基础也最致命的问题。
2. 复制了 SpinLock
因为它是值类型,这个坑非常隐蔽。
3. 在锁里做了慢操作
这是最容易把 CPU 打满的原因之一。
4. 把它当成异步锁来用
这是方向性错误。
5. 高争用下误以为"自旋一定更快"
很多时候并不是。
争用越高、持锁越长,自旋浪费的 CPU 就越多。
一个更务实的理解方式
可以把 SpinLock 想成这样:
lock是默认家用工具;Interlocked是最轻量的原子工具;SpinLock是性能手术刀。
它不是不能用,而是:
- 只有在边界非常清晰;
- 且已经有性能数据支撑时;
- 才值得上。
面试里怎么答比较到位?
如果面试官问:
"SpinLock 和 lock 的区别是什么?"
一个比较完整的回答可以是:
SpinLock是忙等待锁,抢不到锁时线程不会立刻阻塞,而是先自旋尝试获取;lock基于Monitor,更适合普通业务互斥。SpinLock适合极短临界区和低到中等争用的高性能热点路径,但它是值类型、容易被复制、不适合重入、不适合 async,也不能在锁内放慢操作。大多数业务场景下,仍然应该优先使用lock或Interlocked。
如果继续追问"什么时候该用 SpinLock",就接着答:
- 临界区极短;
- 已经过性能分析确认普通锁是瓶颈;
- 代码完全同步且可控;
- 不存在阻塞和复杂调用链。
这基本就答到点上了。
如果面试官继续追问"它底层大概怎么实现",可以再补一句:
它本质上是一个基于原子操作维护锁状态的用户态同步原语。进入时先尝试 CAS 抢锁,失败后结合自旋和逐步让步策略继续竞争,目标是在极短临界区里避免过早进入更重的等待路径。
如果再追问"那为什么不直接一直用 SpinLock,反正省掉阻塞了",更稳的回答是:
因为它省掉的只是某些场景下的线程切换成本,但换来的是等待线程持续占用 CPU。只有锁持有时间极短时,这个交换才值得;一旦持锁时间变长或竞争变高,
Monitor往往会更稳。
如果问"SpinLock 和 SpinWait 是什么关系",可以回答:
SpinWait更像一种等待策略组件,负责控制自旋时如何逐步退让;SpinLock是完整的互斥原语,它会在内部用到类似的自旋思路来平衡抢锁速度和 CPU 消耗。
如果问"最容易暴露你是否真的用过 SpinLock 的坑是什么",优先答这三个:
- 它是
struct,复制会破坏同步语义; - 它不适合
async/await; - 锁里只要混入慢操作,就很容易把 CPU 浪费掉。
总结
SpinLock 的本质,不是"比 lock 更高级的锁",而是:
一个用 CPU 空转换取线程切换成本的低级同步原语。
最值得记住的其实只有四句话:
SpinLock适合极短、极小、极可控的临界区;- 它是值类型,复制和按值传递都会带来严重问题;
- 它不适合异步代码、不适合慢操作、不适合高争用长持锁;
- 没有性能分析证据前,优先用
Interlocked或lock,而不是直接上SpinLock。
如果把它当成"性能优化手术刀",你的判断通常会比较稳。
如果把它当成"更快的通用锁",那基本迟早会出问题。