前言:为什么要学习 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 代码执行流程的逐行剖析
-
创建 SemaphoreSlim:我们创建了一个 static readonly 的实例,这意味着整个应用程序共享这同一个“叫号机”。它的容量和初始号码数都是 4。
-
创建任务计划:urls.Select(async url => ...) 这段代码会立即为列表中的每个 URL 创建一个异步任务,但这些任务并不会立即开始下载。它们像是拿到了“排队小票”的顾客,准备去排队。
-
等待许可:当每个任务开始执行时,第一件事就是 await _downloaderSemaphore.WaitAsync()。这正是“排队”的关键点。前 4 个任务会发现有可用的许可,于是顺利通过。从第 5 个任务开始,执行到这里时会发现许可已经用完,于是它会异步地在此暂停,让出线程,静静等待。
-
执行核心逻辑:只有成功通过 WaitAsync() 的任务,才能继续往下执行下载代码。
-
释放许可:finally 块是代码的“安全网”。不管 try 块中的下载是成功还是因为网络问题抛出异常,_downloaderSemaphore.Release() 必定会执行。这保证了用过的许可一定会被归还,否则队伍后面的任务将永远等待下去,导致系统卡死。
-
等待全部完成: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…