Skip to Content
Week 04Day 7 - 复盘与巩固

Day 7 - 第四周复盘与综合练习

建议用时:240-300 分钟

你将学会什么

  • 如何把异步、IO、并发、取消、异常处理串成一条主线
  • 如何判断什么时候用 awaitWhenAllSemaphoreSlimCancellationToken
  • 如何用结果对象验收批处理是否正确
  • 如何从单个异步任务扩展到可控的异步小项目

第四周真正要掌握的是“等待和批处理怎么写得可控”。异步不是只会写 await,还要能处理失败、取消、并发数量和最终汇总。

本页固定顺序

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

今天只抓住 3 件事

  1. 第四周的主线是等待、并发、取消、异常、汇总。
  2. TaskWhenAllSemaphoreSlimCancellationToken 要配合使用。
  3. 判断是否学会,要看能不能写出可控的文件批处理程序。

学习衔接

上一页学习的是“小项目:异步批处理”,今天继续学习“第四周复盘与综合练习”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

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

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。第四周的知识点看起来多,其实都围绕“等待”和“批处理可控”。

1. 第四周主线

第四周解决的问题是:

程序在等待外部资源时,如何不乱、不堵、不失控。

这句话拆开就是:

问题解决方式
等文件、网络、数据库async/await
等多个任务完成Task.WhenAll
谁先完成就先用谁Task.WhenAny
同时任务太多SemaphoreSlim
用户不想等或超时CancellationToken
某个任务失败try/catch 和结果对象
批量处理要汇总成功数、失败数、失败原因

2. 什么时候用 await

只要你调用的是返回 TaskTask<T> 的异步方法,并且后续代码需要等它完成,就应该 await

例如:

string text = await File.ReadAllTextAsync(path)

如果忘记 await,你拿到的是任务,不是真正的结果。

3. 什么时候用 Task.WhenAll

多个任务可以一起开始,并且你需要等它们全部完成时,用 Task.WhenAll

例如:

同时读取多个文件 同时请求多个接口 同时处理多个订单

4. 什么时候用 Task.WhenAny

多个任务谁先完成就先处理谁时,用 Task.WhenAny

例如:

缓存和接口谁先返回用谁 业务任务和超时任务谁先完成用谁

5. 什么时候用 SemaphoreSlim

任务很多,但不能全部同时跑时,用 SemaphoreSlim

典型场景:

  1. 文件很多。
  2. 接口请求很多。
  3. 数据库写入很多。
  4. 第三方服务有限流。

固定写法:

await gate.WaitAsync(token) try { ... } finally { gate.Release() }

6. 什么时候用 CancellationToken

任务可能很久、可以被用户取消、或者必须有超时保护时,用 CancellationToken

关键不是创建 token,而是把它传到真正等待的地方:

Task.Delay(..., token) File.ReadAllTextAsync(path, token) semaphore.WaitAsync(token)

7. 什么时候用结果对象

批量处理时,结果对象比只打印文字更可靠。

结果对象至少包含:

  1. 处理的是谁。
  2. 是否成功。
  3. 成功数据。
  4. 失败原因。

有了结果对象,才能统计和重试。

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

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。这个例子把第四周主要内容合在一起。

先看效果:可控的异步文件处理

await File.WriteAllTextAsync("a.txt", "AAA"); await File.WriteAllTextAsync("b.txt", "BBBB"); string[] files = { "a.txt", "missing.txt", "b.txt" }; using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using SemaphoreSlim semaphore = new SemaphoreSlim(2); Task<FileResult>[] tasks = files async Task<FileResult> ReadFileSafeAsync(string path, CancellationToken token) { try { string text = await File.ReadAllTextAsync(path, token); return new FileResult(path, true, $"长度 {text.Length}"); } catch (OperationCanceledException) { return new FileResult(path, false, "已取消"); } catch (Exception ex) { return new FileResult(path, false, ex.Message); } } async Task<FileResult> ReadFileWithLimitAsync(string path, SemaphoreSlim gate, CancellationToken token) { await gate.WaitAsync(token); try { return await ReadFileSafeAsync(path, token); } finally { gate.Release(); } } .Select(path => ReadFileWithLimitAsync(path, semaphore, cts.Token)) .ToArray(); FileResult[] results = await Task.WhenAll(tasks); Console.WriteLine($"成功: {results.Count(result => result.Success)}"); Console.WriteLine($"失败: {results.Count(result => !result.Success)}"); foreach (FileResult result in results) { Console.WriteLine($"{result.Path} | {result.Success} | {result.Message}"); } class FileResult { public FileResult(string path, bool success, string message) { Path = path; Success = success; Message = message; } public string Path { get; } public bool Success { get; } public string Message { get; } }

这个例子里每个知识点的位置:

知识点代码位置
异步方法async Task<FileResult>
异步 IOFile.ReadAllTextAsync
等全部任务Task.WhenAll
并发限制SemaphoreSlim(2)
取消CancellationToken
异常处理try/catch
汇总结果FileResult[] results

第三部分:跟着敲代码

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

动手前先做这 3 件事

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

例子 1:Task<T> 复盘

async Task<int> LoadCountAsync() { await Task.Delay(300); return 3; } int count = await LoadCountAsync(); Console.WriteLine($"数量: {count}");

Task<int> 表示将来会得到一个 int

例子 2:异步 IO 复盘

string path = "orders.txt"; await File.WriteAllTextAsync(path, "SO-001\nSO-002"); string text = await File.ReadAllTextAsync(path); Console.WriteLine(text);

文件读写是典型 IO 等待场景。

例子 3:Task.WhenAll 复盘

async Task<string> LoadAsync(string name) { await Task.Delay(300); return $"{name} 完成"; } Task<string> task1 = LoadAsync("A"); Task<string> task2 = LoadAsync("B"); Task<string> task3 = LoadAsync("C"); string[] results = await Task.WhenAll(task1, task2, task3); foreach (string result in results) { Console.WriteLine(result); }

WhenAll 等所有任务完成,并收集结果。

例子 4:Task.WhenAny 复盘

async Task<string> LoadFastAsync() { await Task.Delay(300); return "快速结果"; } async Task<string> LoadSlowAsync() { await Task.Delay(1000); return "慢速结果"; } Task<string> fast = LoadFastAsync(); Task<string> slow = LoadSlowAsync(); Task<string> first = await Task.WhenAny(fast, slow); string result = await first; Console.WriteLine(result);

WhenAny 只等最快完成的那个。

例子 5:SemaphoreSlim 复盘

int[] ids = Enumerable.Range(1, 6).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);

一次最多两个任务进入处理区。

例子 6:取消复盘

using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(700)); try { async Task WorkAsync(CancellationToken token) { for (int i = 1; i <= 10; i++) { Console.WriteLine($"处理 {i}"); await Task.Delay(300, token); } } await WorkAsync(cts.Token); } catch (OperationCanceledException) { Console.WriteLine("任务已取消"); }

取消要传给真正等待的代码。

例子 7:异常结果对象复盘

string[] paths = { "a.txt", "missing.txt" }; await File.WriteAllTextAsync("a.txt", "AAA"); Task<FileResult>[] tasks = paths async Task<FileResult> ReadSafeAsync(string path) { try { string text = await File.ReadAllTextAsync(path); return new FileResult(path, true, $"长度 {text.Length}"); } catch (Exception ex) { return new FileResult(path, false, ex.Message); } } .Select(path => ReadSafeAsync(path)) .ToArray(); FileResult[] results = await Task.WhenAll(tasks); foreach (FileResult result in results) { Console.WriteLine($"{result.Path} | {result.Success} | {result.Message}"); } class FileResult { public FileResult(string path, bool success, string message) { Path = path; Success = success; Message = message; } public string Path { get; } public bool Success { get; } public string Message { get; } }

批量任务更适合让每项返回自己的成功失败结果。

第四周常用操作总表

场景推荐写法关键点
等一个异步结果await ReadAsync()后续代码需要结果
多个任务一起等await Task.WhenAll(tasks)全部完成后汇总
等最快任务await Task.WhenAny(tasks)先处理最快结果
限制同时数量SemaphoreSlim防止资源被压垮
支持取消CancellationToken传到真正等待的 API
异步异常try { await ... } catch异常在 await 处处理
批量结果FileResultProcessResult保存成功状态和错误原因

常见错误和修法

错误为什么错修法
忘记 await拿到的是任务,不是结果返回 Task 的方法通常要 await
WhenAll 失败后没有结果某个任务抛异常中断整体等待单项内部转成结果对象
SemaphoreSlim 不释放后续任务会一直等待Release 固定放进 finally
创建 token 后不用取消请求不会传到任务内部把 token 传给所有可取消 API
只会写单个异步方法无法处理真实批量场景按“单项 -> 多项 -> 并发 -> 限流 -> 汇总”练

小白重复敲写训练

复盘日从零写三个异步小程序,不看完整项目答案。

训练 1:异步返回值

async Task<string> LoadAsync() { await Task.Delay(300); return "加载完成"; } string message = await LoadAsync(); Console.WriteLine(message);

训练 2:并发等待

Task a = Task.Delay(300); Task b = Task.Delay(500); await Task.WhenAll(a, b); Console.WriteLine("两个任务都完成");

训练 3:超时和异常

using var cts = new CancellationTokenSource(300); try { await Task.Delay(2000, cts.Token); } catch (OperationCanceledException) { Console.WriteLine("等待超时"); }

第三遍关掉页面,把 asyncawait、取消和 catch 全部独立写出来。

每日小测

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

1. 判断题

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

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

2. 填空题

本页主题是:第四周复盘与综合练习。今天至少要掌握的 3 个点是:

1. 如何把异步、IO、并发、取消、异常处理串成一条主线 2. 如何判断什么时候用 `await`、`WhenAll`、`SemaphoreSlim`、`CancellationToken` 3. 如何用结果对象验收批处理是否正确

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

3. 流程题

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

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

4. 找错误题

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

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

5. 改需求题

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

可选改法:

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

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:如何把异步、IO、并发、取消、异常处理串成一条主线。

上位机专项练习

本周要能解释:为什么异步不等于新线程、为什么要取消、为什么并发必须限流。

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

专项例子 1:复习异步返回值

class Program { static async Task<int> ReadSpeedAsync() { await Task.Delay(100); return 1200; } static async Task Main() => Console.WriteLine($"转速: {await ReadSpeedAsync()} rpm"); }

运行结果或界面效果:

转速: 1200 rpm

改动任务: 返回 1500。

专项例子 2:复习并发读取

class Program { static async Task<string> ReadAsync(string name) { await Task.Delay(100); return name + " 完成"; } static async Task Main() { string[] values = await Task.WhenAll(ReadAsync("温度"), ReadAsync("压力")); foreach (string value in values) Console.WriteLine(value); } }

运行结果或界面效果:

温度 完成 压力 完成

改动任务: 增加“流量”任务。

专项例子 3:复习超时与异常

class Program { static async Task Main() { using var cts = new CancellationTokenSource(100); try { await Task.Delay(1000, cts.Token); } catch (OperationCanceledException) { Console.WriteLine("本轮采集超时"); } } }

运行结果或界面效果:

本轮采集超时

改动任务: 把延时改成 50 毫秒,确认不再超时。

第四部分:作业完整答案

这一部分给出当天综合作业的完整答案。建议先照着敲一遍,再修改并发上限和超时时间。

作业 1:概念判断

场景应该用什么
异步读取文件await File.ReadAllTextAsync
同时读取多个文件Task.WhenAll
谁先返回用谁Task.WhenAny
一次最多处理 3 个SemaphoreSlim(3)
超过 5 秒停止CancellationTokenSource(TimeSpan.FromSeconds(5))
每个任务保留失败原因结果对象

作业 2:第四周综合完整答案

要求:

  1. 创建两个存在文件和一个不存在文件。
  2. 并发读取。
  3. 一次最多处理 2 个。
  4. 支持取消。
  5. 每项返回结果对象。
  6. 输出成功数、失败数和每项结果。

完整答案

await File.WriteAllTextAsync("a.txt", "AAA"); await File.WriteAllTextAsync("b.txt", "BBBB"); string[] paths = { "a.txt", "missing.txt", "b.txt" }; using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using SemaphoreSlim semaphore = new SemaphoreSlim(2); Task<FileResult>[] tasks = paths async Task<FileResult> ReadFileSafeAsync(string path, CancellationToken token) { try { string text = await File.ReadAllTextAsync(path, token); return new FileResult(path, true, $"长度 {text.Length}"); } catch (OperationCanceledException) { return new FileResult(path, false, "已取消"); } catch (Exception ex) { return new FileResult(path, false, ex.Message); } } async Task<FileResult> ReadFileWithLimitAsync(string path, SemaphoreSlim gate, CancellationToken token) { await gate.WaitAsync(token); try { return await ReadFileSafeAsync(path, token); } finally { gate.Release(); } } .Select(path => ReadFileWithLimitAsync(path, semaphore, cts.Token)) .ToArray(); FileResult[] results = await Task.WhenAll(tasks); Console.WriteLine($"成功: {results.Count(result => result.Success)}"); Console.WriteLine($"失败: {results.Count(result => !result.Success)}"); foreach (FileResult result in results) { Console.WriteLine($"{result.Path} | {result.Success} | {result.Message}"); } class FileResult { public FileResult(string path, bool success, string message) { Path = path; Success = success; Message = message; } public string Path { get; } public bool Success { get; } public string Message { get; } }

综合验收

完成后按下面顺序检查:

  1. 把并发上限从 2 改成 1,观察任务变成更接近顺序处理。
  2. 把超时时间改得很短,确认能进入取消分支。
  3. 增加一个不存在的文件,确认失败数增加。
  4. 增加一个存在的文件,确认成功数增加。
  5. 删除 finally 里的 Release 再观察为什么代码结构不安全,然后恢复它。

本页最后要记住

  1. await 解决等待一个异步任务。
  2. WhenAll 解决等待一组任务全部完成。
  3. WhenAny 解决等待最快完成的任务。
  4. SemaphoreSlim 解决并发数量控制。
  5. CancellationToken 解决取消和超时。
  6. 结果对象解决批量任务的成功失败汇总。