.NET 并发编程 SemaphoreSlim

143 阅读12分钟

前言:为什么要学习 SemaphoreSlim?

想象一下,你的后端服务是一个非常火爆的网红餐厅,但厨房只有一个小窗口可以出餐。如果成百上千的顾客(请求)一瞬间全都涌到窗口,厨房就会立刻瘫痪。你需要一个“叫号系统”来控制秩序,一次只允许有限的几位顾客到窗口取餐,其他人则需要排队等候。

在 .NET 中,SemaphoreSlim 就是这个功能强大的“叫号系统”。它是一个核心工具,用于解决因资源有限而导致的并发问题,是构建稳定、高性能后台服务的必备知识。


1. 核心概念入门:一个严格限流的游乐园

1.1 核心比喻:一个座位有限的过山车

为了让你彻底理解,我们不用枯燥的术语,而是用一个游乐园的过山车来比喻:

  • 过山车本身:就是 SemaphoreSlim 对象。

  • 座位数 (maxCount):这辆过山车总共有多少个座位。假设是 5 个。

  • 空位数 (initialCount):游乐园刚开门时,过山车上有多少个空位。通常也是 5 个。

  • 排队的游客 (Task):每一个想要“乘坐过山车”的并发任务。

  • 检票员:SemaphoreSlim 的内部机制。

  • 排队等待 (await WaitAsync()):游客在入口处排队,等待检票员放行。如果有空位,检票员立刻放你进去;如果没有,你必须在队首等待。

  • 下车 (Release()):一个游客完成乘坐后,从出口离开,空出一个座位。这个动作至关重要,因为只有这样,后面排队的游客才能补上。

  • 显示屏上的空位数 (CurrentCount):乐园入口的电子屏,实时显示当前还有几个空位。

1.2 SemaphoreSlim 的关键指令 (核心 API 详解)

1.2.1. 构造函数 new SemaphoreSlim(...)

这是创建“过山车”的方法。

// 语法: new SemaphoreSlim(int initialCount, int maxCount)
// initialCount: 初始空位数
// maxCount:     总座位数

// 示例:创建一个总座位数为5,且初始就有5个空位的“过山车”
var semaphore = new SemaphoreSlim(5, 5); 

1.2.2. 等待指令 WaitAsync()

WaitAsync 是与 SemaphoreSlim 交互的核心,它有几个不同的“版本”(重载),让我们可以精细地控制“排队等待”这个行为。下面我们通过三个独立的案例来学习最常见的三种形式。

形式一:WaitAsync() - 无限期等待

这是最基础、最常用的形式。它代表的意图是:“我必须等到一个许可证,无论需要多久。”

适用场景:

  • 后台任务处理、消息队列消费者等。

  • 任务必须被执行,不能因为暂时繁忙而被丢弃。等待是被允许且预期的行为。

代码示例:后台作业处理器

假设我们有一个后台服务,不断从数据库中取出待办事项进行处理。为了不压垮服务器,我们限制最多同时处理 3 个事项。

public class BackgroundProcessor
{
    // 最多同时处理 3 个作业
    private readonly SemaphoreSlim _processorSemaphore = new SemaphoreSlim(3, 3);

    // 模拟一个需要被处理的事项
    public async Task ProcessItemAsync(int itemId)
    {
        Console.WriteLine($"[事项 {itemId}] 已提交,正在排队等待处理...");

        // 1. 使用无参数的 WaitAsync() 无限期等待
        //    任务会在此处暂停,直到获得一个处理槽位
        await _processorSemaphore.WaitAsync();

        try
        {
            Console.WriteLine($" -> [事项 {itemId}] 开始处理!(当前工作中的任务数: {3 - _processorSemaphore.CurrentCount})");
            // 模拟耗时的 CPU 或 I/O 操作
            await Task.Delay(TimeSpan.FromSeconds(5)); 
            Console.WriteLine($" <- [事项 {itemId}] 处理完成。");
        }
        finally
        {
            _processorSemaphore.Release();
            Console.WriteLine($"    [事项 {itemId}] 释放了一个槽位。");
        }
    }

    // 模拟启动多个作业
    public async Task RunSimulation()
    {
        var tasks = new List<Task>();
        for (int i = 1; i <= 10; i++)
        {
            tasks.Add(ProcessItemAsync(i));
        }
        await Task.WhenAll(tasks);
    }
}

行为分析:

  • 当 RunSimulation 启动 10 个任务时,前 3 个任务会立即获得许可并开始处理。
  • 从第 4 个任务开始,都会在 await _processorSemaphore.WaitAsync(); 这一行异步地暂停。它们会一直处于等待状态。
  • 直到有一个正在处理的任务完成并通过 finally 块释放了许可,队伍最前面的等待者才会被唤醒并继续执行。这种方式保证了所有 10 个任务最终都会被处理,一个都不会丢失。
形式二:WaitAsync(CancellationToken) - 可取消的等待

这种形式代表的意图是:“我需要一个许可证,但我会一直监听‘取消’信号。如果在我等待期间收到了取消信号,我就不再等待了,立刻放弃。”

适用场景:

  • ASP.NET Core 中的 Web 请求。如果用户关闭了浏览器(请求被取消),服务器应该停止为这个请求浪费资源。

  • 任何需要被外部控制、可以被提前终止的长时间运行操作。

代码示例:生成报表的服务

假设有一个生成报表的 API,这是一个非常耗费资源的操作,我们限制最多同时生成 2 份报表。如果用户在等待时关闭了网页,我们应该立即取消这个生成任务。

public class ReportGenerator
{
    // 最多同时生成 2 份报表
    private readonly SemaphoreSlim _reportSemaphore = new SemaphoreSlim(2, 2);

    // 模拟一个 Web API 端点的方法
    public async Task<string> GenerateReportAsync(int reportId, CancellationToken cancellationToken)
    {
        Console.WriteLine($"[报表 {reportId}] 请求已收到,正在等待生成资源...");

        try
        {
            // 2. 将 CancellationToken 传入 WaitAsync
            //    它会在等待许可的同时,监听 token 是否被取消
            await _reportSemaphore.WaitAsync(cancellationToken);
        }
        catch (OperationCanceledException)
        {
            // 3. 如果在等待期间 token 被取消,WaitAsync 会抛出此异常
            Console.WriteLine($"[报表 {reportId}] 等待被取消!用户关闭了页面。");
            return "请求已被用户取消。";
        }

        try
        {
            Console.WriteLine($" -> [报表 {reportId}] 开始生成...");
            // 模拟耗时的报表生成过程,并持续检查是否被取消
            await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); 
            Console.WriteLine($" <- [报表 {reportId}] 生成成功!");
            return $"报表 {reportId} 的内容";
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine($"[报表 {reportId}] 生成过程中被取消!");
            return "生成过程被用户取消。";
        }
        finally
        {
            _reportSemaphore.Release();
            Console.WriteLine($"    [报表 {reportId}] 释放了生成资源。");
        }
    }

    // 模拟 Web 服务器的行为
    public async Task RunSimulation()
    {
        var generator = new ReportGenerator();
        var tasks = new List<Task>();
        
        // 创建一个5秒后自动取消的 CancellationTokenSource
        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

        for (int i = 1; i <= 5; i++)
        {
            tasks.Add(generator.GenerateReportAsync(i, cts.Token));
        }
        
        await Task.WhenAll(tasks.Select(async t => { try { await t; } catch {}})); // 等待所有任务结束
    }
}

行为分析:

在这个模拟中,前 2 个报表请求会获得许可并开始生成(需要10秒)。第 3、4、5 个请求会在 WaitAsync 处等待。我们在 5 秒后触发了取消信号 (cts.Cancel())。

  • 正在生成的报表 1 和 2 会在 Task.Delay 中收到取消信号,抛出异常。

  • 关键点:正在等待许可的报表 3、4、5,它们的 WaitAsync 会立即感知到取消信号,并抛出 OperationCanceledException,直接进入 catch 块返回“被取消”的消息。它们甚至没有机会获得许可,从而避免了不必要的资源消耗。

形式三:WaitAsync(TimeSpan) - 有时限的等待

这种形式代表的意图是:“我想要一个许可证,但我只愿意等一段时间。如果超时了还没轮到我,我就不等了,去做别的事情。”

适用场景:

  • 对响应时间敏感的操作。与其让用户长时间等待一个繁忙的资源,不如快速失败或提供一个备用方案(例如,返回缓存数据)。

  • 尝试性地获取资源,如果获取不到,则执行降级逻辑。

代码示例:实时数据获取服务

假设我们有一个服务,提供对某个昂贵实时数据源的访问。我们有 3 个并发连接许可。我们希望请求在 2 秒内拿到连接,否则就返回稍微过时一点的缓存数据。

public class LiveDataProvider
{
    private readonly SemaphoreSlim _connectionSemaphore = new SemaphoreSlim(3, 3);

    public async Task<string> GetDataAsync(string symbol)
    {
        Console.WriteLine($"[{symbol}] 尝试获取实时数据连接...");
        
        // 4. 使用带超时的 WaitAsync,它返回一个布尔值
        bool gotConnection = await _connectionSemaphore.WaitAsync(TimeSpan.FromSeconds(2));

        if (gotConnection)
        {
            // 5a. 如果在2秒内成功获取许可
            try
            {
                Console.WriteLine($" -> [{symbol}] 成功获取连接,正在查询实时数据...");
                await Task.Delay(TimeSpan.FromSeconds(5)); // 模拟查询
                Console.WriteLine($" <- [{symbol}] 查询完成。");
                return $"实时价格 for {symbol}: ${DateTime.Now.Millisecond}";
            }
            finally
            {
                _connectionSemaphore.Release();
                Console.WriteLine($"    [{symbol}] 释放了连接。");
            }
        }
        else
        {
            // 5b. 如果2秒后仍未获取许可
            Console.WriteLine($" ! [{symbol}] 获取连接超时!返回缓存数据。");
            return $"缓存价格 for {symbol}: ${DateTime.Now.Millisecond - 1000}";
        }
    }

    public async Task RunSimulation()
    {
        var provider = new LiveDataProvider();
        var tasks = Enumerable.Range(1, 10).Select(i => provider.GetDataAsync($"股票-{i}"));
        await Task.WhenAll(tasks);
    }
}

行为分析: 当 10 个请求同时涌入时:

  • 前 3 个请求会立即获得许可(gotConnection 为 true),然后开始长达 5 秒的模拟查询。

  • 从第 4 个请求开始,它们会在 WaitAsync 处等待。它们会计时 2 秒。

  • 因为前 3 个请求需要 5 秒才能完成,所以在第 4 个及之后的请求等待 2 秒后,它们仍然无法获得许可。

  • 于是,它们的 WaitAsync 会超时并返回 false。代码会进入 else 块,立即返回缓存数据,而不是继续漫长地等待。这使得服务在高负载下依然能快速响应(尽管数据不是实时的),提升了系统的可用性和用户体验。

1.2.3. 释放指令 Release()

这是“下车”的动作,用于归还一个或多个“座位”。

  • semaphore.Release();

    最常用的形式,括号里不需要填任何值。它会将可用许可证的数量加一。每次 WaitAsync() 的成功调用,都必须有一个对应的 Release() 与之配对。

  • semaphore.Release(int releaseCount);

    一次性归还指定数量的许可证。例如 semaphore.Release(5); 会一次性将可用许可证数量加 5。这个用法我们会在进阶部分看到。


2. 基础实践:从零开始实现并发下载

2.1 场景设定与代码实现

目标:我们有一个 URL 列表,需要全部下载。但为了避免瞬间给对方服务器造成太大压力,我们规定最多只能同时进行 4 个下载任务。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

public class BasicDownloader
{
    private static readonly HttpClient _httpClient = new HttpClient();
    
    // 1. 创建“叫号机”:容量为 4,初始就有 4 个可用号码。
    private static readonly SemaphoreSlim _downloaderSemaphore = new SemaphoreSlim(4, 4);

    public async Task DownloadAllUrlsAsync(IEnumerable<string> urls)
    {
        Console.WriteLine($"准备开始下载 {urls.Count()} 个URL,并发限制为 4...");
        
        // 2. 为每个URL创建一个下载任务的“计划”
        var downloadTasks = urls.Select(async url =>
        {
            // 3. 进入“排队区”:任务执行到这里,会异步等待获取一个下载许可。
            //    前4个任务会立刻拿到许可,第5个及之后的任务会在此处暂停等待。
            Console.WriteLine($"  [{url}] 正在等待下载许可...");
            await _downloaderSemaphore.WaitAsync();

            // 4. “叫到号了!”:拿到许可后,进入 try 块执行核心下载逻辑。
            try
            {
                // CurrentCount 显示的是剩余的许可数
                Console.WriteLine($"   -> [{url}] 获得许可,开始下载!(当前剩余许可: {_downloaderSemaphore.CurrentCount})");
                
                string content = await _httpClient.GetStringAsync(url);
                
                Console.WriteLine($"   <- [{url}] 下载完成,内容长度: {content.Length}。");
            }
            catch (Exception ex)
            {
                Console.WriteLine($"   !! [{url}] 下载失败: {ex.Message}");
            }
            finally
            {
                // 5. “归还号码”:无论下载成功还是失败,都必须在 finally 块中释放许可!
                //    这样,排队等待的其他任务才能获得机会。
                _downloaderSemaphore.Release();
                Console.WriteLine($"      [{url}] 已释放许可。");
            }
        });

        // 6. 等待所有“计划”的任务全部执行完毕。
        await Task.WhenAll(downloadTasks);
        Console.WriteLine("所有下载任务均已完成。");
    }
}

2.2 代码执行流程的逐行剖析

  1. 创建 SemaphoreSlim:我们创建了一个 static readonly 的实例,这意味着整个应用程序共享这同一个“叫号机”。它的容量和初始号码数都是 4。

  2. 创建任务计划:urls.Select(async url => ...) 这段代码会立即为列表中的每个 URL 创建一个异步任务,但这些任务并不会立即开始下载。它们像是拿到了“排队小票”的顾客,准备去排队。

  3. 等待许可:当每个任务开始执行时,第一件事就是 await _downloaderSemaphore.WaitAsync()。这正是“排队”的关键点。前 4 个任务会发现有可用的许可,于是顺利通过。从第 5 个任务开始,执行到这里时会发现许可已经用完,于是它会异步地在此暂停,让出线程,静静等待。

  4. 执行核心逻辑:只有成功通过 WaitAsync() 的任务,才能继续往下执行下载代码。

  5. 释放许可:finally 块是代码的“安全网”。不管 try 块中的下载是成功还是因为网络问题抛出异常,_downloaderSemaphore.Release() 必定会执行。这保证了用过的许可一定会被归还,否则队伍后面的任务将永远等待下去,导致系统卡死。

  6. 等待全部完成:Task.WhenAll 会等待所有创建的任务都达到完成状态。


3. 进阶主题:解密构造函数与异步初始化

3.1 构造函数中的 initialCount 与 maxCount 到底是什么?

我们再用一个停车场停车的例子来解释它们的区别。

  • maxCount:停车场的总车位数。比如,一个停车场总共能停 100 辆车,那么 maxCount = 100。

  • initialCount:停车场刚开门时可用的空车位数。

场景一(最常见):new SemaphoreSlim(100, 100) 这表示停车场总共有 100 个车位,并且在开门时,这 100 个车位全都是空的,立即可用。这对应了我们上面的并发节流场景。

场景二(特殊情况):new SemaphoreSlim(80, 100) 这表示停车场总共有 100 个车位,但在开门时,由于有 20 个车位被内部员工或月租车辆占用了,所以只对外开放 80 个空车位。

3.2 高级场景:必须先“开园”才能“玩项目”

目标:我们有一个 API 服务,它必须先调用认证接口获取一个 Token 才能工作。在 Token 获取成功之前,所有业务请求都必须等待。服务启动后,最多允许 5 个并发业务请求。

这个场景完美匹配 initialCount < maxCount 的用法。

  • 总座位数 (maxCount):5

  • 初始空位数 (initialCount):0 (因为还没拿到Token,服务尚未“开园”,一个游客都不能放进来)。

  • 获取Token的过程:就是“开园仪式”。

  • 仪式完成,打开大门:调用 Release(5),一次性把 5 个“空位”全部放出来。

public class AdvancedApiService
{
    private const int MaxConcurrentCalls = 5;
    
    // 1. 服务总容量为5,但初始时一个都不可用。
    private readonly SemaphoreSlim _apiSemaphore = new SemaphoreSlim(0, MaxConcurrentCalls);
    private string _accessToken;

    // 2. “开园仪式”:获取 Token
    public async Task InitializeAsync()
    {
        Console.WriteLine("【系统】服务正在初始化,准备获取 Token...");
        await Task.Delay(1500); // 模拟网络请求
        _accessToken = "A_VERY_SECRET_TOKEN";
        Console.WriteLine("【系统】Token 获取成功,服务正式开放!");
        
        // 3. “打开大门”:一次性释放所有5个许可!
        _apiSemaphore.Release(MaxConcurrentCalls); 
    }

    // 4. 游客“玩项目”
    public async Task CallBusinessApiAsync(string user)
    {
        Console.WriteLine($"  [{user}] 尝试访问服务,正在等待服务开放...");
        
        // 5. 在此排队。如果服务未初始化(许可为0),所有任务都会在这里等待。
        await _apiSemaphore.WaitAsync();
        try
        {
            Console.WriteLine($"   -> [{user}] 成功进入服务,开始处理业务...");
            await Task.Delay(2000); 
            Console.WriteLine($"   <- [{user}] 业务处理完成。");
        }
        finally
        {
            // 6. 处理完毕,归还自己占用的那一个许可。
            _apiSemaphore.Release();
        }
    }
}

4. 工程化封装:构建可复用的并发工具

在真实项目中,我们不希望在每个需要限流的地方都重复编写 try...finally...Release 的逻辑。这不仅繁琐,而且容易出错。这时,我们可以把这些模式封装成通用的工具类,让业务代码只关注业务本身。

4.1 工具一:通用节流器 AsyncThrottler

这个工具类用于解决最常见的并发节流问题,让你的业务代码更干净。

using System;
using System.Threading;
using System.Threading.Tasks;

/// <summary>
/// 一个可复用的异步任务节流器,用于控制并发操作的数量。
/// </summary>
public class AsyncThrottler : IDisposable
{
    /// <summary>
    /// 底层的信号量,用于控制并发。
    /// </summary>
    private readonly SemaphoreSlim _semaphore;

    /// <summary>
    /// 初始化一个异步任务节流器。
    /// </summary>
    /// <param name="maxConcurrentTasks">允许的最大并发任务数。必须大于0。</param>
    public AsyncThrottler(int maxConcurrentTasks)
    {
        if (maxConcurrentTasks < 1)
        {
            throw new ArgumentOutOfRangeException(nameof(maxConcurrentTasks), "并发数必须至少为1。");
        }
        _semaphore = new SemaphoreSlim(maxConcurrentTasks, maxConcurrentTasks);
    }

    /// <summary>
    /// 将一个无返回值的异步操作加入队列,并在有可用并发槽位时执行它。
    /// </summary>
    /// <param name="asyncAction">要执行的异步操作,包装在 Func<Task> 中。</param>
    /// <returns>一个表示整个操作(等待、执行、释放)的 Task。</returns>
    public async Task ExecuteAsync(Func<Task> asyncAction)
    {
        await _semaphore.WaitAsync();
        try
        {
            await asyncAction();
        }
        finally
        {
            _semaphore.Release();
        }
    }

    /// <summary>
    /// 将一个有返回值的异步操作加入队列,并在有可用并发槽位时执行它。
    /// </summary>
    /// <typeparam name="T">异步操作的返回类型。</typeparam>
    /// <param name="asyncFunc">要执行的异步操作,包装在 Func<Task<T>> 中。</param>
    /// <returns>一个表示整个操作的 Task<T>,其结果为异步操作的返回值。</returns>
    public async Task<T> ExecuteAsync<T>(Func<Task<T>> asyncFunc)
    {
        await _semaphore.WaitAsync();
        try
        {
            return await asyncFunc();
        }
        finally
        {
            _semaphore.Release();
        }
    }

    /// <summary>
    /// 释放由 SemaphoreSlim 持有的非托管资源。
    /// </summary>
    public void Dispose()
    {
        _semaphore?.Dispose();
    }
}

如何使用 AsyncThrottler (前后对比)

让我们回到第 2 部分的并发下载场景,看看使用这个工具类前后的代码变化。

使用前:在业务逻辑中手动管理 SemaphoreSlim

public class BasicDownloader
{
    private static readonly SemaphoreSlim _downloaderSemaphore = new SemaphoreSlim(4, 4);
    private static readonly HttpClient _httpClient = new HttpClient();

    public async Task DownloadAllUrlsAsync(IEnumerable<string> urls)
    {
        var downloadTasks = urls.Select(async url =>
        {
            // --- 并发控制逻辑和业务逻辑耦合在一起 ---
            await _downloaderSemaphore.WaitAsync();
            try
            {
                // 核心业务逻辑
                string content = await _httpClient.GetStringAsync(url);
                Console.WriteLine($"下载完成: {url}");
            }
            finally
            {
                _downloaderSemaphore.Release();
            }
            // --- 耦合结束 ---
        });

        await Task.WhenAll(downloadTasks);
    }
}

使用后:通过 AsyncThrottler 封装并发逻辑

public class RefactoredDownloader
{
    private static readonly HttpClient _httpClient = new HttpClient();

    public async Task DownloadAllUrlsAsync(IEnumerable<string> urls)
    {
        // 1. 创建节流器实例
        using (var throttler = new AsyncThrottler(4))
        {
            var downloadTasks = urls.Select(url =>
                // 2. 将业务逻辑作为委托传递给 ExecuteAsync
                //    不再需要关心 WaitAsync, try, finally, Release
                throttler.ExecuteAsync(async () =>
                {
                    // --- 现在这里只剩下纯粹的业务逻辑 ---
                    string content = await _httpClient.GetStringAsync(url);
                    Console.WriteLine($"下载完成: {url}");
                })
            );

            await Task.WhenAll(downloadTasks);
        }
    }
}

对比总结:

  • 代码更简洁:业务方法 DownloadAllUrlsAsync 中不再有 try...finally 的模板代码,核心逻辑一目了然。

  • 关注点分离:并发控制的复杂性被封装在 AsyncThrottler 中,业务代码只需关注于“做什么”,而不是“如何安全地做”。

  • 更安全:由于模式被固化在工具类中,大大降低了因忘记 Release() 而导致许可证泄漏的风险。

  • 可复用性:AsyncThrottler 可以在项目的任何地方被复用,用于数据库访问、文件处理等其他需要限流的场景。

4.2 工具二:高级初始化节流器 AsyncInitializationThrottler

这个工具类专为“先初始化,后并发”的复杂场景设计,它能确保初始化只执行一次,并且是线程安全的。


using System;
using System.Threading;
using System.Threading.Tasks;

/// <summary>
/// 提供一个高级的异步节流器,它要求在处理任何任务之前,必须先完成一个指定的异步初始化操作。
/// 这个类是线程安全的,并保证初始化操作只执行一次。
/// </summary>
public class AsyncInitializationThrottler : IDisposable
{
    private readonly SemaphoreSlim _concurrencySemaphore;
    private readonly Lazy<Task> _initializationTask;
    private readonly int _maxConcurrency;

    /// <summary>
    /// 初始化一个需要异步初始化的节流器。
    /// </summary>
    /// <param name="maxConcurrency">初始化完成后,允许的最大并发任务数。</param>
    /// <param name="initializer">一个返回 Task 的异步委托,用于执行一次性的初始化操作。</param>
    public AsyncInitializationThrottler(int maxConcurrency, Func<Task> initializer)
    {
        if (maxConcurrency < 1) throw new ArgumentOutOfRangeException(nameof(maxConcurrency));
        if (initializer == null) throw new ArgumentNullException(nameof(initializer));

        _maxConcurrency = maxConcurrency;
        _concurrencySemaphore = new SemaphoreSlim(0, _maxConcurrency);
        _initializationTask = new Lazy<Task>(() => InitializeInternalAsync(initializer));
    }

    /// <summary>
    /// 内部方法,由 Lazy<Task> 调用,真正执行初始化并释放信号量。
    /// </summary>
    private async Task InitializeInternalAsync(Func<Task> initializer)
    {
        try
        {
            await initializer();
            _concurrencySemaphore.Release(_maxConcurrency);
        }
        catch (Exception)
        {
            // 如果初始化失败,不释放信号量,后续请求会收到初始化异常。
            throw;
        }
    }

    /// <summary>
    /// 将一个无返回值的异步操作加入队列。
    /// 它会首先等待初始化完成,然后在有可用并发槽位时执行它。
    /// </summary>
    /// <param name="asyncAction">要执行的异步操作。</param>
    public async Task ExecuteAsync(Func<Task> asyncAction)
    {
        // 1. 等待初始化任务完成。Lazy<T>确保了初始化只进行一次。
        await _initializationTask.Value;
        
        // 2. 等待并发许可
        await _concurrencySemaphore.WaitAsync();
        try
        {
            // 3. 执行核心工作
            await asyncAction();
        }
        finally
        {
            // 4. 释放许可
            _concurrencySemaphore.Release();
        }
    }

    /// <summary>
    /// 将一个有返回值的异步操作加入队列。
    /// </summary>
    /// <typeparam name="T">返回值类型</typeparam>
    /// <param name="asyncFunc">要执行的异步函数</param>
    public async Task<T> ExecuteAsync<T>(Func<Task<T>> asyncFunc)
    {
        await _initializationTask.Value;
        await _concurrencySemaphore.WaitAsync();
        try
        {
            return await asyncFunc();
        }
        finally
        {
            _concurrencySemaphore.Release();
        }
    }
    
    public void Dispose() => _concurrencySemaphore?.Dispose();
}

如何使用 AsyncInitializationThrottler (前后对比)

让我们重构第 3 部分的 AdvancedApiService。

使用前:在服务类中手动实现“先初始化后并发”模式

public class AdvancedApiService
{
    private const int MaxConcurrentCalls = 5;
    private readonly SemaphoreSlim _apiSemaphore = new SemaphoreSlim(0, MaxConcurrentCalls);
    private string _accessToken;
    // ... 可能还需要一个 lock 来确保 InitializeAsync 线程安全 ...

    public async Task InitializeAsync()
    {
        // 手动管理初始化逻辑和信号量释放
        await Task.Delay(1500);
        _accessToken = "SECRET_TOKEN";
        _apiSemaphore.Release(MaxConcurrentCalls); 
    }

    public async Task CallBusinessApiAsync(string user)
    {
        // 业务代码与复杂的并发控制逻辑混合
        await _apiSemaphore.WaitAsync();
        try
        {
            // 核心业务
            Console.WriteLine($" -> [{user}] 开始处理业务...");
            await Task.Delay(2000); 
        }
        finally
        {
            _apiSemaphore.Release();
        }
    }
}

使用后:通过 AsyncInitializationThrottler 简化并加固模式

public class RefactoredAdvancedApiService : IDisposable
{
    private readonly AsyncInitializationThrottler _throttler;
    private string _accessToken;

    public RefactoredAdvancedApiService()
    {
        // 1. 在构造函数中,将初始化逻辑和并发数直接交给工具类
        _throttler = new AsyncInitializationThrottler(5, AuthenticateAsync);
    }

    // 2. 初始化逻辑本身保持不变,但现在它只是一个普通的私有方法
    private async Task AuthenticateAsync()
    {
        await Task.Delay(1500); 
        _accessToken = "A_VERY_SECRET_TOKEN";
        Console.WriteLine("认证成功!");
    }

    // 3. 业务方法的实现变得极其简单!
    public Task CallBusinessApiAsync(string user)
    {
        // 只需将业务逻辑委托给节流器,所有复杂性都被隐藏了
        return _throttler.ExecuteAsync(async () =>
        {
            Console.WriteLine($" -> [{user}] 开始处理业务...");
            await Task.Delay(2000); 
        });
    }

    public void Dispose() => _throttler?.Dispose();
}

对比总结:

  • 逻辑极度简化:服务类中不再有任何 SemaphoreSlim 的痕迹。CallBusinessApiAsync 方法现在只剩下一行核心逻辑的委托。

  • 线程安全:Lazy 的使用确保了 AuthenticateAsync 方法即使在多线程环境下也只会被调用一次,无需手动加锁。

  • 鲁棒性:如果 AuthenticateAsync 失败,AsyncInitializationThrottler 会自动阻止所有后续任务执行,并传播初始化失败的异常,行为清晰可预测。

  • 完全解耦:“何时初始化”与“如何并发控制”这两个复杂问题被彻底从业务代码中剥离。


5. 注意事项与常见陷阱(新手必看)

5.1 陷阱一:忘记 Release() —— 导致服务“假死”的许可证泄漏

这是最严重、最常见的错误。如果你调用了 WaitAsync() 但在某个代码路径(例如,因为异常)没有调用 Release(),那么这个许可证就永远丢失了。

后果:随着时间推移,可用的许可证会越来越少,直到减为 0。此时,所有新的请求都会在 WaitAsync() 处无限期地等待,导致整个服务看起来像“卡死”或“假死”了。

解决方案:永远、永远、永远将 Release() 调用放在 try...finally 块的 finally 部分。

5.2 陷阱二:在 async 方法中误用 Wait() —— 榨干服务器线程的“性能杀手”

SemaphoreSlim 有两个等待方法:WaitAsync() 和 Wait()。

  • WaitAsync():异步等待。如果需要等待,它会把当前线程“还给”线程池,去做其他工作。这是 async/await 的正确用法。

  • Wait():同步等待。如果需要等待,它会霸占当前线程,让线程空转,直到获得许可。

后果:在 ASP.NET Core 这样的高并发 Web 服务器中,线程是宝贵的共享资源。使用 Wait() 会迅速耗尽线程池中的线程,导致服务器无法响应新的请求,吞吐量急剧下降。

解决方案:在任何 async 方法中,请始终使用 await WaitAsync()。

5.3 陷阱三:错误的实例作用域 —— 形同虚设的“保安”

SemaphoreSlim 必须是被多个任务共享的同一个实例,才能起到限流作用。

错误的做法:在每次请求处理的方法内部 new SemaphoreSlim(...)。

// 错误示例!
public async Task HandleRequestAsync()
{
    // 每次调用都创建一个新的信号量,它只能限制自己,毫无意义。
    var semaphore = new SemaphoreSlim(1, 1); 
    await semaphore.WaitAsync();
    // ...
}

后果:每个请求都有自己的“保安”,而不是共享一个“大门保安”。这完全失去了并发控制的能力。

解决方案:将 SemaphoreSlim 实例声明为 static readonly 字段,或通过依赖注入(DI)注册为单例(Singleton)。

5.4 陷阱四:初始化过程中的竞争问题

在“先初始化后服务”的模式中,如果多个请求同时到来,可能会有多个线程都尝试去执行初始化逻辑。

后果:可能会重复获取 Token,或者产生其他不可预知的行为。

解决方案:使用 Lazy 来包装初始化逻辑,如我们的 AsyncInitializationThrottler 工具类所示。Lazy 这个类型天生就是为了解决“一次性、线程安全”的初始化问题而设计的。


6. 官方文档与深入学习

Microsoft Learn (官方文档): learn.microsoft.com/zh-cn/dotne…