Day 7 - 第四周复盘与综合练习
建议用时:240-300 分钟
你将学会什么
- 如何把异步、IO、并发、取消、异常处理串成一条主线
- 如何判断什么时候用
await、WhenAll、SemaphoreSlim、CancellationToken - 如何用结果对象验收批处理是否正确
- 如何从单个异步任务扩展到可控的异步小项目
第四周真正要掌握的是“等待和批处理怎么写得可控”。异步不是只会写 await,还要能处理失败、取消、并发数量和最终汇总。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
今天只抓住 3 件事
- 第四周的主线是等待、并发、取消、异常、汇总。
Task、WhenAll、SemaphoreSlim、CancellationToken要配合使用。- 判断是否学会,要看能不能写出可控的文件批处理程序。
学习衔接
上一页学习的是“小项目:异步批处理”,今天继续学习“第四周复盘与综合练习”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。第四周的知识点看起来多,其实都围绕“等待”和“批处理可控”。
1. 第四周主线
第四周解决的问题是:
程序在等待外部资源时,如何不乱、不堵、不失控。这句话拆开就是:
| 问题 | 解决方式 |
|---|---|
| 等文件、网络、数据库 | async/await |
| 等多个任务完成 | Task.WhenAll |
| 谁先完成就先用谁 | Task.WhenAny |
| 同时任务太多 | SemaphoreSlim |
| 用户不想等或超时 | CancellationToken |
| 某个任务失败 | try/catch 和结果对象 |
| 批量处理要汇总 | 成功数、失败数、失败原因 |
2. 什么时候用 await
只要你调用的是返回 Task 或 Task<T> 的异步方法,并且后续代码需要等它完成,就应该 await。
例如:
string text = await File.ReadAllTextAsync(path)如果忘记 await,你拿到的是任务,不是真正的结果。
3. 什么时候用 Task.WhenAll
多个任务可以一起开始,并且你需要等它们全部完成时,用 Task.WhenAll。
例如:
同时读取多个文件
同时请求多个接口
同时处理多个订单4. 什么时候用 Task.WhenAny
多个任务谁先完成就先处理谁时,用 Task.WhenAny。
例如:
缓存和接口谁先返回用谁
业务任务和超时任务谁先完成用谁5. 什么时候用 SemaphoreSlim
任务很多,但不能全部同时跑时,用 SemaphoreSlim。
典型场景:
- 文件很多。
- 接口请求很多。
- 数据库写入很多。
- 第三方服务有限流。
固定写法:
await gate.WaitAsync(token)
try
{
...
}
finally
{
gate.Release()
}6. 什么时候用 CancellationToken
任务可能很久、可以被用户取消、或者必须有超时保护时,用 CancellationToken。
关键不是创建 token,而是把它传到真正等待的地方:
Task.Delay(..., token)
File.ReadAllTextAsync(path, token)
semaphore.WaitAsync(token)7. 什么时候用结果对象
批量处理时,结果对象比只打印文字更可靠。
结果对象至少包含:
- 处理的是谁。
- 是否成功。
- 成功数据。
- 失败原因。
有了结果对象,才能统计和重试。
第二部分:把知识组合成完整例子
前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。这个例子把第四周主要内容合在一起。
先看效果:可控的异步文件处理
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> |
| 异步 IO | File.ReadAllTextAsync |
| 等全部任务 | Task.WhenAll |
| 并发限制 | SemaphoreSlim(2) |
| 取消 | CancellationToken |
| 异常处理 | try/catch |
| 汇总结果 | FileResult[] results |
第三部分:跟着敲代码
从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。
动手前先做这 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 处处理 |
| 批量结果 | FileResult、ProcessResult | 保存成功状态和错误原因 |
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
忘记 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("等待超时");
}第三遍关掉页面,把 async、await、取消和 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:第四周综合完整答案
要求:
- 创建两个存在文件和一个不存在文件。
- 并发读取。
- 一次最多处理 2 个。
- 支持取消。
- 每项返回结果对象。
- 输出成功数、失败数和每项结果。
完整答案
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; }
}综合验收
完成后按下面顺序检查:
- 把并发上限从
2改成1,观察任务变成更接近顺序处理。 - 把超时时间改得很短,确认能进入取消分支。
- 增加一个不存在的文件,确认失败数增加。
- 增加一个存在的文件,确认成功数增加。
- 删除
finally里的Release再观察为什么代码结构不安全,然后恢复它。
本页最后要记住
await解决等待一个异步任务。WhenAll解决等待一组任务全部完成。WhenAny解决等待最快完成的任务。SemaphoreSlim解决并发数量控制。CancellationToken解决取消和超时。- 结果对象解决批量任务的成功失败汇总。