在并发编程中,我们经常需要限制同时访问某个共享资源或执行某段代码的线程数量,以避免资源耗尽或产生竞态条件。例如,数据库连接池、HTTP 请求限流、文件写入等场景都需要一种轻量级的同步机制。 .NET 提供了多种同步原语,其中 SemaphoreSlim 是一个针对进程内同步优化的轻量级信号量,本文将详细介绍它的使用方式、典型场景以及注意事项。

1. 什么是 SemaphoreSlim?

SemaphoreSlimSystem.Threading 命名空间下的一个类,它是对经典 Semaphore 的轻量级替代。 与 Semaphore 不同,SemaphoreSlim

  • 仅支持进程内同步(不能跨进程),因此开销更小。

  • 提供了异步友好的等待方法 WaitAsync(),可以在不阻塞线程的情况下等待信号量。

  • 更简洁的 API 和更好的性能。

一个信号量维护了一个计数器,计数器大于零时表示有可用的资源;当线程进入(调用 Wait)时计数器减一,当计数器为零时后续线程必须等待;当线程退出(调用 Release)时计数器加一,唤醒等待的线程。

2. 核心方法

方法

说明

Wait()

阻塞当前线程直到可以进入信号量。

WaitAsync()

异步等待,不阻塞线程,返回 Task,适合在异步方法中使用。

Release()

释放信号量,计数器加一,默认释放一次。也可以指定释放次数 Release(int releaseCount)

CurrentCount

属性,获取当前可用资源的数量。

3. 典型使用场景

  • 限制同时执行的任务数:例如只允许 3 个任务同时下载文件,其余任务排队。

  • 控制数据库连接数:避免应用程序创建过多连接压垮数据库。

  • 实现简单的限流器:如限制 API 调用频率(配合时间窗口)。

4. 基本用法示例

4.1 同步等待(阻塞线程)

csharp

using System;
using System.Threading;
using System.Threading.Tasks;
​
public class Program
{
    private static SemaphoreSlim semaphore = new SemaphoreSlim(initialCount: 2, maxCount: 2); // 最多同时允许2个线程
​
    public static void Main()
    {
        for (int i = 1; i <= 5; i++)
        {
            int taskId = i;
            Task.Run(() => DoWork(taskId));
        }
​
        Console.ReadLine();
    }
​
    private static void DoWork(int taskId)
    {
        Console.WriteLine($"Task {taskId} 正在等待进入...");
        semaphore.Wait(); // 阻塞直到获得许可
        Console.WriteLine($"Task {taskId} 进入,当前可用计数: {semaphore.CurrentCount}");
​
        // 模拟工作
        Thread.Sleep(2000);
​
        semaphore.Release();
        Console.WriteLine($"Task {taskId} 释放,当前可用计数: {semaphore.CurrentCount}");
    }
}

运行结果(可能略有不同):

text

Task 1 正在等待进入...
Task 1 进入,当前可用计数: 1
Task 2 正在等待进入...
Task 2 进入,当前可用计数: 0
Task 3 正在等待进入...
Task 4 正在等待进入...
Task 5 正在等待进入...
Task 1 释放,当前可用计数: 1
Task 3 进入,当前可用计数: 0
Task 2 释放,当前可用计数: 1
Task 4 进入,当前可用计数: 0
...

4.2 异步等待(推荐用于高并发场景)

在异步编程中使用 WaitAsync 可以避免线程阻塞,提高可伸缩性。

csharp

using System;
using System.Threading;
using System.Threading.Tasks;
​
public class Program
{
    private static SemaphoreSlim semaphore = new SemaphoreSlim(3); // 最多3个并发
​
    public static async Task Main()
    {
        Task[] tasks = new Task[10];
        for (int i = 0; i < 10; i++)
        {
            int taskId = i;
            tasks[i] = AccessResourceAsync(taskId);
        }
​
        await Task.WhenAll(tasks);
    }
​
    private static async Task AccessResourceAsync(int taskId)
    {
        Console.WriteLine($"Task {taskId} 等待许可...");
        await semaphore.WaitAsync(); // 异步等待,不阻塞线程
        try
        {
            Console.WriteLine($"Task {taskId} 获得许可,开始工作。剩余许可: {semaphore.CurrentCount}");
            await Task.Delay(1000); // 模拟异步工作
        }
        finally
        {
            semaphore.Release();
            Console.WriteLine($"Task {taskId} 释放许可,剩余许可: {semaphore.CurrentCount}");
        }
    }
}

4.3 超时等待

WaitWaitAsync 都支持传入超时时间或 CancellationToken,以避免无限等待。

csharp

if (await semaphore.WaitAsync(TimeSpan.FromSeconds(2)))
{
    try
    {
        // 获得许可
    }
    finally
    {
        semaphore.Release();
    }
}
else
{
    Console.WriteLine("超时,未能获得许可");
}

5. 注意事项

5.1 务必确保 Release 被调用

忘记调用 Release 会导致信号量计数器无法恢复,最终所有等待的线程都会死锁。 推荐将 Release 放在 finally 块中,或使用 using 模式(但 SemaphoreSlim 本身未实现 IDisposable 用于自动释放许可,需要自行处理)。

5.2 初始计数与最大计数

构造 SemaphoreSlim 时可以指定 initialCountmaxCount

  • initialCount 初始可用资源数。

  • maxCount 最大计数,调用 Release 时不能使计数超过此值(否则会抛出 SemaphoreFullException)。

5.3 释放 SemaphoreSlim 本身

SemaphoreSlim 实现了 IDisposable,当不再使用时应该调用 Dispose 释放底层资源(如等待队列使用的对象)。 通常可以放在 using 块中,但如果信号量是长期存在的(如单例),则在应用程序关闭时释放即可。

5.4 与 Semaphore 的区别

  • Semaphore 可以跨进程(基于操作系统信号量),SemaphoreSlim 只能在进程内使用。

  • SemaphoreSlim 提供了 WaitAsync,更适合异步编程。

  • 在大多数进程内同步场景中,SemaphoreSlim 是首选。

6. 实际应用:实现一个简单的限流器

下面是一个结合 SemaphoreSlim 和定时器实现的简易限流器,限制每秒最多执行 5 个操作。

csharp

public class SimpleRateLimiter
{
    private readonly SemaphoreSlim _semaphore;
    private readonly Timer _timer;
    private readonly int _maxRequestsPerSecond;
​
    public SimpleRateLimiter(int maxRequestsPerSecond)
    {
        _maxRequestsPerSecond = maxRequestsPerSecond;
        _semaphore = new SemaphoreSlim(maxRequestsPerSecond, maxRequestsPerSecond);
        _timer = new Timer(ResetSemaphore, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
    }
​
    private void ResetSemaphore(object state)
    {
        // 每秒重置可用计数
        int current = _semaphore.CurrentCount;
        int toRelease = _maxRequestsPerSecond - current;
        if (toRelease > 0)
        {
            _semaphore.Release(toRelease);
        }
    }
​
    public async Task<bool> TryAccessAsync(int timeoutMilliseconds)
    {
        return await _semaphore.WaitAsync(timeoutMilliseconds);
    }
​
    public void Dispose()
    {
        _timer?.Dispose();
        _semaphore?.Dispose();
    }
}

7. 总结

SemaphoreSlim 是 .NET 中控制并发访问的一把利器,特别适合限制异步任务的并发数量。它简单、高效,并且与异步编程模型完美集成。 在实际开发中,无论是限制数据库连接、控制后台任务并行度,还是实现自定义限流器,SemaphoreSlim 都是值得信赖的工具。

记住:使用 WaitAsync + finally 确保释放,让您的并发代码既安全又高效。


希望本文能帮助你更好地理解和使用 SemaphoreSlim。如果你有任何问题或经验分享,欢迎在评论区留言讨论!