在 .NET 异步代码里,真正麻烦的不是“怎么并发”,而是“并发时怎么安全地只让一个任务进入临界区”。很多人第一反应还是用 lock,但一旦方法进入 async/await 语境,这条路很快就会遇到编译限制、线程阻塞和死锁风险。下面就按问题拆开:先看 lock 为什么不适合异步方法,再看 AsyncLock 的实现原理、典型写法,以及什么时候该用它、什么时候该换成别的并发控制手段。
为什么 async/await 场景需要 AsyncLock
AsyncLock 属于自定义的异步互斥锁,目标是在 async/await 方法里实现线程安全的互斥访问。它解决的是这样一类问题:多个异步任务会同时访问同一份共享资源,比如文件、数据库、缓存状态或 UI 更新逻辑,但这些访问又必须串行执行。
在 .NET 中,传统的 lock 语句基于 Monitor,本质上是同步阻塞的。放到异步环境里,等待锁的线程会被卡住,线程池资源也会跟着被占用。在高并发场景下,例如 Web API 或 TCP 处理,这种阻塞会直接拖慢吞吐,严重时还会放大死锁风险。
AsyncLock 的核心思路正好相反:等待锁时不阻塞线程,而是通过 await 挂起任务,把线程池资源让出来。常见实现基于 SemaphoreSlim(1, 1),也通常支持 CancellationToken 取消操作。
需要注意的是,.NET 标准库里并没有内置 AsyncLock 类型。生产环境中更常见的做法,是直接使用 NuGet 包 Nito.AsyncEx,这是由 Stephen Cleary 维护的成熟实现。
private readonly AsyncLock _mutex = new AsyncLock();
public async Task DoWorkAsync()
{
using (await _mutex.LockAsync())
{
await Task.Delay(100);
}
}
传统 lock 在异步代码里有什么问题
同步代码中使用 lock 没问题
在同步方法里,lock 仍然是保护临界区的标准写法:

private readonly object _syncRoot = new object();
public void Increment()
{
lock (_syncRoot)
{
_count++;
}
}
一旦遇到 await,lock 就走不通了
把相同思路直接搬到异步方法里,会立刻碰到编译错误:
public async Task IncrementAsync()
{
lock (_syncRoot)
{
await SomeAsyncOperation(); // ❌ 编译错误
}
}
原因有三个,而且都和 await 的执行模型直接相关:
await会让出当前线程控制权;- 如果允许在
lock内部挂起,锁的持有和恢复语义会变得不可靠; - 一旦作用域释放与异步恢复时机错位,就会破坏原本的线程安全保证。
这也是为什么在异步代码里,不能用“同步阻塞锁 + await”去拼接出互斥语义。你需要的是异步安全的锁,而不是把同步锁硬塞进异步方法里。
AsyncLock 的核心原理是什么
AsyncLock 的目标很明确:让异步任务按顺序进入临界区,释放时唤醒下一个等待者,同时整个等待过程不阻塞线程。

为什么通常基于 SemaphoreSlim(1, 1)
SemaphoreSlim 本身就支持 WaitAsync,而 (1, 1) 这个配置意味着同一时刻只允许一个执行单元进入,相当于把它当作异步互斥锁来使用。
一个典型实现如下:
public sealed class AsyncLock
{
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
private readonly Task<IDisposable> _releaser;
public AsyncLock()
{
_releaser = Task.FromResult((IDisposable)new Releaser(this));
}
public Task<IDisposable> LockAsync()
{
var wait = _semaphore.WaitAsync();
return wait.IsCompleted
? _releaser
: wait.ContinueWith((_, state) => (IDisposable)state,
_releaser.Result, CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
private sealed class Releaser : IDisposable
{
private readonly AsyncLock _toRelease;
internal Releaser(AsyncLock toRelease) => _toRelease = toRelease;
public void Dispose()
{
_toRelease._semaphore.Release();
}
}
}
Releaser 模式解决了什么问题
这段实现的关键不是“能等到锁”,而是“怎么稳定释放锁”。
LockAsync()返回的是一个Task<IDisposable>;- 调用方在
using (await ...)中拿到这个对象; - 作用域结束时自动执行
Dispose(),最终调用_semaphore.Release()。
换句话说,AsyncLock 把原本容易漏掉的释放动作包装成了更不容易写错的调用约定。对业务代码来说,进入和退出临界区会更直观。
怎么使用 AsyncLock
最常见的写法:using (await _lock.LockAsync())
基础用法通常就是下面这样:
private readonly AsyncLock _lock = new AsyncLock();
private int _count = 0;
public async Task IncrementAsync()
{
using (await _lock.LockAsync())
{
_count++;
await Task.Delay(100); // 模拟异步操作
Console.WriteLine($"Count: {_count}");
}
}
如果并发调用多个任务:
var tasks = Enumerable.Range(0, 5).Select(_ => IncrementAsync());
await Task.WhenAll(tasks);
输出将是:
Count: 1
Count: 2
Count: 3
Count: 4
Count: 5
也就是说,虽然任务是并发发起的,但进入临界区的顺序仍然是串行的,因此不会出现计数竞争。
异步文件写入是典型适用场景
当多个任务要并发写同一个文件时,AsyncLock 很适合拿来包住实际写入动作:
public class AsyncFileWriter
{
private readonly AsyncLock _lock = new AsyncLock();
private readonly string _filePath;
public AsyncFileWriter(string path) => _filePath = path;
public async Task WriteAsync(string message)
{
using (await _lock.LockAsync())
{
await File.AppendAllTextAsync(_filePath, message + Environment.NewLine);
}
}
}
这样即使有多个异步任务同时写入,同一时刻也只会有一个任务真正进入写文件的临界区,内容不会交错。
AsyncLock 和 SemaphoreSlim 有什么区别
两者关系很近,但定位并不完全一样。AsyncLock 往往是建立在 SemaphoreSlim 之上的更高层封装。
| 特性 | SemaphoreSlim | AsyncLock |
|---|---|---|
| 可同时进入的任务数 | 可指定 (n) | 永远只允许 1 |
| 使用方式 | WaitAsync/Release | using(await LockAsync()) |
| 使用便捷性 | 稍复杂 | 简洁且自动释放 |
| 推荐场景 | 控制并发数量 | 异步临界区互斥 |
如果你的目标是“同时最多允许 n 个任务执行”,那就应该直接选 SemaphoreSlim。如果你的目标是“这个异步代码块同一时刻只能进一个任务”,那么 AsyncLock 的语义更直接,也更不容易误用。
进阶用法:取消、超时与返回结果
支持 CancellationToken 的版本
很多业务场景里,锁等待不能无限持续,这时就需要把取消能力接进来:
public async Task<IDisposable> LockAsync(CancellationToken cancellationToken)
{
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return new Releaser(_semaphore);
}
这个版本的重点在于:如果调用方的 CancellationToken 被触发,等待锁的任务就可以提前退出,而不是一直挂起。
带超时控制的 AsyncLock
如果你不仅想支持取消,还想明确区分“成功获取锁”和“等待超时”,可以进一步包装成返回结果对象的形式:
public class AsyncLockWithTimeout
{
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
public async Task<LockResult> TryLockAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
{
if (await _semaphore.WaitAsync(timeout, cancellationToken))
{
return new LockResult(this, true);
}
return new LockResult(this, false);
}
public class LockResult : IDisposable
{
private readonly AsyncLockWithTimeout _lock;
private readonly bool _acquired;
public bool Acquired => _acquired;
public LockResult(AsyncLockWithTimeout asyncLock, bool acquired)
{
_lock = asyncLock;
_acquired = acquired;
}
public void Dispose()
{
if (_acquired)
{
_lock._semaphore.Release();
}
}
}
}
// 使用示例
public async Task<bool> TryProcessWithTimeoutAsync()
{
using var lockResult = await _lock.TryLockAsync(TimeSpan.FromSeconds(5));
if (lockResult.Acquired)
{
// 成功获取锁
await ProcessDataAsync();
return true;
}
else
{
// 获取锁超时
return false;
}
}
这里保留了一个很实用的业务判断:Acquired。调用方可以清楚知道这次是正常进入临界区,还是因为等待超过 5 秒而放弃执行。
性能特点与使用注意事项
优点
- 异步友好,不阻塞线程;
- 调用方式简洁,容易和
using配合; - 适合保护文件、数据库、共享状态等异步资源。
注意事项
- 不适合高频率、极短临界区操作,因为
SemaphoreSlim本身有额外开销; - 不要长时间持有锁,否则等待队列会迅速堆积;
- 它解决的是单进程内异步互斥,不适用于跨进程或跨机器协调。
该选 lock、AsyncLock 还是别的方案
最后可以按场景快速判断:
| 场景 | 推荐锁类型 |
|---|---|
| 同步代码块 | lock |
| 异步方法 | AsyncLock |
| 控制并发数 | SemaphoreSlim |
| 跨进程或跨机器 | 分布式锁(Redis、SQL、Zookeeper 等) |
可以把它理解为一句话:lock 适合同步临界区,AsyncLock 适合异步临界区,SemaphoreSlim 适合限流式并发控制,而一旦范围扩展到跨进程或分布式系统,就该考虑分布式锁了。









