【.NET并发编程 – 11】锁机制完全指南:从 lock 到异步锁

11. 锁机制完全指南:从 lock 到异步锁

本章 GitHub 仓库csharp-concurrency-cookbook

欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。


本章导读

本文目标:彻底搞清楚 C# 里所有常用锁的底层原理、适用场景和正确用法,从最基础的 lock 到异步场景的 AsyncLock,一篇讲透。

我见过很多项目,代码里几乎所有地方都用 lock,无论临界区是 10ns 还是 10s,无论同步方法还是异步方法,一律 lock 了事。

当然,能跑起来。但这就像你家里有个锤子,于是所有钉子、螺丝、铆钉,统统你用锤子砸。大部分时候能用,但效率感人,有时候还会把东西砸坏。

今天这章,我们系统地把 C# 里的”工具箱”打开:

工具 适合干什么
Interlocked 单变量原子操作,首选! 计数、赋值、CAS,无锁最快
lock / Lock(.NET 9+) 多变量/复合操作的通用互斥,绝大多数场景
SpinLock 极短临界区(< 100ns),不进内核,纯自旋
ReaderWriterLockSlim 读多写少,读操作可以并发
SemaphoreSlim 限流、异步场景,支持 WaitAsync
Mutex 跨进程互斥(单实例程序)
AsyncLock 异步方法内部互斥,lock 的异步替代

🤔 前置问题:锁究竟是什么?它锁住了什么?

在上代码之前,我想先回答一个很多人其实没想清楚的问题:锁到底是个什么东西?它把什么”锁住”了?

锁不是门,是协议

很多初学者的心智模型是:锁就像一扇门,锁上了别人就进不来。但这个比喻有个严重的误导——锁本身并不能阻止任何人访问任何东西

lock(_obj) 的时候,_obj 这个对象上什么都没发生,它里面的数据也不会被冻结,任何代码依然可以直接访问它。

锁的本质是一种线程间的君子协定

约定:所有人在访问【某块共享资源】之前,必须先获得【某把锁】。
      如果拿不到锁,就等着。

关键:锁和资源之间没有任何强制绑定关系,
      是程序员自己保证"凡是访问该资源,都先获锁"。

所以严格来说,锁锁住的不是数据,而是一段代码的执行权(临界区)

那 lock(_obj) 里的 _obj 是什么?

_obj 是一个锁令牌(lock token),也叫监视器对象(monitor object)。它的作用仅仅是:给 CLR 一个”挂锁状态”的地方。

CLR 在每个引用类型对象的内存头部,都保留了一个 同步块索引(Sync Block Index)字段(4字节)。当你 lock(obj) 时,CLR 就利用这个字段来记录”现在谁持有了这把锁”。

对象在内存中的布局(简化):
┌─────────────────────────────────────────┐
│  对象头 Header(8字节)                   │
│  ├── 同步块索引 [4字节]  ← lock 就用这里  │
│  └── 方法表指针 [4字节]                  │
├─────────────────────────────────────────┤
│  实例字段数据...                          │
└─────────────────────────────────────────┘

lock(obj) 并没有锁住 obj 本身,它只是借用了 obj 的对象头来存放锁状态。你可以对一个空的 new object() 加锁,然后保护完全不相关的资源——这完全合法,也是推荐做法:

private readonly object _lock = new object(); // 专用令牌,不关心它里面有什么
private int _counter = 0;                      // 真正要保护的资源

public void Increment()
{
    lock (_lock)         // 用 _lock 的对象头记录锁状态
    {
        _counter++;      // 保护的其实是这里
    }
}

这就是为什么推荐用 private readonly object _lock = new object():它是一个纯粹的锁令牌,专门用来挂锁,不承担任何业务含义。


Part 0:先搞懂底层原理

在讲具体的锁之前,我们必须先理解一个问题:为什么有的锁快、有的锁慢?

答案在于:锁的实现机制分三种。

用户态 vs 内核态 vs 混合

用户态锁(User-mode)

lockSpinLock 这类锁,完全在用户空间通过 CPU 原子指令实现。获取锁的核心是一条 CMPXCHG(比较并交换)指令:

原子操作:比较内存中的值,如果等于期望值就写入新值,整个过程不可被中断

没有系统调用,不需要操作系统介入,延迟在 10-30 纳秒 级别。

内核态锁(Kernel-mode)

Mutex 是典型代表。它本质上是一个 Windows 内核对象,获取/释放锁需要从用户态切换到内核态,再切回来。这个上下文切换本身就需要 约 1000-5000 纳秒——比用户态锁慢 100-1000 倍。

但内核对象有一个用户态锁永远做不到的特性:跨进程可见

用户态和内核态到底有什么区别?

很多人知道”内核态慢”,但不知道为什么慢。这里稍微展开一下。

现代操作系统把 CPU 的执行权限分成两个级别:

Ring 3(用户态):你的应用程序运行的地方
  - 只能访问自己的内存
  - 不能直接操作硬件
  - 不能访问操作系统内核数据结构

Ring 0(内核态):操作系统内核运行的地方  
  - 可以访问所有内存
  - 可以直接操作硬件
  - 管理线程调度、同步原语等

当你用 Mutex 时,发生了什么:

1. 应用代码调用 WaitOne()
2. CPU 执行 syscall 指令 → 切换到 Ring 0(内核态)
   - 保存当前线程的所有寄存器状态(上下文保存)
   - 切换到内核栈
3. 内核检查 Mutex 状态
   - 如果空闲:标记为当前线程所有,返回
   - 如果占用:把当前线程放入等待队列,挂起线程(线程调度切换)
4. CPU 切回 Ring 3(用户态)
   - 恢复寄存器状态

总计:数百到数千条指令的开销

而用户态的 lockMonitor):

1. 执行 CMPXCHG 原子指令(1条指令!)
   - 如果锁空闲:写入"占用"标记,完成
   - 如果锁占用:先自旋几圈,再考虑进内核

无竞争情况下:约 30ns,仅仅是内存读写 + 原子操作

这就是为什么用户态锁快:没有系统调用,没有特权级切换,没有线程调度介入。锁的获取/释放就是几条 CPU 指令的事。

混合锁(Hybrid)

Monitor(也就是 lock 背后的实现)和 SemaphoreSlim 都是混合锁:

  1. 先自旋:快速自旋几圈,期望锁能在这期间被释放(避免内核切换)
  2. 如果自旋结束还没拿到锁:进入内核等待,让出 CPU

这个策略综合了两者优点:短暂竞争用自旋(快),长时间等待进内核(不浪费 CPU)。

延迟对比(单次加锁/解锁,无竞争):

Interlocked(原子操作) : ~5  ns  ████
SpinLock                : ~10 ns  ████████
lock / Monitor          : ~30 ns  ████████████████████████
SemaphoreSlim           : ~100 ns ████████████████████████████████
Mutex                   : ~2000 ns ████...(长到图都画不下)

记住这张图,它会帮你在后面快速做选择。


Part 1:lock 与 Monitor

1.1 lock 是语法糖,展开来是这样的

// 你写的代码
lock (_lock)
{
	_counter++;
}

// 编译器展开后等价于:
bool lockTaken = false;
try
{
	Monitor.Enter(_lock, ref lockTaken);
	_counter++;
}
finally
{
	if (lockTaken) Monitor.Exit(_lock);
}

注意这里的 ref lockTaken 参数——这是 .NET 2.0 之后的安全写法。lockTaken 记录是否真的获取了锁,防止在 Enter 和赋值之间发生异常时错误地尝试 Exit

结论:99% 的情况直接用 lock。只有需要 TryEnter 超时语义时,才手写 Monitor

1.2 lock(obj) 底层到底做了什么?

我们已经知道 lock(obj) 展开是 Monitor.Enter。那 Monitor.Enter 内部究竟发生了什么?

Monitor.Enter(obj) 的执行流程:

步骤1:检查 obj 的同步块索引
        └── 如果 = 0(未锁定)
                 → 用原子 CAS 操作写入当前线程 ID → 获锁成功,返回
        └── 如果 = 当前线程 ID(同一线程重入)
                 → 递归计数 +1 → 获锁成功,返回(Monitor 支持重入!)
        └── 如果 = 其他线程 ID(竞争)
                 → 进入等待流程(见下)

等待流程(竞争情况):
  阶段1:自旋(SpinWait)
         → CPU 原地忙等,循环检查锁是否释放
         → 自旋次数约 10-20 次(CLR 动态调整)
         → 短临界区通常在这个阶段就能拿到锁

  阶段2:内核等待(如果自旋失败)
         → 调用 OS 等待原语,线程进入挂起状态
         → CPU 被让出,线程从调度队列移除
         → 等锁持有者 Exit 时,OS 唤醒等待线程

1.3 重入:对同一对象 lock 两次会怎样?

Monitor可重入锁(Reentrant Lock),同一线程可以对同一对象多次加锁:

private readonly object _lock = new object();

public void Outer()
{
    lock (_lock)
    {
        Console.WriteLine("外层锁获取");
        Inner(); // 调用同样需要锁的方法
    }
}

public void Inner()
{
    lock (_lock) //  不会死锁!同一线程,Monitor 直接通过,递归计数 +1
    {
        Console.WriteLine("内层锁获取");
    }
    // 退出内层 lock:递归计数 -1,锁还没释放
}
// 退出外层 lock:递归计数变 0,锁正式释放

内部的递归计数机制:

第一次 Enter:计数 = 1,锁定
第二次 Enter:计数 = 2(同线程,直接通过)
第一次 Exit:计数 = 1,锁依然持有
第二次 Exit:计数 = 0,锁正式释放,其他线程可以进入

两个不同线程对同一对象加锁会怎样?

// 线程 A 持有锁的期间
lock (_lock) // 线程 A 已进入
{
    // 线程 B 此时调用 lock(_lock)
    // → 线程 B 检查同步块:是别的线程持有 → 进入等待
    // → 线程 B 被挂起,等待线程 A 退出
    Thread.Sleep(1000);
}
// 线程 A 退出,计数归零 → OS 唤醒线程 B → 线程 B 获取锁

这就是锁的互斥语义的完整实现。

1.4 为什么绝对不能 lock(“字符串”)?

这是个典型的”代码能跑,但是埋了定时炸弹”问题,而且很多人踩坑的时候根本不知道出了什么事。

要搞懂这个,得先弄清楚一件事:C# 里的字符串,和你想象的”普通对象”不一样

先做个实验,结果会让你意外

string s1 = "abc";
string s2 = "abc";

Console.WriteLine(object.ReferenceEquals(s1, s2)); // 你猜输出什么?

你可能觉得:s1s2 是两个变量,应该是两个独立的对象,输出 False

实际输出:True

s1s2 指向的是内存中同一个对象

为什么?——字符串拘留池

C# 里的字符串字面量(就是你代码里写死的 "abc" 这种),CLR 在编译和加载时会统一放进一个叫做字符串拘留池(String Intern Pool) 的地方。

规则很简单:内容相同的字面量,只在内存里保存一份

程序启动时,CLR 建立字符串拘留池:

拘留池
┌──────────────────────────────┐
│  "abc"  ──→  内存地址 0x1A2B  │
│  "hello" ──→ 内存地址 0x3C4D  │
│  ...                          │
└──────────────────────────────┘

string s1 = "abc";  // s1 指向 0x1A2B
string s2 = "abc";  // s2 也指向 0x1A2B(同一个!)

这个设计本身是好的——节省内存,字符串比较也更快。但它给 lock 带来了致命问题。

现在来看 lock 的问题

前面说了,lock(obj) 是用 obj 这个对象的对象头来记录锁状态。那么:

string s1 = "abc";
string s2 = "abc";

// 线程A:
lock (s1) // ← s1 指向拘留池里的 "abc" 对象(地址 0x1A2B)
{
    Thread.Sleep(1000);
}

// 线程B(同时执行):
lock (s2) // ← s2 也指向同一个 "abc" 对象(地址 0x1A2B)!!!
{
    // 这里会被线程A的 lock(s1) 阻塞!
    // 因为 s1 和 s2 根本是同一个对象!
}

你以为 s1s2 是两把独立的锁,实际上它们是同一把锁

这在实际项目里会怎样爆炸?

更真实的场景是跨文件、跨类、甚至跨库:

// ── 文件一:OrderService.cs(你写的)
public class OrderService
{
    public void ProcessOrder(int orderId)
    {
        lock ("db_lock") // 你觉得这是你自己的锁
        {
            // 写数据库...
            Thread.Sleep(200); // 模拟耗时操作
        }
    }
}

// ── 文件二:UserService.cs(同事写的,甚至可能在另一个项目里)
public class UserService
{
    public void SaveUser(string name)
    {
        lock ("db_lock") // 同事也用了相同字符串
        {
            // 也在写数据库...
        }
    }
}

"db_lock" 在整个进程里只有一个对象。结果:

OrderService.ProcessOrder 持有锁,耗时 200ms
  ↓
同时,10 个线程调用 UserService.SaveUser
  ↓
所有 SaveUser 调用全部阻塞在 lock("db_lock") 上
  ↓
OrderService 处理完,释放锁
  ↓
10 个 SaveUser 线程逐个获取锁,排队执行

表面现象:SaveUser 莫名其妙地变慢
排查难点:OrderService 和 UserService 看起来没有任何关系!
          你甚至不会想到去看 OrderService 的代码

这还是同一个项目的情况。如果你引用的某个 NuGet 包内部也用了 lock("db_lock")(这种代码在老项目里真的存在),那你就和一个你根本看不到源码的库争同一把锁——这种 bug 极难发现。

不同程序集是同一个进程,字符串拘留池是共享的

有人会问:不同的 .cs 文件里,字面量会是同一个对象吗?

答案是:只要在同一个进程里,就可能是

你的程序 (MyApp.exe)
├── MyApp.dll       → lock("key") → 拘留池里的 "key" 对象
├── OrderLib.dll    → lock("key") → 同一个对象!
└── ThirdParty.dll  → lock("key") → 还是同一个对象!!!

所有程序集共享同一个字符串拘留池,只要字面量相同,全是同一个对象。

正确做法

//  以下全部有问题
lock ("db_lock")        { }  // 全进程共享,任何地方的同字面量都是同一把锁
lock (this)             { }  // 你的实例是公开的,外部代码也能 lock 它
lock (typeof(MyClass))  { }  // Type 对象全进程唯一,不同地方意外竞争

//  唯一正确的写法
private readonly object _lock = new object();
// 这是 new 出来的新对象,地址唯一,只有当前类能访问
lock (_lock) { }

一句话总结:new object() 每次都是全新的内存地址,不存在”被别人偷偷共享”的可能性。锁令牌必须是 private readonly 的专用对象。


1.5 为什么需要锁?看一个现实的数据竞争

//  危险的代码:10个线程同时累加
long counter = 0;
var tasks = Enumerable.Range(0, 10).Select(_ => Task.Run(() =>
{
	for (int i = 0; i < 10_000; i++)
		counter++; // 看起来是一行,实际是三步:读、加、写
}));
await Task.WhenAll(tasks);

// 期望: 100,000
// 实际: 每次运行结果不同,通常在 60,000-99,000 之间

counter++ 在 CPU 层面是读-改-写三步操作:

步骤1: 读  → 把 counter 的值加载到寄存器(比如读到 42)
步骤2: 改  → 寄存器值 +1(变成 43)
步骤3: 写  → 把 43 写回 counter

如果线程 A 在步骤 1 和步骤 3 之间,线程 B 也执行了步骤 1(读到的还是 42),然后两个线程都把 43 写回去——本该是 44,却只有 43。这就是数据竞争(Race Condition),数据就这样悄悄丢失了。

//  有锁版本
var lockObj = new object();
var tasks = Enumerable.Range(0, 10).Select(_ => Task.Run(() =>
{
	for (int i = 0; i < 10_000; i++)
		lock (lockObj) { counter++; } // 整个读-改-写被保护
}));
// 结果始终是 100,000 

//  更好的做法:对于简单计数,用 Interlocked(更快,无锁)
Interlocked.Increment(ref counter); // 原子操作,不需要 lock

1.6 Monitor.TryEnter:避免永久阻塞

有时候你不想无限等锁,等不到就跳过处理:

// 带超时的尝试获锁
bool acquired = Monitor.TryEnter(_lock, TimeSpan.FromMilliseconds(200));
if (acquired)
{
	try
	{
		// 临界区代码
	}
	finally
	{
		Monitor.Exit(_lock);
	}
}
else
{
	// 200ms 内没拿到锁,执行降级逻辑
	Console.WriteLine("获锁超时,跳过本次处理");
}

这个技巧在防止死锁时特别有用:两个线程如果都用 TryEnter 而不是无限等待,即使出现循环依赖,也能超时退出而不是永久阻塞。

1.7 Monitor.Wait/Pulse:最原始的条件变量

Monitor.WaitMonitor.Pulse 可以实现生产者-消费者模式。不过现代代码里你几乎不需要自己写这个了——Channel<T> 更好用更安全(我们在第09章提到了 Channel)。这里只需要知道它的存在:

// 生产者:放入数据并通知消费者
lock (_queueLock)
{
	_queue.Enqueue(item);
	Monitor.Pulse(_queueLock); // 通知一个等待线程
}

// 消费者:没有数据就等待
lock (_queueLock)
{
	while (_queue.Count == 0 && !_done)
		Monitor.Wait(_queueLock); // 释放锁并等待,被 Pulse 后重新获锁

	if (_queue.Count > 0)
		Process(_queue.Dequeue());
}

现代替代System.Threading.Channels.Channel<T> 在第09章有详细讲解,它比 Monitor.Wait/Pulse 更安全、更易用,推荐优先使用。


Part 1.5(插曲):.NET 9 新增专属 Lock 类

说完 lock + Monitor 的内部机制,插一段彩蛋。

.NET 9 引入了一个专用的 System.Threading.Lock,它是专门为”我就是想做互斥锁”这个场景设计的,用来解决 lock(object) 的几个历史遗留问题。

为什么要有专用 Lock 类?

lock(object) 做互斥锁有个先天缺陷:任何引用类型对象都可以被 lock。这意味着:

  1. 一个对象的同步块(Sync Block)被设计用来存放类型信息、哈希码等杂项数据,锁状态只是其中一个用途——设计上不纯粹
  2. 值类型不能被 lock(lock(42) 会被装箱,每次装箱出来的对象不同,锁完全无效)
  3. 没有 API 能直接查询”这个对象当前有没有被 lock”,调试困难

Lock 类就是为了解决这些问题而生:一个对象,一个职责,就是做互斥锁

基本用法

using System.Threading;

//  使用专属 Lock 类(.NET 9+)
private readonly Lock _lock = new Lock();

public void DoWork()
{
    // 写法一:和 lock(object) 一样的语法!
    // C# 编译器会识别 Lock 类型,生成优化代码
    lock (_lock)
    {
        // 临界区
    }

    // 写法二:using 模式(更现代,更明确)
    using (_lock.EnterScope())
    {
        // 临界区,退出 using 自动释放
    }
}

语法上和 lock(object) 几乎一模一样,但编译器在遇到 lock(Lock类型) 时,会生成专门优化的代码路径。

Lock 类的 API

var lck = new Lock();

// 获取锁(阻塞直到成功)
lck.Enter();
// ... 临界区 ...
lck.Exit();

// 尝试获取锁(带超时)
if (lck.TryEnter(TimeSpan.FromMilliseconds(100)))
{
    try { /* 临界区 */ }
    finally { lck.Exit(); }
}

//  推荐:using 模式,自动 Exit
using (lck.EnterScope())
{
    // 临界区
} // 自动调用 Scope.Dispose() → lck.Exit()

// 查询锁状态
Console.WriteLine(lck.IsHeldByCurrentThread); // 当前线程是否持有

Lock vs lock(object):怎么选?

// .NET 9 之前 / 需要兼容旧版本
private readonly object _lock = new object();
lock (_lock) { /* ... */ }

// .NET 9+ 新代码,推荐用 Lock
private readonly Lock _lock = new Lock();
lock (_lock) { /* ... */ }
// 或
using (_lock.EnterScope()) { /* ... */ }
特性 lock(object) Lock
语法 lock(obj) lock(lck)using (lck.EnterScope())
最低版本 所有 .NET 版本 .NET 9+
专用性 object 兼职做锁 专职互斥锁
调试可见性 一般 IsHeldByCurrentThread 等属性
性能 持平或略好(特化实现)
值类型安全 (值类型被装箱失效) (Lock 就是 class)

结论

  • 维护老代码:继续用 lock(object),不用改
  • .NET 9+ 新代码:推荐改用 Lock 类,语义更清晰,API 更完整
  • 两者可以共存,不存在迁移压力

注意Lock 类和 lock 关键字同名不同物。lock 是 C# 关键字,LockSystem.Threading.Lock 类型。C# 编译器在 .NET 9+ 会自动识别 Lock 类型并生成优化代码。


️ Part 2:Interlocked —— 最快的”无锁”方案

在讲 SpinLock 之前,必须先讲 Interlocked——因为在很多场景里,你根本不需要锁,用 Interlocked 就够了,而且它比任何锁都快。

2.1 什么是原子操作?

先理解一个概念。前面我们说过,counter++ 在 CPU 层面是三步:读→改→写。多线程并发时,这三步可能被打断,导致数据竞争。

解决这个问题有两种思路:

思路 A(加锁):把这三步打包成一个"不可分割的临界区"
                 → lock、Monitor、SemaphoreSlim...
                 → 有开销:获锁/释放锁、可能的内核等待

思路 B(原子指令):让 CPU 直接用一条不可被中断的指令完成整个操作
                   → Interlocked
                   → 无锁,直接由 CPU 硬件保证原子性
                   → 最快!

Interlocked 背后用的是 CPU 的 LOCK XADDLOCK CMPXCHG 等带 LOCK 前缀的指令,这些指令执行期间会锁住内存总线(或用缓存锁),保证整个读-改-写是原子的,没有任何其他核心能插队。

2.2 Interlocked 的完整 API

long counter = 0;

// ── 递增 / 递减(最常用)──────────────────────────────────────────
long result = Interlocked.Increment(ref counter);   // counter++ 的原子版,返回新值
long result2 = Interlocked.Decrement(ref counter);  // counter-- 的原子版,返回新值

// ── 加法 ─────────────────────────────────────────────────────────
long prev = Interlocked.Add(ref counter, 10);       // counter += 10,返回新值

// ── 读取(64位在32位系统上需要原子读)────────────────────────────
long value = Interlocked.Read(ref counter);         // 原子读(64位值)

// ── 赋值 ─────────────────────────────────────────────────────────
long old = Interlocked.Exchange(ref counter, 100);  // 原子赋值,返回旧值

// ── 比较并交换(CAS,最强大的原子操作)────────────────────────────
// 含义:如果 counter 当前值 == comparand,就把它改为 value,返回旧值
long original = Interlocked.CompareExchange(ref counter, value: 50, comparand: 0);
// original == 0  → 说明交换成功(counter 之前是 0,现在是 50)
// original != 0  → 说明交换失败(counter 已被别人改过了)

2.3 典型场景一:计数器

这是最常见的用法,也是 Interlocked 最能替代 lock 的场景:

//  有数据竞争
private long _requestCount = 0;
public void OnRequest() { _requestCount++; } // 不安全!

//  加锁,能用,但太重
private readonly object _lock = new();
public void OnRequest() { lock (_lock) { _requestCount++; } }

//  Interlocked,既安全又最快
private long _requestCount = 0;
public void OnRequest() { Interlocked.Increment(ref _requestCount); }
public long GetCount()  { return Interlocked.Read(ref _requestCount); }

性能差距有多大?在高并发场景下(如每秒百万次请求计数),Interlockedlock3-5 倍

2.4 典型场景二:懒加载(无锁单例)

private static string? _cachedConfig = null;

public static string GetConfig()
{
    // 先读一次,有的话直接返回(无锁快路径)
    if (_cachedConfig != null) return _cachedConfig;

    string loaded = LoadFromDisk(); // 耗时操作

    // CompareExchange:如果还是 null(没人抢先写入),就写入我的值
    // 如果已经被别的线程写入,交换失败,用对方写入的值(避免重复写)
    string? result = Interlocked.CompareExchange(ref _cachedConfig, loaded, comparand: null);

    // result == null  → 我是第一个写入的,_cachedConfig 现在是我加载的值
    // result != null  → 别的线程先一步写了,_cachedConfig 是那个线程的值
    return _cachedConfig!;
}

这个模式叫 CAS(Compare-And-Swap) 无锁编程,是整个无锁算法领域的基础原语。不用任何锁,纯靠 CPU 原子指令保证线程安全。

2.5 典型场景三:状态标志位

// 用 int 模拟 bool(Interlocked 不直接支持 bool)
private int _isRunning = 0; // 0 = false, 1 = true

public bool TryStart()
{
    // 如果当前是 0(未运行),就改为 1(运行中)
    // 返回旧值:== 0 说明我们成功"抢到"了启动权
    int prev = Interlocked.CompareExchange(ref _isRunning, value: 1, comparand: 0);
    return prev == 0; // true = 成功启动,false = 已有人在运行
}

public void Stop()
{
    Interlocked.Exchange(ref _isRunning, 0);
}

这种技巧在需要”只允许一个线程执行某操作”但又不想用重量级锁的场景里非常常用。

2.6 Interlocked 的局限性

Interlocked 很快,但它只能做单个变量的原子操作。一旦你需要同时操作多个变量,就必须用锁:

//  单变量:Interlocked 搞定
Interlocked.Increment(ref _count);

//  多变量:Interlocked 无法保证两个操作的整体原子性
Interlocked.Increment(ref _count);      // 操作1
Interlocked.Add(ref _total, amount);    // 操作2
// 两次操作之间仍然可能被其他线程插入!不是原子的!

//  多变量:必须用 lock
lock (_lock)
{
    _count++;
    _total += amount;
    // 这两步一起是原子的
}

2.7 选择指南

需要操作的变量数量?
  │
  ├─ 单个变量
  │    ├─ 计数(++/--/+=)         → Interlocked.Increment/Decrement/Add
  │    ├─ 赋值                     → Interlocked.Exchange
  │    ├─ 条件赋值(CAS)          → Interlocked.CompareExchange
  │    └─ 64位值读取(32位系统)   → Interlocked.Read
  │
  └─ 多个变量 / 复合操作           → lock(或其他锁)

原则:能用 Interlocked 的地方,永远不要用 lock。前者是 CPU 指令级原子操作,后者是软件层面的互斥协议,性能差距显而易见。


Part 3:SpinLock —— 速度的代价

如果你的场景能用 Interlocked,请先用 Interlocked(上一节)。SpinLock 是在 Interlocked 无法满足(需要保护多个变量的复合操作)、又追求极致性能时的选择。

2.1 什么时候用 SpinLock?

SpinLock 的核心是:我不进内核,我就一直在 CPU 上转圈等,等你释放锁

这听起来很浪费——明明在等,却一直占着 CPU。但如果锁被持有的时间极短(几十纳秒),那进入内核的切换开销反而比”转几圈”更贵。所以 SpinLock 的使用前提是:

 适合 SpinLock:
   临界区只有 1-2 行纯内存操作(如更新几个字段)
   持有时间 < 100ns
   竞争程度适中(不是疯狂竞争)

 不适合 SpinLock:
   临界区里有 IO、数据库、网络操作
   临界区里有锁嵌套
   高度竞争(大量线程同时争锁),会疯狂消耗 CPU

2.2 SpinLock 的正确用法(有个大坑!)

// ️ 重要:SpinLock 是 struct(值类型)!
// 必须声明为字段,永远不要复制!
private static SpinLock _spinLock = new SpinLock(enableThreadOwnerTracking: false);

//  正确用法
bool lockTaken = false;
try
{
	_spinLock.Enter(ref lockTaken); // 注意 ref!
	// 临界区(应该极短)
	_counter++;
}
finally
{
	if (lockTaken) _spinLock.Exit(useMemoryBarrier: false);
}

那个大坑是什么?

//  致命错误!SpinLock 是值类型,这是在复制!
SpinLock localCopy = _spinLock; // 现在 localCopy 是一个独立的副本!
bool taken = false;
localCopy.Enter(ref taken);     // 锁的是副本,原来的 _spinLock 根本没被锁!

//  同样的错误,传参时不加 ref
void DoWork(SpinLock sl) { ... }     // sl 是副本
DoWork(_spinLock);                    // 调用时复制了!

//  正确:用 ref 传递
void DoWork(ref SpinLock sl) { ... } // ref 传递,操作原始对象
DoWork(ref _spinLock);

SpinLockstruct 这个特性在整个 .NET 里算是比较特殊的——大多数同步原语都是 class,偏偏 SpinLock 为了性能选择了值类型。这个坑踩过一次就忘不了。


Part 4:ReaderWriterLockSlim —— 读多写少的神器

3.1 核心设计思想

想象一个图书馆:同时可以有 100 个人在读书(读锁),但上架/移走书籍时(写锁),必须清场,所有人等外面。

读-读: 可以同时进行
读-写: 互斥,必须等待
写-写: 互斥,必须等待

这就是 ReaderWriterLockSlim 的核心语义。对于读多写少的场景,它能让读操作完全并行,比 lock 效率高得多。

3.2 三种锁模式

private readonly ReaderWriterLockSlim _rwLock = new();
private readonly Dictionary<string, string> _cache = new();

// ── 读锁(多个线程可以同时持有)
public string? Get(string key)
{
	_rwLock.EnterReadLock();
	try
	{
		_cache.TryGetValue(key, out var val);
		return val;
	}
	finally { _rwLock.ExitReadLock(); }
}

// ── 写锁(独占,所有其他读/写都要等)
public void Set(string key, string value)
{
	_rwLock.EnterWriteLock();
	try { _cache[key] = value; }
	finally { _rwLock.ExitWriteLock(); }
}

// ── 可升级读锁(先读,必要时升级为写)
public string GetOrAdd(string key, Func<string> valueFactory)
{
	_rwLock.EnterUpgradeableReadLock(); // 先以读锁进入
	try
	{
		if (_cache.TryGetValue(key, out var existing))
			return existing; // 命中缓存,直接返回

		// 需要写入,升级为写锁
		_rwLock.EnterWriteLock();
		try
		{
			// ️ 二次检查!升级过程中可能另一线程已写入
			if (!_cache.TryGetValue(key, out existing))
			{
				existing = valueFactory();
				_cache[key] = existing;
			}
			return existing;
		}
		finally { _rwLock.ExitWriteLock(); }
	}
	finally { _rwLock.ExitUpgradeableReadLock(); }
}

3.3 为什么需要”可升级读锁”?

你可能会想:为什么不直接释放读锁再获写锁?

//  危险的写法(有竞态窗口)
_rwLock.EnterReadLock();
bool exists = _cache.ContainsKey(key);
_rwLock.ExitReadLock();    // ← 到这里,有一个窗口期

// ← 此时另一个线程可能也判断 exists=false,并开始写入

_rwLock.EnterWriteLock();  // ← 两个线程都会写入!
_cache[key] = valueFactory(); // 重复计算,资源浪费,甚至错误
_rwLock.ExitWriteLock();

可升级读锁解决了这个窗口:整个”判断+写入”过程是原子的,不会有其他线程插队。

3.4 使用注意

// ️ ReaderWriterLockSlim 实现了 IDisposable,用完要释放
using var rwLock = new ReaderWriterLockSlim();

// ️ 默认不允许同一线程重入(LockRecursionPolicy.NoRecursion)
// 如果你的代码可能递归进入同一把锁,构造时要说明:
var rwLock2 = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
// 但这通常意味着设计有问题,能不用就别用

Part 5:SemaphoreSlim —— 并发控制的瑞士军刀

4.1 信号量的本质

如果 lock 是”同时只有一人进入”,那 SemaphoreSlim 就是”同时最多 N 人进入”。

想象一个停车场:有 5 个车位(initialCount = 5),每进一辆车计数 -1,每出一辆车计数 +1,满了就等。

// 创建最多允许 5 个并发的信号量
using var semaphore = new SemaphoreSlim(initialCount: 5, maxCount: 5);

4.2 并发限制器(最常用场景)

// 场景:批量调用外部 API,最多 5 个并发,避免打垮对方
using var semaphore = new SemaphoreSlim(5, 5);
var orderIds = Enumerable.Range(1, 50).ToList();

var tasks = orderIds.Select(async orderId =>
{
	await semaphore.WaitAsync(); // 异步等待令牌(不阻塞线程!)
	try
	{
		var result = await httpClient.GetAsync($"/api/orders/{orderId}");
		return await result.Content.ReadAsStringAsync();
	}
	finally
	{
		semaphore.Release(); // 归还令牌
	}
});

var results = await Task.WhenAll(tasks);

注意这里的关键:WaitAsync()异步等待,等待期间不会阻塞线程(第04章讲过的状态机原理在这里发挥作用),而 WaitOne() 是同步阻塞。在异步代码里永远用 WaitAsync()

4.3 当成异步互斥锁用(initialCount = 1)

这是 SemaphoreSlim 最重要的用法:在异步方法里保护共享状态

// initialCount=1, maxCount=1 → 退化为互斥锁,但支持 async/await
private readonly SemaphoreSlim _mutex = new(1, 1);
private int _sharedValue = 0;

public async Task UpdateAsync(int delta)
{
	await _mutex.WaitAsync(); // 异步获锁
	try
	{
		int current = _sharedValue;
		await SomeAsyncOperation(); //  锁内可以 await!
		_sharedValue = current + delta;
	}
	finally
	{
		_mutex.Release(); // 务必在 finally 里!
	}
}

为什么不用 lock?因为 lock 里不能 await——编译器直接报错(CS1996)。原因在第五章讲过:lock 基于线程 ID,await 后可能换线程执行,导致锁无法释放。

4.4 带超时和取消的等待

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

try
{
	// 等待最多 10 秒,但如果 CancellationToken 触发则提前退出
	bool acquired = await semaphore.WaitAsync(
		millisecondsTimeout: 10_000,
		cancellationToken: cts.Token);

	if (!acquired)
	{
		Console.WriteLine("获取信号量超时");
		return;
	}

	try { /* 临界区 */ }
	finally { semaphore.Release(); }
}
catch (OperationCanceledException)
{
	Console.WriteLine("被取消令牌取消");
}

Part 6:Mutex —— 跨进程的守门人

5.1 Mutex 只有一个适用场景

说真的,在单进程应用里,你几乎不需要 Mutex。它比 lock100-1000 倍,因为每次获取/释放都要经历用户态→内核态→用户态的切换。

但它有一个 lock 永远做不到的事情:跨进程互斥

最经典的场景:防止程序被重复启动

const string mutexName = "Global\\MyApp_SingleInstance";
// "Global\\" 前缀表示跨所有用户会话可见

bool createdNew;
using var mutex = new Mutex(initiallyOwned: true, name: mutexName, createdNew: out createdNew);

if (!createdNew)
{
	// 已有实例在运行
	MessageBox.Show("程序已在运行!");
	// 激活已有窗口...
	Application.Exit();
	return;
}

// 正常启动
Application.Run(new MainForm());
// 程序退出时,using 块会 Dispose Mutex,其他实例就能启动了

命名 Mutex 是 Windows 全局对象,只要名字相同,不同进程里的 Mutex 就是同一把锁。这是实现单实例程序的标准做法。

5.2 性能直觉

lock 一次:约 30 ns
Mutex 一次:约 1000-5000 ns(约慢 33-150 倍)

10 万次操作:
  lock:约 3 ms
  Mutex:约 100-500 ms

结论很明确:除非必须跨进程,否则绝不用 Mutex 做线程同步。


Part 7:异步锁 AsyncLock

7.1 为什么 lock 不能用在 async 方法里?

这个问题在第05章讲 SynchronizationContext 时已经提到过,这里把原理讲清楚:

//  这段代码连编译都过不了!(CS1996)
lock (_obj)
{
	await SomeAsyncMethod(); // 编译器直接拒绝
}

原因:

  1. lock 本质上是 Monitor,它基于线程归属——线程 A 获锁,线程 A 释放
  2. await 之后,代码的续接可能在不同线程上执行(SynchronizationContext 决定)
  3. 如果换了线程,Monitor.Exit 会抛出 SynchronizationLockException(”当前线程没有持有这把锁”)
  4. 即使没换线程,持锁期间让出 CPU 也会造成问题

所以编译器干脆不让你这么写。

7.2 等等——AsyncLocal<T> 不是已经有了吗?它能替代 AsyncLock 吗?

这是一个非常常见的混淆,必须专门说清楚。

AsyncLocal<T>AsyncLock 是两个完全不同的东西,解决的是两个不同的问题。

AsyncLocal<T> AsyncLock(手写/Nito.AsyncEx)
是什么 异步上下文的”随身数据” 异步方法里的互斥锁
解决什么 数据如何随 async 调用链传递 多个 async 任务如何互斥访问共享资源
类比 像快递盒上贴的”收件人信息”,随包裹流动 像厕所的门锁,同时只有一个人能进
多线程安全 每个异步链路有自己的副本,天然隔离 主动互斥,同时只有一个任务能持有

用一个例子来说清楚区别:

// ── AsyncLocal<T>:数据隔离,不同调用链互不干扰 ──

private static readonly AsyncLocal<string> _currentUser = new();

async Task HandleRequest(string userId)
{
	_currentUser.Value = userId;        // 把当前用户绑定到这条调用链上
	await ProcessAsync();               // 下游可以读到 _currentUser.Value
}

async Task ProcessAsync()
{
	// 无论哪个线程执行这里,拿到的都是"发起这条调用链的用户"
	Console.WriteLine(_currentUser.Value); // 每条调用链看到的是自己的值
}

// 三条并发请求,各自看到自己的 userId,互不干扰
await Task.WhenAll(
	HandleRequest("user_A"),  // 这条链里 _currentUser.Value = "user_A"
	HandleRequest("user_B"),  // 这条链里 _currentUser.Value = "user_B"
	HandleRequest("user_C")   // 这条链里 _currentUser.Value = "user_C"
);
// AsyncLocal 没有任何互斥语义,三条链完全并行,没有谁等谁
// ── AsyncLock:互斥,同时只有一个任务能进入临界区 ──

private static readonly SemaphoreSlim _lock = new(1, 1);
private static int _sharedBalance = 1000; // 所有请求共享的余额

async Task TransferAsync(string userId, int amount)
{
	await _lock.WaitAsync();   // 等待:前一个任务完成前不能进入
	try
	{
		// 这里同时只有一个任务在执行,保护共享状态
		int current = _sharedBalance;
		await Task.Delay(10);  // 模拟数据库操作
		_sharedBalance = current - amount;
		Console.WriteLine($"{userId} 转账 {amount},余额 {_sharedBalance}");
	}
	finally { _lock.Release(); }
}

一句话总结:

  • AsyncLocal<T> —— 解决”数据怎么随 async 调用链传播”,每条链有自己的副本,没有任何互斥
  • AsyncLock —— 解决”多个 async 任务如何互斥保护共享资源”,有互斥,有等待

如果你要在 async 方法里保护一个共享的余额、缓存、或者限制并发数,AsyncLocal<T> 完全帮不上忙——你需要的是 AsyncLock(或 SemaphoreSlim)。

AsyncLocal<T> 的详细用法和原理,会在后续章节(ThreadLocal 与 AsyncLocal)中专门讲解。

7.3 实现一个支持 using 语法的 AsyncLock

public sealed class AsyncLock
{
	private readonly SemaphoreSlim _semaphore = new(1, 1);

	public async Task<IDisposable> LockAsync(CancellationToken ct = default)
	{
		await _semaphore.WaitAsync(ct);
		return new Releaser(_semaphore);
	}

	private sealed class Releaser : IDisposable
	{
		private readonly SemaphoreSlim _sem;
		private bool _disposed;

		internal Releaser(SemaphoreSlim sem) => _sem = sem;

		public void Dispose()
		{
			if (!_disposed)
			{
				_sem.Release();
				_disposed = true;
			}
		}
	}
}

7.4 使用 AsyncLock

private static readonly AsyncLock _asyncLock = new();
private int _sharedValue = 0;

public async Task UpdateValueAsync(int delta)
{
	//  语法和 lock 非常像,但支持 async/await
	using (await _asyncLock.LockAsync())
	{
		int current = _sharedValue;
		await FetchFromDatabaseAsync(); //  锁内可以任意 await
		_sharedValue = current + delta;
	} // 退出 using 块,自动调用 Dispose,释放信号量
}

实际生产中,如果项目引用了 Nito.AsyncEx 包,它提供了更完善的 AsyncLock 实现,支持取消、超时、重入等特性。但自己实现这个版本足够应对大多数场景。


️ Part 8:常见陷阱速查

陷阱 1:lock 对象选错了

//  lock(this):外部代码也能 lock 你的对象,意外死锁
public void Process() { lock (this) { ... } }
// 外部:lock (myService) { ... } ← 和 Process 争同一把锁!

//  lock(typeof(T)):Type 对象是全进程共享的,不同类可能意外争锁
lock (typeof(MyClass)) { ... }

//  lock("字符串"):CLR 字符串拘留机制,相同字面量是同一对象
lock ("connection") { ... } // 所有用 "connection" 加锁的地方共用同一把锁!

//  始终用私有的专用 object
private readonly object _lock = new object();

陷阱 2:死锁经典场景

//  两个线程以相反顺序获取锁 → 死锁
// 线程A:先锁 lockA,再锁 lockB
// 线程B:先锁 lockB,再锁 lockA
// → 两人互相等对方释放,永远阻塞

//  预防方法:
// 1. 统一加锁顺序(所有代码都按 lockA → lockB 的顺序)
// 2. 用 Monitor.TryEnter 加超时,超时后退出重试
// 3. 重构代码,避免同时持有多把锁

陷阱 3:SpinLock 值类型陷阱

//  复制 SpinLock!
SpinLock copy = _spinLock; // struct 复制,完全不同的对象!

//  ref 传递
void Work(ref SpinLock sl) { ... }
Work(ref _spinLock);

陷阱 4:SemaphoreSlim 忘记 Release

//  异常路径 Release 不执行,信号量永久泄漏
await semaphore.WaitAsync();
await DoWorkAsync(); // 如果抛异常...
semaphore.Release(); // ...这行不会执行!

//  始终用 try/finally
await semaphore.WaitAsync();
try { await DoWorkAsync(); }
finally { semaphore.Release(); } // 无论如何都会执行

Part 9:如何选择锁?

快速决策树

需要保护的是单个变量,还是多个变量/复合操作?
  │
  ├─ 单个变量(计数、赋值、CAS)→ Interlocked(无锁,最快)
  │
  └─ 多个变量 / 临界区
		│
		├─ 同步代码
		│    ├─ 极短临界区(< 100ns,纯内存)→ SpinLock
		│    ├─ 普通临界区 ──→ 读写比例?
		│    │                    ├─ 读多写少 → ReaderWriterLockSlim
		│    │                    └─ 读写均衡 → lock(.NET 9+ 用 Lock 类)
		│    └─ 需要跨进程        → Mutex
		│
		└─ 异步代码
			 ├─ 互斥(同时只有一个) → SemaphoreSlim(1,1) 或 AsyncLock
			 └─ 限流(同时最多 N 个)→ SemaphoreSlim(N,N)

性能参考(相对于无竞争 lock)

场景 相对性能 备注
Interlocked 简单原子操作 最快 不是锁,但很多场景可以替代
SpinLock 极短临界区 高竞争下反而慢
lock / Lock 通用 首选;.NET 9+ 推荐 Lock
ReaderWriterLockSlim 读多写少 读并行更快 写锁比 lock 稍慢
SemaphoreSlim 限流/异步互斥 中等 异步场景必选
Mutex 跨进程 慢 100x 只用于跨进程

🧪 动手实验

示例代码在 Locks/ 项目中,运行方式:

dotnet run --project Locks
文件 内容
Internals/LockInternalsDemo.cs 用户态/内核态/混合锁原理、SpinWait 演示
LockBasics/LockAndMonitorDemo.cs lock 本质、数据竞争、重入、TryEnter、Wait/Pulse、Lock 类
LockBasics/InterlockedDemo.cs 原子计数、Exchange、CAS、无锁懒加载、性能对比、局限性
LockBasics/SpinLockDemo.cs SpinLock 用法、性能对比、值类型陷阱
AdvancedLocks/ReaderWriterLockSlimDemo.cs 读写锁、可升级锁、线程安全缓存
AdvancedLocks/SemaphoreSlimDemo.cs 并发限制、异步互斥、超时取消
AdvancedLocks/MutexDemo.cs 跨进程互斥、单实例程序、性能对比
AsyncLock/AsyncLockDemo.cs 为什么 lock 不能 await、AsyncLock 实现与用法
Pitfalls/LockPitfallsDemo.cs 四大陷阱完整演示
Comparison/LockPerformanceComparison.cs 所有锁的横向性能对比

本章小结

工具 适用场景 不适用
Interlocked 单变量原子操作(计数、赋值、CAS) 多变量复合操作
lock(object) 多变量/复合操作,兼容所有版本 async 方法内部
Lock(.NET 9+) 同上,语义更清晰,API 更丰富 async 方法内部
SpinLock 极短临界区(< 100ns,纯内存) 有 IO、高竞争
ReaderWriterLockSlim 读多写少(缓存、配置) 读写均衡的场景
SemaphoreSlim 并发限流、异步互斥 不需要限流的简单互斥
Mutex 跨进程互斥(单实例应用) 进程内同步(太慢)
AsyncLock async 方法内部互斥 同步代码(用 lock)

三条原则

  1. 能用 Interlocked 就用 Interlocked——操作单个变量时,这是最快的,没有之一
  2. 同步代码默认用 lock(.NET 9+ 用 Lock),异步代码默认用 SemaphoreSlim(1,1)
  3. 读多写少用 ReaderWriterLockSlim,需要跨进程才用 Mutex

文章摘自:https://www.cnblogs.com/diamondhusky/p/20191417