
我有很多任务需要执行,但是不能让它们全部同时执行,最多只能同时执行 N 个。
例如:
如果任务数量比较少,可以直接:
var tasks = items.Select(item => ProcessAsync(item));
await Task.WhenAll(tasks);但是如果有 1000 个甚至 10000 个任务,就不能让它们全部同时执行。
这时候就需要:
SemaphoreSlim它是 .NET 异步并发编程中非常重要的一个工具。
本文不讨论复杂的并发理论,只通过几个简单的例子,彻底搞懂:
SemaphoreSlim 到底是什么、为什么需要它、WaitAsync 和 Release 是什么,以及它为什么特别适合异步代码。
假设我们有 100 个文件:
var files = GetFiles();现在需要逐个处理。
最简单的方式:
foreach (var file in files)
{
await ProcessAsync(file);
}这种方式是:
文件1
↓
完成
文件2
↓
完成
文件3
↓
完成
文件4
↓
完成同一时间只有一个文件处理。
这叫:
串行执行
假设:
每个文件处理需要 2 秒100 个文件串行执行:
100 × 2 = 200 秒如果可以同时处理 5 个:
100 ÷ 5 × 2 ≈ 40 秒所以我们可能会想到:
var tasks = files.Select(file => ProcessAsync(file));
await Task.WhenAll(tasks);这样多个文件就可以同时处理。
例如:
文件1 ───────→ 完成
文件2 ─────────→ 完成
文件3 ─────→ 完成
文件4 ───────────→ 完成
文件5 ───────→ 完成效率提高了。
但是问题来了。
不是。
假设有:
10000 个文件如果直接:
var tasks = files.Select(file => ProcessAsync(file));
await Task.WhenAll(tasks);那么可能会产生大量并发任务。
具体能不能承受,要看你的任务到底是什么。
例如文件处理可能同时占用:
CPU
内存
磁盘 I/O
文件句柄
第三方库资源如果同时处理几千个文件,很可能导致:
CPU过高
内存占用过高
磁盘压力过大
程序变慢
甚至程序异常所以真正合理的需求通常不是:
尽可能多地并发。
而是:
限制并发数量。
例如:
10000 个文件
最多同时处理:
10 个这就是 SemaphoreSlim 的核心用途。
可以把 SemaphoreSlim 理解成:
一个“有限数量的通行证”。
例如:
var semaphore = new SemaphoreSlim(3);相当于现在有:
🎫 🎫 🎫一共 3 张通行证。
一个任务想要执行某个操作:
必须先拿到一张通行证。
拿到以后:
任务1 → 🎫
任务2 → 🎫
任务3 → 🎫3 张票全部被拿走。
这时候任务4来了:
任务4 → 没票 → 等待等任务1执行完成,把票还回来:
任务1
↓
Release()
↓
归还 🎫任务4就可以拿到票:
任务4 → 🎫所以最终就实现了:
最多 3 个任务同时执行。
理解 SemaphoreSlim,最重要的是两个方法:
WaitAsync()和:
Release()可以直接记成:
WaitAsync()
↓
拿一张票
Release()
↓
还一张票例如:
var semaphore = new SemaphoreSlim(3);
await semaphore.WaitAsync();意思就是:
我要进入,但是我要先获取一个许可证。
如果还有许可证:
🎫 🎫 🎫
任务1 → 🎫剩下:
🎫 🎫任务2:
任务2 → 🎫剩下:
🎫任务3:
任务3 → 🎫现在:
没有许可证了任务4:
await semaphore.WaitAsync();就会等待。
这是 SemaphoreSlim 非常重要的一点。
看到:
await semaphore.WaitAsync();很多人会误以为:
“是不是有一个线程卡在那里?”
不是。
这是:
异步等待。
简单理解:
同步阻塞:
线程
↓
等待
↓
线程一直被占着而:
异步等待:
Task
↓
等待
↓
不需要线程一直傻等
↓
条件满足
↓
继续执行因此:
await semaphore.WaitAsync();非常适合放在异步方法中。
任务执行完成以后:
semaphore.Release();表示:
我已经完成了,把许可证还回去。
例如:
SemaphoreSlim(3)
任务1 → 处理中
任务2 → 处理中
任务3 → 处理中
任务4 → 等待任务1完成:
任务1
↓
Release()
↓
归还许可证于是:
任务2 → 处理中
任务3 → 处理中
任务4 → 处理中任务4获得了执行机会。
这是实际项目中使用 SemaphoreSlim 最重要的代码规范之一。
不要这样写:
await semaphore.WaitAsync();
await ProcessAsync();
semaphore.Release();因为:
await ProcessAsync();可能发生异常。
例如:
WaitAsync()
↓
成功拿到许可证
↓
ProcessAsync()
↓
发生异常
↓
程序跳出
↓
Release() 没有执行那么许可证就丢了。
如果这种情况发生很多次,最终可能导致:
许可证越来越少
↓
越来越多任务等待
↓
程序看起来像“卡死”因此标准写法是:
await semaphore.WaitAsync();
try
{
await ProcessAsync();
}
finally
{
semaphore.Release();
}这样无论:
正常完成
异常
取消最终都会执行:
semaphore.Release();实际项目中通常还要支持取消操作。
例如:
await semaphore.WaitAsync(ct);这里的:
ct就是 CancellationToken。
如果用户点击“取消”:
CancellationToken
↓
WaitAsync(ct)
↓
等待中的任务取消等待因此推荐:
await semaphore.WaitAsync(ct);
try
{
ct.ThrowIfCancellationRequested();
await ProcessAsync(ct);
}
finally
{
semaphore.Release();
}这里有一个非常细节但重要的问题。
WaitAsync 放在 try 外面?看:
await semaphore.WaitAsync(ct);
try
{
...
}
finally
{
semaphore.Release();
}而不是:
try
{
await semaphore.WaitAsync(ct);
...
}
finally
{
semaphore.Release();
}原因是:
只有成功拿到许可证以后,才能 Release。
如果:
await semaphore.WaitAsync(ct);还没拿到许可证,就因为取消抛出了:
OperationCanceledException那么你根本没有获得许可证。
这时候不能:
semaphore.Release();否则许可证数量就会被错误增加。
因此:
await semaphore.WaitAsync(ct);
try
{
...
}
finally
{
semaphore.Release();
}是非常经典的写法。
假设:
100 个任务
最多同时执行 3 个可以写:
using var semaphore = new SemaphoreSlim(3);
var tasks = items.Select(async item =>
{
await semaphore.WaitAsync();
try
{
await ProcessAsync(item);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);这里有三个关键点。
new SemaphoreSlim(3)表示:
最多允许 3 个任务同时进入。
await semaphore.WaitAsync();表示:
我要执行,先拿许可证。
semaphore.Release();表示:
我执行完了,可以让下一个任务进来。
这两个东西经常一起出现,但职责完全不同。
await Task.WhenAll(tasks);负责:
等待所有任务完成。
await semaphore.WaitAsync();负责:
限制同时执行的任务数量。
所以:
Task.WhenAll
↓
什么时候全部结束?
SemaphoreSlim
↓
同时允许多少个?这是两个完全不同的问题。
Select 可以创建很多 Task?例如:
var tasks = items.Select(async item =>
{
await semaphore.WaitAsync();
try
{
await ProcessAsync(item);
}
finally
{
semaphore.Release();
}
});假设:
items = 1000 个那么可以理解成:
Task1
Task2
Task3
...
Task1000但是:
并不是 1000 个任务同时进入
ProcessAsync。
因为前面有:
await semaphore.WaitAsync();如果:
new SemaphoreSlim(5)那么:
1000个Task
↓
SemaphoreSlim(5)
↓
最多5个进入
↓
995个等待所以:
创建多少 Task 和同时执行多少任务,是两个不同的问题。
这一点一定要记住。
例如:
new SemaphoreSlim(5)不是:
创建 5 个线程。
而是:
最多允许 5 个任务同时通过。
所以:
Task ≠ Thread尤其是异步 I/O 场景:
await httpClient.GetAsync(...);
await stream.ReadAsync(...);
await stream.WriteAsync(...);任务等待 I/O 时,并不意味着一个线程一直在那里等待。
因此:
SemaphoreSlim特别适合:
异步任务的并发限制。
这也是非常容易混淆的地方。
lock (obj)
{
// 临界区
}表达的是:
同一时间只能有一个线程执行这里。
var semaphore = new SemaphoreSlim(5);表达的是:
同一时间最多允许 5 个任务执行这里。
因此:
lock
↓
最多1个
SemaphoreSlim(5)
↓
最多5个而且 SemaphoreSlim 支持:
await semaphore.WaitAsync();因此更适合异步场景。
如果:
var semaphore = new SemaphoreSlim(1);那么就相当于:
🎫只有一张票。
所以:
Task1 → 执行
Task2 → 等待
Task3 → 等待Task1完成:
Task1
↓
Release()
↓
Task2执行因此:
SemaphoreSlim(1)可以用来实现一种:
异步环境下的互斥访问。
不过如果只是为了保护一个简单变量,不应该为了这个目的专门使用 SemaphoreSlim,这时候可能 Interlocked 更合适。
.NET 中还有一个:
Semaphore它和:
SemaphoreSlim名字非常像。
简单理解:
偏向于:
跨进程同步。
偏向于:
当前进程内部的并发控制。
而且 SemaphoreSlim 提供了:
WaitAsync()因此在现代 .NET 异步代码中:
如果只是控制当前应用程序内部的异步并发,通常优先考虑
SemaphoreSlim。
现在回到最开始的场景。
例如你有:
targets里面有 100 个文件。
你希望:
MaxParallel = 3那么:
using var gate = new SemaphoreSlim(MaxParallel);就是:
100 个文件任务
↓
SemaphoreSlim(3)
↓
最多3个文件同时改写核心代码:
var tasks = targets.Select(async item =>
{
await gate.WaitAsync(ct);
try
{
ct.ThrowIfCancellationRequested();
item.RewriteState = RewriteState.处理中;
var result = await RewriteByExtensionAsync(
sourcePath,
destPath,
ct,
EnableDiffHighlight);
item.RewriteState =
result.IsOK
? RewriteState.已完成
: RewriteState.错误;
}
finally
{
gate.Release();
}
});
await Task.WhenAll(tasks);运行过程可以理解成:
100个文件
│
▼
创建100个Task
│
▼
SemaphoreSlim(3)
│
┌─────────┼─────────┐
▼ ▼ ▼
文件1 文件2 文件3
处理中 处理中 处理中
│ │ │
└─────────┼─────────┘
▼
完成
│
▼
Release()
│
▼
文件4进入这是代码最核心的设计。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。