C#/.NET AsyncLock 完全解析:async/await 下的并发控制方案

凌晨两点半
凌晨两点半
发布时间:2026-08-23 | 179人浏览过
原创

前言

在 C# 异步开发里,只要多个任务会同时碰到同一份共享状态,互斥控制就绕不过去。问题在于,同步世界里的 lock 放进 async/await 后并不能直接复用;本文会从这个断点切入,进一步拆解 AsyncLock 的实现方式、适用边界,以及它和 SemaphoreSlim、分布式锁该怎么区分。

在 .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 仍然是保护临界区的标准写法:

展示传统 lock 与 AsyncLock 在 async/await 场景中的行为差异,包括线程阻塞、等待方式和临界区进入顺序。
lock 与 AsyncLock 的行为差异用流程对比看清:为什么异步方法里不能继续沿用传统 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 的目标很明确:让异步任务按顺序进入临界区,释放时唤醒下一个等待者,同时整个等待过程不阻塞线程。

展示 AsyncLock 基于 SemaphoreSlim(1,1) 的内部结构与 using(await LockAsync()) 的释放流程。
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 之上的更高层封装。

特性SemaphoreSlimAsyncLock
可同时进入的任务数可指定 (n)永远只允许 1
使用方式WaitAsync/Releaseusing(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 适合限流式并发控制,而一旦范围扩展到跨进程或分布式系统,就该考虑分布式锁了。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表