Skip to Content
Week 04Day 3 - 并发控制

Day 3 - 并发控制

建议用时:260-320 分钟

你将学会什么

  • 顺序执行、并发执行、并行执行有什么区别
  • Task.WhenAll 为什么能等待多个任务全部完成
  • Task.WhenAny 为什么能拿到最快完成的任务
  • 为什么并发不是越多越好
  • SemaphoreSlim 如何限制同时运行的任务数量
  • 并发任务里发生异常时应该怎么处理

并发控制不是为了炫技,而是为了让多个任务一起推进,同时不把文件、网络、数据库或接口压垮。真正的重点是“控制数量”。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

今天只抓住 3 件事

  1. 并发是多个任务一起推进,不等于无限制同时执行。
  2. Task.WhenAll 负责等待一组任务结束。
  3. SemaphoreSlim 负责限制同时进入核心处理区的任务数量。

学习衔接

上一页学习的是“异步 IO”,今天继续学习“并发控制”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。并发控制的难点不是语法,而是理解“同时做多少才合适”。

1. 顺序执行是什么

顺序执行就是一个做完,再做下一个。

例如 4 个任务,每个 1 秒:

任务1 -> 任务2 -> 任务3 -> 任务4

总耗时大约 4 秒。

优点是简单、稳定。

缺点是有些等待时间被浪费了。

2. 并发执行是什么

并发执行是多个任务在同一段时间里一起推进。

例如 4 个异步等待任务一起开始:

任务1 开始 任务2 开始 任务3 开始 任务4 开始 一起等待 一起完成

总耗时可能接近 1 秒。

注意:并发不等于一定在多个 CPU 核心上同时计算。对于 IO 等待来说,并发更多是让多个等待重叠。

3. 并行执行是什么

并行更强调“真的同时执行”,通常和多个 CPU 核心有关。

本页先不深入并行计算,只要分清:

概念重点
异步等待期间不堵住
并发多个任务在同一段时间推进
并行多个任务真正同时计算

文件、网络、数据库等待,优先考虑异步并发。

大量 CPU 计算,后面再考虑并行计算。

4. Task.WhenAll 是什么

Task.WhenAll 表示:

等一组任务全部完成。

常见场景:

  1. 同时读取多个文件。
  2. 同时请求多个接口。
  3. 同时处理多个订单。
  4. 等所有任务完成后统一汇总结果。

如果任务有返回值:

Task<string>[]

await Task.WhenAll(...) 后可以得到:

string[]

5. Task.WhenAny 是什么

Task.WhenAny 表示:

等一组任务里最快完成的那个。

常见场景:

  1. 多个来源谁先返回用谁。
  2. 一个任务和一个超时任务竞争。
  3. 先展示最快结果,慢的后面再处理。

注意:WhenAny 只告诉你哪个任务先完成,不代表其他任务自动停止。

6. 为什么并发不是越多越好

如果有 1000 个订单,同时开 1000 个任务,看起来很快,但可能会出问题:

资源可能被压垮
文件同时打开太多文件
网络同时请求太多接口
数据库连接池耗尽
第三方接口被限流或封禁
本机内存任务太多占用过高

所以并发要控制数量。

7. SemaphoreSlim 是什么

SemaphoreSlim 可以理解成“有限名额的门”。

例如:

SemaphoreSlim(2)

表示同一时间最多 2 个任务通过。

核心写法:

await semaphore.WaitAsync() try { 做真正任务 } finally { semaphore.Release() }

WaitAsync() 拿名额。

Release() 还名额。

8. 为什么 Release 要放在 finally

如果处理任务中间发生异常,但没有释放名额,后面的任务可能永远等不到名额。

所以固定写法是:

await semaphore.WaitAsync() try { ... } finally { semaphore.Release() }

这不是形式主义,而是防止程序卡死。

9. 并发任务里的异常

Task.WhenAll 中只要有任务失败,await Task.WhenAll(...) 就会抛异常。

有两种处理思路:

思路适合场景
外层统一 try/catch任意一个失败就认为整体失败
每个任务内部自己捕获希望成功和失败分开统计

批量处理更常用第二种,因为一个订单失败,不一定要让所有订单都失败。

第二部分:把知识组合成完整例子

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要能做到:有很多任务,但一次最多只让几个进入核心处理区。

先看效果:一次最多处理 2 个订单

using System.Diagnostics; int[] orderIds = { 1, 2, 3, 4, 5, 6 }; using SemaphoreSlim gate = new SemaphoreSlim(2); Stopwatch stopwatch = Stopwatch.StartNew(); Task[] tasks = orderIds async Task ProcessOrderWithLimitAsync(int orderId, SemaphoreSlim semaphore) { await semaphore.WaitAsync(); try { Console.WriteLine($"开始处理订单 {orderId}"); await Task.Delay(1000); Console.WriteLine($"完成处理订单 {orderId}"); } finally { semaphore.Release(); } } .Select(id => ProcessOrderWithLimitAsync(id, gate)) .ToArray(); await Task.WhenAll(tasks); stopwatch.Stop(); Console.WriteLine($"全部完成,耗时约 {stopwatch.ElapsedMilliseconds} 毫秒");

这里最重要的是三件事:

  1. Task.WhenAll(tasks) 等所有任务完成。
  2. SemaphoreSlim(2) 表示最多放 2 个任务进去。
  3. Release() 放在 finally,保证出错时也会释放名额。

第三部分:跟着敲代码

从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。

动手前先做这 3 件事

  1. 打开一个控制台项目。
  2. 每次只保留一个例子的代码,运行通过后再换下一个。
  3. 每个例子运行后,改任务数量、等待时间或并发上限,再运行观察结果。

例子 1:顺序执行

using System.Diagnostics; int[] ids = { 1, 2, 3, 4 }; Stopwatch stopwatch = Stopwatch.StartNew(); foreach (int id in ids) { async Task ProcessAsync(int id) { Console.WriteLine($"开始任务 {id}"); await Task.Delay(500); Console.WriteLine($"完成任务 {id}"); } await ProcessAsync(id); } stopwatch.Stop(); Console.WriteLine($"顺序执行耗时约: {stopwatch.ElapsedMilliseconds} 毫秒");

这个版本一次只处理一个任务,4 个任务大约需要 2 秒。

例子 2:并发执行并等待全部完成

using System.Diagnostics; int[] ids = { 1, 2, 3, 4 }; Stopwatch stopwatch = Stopwatch.StartNew(); Task[] tasks = ids async Task ProcessAsync(int id) { Console.WriteLine($"开始任务 {id}"); await Task.Delay(500); Console.WriteLine($"完成任务 {id}"); } .Select(id => ProcessAsync(id)) .ToArray(); await Task.WhenAll(tasks); stopwatch.Stop(); Console.WriteLine($"并发执行耗时约: {stopwatch.ElapsedMilliseconds} 毫秒");

这个版本 4 个任务几乎同时开始,总耗时接近 500 毫秒。

例子 3:Task.WhenAll 返回结果

int[] ids = { 1, 2, 3 }; Task<string>[] tasks = ids async Task<string> LoadOrderAsync(int id) { await Task.Delay(300); return $"订单 SO-{id:000}"; } .Select(id => LoadOrderAsync(id)) .ToArray(); string[] results = await Task.WhenAll(tasks); foreach (string result in results) { Console.WriteLine(result); }

如果每个任务返回 stringWhenAll 完成后得到 string[]

例子 4:Task.WhenAny 等最快任务

async Task<string> LoadFromCacheAsync() { await Task.Delay(300); return "缓存数据"; } async Task<string> LoadFromApiAsync() { await Task.Delay(1000); return "接口数据"; } Task<string> cacheTask = LoadFromCacheAsync(); Task<string> apiTask = LoadFromApiAsync(); Task<string> completedTask = await Task.WhenAny(cacheTask, apiTask); string result = await completedTask; Console.WriteLine($"最快结果: {result}");

WhenAny 拿到的是最快完成的那个任务。要得到结果,还需要再 await completedTask

例子 5:无限制并发

int[] ids = Enumerable.Range(1, 10).ToArray(); Task[] tasks = ids async Task ProcessAsync(int id) { Console.WriteLine($"开始任务 {id}"); await Task.Delay(500); Console.WriteLine($"完成任务 {id}"); } .Select(id => ProcessAsync(id)) .ToArray(); await Task.WhenAll(tasks);

10 个任务会几乎同时开始。任务少时没问题,任务很多时就需要限制数量。

例子 6:使用 SemaphoreSlim 限制并发

int[] ids = Enumerable.Range(1, 10).ToArray(); using SemaphoreSlim semaphore = new SemaphoreSlim(3); Task[] tasks = ids async Task ProcessWithLimitAsync(int id, SemaphoreSlim gate) { await gate.WaitAsync(); try { Console.WriteLine($"进入处理区: {id}"); await Task.Delay(500); Console.WriteLine($"离开处理区: {id}"); } finally { gate.Release(); } } .Select(id => ProcessWithLimitAsync(id, semaphore)) .ToArray(); await Task.WhenAll(tasks);

SemaphoreSlim(3) 表示同一时间最多 3 个任务进入处理区。

例子 7:异常时也要释放名额

int[] ids = { 1, 2, 3, 4, 5 }; using SemaphoreSlim semaphore = new SemaphoreSlim(2); Task[] tasks = ids async Task ProcessWithLimitAsync(int id, SemaphoreSlim gate) { await gate.WaitAsync(); try { Console.WriteLine($"开始任务 {id}"); await Task.Delay(300); if (id == 3) { throw new InvalidOperationException("任务 3 模拟失败"); } Console.WriteLine($"完成任务 {id}"); } finally { gate.Release(); Console.WriteLine($"释放名额: {id}"); } } .Select(id => ProcessWithLimitAsync(id, semaphore)) .ToArray(); try { await Task.WhenAll(tasks); } catch (Exception ex) { Console.WriteLine($"批量处理失败: {ex.Message}"); }

即使任务 3 失败,finally 仍然会释放名额,后面的任务才有机会继续。

例子 8:每个任务内部处理自己的失败

int[] ids = { 1, 2, 3, 4, 5 }; Task<ProcessResult>[] tasks = ids async Task<ProcessResult> ProcessSafeAsync(int id) { try { await Task.Delay(300); if (id == 3) { throw new InvalidOperationException("模拟失败"); } return new ProcessResult(id, true, "完成"); } catch (Exception ex) { return new ProcessResult(id, false, ex.Message); } } .Select(id => ProcessSafeAsync(id)) .ToArray(); ProcessResult[] results = await Task.WhenAll(tasks); foreach (ProcessResult result in results) { Console.WriteLine($"{result.Id} | 成功: {result.Success} | {result.Message}"); } class ProcessResult { public ProcessResult(int id, bool success, string message) { Id = id; Success = success; Message = message; } public int Id { get; } public bool Success { get; } public string Message { get; } }

批量处理时,这种写法很常见:每个任务返回自己的成功或失败结果,最后统一汇总。

例子 9:限制并发并收集结果

int[] ids = Enumerable.Range(1, 8).ToArray(); using SemaphoreSlim semaphore = new SemaphoreSlim(2); Task<ProcessResult>[] tasks = ids async Task<ProcessResult> ProcessWithLimitAsync(int id, SemaphoreSlim gate) { await gate.WaitAsync(); try { await Task.Delay(300); if (id % 4 == 0) { return new ProcessResult(id, false, "模拟业务失败"); } return new ProcessResult(id, true, "处理完成"); } finally { gate.Release(); } } .Select(id => ProcessWithLimitAsync(id, semaphore)) .ToArray(); ProcessResult[] results = await Task.WhenAll(tasks); int successCount = results.Count(result => result.Success); int failedCount = results.Length - successCount; Console.WriteLine($"成功: {successCount}"); Console.WriteLine($"失败: {failedCount}"); foreach (ProcessResult result in results) { Console.WriteLine($"{result.Id} | {result.Message}"); } class ProcessResult { public ProcessResult(int id, bool success, string message) { Id = id; Success = success; Message = message; } public int Id { get; } public bool Success { get; } public string Message { get; } }

这个例子把本页关键内容合在一起:批量任务、限制并发、收集结果。

例子 10:异步读取多个文件并限制并发

Directory.CreateDirectory("data"); await File.WriteAllTextAsync("data/a.txt", "A"); await File.WriteAllTextAsync("data/b.txt", "B"); await File.WriteAllTextAsync("data/c.txt", "C"); await File.WriteAllTextAsync("data/d.txt", "D"); string[] paths = Directory.GetFiles("data", "*.txt"); using SemaphoreSlim semaphore = new SemaphoreSlim(2); Task<FileResult>[] tasks = paths async Task<FileResult> ReadFileWithLimitAsync(string path, SemaphoreSlim gate) { await gate.WaitAsync(); try { string content = await File.ReadAllTextAsync(path); return new FileResult(path, content); } finally { gate.Release(); } } .Select(path => ReadFileWithLimitAsync(path, semaphore)) .ToArray(); FileResult[] results = await Task.WhenAll(tasks); foreach (FileResult result in results) { Console.WriteLine($"{result.Path} -> {result.Content}"); } class FileResult { public FileResult(string path, string content) { Path = path; Content = content; } public string Path { get; } public string Content { get; } }

文件 IO 也可以批量并发,但同样要注意不要同时打开太多文件。

并发控制常用写法速查

需求写法作用
创建多个任务items.Select(item => WorkAsync(item)).ToArray()把多条数据变成一组任务
等全部完成await Task.WhenAll(tasks)所有任务结束后继续
等最快完成await Task.WhenAny(tasks)先拿到最快结果
限制并发数new SemaphoreSlim(2)同时最多 2 个任务进入
拿名额await gate.WaitAsync()进入受限区域
还名额gate.Release()允许后续任务进入
保证归还finally { gate.Release(); }出错也不占住名额
统计耗时Stopwatch.StartNew()观察并发效果

常见错误和修法

错误为什么错修法
开很多任务但不限制数量文件、网络、数据库可能被压垮SemaphoreSlim 限制并发
Release() 不放 finally任务出错后名额不会归还固定写 try/finally
只创建任务不 await程序可能提前结束await Task.WhenAll(tasks)
把并发当成并行计算IO 等待和 CPU 计算不是一回事文件、网络优先异步并发
任务失败后不知道哪一个失败没有记录任务输入和错误返回结果对象,包含 id、成功状态、错误消息

小白重复敲写训练

并发先从两个短任务开始,先观察顺序,再增加数量。

训练 1:两个任务一起等待

async Task WorkAsync(string name, int delay) { await Task.Delay(delay); Console.WriteLine(name); } Task first = WorkAsync("A", 1000); Task second = WorkAsync("B", 500); await Task.WhenAll(first, second); Console.WriteLine("全部完成");

先预测 A、B 谁先输出。

训练 2:并发得到多个结果

async Task<int> GetValueAsync(int value) { await Task.Delay(300); return value; } Task<int> first = GetValueAsync(10); Task<int> second = GetValueAsync(20); int[] values = await Task.WhenAll(first, second); Console.WriteLine(values.Sum());

改动任务:加入第三个任务,返回 30。

训练 3:限制同时执行数量

var gate = new SemaphoreSlim(2); async Task WorkAsync(int id) { await gate.WaitAsync(); try { Console.WriteLine($"{id} 开始"); await Task.Delay(500); } finally { gate.Release(); } } await Task.WhenAll(Enumerable.Range(1, 5).Select(WorkAsync));

观察同一时间最多有几个任务开始。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:并发控制。今天至少要掌握的 3 个点是:

1. 顺序执行、并发执行、并行执行有什么区别 2. `Task.WhenAll` 为什么能等待多个任务全部完成 3. `Task.WhenAny` 为什么能拿到最快完成的任务

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:顺序执行、并发执行、并行执行有什么区别。

上位机专项练习

多台设备可以并发读取,但不能无节制地同时连接。练习 WhenAll、WhenAny 和 SemaphoreSlim。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:并发读取三台设备

class Program { static async Task<string> ReadAsync(string name) { await Task.Delay(300); return $"{name}: 25.0"; } static async Task Main() { string[] names = ["PLC-01", "PLC-02", "PLC-03"]; Task<string>[] tasks = names.Select(ReadAsync).ToArray(); string[] results = await Task.WhenAll(tasks); foreach (string result in results) Console.WriteLine(result); } }

运行结果或界面效果:

PLC-01: 25.0 PLC-02: 25.0 PLC-03: 25.0

改动任务: 增加 PLC-04。

专项例子 2:先显示最快返回的设备

class Program { static async Task<string> ReadAsync(string name, int delay) { await Task.Delay(delay); return name; } static async Task Main() { Task<string>[] tasks = [ReadAsync("PLC-01", 600), ReadAsync("PLC-02", 200)]; Task<string> first = await Task.WhenAny(tasks); Console.WriteLine($"最先返回: {await first}"); } }

运行结果或界面效果:

最先返回: PLC-02

改动任务: 交换两个等待时间,观察结果。

专项例子 3:限制最多两个连接

class Program { static readonly SemaphoreSlim Gate = new(2); static async Task ReadAsync(string name) { await Gate.WaitAsync(); try { Console.WriteLine($"开始 {name}"); await Task.Delay(300); } finally { Gate.Release(); } } static async Task Main() { await Task.WhenAll([ReadAsync("A"), ReadAsync("B"), ReadAsync("C")]); } }

运行结果或界面效果:

A、B 先开始,C 等待空位

改动任务: 把并发数从 2 改成 1。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。建议先照着敲一遍,再修改并发上限验证效果。

作业 1:并发读取订单

要求:

  1. 准备 6 个订单编号。
  2. LoadOrderAsync 模拟读取订单。
  3. Task.WhenAll 同时读取。
  4. 输出所有结果。

完整答案

int[] ids = { 1, 2, 3, 4, 5, 6 }; Task<string>[] tasks = ids async Task<string> LoadOrderAsync(int id) { await Task.Delay(300); return $"订单 SO-{id:000}"; } .Select(id => LoadOrderAsync(id)) .ToArray(); string[] orders = await Task.WhenAll(tasks); foreach (string order in orders) { Console.WriteLine(order); }

作业 2:一次最多处理 2 个任务

要求:

  1. 准备 8 个任务编号。
  2. 使用 SemaphoreSlim(2)
  3. 一次最多 2 个任务进入处理区。
  4. 所有任务最终完成。

完整答案

int[] ids = Enumerable.Range(1, 8).ToArray(); using SemaphoreSlim semaphore = new SemaphoreSlim(2); Task[] tasks = ids async Task ProcessAsync(int id, SemaphoreSlim gate) { await gate.WaitAsync(); try { Console.WriteLine($"开始处理: {id}"); await Task.Delay(500); Console.WriteLine($"处理完成: {id}"); } finally { gate.Release(); } } .Select(id => ProcessAsync(id, semaphore)) .ToArray(); await Task.WhenAll(tasks);

作业 3:最快任务

要求:

  1. 写两个异步读取方法。
  2. 一个 300 毫秒返回,一个 1000 毫秒返回。
  3. 使用 Task.WhenAny 拿最快结果。

完整答案

async Task<string> LoadLocalAsync() { await Task.Delay(300); return "本地缓存"; } async Task<string> LoadRemoteAsync() { await Task.Delay(1000); return "远程接口"; } Task<string> localTask = LoadLocalAsync(); Task<string> remoteTask = LoadRemoteAsync(); Task<string> firstTask = await Task.WhenAny(localTask, remoteTask); string firstResult = await firstTask; Console.WriteLine($"最快结果: {firstResult}");

作业 4:限制并发并统计成功失败

要求:

  1. 准备 10 个任务。
  2. 一次最多处理 3 个。
  3. 编号能被 4 整除的任务返回失败。
  4. 最后统计成功和失败数量。

完整答案

int[] ids = Enumerable.Range(1, 10).ToArray(); using SemaphoreSlim semaphore = new SemaphoreSlim(3); Task<ProcessResult>[] tasks = ids async Task<ProcessResult> ProcessAsync(int id, SemaphoreSlim gate) { await gate.WaitAsync(); try { await Task.Delay(300); if (id % 4 == 0) { return new ProcessResult(id, false, "业务失败"); } return new ProcessResult(id, true, "处理成功"); } finally { gate.Release(); } } .Select(id => ProcessAsync(id, semaphore)) .ToArray(); ProcessResult[] results = await Task.WhenAll(tasks); int successCount = results.Count(result => result.Success); int failedCount = results.Length - successCount; Console.WriteLine($"成功数量: {successCount}"); Console.WriteLine($"失败数量: {failedCount}"); foreach (ProcessResult result in results) { Console.WriteLine($"{result.Id} | {result.Message}"); } class ProcessResult { public ProcessResult(int id, bool success, string message) { Id = id; Success = success; Message = message; } public int Id { get; } public bool Success { get; } public string Message { get; } }

作业 5:批量读取文件并限制并发

要求:

  1. 创建 5 个文本文件。
  2. 一次最多读取 2 个。
  3. 读取后输出文件名和内容。

完整答案

Directory.CreateDirectory("data"); for (int i = 1; i <= 5; i++) { await File.WriteAllTextAsync($"data/{i}.txt", $"文件 {i}"); } string[] paths = Directory.GetFiles("data", "*.txt"); using SemaphoreSlim semaphore = new SemaphoreSlim(2); Task<FileResult>[] tasks = paths async Task<FileResult> ReadFileAsync(string path, SemaphoreSlim gate) { await gate.WaitAsync(); try { string content = await File.ReadAllTextAsync(path); return new FileResult(path, content); } finally { gate.Release(); } } .Select(path => ReadFileAsync(path, semaphore)) .ToArray(); FileResult[] results = await Task.WhenAll(tasks); foreach (FileResult result in results) { Console.WriteLine($"{result.Path} -> {result.Content}"); } class FileResult { public FileResult(string path, string content) { Path = path; Content = content; } public string Path { get; } public string Content { get; } }

本页最后要记住

  1. Task.WhenAll 等全部任务完成。
  2. Task.WhenAny 等最快完成的任务。
  3. 并发不是越多越好,要看资源能不能承受。
  4. SemaphoreSlim 可以限制同一时间进入处理区的任务数量。
  5. WaitAsync 拿名额,Release 还名额。
  6. Release 必须放在 finally 里。
  7. 批量任务最好能统计成功和失败,而不是一个失败就看不清全局。