一个“课程看板”请求要同时读取用户、课程和通知,再把结果写成 JSON 返回。代码看起来只是几次方法调用,但真正困难的地方都在边界上:等待时不能浪费线程,多个任务失败时不能丢信息,取消要一路传递,外部 JSON 也不能被盲目信任。
本章用这个贯穿案例建立两套心智模型:用 Task 表达尚未完成的工作,用 JSON 契约表达跨进程的数据。学完后,你应该能解释一次 await 如何暂停与恢复,能正确组合并发、取消和异常,也能用 System.Text.Json 安全地完成对象、流与 JSON 之间的转换。

本章代码以现代 .NET 控制台项目为背景。为突出机制,示例用
Task.Delay模拟 I/O;在真实项目中应替换为带异步后缀的 HTTP、数据库或文件 API。
我们要实现 BuildDashboardAsync。它接收用户编号与取消令牌,并返回一个强类型的 Dashboard:
public sealed record Profile(int UserId, string DisplayName);
public sealed record Course(int Id, string Title);
public sealed record Notice(int Id, string Message);
public sealed record Dashboard(
Profile Profile,
IReadOnlyList<Course> Courses,
IReadOnlyList<Notice> Notices);完整路线按真实数据流展开:
Task<T> 表达每个尚未完成的 I/O 操作。Task.WhenAll 并发等待互不依赖的读取。CancellationToken 把“已经不需要结果”传到最底层。System.Text.Json 把结果变成稳定的数据契约。Task 是一次操作的状态与最终结果。它可能仍在运行,也可能已经以成功、失败或取消结束。Task<T> 还会在成功时携带一个 T。
static async Task<Profile> LoadProfileAsync(
int userId,
CancellationToken cancellationToken)
{
await Task.Delay(80, cancellationToken);
return new Profile(userId, "小岚");
}
Profile profile = await LoadProfileAsync(7, cancellationToken);
Console.WriteLine($"profile=输出:
profile=小岚执行到尚未完成的 await 时,方法会保存“从哪里继续”和所需的局部状态,然后把控制权交还给调用方。任务完成后,后续代码会被安排继续执行。这里没有“每个 await 自动创建一个线程”的规则;异步 I/O 的价值恰恰是等待期间通常不需要占住线程。
常见返回类型可以这样选择:
默认使用 Task。ValueTask<T> 是特定热点的分配优化,不是更“高级”的 Task;它不应被随意多次等待或缓存。如果没有性能数据,增加这种复杂度通常得不偿失。
如果三个读取彼此独立,逐个 await 会把等待时间相加:
Profile profile = await LoadProfileAsync(userId, token);
Course[] courses = await LoadCoursesAsync(userId, token);
Notice[] notices = await LoadNoticesAsync(userId, token);更合适的做法是先保存任务,再统一等待:
Task<Profile> profileTask = LoadProfileAsync(userId, token);
Task<Course[]> coursesTask = LoadCoursesAsync(userId, token);
Task<Notice[]> noticesTask = LoadNoticesAsync(userId, token);
await Task.WhenAll(profileTask, coursesTask, noticesTask);
var dashboard = new Dashboard(
await profileTask,
await coursesTask,
await noticesTask);假设三次 I/O 分别需要 80、120、60 毫秒,串行版本的理论等待约为 260 毫秒,并发版本接近最慢的 120 毫秒。WhenAll 没有让单个请求变快,只是消除了不必要的顺序。

并发也有成本。一次性对十万条记录启动十万个请求,可能压垮连接池或下游服务。应根据资源容量限流,例如用 SemaphoreSlim 控制同时进行的请求数量,而不是把 WhenAll 当成无限并发开关。
取消是合作式协议,不是强行终止线程。上层创建或接收 token,下层把它继续传给所有支持取消的异步 API:
using var timeout = new CancellationTokenSource(
TimeSpan.FromMilliseconds(100));
try
{
Dashboard dashboard = await BuildDashboardAsync(7, timeout.Token);
Console.WriteLine(dashboard.Profile.DisplayName);
}
catch (OperationCanceledException)
when (timeout.IsCancellationRequested)
{
Console.WriteLine("dashboard timed out");
输出:
dashboard timed out一个 Task 的结束状态必须被正确解释:
await Task.WhenAll(...) 只要有子任务失败,组合任务就会失败;多个子任务可能同时失败。业务只需要“整体失败”时,直接 await 并捕获最符合直觉。如果诊断必须保留所有失败,应保存组合任务,并在 catch 中检查各子任务的 Exception,不要只记录一条消息后丢掉其余上下文。
Task all = Task.WhenAll(profileTask, coursesTask, noticesTask);
try
{
await all;
}
catch
{
foreach (Task task in new Task[]
{ profileTask, coursesTask, noticesTask })
{
if (task.Exception is not null)
Console.WriteLine(task.Exception.Flatten());
}
throw;
不要用 .Result、.Wait() 把异步链截成同步等待。它们会阻塞线程,还会改变异常呈现方式;在某些同步上下文中,阻塞线程与等待 continuation 可能互相卡住。应用代码保持“异步到底”最可靠。ConfigureAwait(false) 只需要在不依赖调用方上下文的可复用库边界审慎使用,不应作为业务代码的机械后缀。
Task<List<T>> 表示“最终一次拿到全部数据”,IAsyncEnumerable<T> 表示“数据会异步地逐项到达”。分页接口、日志订阅或大型查询适合后者:
using System.Runtime.CompilerServices;
static async IAsyncEnumerable<Notice> StreamNoticesAsync(
[EnumeratorCancellation] CancellationToken token = default)
{
for (int page = 1; page <= 3; page++)
{
await Task.Delay(40
输出:
notice=1:第 1 页到达
notice=2:第 2 页到达
notice=3:第 3 页到达
异步流减少首项延迟与一次性内存占用,但不会自动解决生产速度与消费速度不匹配的问题。消费者很慢时,总耗时仍会增加;如果生产端在后台无界缓存,还可能继续占用大量内存。设计时要明确取消、背压和每项错误的处理位置。
序列化不是“保存内存中的对象本身”,而是按约定把对象投影成 JSON;反序列化则按相同约定重建一个新对象。先定义公开契约:
using System.Text.Json.Serialization;
public sealed record DashboardDto(
[property: JsonPropertyName("user")] string DisplayName,
IReadOnlyList<CourseDto> Courses,
DateTimeOffset GeneratedAt,
[property: JsonIgnore] string InternalTraceId);
统一复用 options,避免系统不同角落生成不同形状:
using System.Text.Json;
using System.Text.Json.Serialization;
static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
var
输出:
{
"user": "小岚",
"courses": [
{ "id": 101, "title": "异步基础" }
],
"generatedAt": "2026-07-12T08:00:00+08:00"
}
PropertyNamingPolicy 是全局规则,JsonPropertyName 是局部覆盖,JsonIgnore 用于明确不公开的成员。不要把密码、访问令牌或内部追踪信息先放进 DTO 再指望调用处记得删除;让契约从类型定义开始就排除敏感字段。
反序列化面对的是外部输入:
try
{
DashboardDto? model =
JsonSerializer.Deserialize<DashboardDto>(json, JsonOptions);
if (model is null)
throw new InvalidDataException("请求体不能是 null");
if (string.IsNullOrWhiteSpace(model.DisplayName))
throw new InvalidDataException("user 不能为空");
}
catch (JsonException ex
JSON 能被解析,不代表业务数据有效。类型、必填项、数值范围、字符串长度与跨字段规则仍需在反序列化之后验证。
大 JSON 不必先完整读成字符串。DeserializeAsync 可以直接读 UTF-8 流;顶层数组还可以逐项反序列化:
await using FileStream stream = File.OpenRead("courses.json");
await foreach (CourseDto? course in
JsonSerializer.DeserializeAsyncEnumerable<CourseDto>(
stream,
JsonOptions,
cancellationToken: token))
{
if (course is not null)
Console.WriteLine(course.Title);
}这与异步流自然衔接:数据一项项解析,一项项验证,一项项处理。它可以降低峰值内存与首项延迟,但要注意流只能按当前位置继续读取,失败后的重试也需要重新建立可靠输入。

源生成器把部分序列化元数据提前到构建期,适用于启动时间、裁剪/AOT 或高频序列化敏感的应用:
[JsonSerializable(typeof(DashboardDto))]
[JsonSerializable(typeof(CourseDto[]))]
internal partial class AppJsonContext : JsonSerializerContext
{
}
string json = JsonSerializer.Serialize(
dto,
AppJsonContext.Default.DashboardDto);它改变的是元数据准备方式,不改变 DTO 契约,也不能替代输入验证。普通应用先从清晰的 options 和模型开始,确认性能或部署约束后再引入。
下面这张表适合作为代码评审清单:
把本章能力组合成一个小项目:输入用户编号,并发读取资料与课程;课程量大时使用异步流;每一项映射为 DTO;最终把 JSON 异步写入文件。最低要求如下:
static async Task ExportDashboardAsync(
int userId,
Stream destination,
CancellationToken token)
{
Task<Profile> profileTask = LoadProfileAsync(userId, token);
Task<Course[]> coursesTask = LoadCoursesAsync(userId, token);
await Task.WhenAll(profileTask, coursesTask);
var dto = new
完成后再做三次故障注入:让资料请求失败、让超时先发生、让输入 JSON 某字段类型错误。观察三者是否分别进入失败、取消和解析错误边界,而不是都变成同一个“导出失败”。
异步边界回答“工作何时完成”:Task 表达状态,await 表达暂停与继续,WhenAll 组合独立等待,CancellationToken 传播不再需要结果的信号,IAsyncEnumerable<T> 让数据逐项到达。异常与取消必须保持各自语义。
JSON 边界回答“数据以什么形状跨出去”:DTO 定义公开面,options 定义全局规则,特性处理局部例外,反序列化后的验证守住业务约束。大型数据可使用异步流式 API,高频或 AOT 场景再考虑源生成。
真正可靠的代码不是堆叠更多关键字,而是让每个边界都有清晰契约:谁启动、谁等待、谁能取消、失败如何传播、数据允许什么形状。