为什么需要并发控制

在 .NET 开发中,并发(concurrency)几乎无处不在:Web 服务器同时处理成百上千的请求、后台任务并行拉取多个数据源、批量调用第三方接口……并发能带来吞吐量的提升,但也会带来麻烦。

最常见的几类问题:

  • 资源被压垮:数据库连接池容量是 100,结果同时来了 500 个查询,剩下的 400 个只能排队甚至超时。
  • 第三方限流:调用的外部 API 有 QPS 限制,并发太高会收到 429 错误。
  • 写入冲突:多个任务同时写同一个文件,最后一个写完的会覆盖前面的。

这些问题的本质都是:有限的资源被过多的并发请求争抢。解决办法也很直接——限制同时能进入”临界区”的请求数量,让多出来的请求排队等待。这就是并发控制。

在 .NET 里做并发控制最常用的工具之一,就是 SemaphoreSlim

SemaphoreSlim vs Semaphore:名字像,差别不小

.NET 里其实有两个信号量:SemaphoreSemaphoreSlim。初学者容易混淆,先说结论:绝大多数场景下用 SemaphoreSlim 就对了。

两者的关键区别:

对比项 Semaphore SemaphoreSlim
是否跨进程 是(可命名,能跨进程同步) 否(仅进程内)
底层实现 封装操作系统信号量 纯托管代码实现
异步支持 仅同步 WaitOne 提供 WaitAsync
性能 有系统调用开销 轻量,开销小
适用场景 跨进程资源控制 单进程内的并发限制

简单记:SemaphoreSlimSemaphore 的”轻量异步版”,去掉了跨进程能力,换来了更小的开销和对 async/await 的原生支持。在 ASP.NET Core 这类纯托管、单进程(多线程)的场景下,SemaphoreSlim 是首选。

基本用法:Wait 和 Release

SemaphoreSlim 的核心模型就是一个”许可证”计数器:初始有 N 个许可证,请求进来拿一个(数量减一),用完还一个(数量加一)。许可证不够时就排队等。

// 创建一个初始有 3 个许可证的信号量
// 也就是说:最多允许 3 个线程同时进入临界区
using var semaphore = new SemaphoreSlim(initialCount: 3);

// 模拟 10 个任务争抢 3 个名额
for (int i = 0; i < 10; i++)
{
    var index = i; // 闭包捕获需要局部变量
    Task.Run(async () =>
    {
        // Wait:申请一个许可证,没有就阻塞等待
        semaphore.Wait();
        try
        {
            Console.WriteLine($"任务 {index} 进入,开始工作");
            await Task.Delay(1000); // 模拟工作
            Console.WriteLine($"任务 {index} 完成");
        }
        finally
        {
            // Release:归还许可证,必须放在 finally 里
            // 否则一旦抛异常,许可证就再也还不回来,最终所有线程都会卡死
            semaphore.Release();
        }
    });
}

运行这段代码你会发现:前 3 个任务立即开始,剩下的 7 个在 Wait() 处排队,等前面有人 Release 后才能进入。这就是最基本的并发限制。

要点Release 一定要放在 finally 块里。这是新手最容易踩的坑——异常路径下忘了归还许可证,会导致信号量被永久占用。

异步用法:WaitAsync

在 ASP.NET Core 这种异步密集的场景里,Wait() 是个隐患:它会阻塞线程。在高并发下,线程池被阻塞的线程占满,整个服务的吞吐量会断崖式下降。

正确做法是使用 WaitAsync,它是异步版本,等待期间不会占用线程:

public class DatabaseAccess
{
    // 限制同时打开的数据库连接数
    private readonly SemaphoreSlim _connectionGate = new SemaphoreSlim(10);

    public async Task<Data> QueryAsync(string sql)
    {
        // WaitAsync:异步等待许可证,不阻塞线程
        await _connectionGate.WaitAsync();
        try
        {
            using var conn = new SqlConnection(_connectionString);
            await conn.OpenAsync();
            return await conn.QueryFirstOrDefaultAsync<Data>(sql);
        }
        finally
        {
            _connectionGate.Release();
        }
    }
}

WaitAsync 的神奇之处在于:当许可证不足时,调用方会被挂起(await 暂停),当前线程被释放回线程池去处理别的请求;一旦有许可证归还,挂起的代码再被唤醒继续执行。这是异步并发的精髓——用最少的线程做最多的活

.NET 6 以后,SemaphoreSlim 还提供了 WaitAsync(CancellationToken) 的重载,支持取消。在 ASP.NET Core 里建议总是带上请求的 CancellationToken,这样客户端断开时能及时取消等待。

实际场景

场景一:限制数据库连接数

数据库的连接池是有上限的(默认一般 100 左右),如果并发查询太多,连接池会被耗尽。用 SemaphoreSlim 在数据访问层加一道闸门:

public class Repository
{
    // 限制同时进行的数据库操作不超过 50
    // 这样即使有 500 个并发请求,也只有 50 个真正去拿连接
    private static readonly SemaphoreSlim DbGate = new SemaphoreSlim(50);

    public async Task<User> GetUserAsync(int id, CancellationToken ct)
    {
        await DbGate.WaitAsync(ct);
        try
        {
            // 真正占用连接的代码
            return await _db.Users.FindAsync(id);
        }
        finally
        {
            DbGate.Release();
        }
    }
}

场景二:HTTP 请求限流

调用第三方 API 时,对方通常有 QPS 限制。超了就 429,反复重试反而更慢。直接限制并发数:

public class ThirdPartyApiClient
{
    private readonly HttpClient _client;
    // 限制对外并发不超过 5,给第三方留余量
    private readonly SemaphoreSlim _rateGate = new SemaphoreSlim(5);

    public async Task<string> CallApiAsync(string url, CancellationToken ct)
    {
        await _rateGate.WaitAsync(ct);
        try
        {
            var resp = await _client.GetAsync(url, ct);
            resp.EnsureSuccessStatusCode();
            return await resp.Content.ReadAsStringAsync(ct);
        }
        finally
        {
            _rateGate.Release();
        }
    }
}

注意:限制并发数 ≠ 限制 QPS。QPS 还受每次请求耗时影响。如果要严格按 QPS 限流,需要配合时间窗口算法(如令牌桶)。SemaphoreSlim 解决的是”同一时刻在路上的请求数量”。

场景三:文件写入控制

日志、报表等场景下,多个任务往同一个文件写,直接并发写会导致内容错乱。用 SemaphoreSlim 把并发数设为 1,就退化成了一个”异步互斥锁”:

public class AsyncFileLogger
{
    // initialCount=1:同一时刻只允许一个写入
    private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1);

    public async Task LogAsync(string message)
    {
        await _writeLock.WaitAsync();
        try
        {
            // 串行写入,避免内容交错
            await File.AppendAllTextAsync("app.log", $"{DateTime.Now:O} {message}\n");
        }
        finally
        {
            _writeLock.Release();
        }
    }
}

初始许可设为 1 的信号量,等价于一把”异步锁”,比 lock 语句好在不阻塞线程,比 Monitor 好在支持 await。

与 Channel 对比

System.Threading.Channels 是另一个流行的并发工具,它和 SemaphoreSlim 经常被放在一起讨论,但定位完全不同:

  • SemaphoreSlim 是”限流器”——控制同时进行的数量,请求之间没有数据流动。
  • Channel 是”管道”——生产者写数据、消费者读数据,控制的是数据流动,天然解耦生产与消费。

举一个对比例子:要从 1000 个 URL 抓取数据。

SemaphoreSlim

// 限制并发为 10,所有任务共享一个信号量
var sem = new SemaphoreSlim(10);
var tasks = urls.Select(async url =>
{
    await sem.WaitAsync();
    try { return await FetchAsync(url); }
    finally { sem.Release(); }
});
var results = await Task.WhenAll(tasks);

Channel

// 创建一个有界 Channel
var channel = Channel.CreateBounded<string>(100);
// 多个消费者从 Channel 读数据并处理
var consumers = Enumerable.Range(0, 10).Select(async _ =>
{
    await foreach (var url in channel.Reader.ReadAllAsync())
    {
        await FetchAsync(url);
    }
});
// 生产者把 URL 写入 Channel
foreach (var url in urls) await channel.Writer.WriteAsync(url);
channel.Writer.Complete();
await Task.WhenAll(consumers);

两者都能实现”10 个并发抓取”,区别在于:

  • SemaphoreSlim 模型简单,适合”一次性并发限制”;
  • Channel 适合”生产消费解耦、长流水线、消费者长期运行”的场景,比如后台常驻 Worker。

经验法则:只是限制并发数,用 SemaphoreSlim;要在不同阶段间传递数据、解耦生产消费,用 Channel。两者也能组合使用——Channel 的消费端再套一个 SemaphoreSlim 做二次限流。

最佳实践

总结几条实战中验证过的经验:

1. 用 usingtry-finally 保证 Release

许可证是稀缺资源,必须归还。最稳妥的写法是 try-finally,或者借助 IDisposable 包装:

// 一个简单的可释放包装,配合 using 语句使用更优雅
public sealed class SemaphoreReleaser : IDisposable
{
    private readonly SemaphoreSlim _sem;
    public SemaphoreReleaser(SemaphoreSlim sem) { _sem = sem; }
    public void Dispose() => _sem.Release();
}

// 用法
public async Task DoWorkAsync()
{
    await _sem.WaitAsync();
    using var _ = new SemaphoreReleaser(_sem);
    // 业务逻辑
}

2. 永远优先 WaitAsync

在异步代码里用同步 Wait() 几乎总是错的。它阻塞线程、可能导致死锁(特别是有同步上下文的场景,比如旧版 ASP.NET 或 WinForms)。新项目统一用 WaitAsync

3. 带上 CancellationToken

await _sem.WaitAsync(cancellationToken);

这样客户端取消请求时,等待中的代码能及时退出,避免无意义的资源占用。

4. 静态共享、实例隔离

如果信号量是用于全局资源(比如数据库连接池),声明为 static readonly;如果是实例级别的(每个 Controller 实例独立),就声明为实例字段。混用会导致限流失效或过度限制。

5. 注意初始许可不要设错

new SemaphoreSlim(0) 意味着一开始没有任何许可,所有人都会卡死——除非别处主动 Release。最常见的还是 new SemaphoreSlim(N),表示允许 N 个并发。

6. 不要把许可数设得过大

许可数应该匹配底层资源的真实容量,而不是”越大越好”。许可设成 1000 但数据库只能扛 100,等于没限制。

小结

SemaphoreSlim 是 .NET 里做进程内并发限制的轻量级利器。它的模型简单——许可证的借与还,但用好了能解决很多实际问题:数据库连接保护、第三方接口限流、文件写入互斥。

记住几个关键词就够了:WaitAsync 优先、Release 放 finally、带上 CancellationToken、许可数对齐真实容量。和 Channel 相比它更轻、更直接,适合”单纯限制并发”的场景;和 Semaphore 相比它去掉了跨进程能力但换来了异步支持和性能。

理解了这套机制,再去看连接池、限流中间件、并行任务调度等组件的内部实现,会发现它们底层都用到了类似的”许可证”思想。掌握 SemaphoreSlim,是迈向高并发 .NET 开发的必经一步。