自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

C#

  1. 01C# 入门:从第一行代码到可运行的小程序
  2. 02函数与逻辑:从方法契约到 Lambda
  3. 03面向对象:从有效对象到可靠边界
  4. 04C# 类型与引用:从复制语义到安全转换
  5. 05C# 继承与运行时多态
  6. 06C# 接口与抽象:从契约到可测试设计
  7. 07C# 异步与 JSON:从 Task 到可靠数据边界
  8. 08C# 错误处理:从异常传播到可靠边界
  9. 09C# LINQ:从集合流水线到可靠的数据边界
  10. 10C# 委托与事件:从类型安全回调到生命周期管理
  11. 11C# MVVM:从可绑定状态到可测试交互
正在加载课程章节内容
课程编程C#C# 异步与 JSON:从 Task 到可靠数据边界

C# 异步与 JSON:把等待和数据边界写清楚

一个“课程看板”请求要同时读取用户、课程和通知,再把结果写成 JSON 返回。代码看起来只是几次方法调用,但真正困难的地方都在边界上:等待时不能浪费线程,多个任务失败时不能丢信息,取消要一路传递,外部 JSON 也不能被盲目信任。

本章用这个贯穿案例建立两套心智模型:用 Task 表达尚未完成的工作,用 JSON 契约表达跨进程的数据。学完后,你应该能解释一次 await 如何暂停与恢复,能正确组合并发、取消和异常,也能用 System.Text.Json 安全地完成对象、流与 JSON 之间的转换。

异步课程看板中,调用、await、Task 三种终态与恢复执行的完整关系。

本章代码以现代 .NET 控制台项目为背景。为突出机制,示例用 Task.Delay 模拟 I/O;在真实项目中应替换为带异步后缀的 HTTP、数据库或文件 API。


学习路线与贯穿案例

我们要实现 BuildDashboardAsync。它接收用户编号与取消令牌,并返回一个强类型的 Dashboard:

csharp
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);

完整路线按真实数据流展开:

  1. 用 Task<T> 表达每个尚未完成的 I/O 操作。
  2. 用 Task.WhenAll 并发等待互不依赖的读取。
  3. 用 CancellationToken 把“已经不需要结果”传到最底层。
  4. 在异步边界观察失败、取消和成功三种终态。
  5. 用异步流逐项读取大量数据。
  6. 用 System.Text.Json 把结果变成稳定的数据契约。

小节测试

1
为什么 Dashboard 更适合保存强类型对象,而不是直接拼接 JSON 字符串?
2
用户、课程和通知三次读取满足哪些条件时适合并发执行?

async、await 与 Task 的运行模型

Task 是一次操作的状态与最终结果。它可能仍在运行,也可能已经以成功、失败或取消结束。Task<T> 还会在成功时携带一个 T。

csharp
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.DisplayName}");

输出:

console
profile=小岚

执行到尚未完成的 await 时,方法会保存“从哪里继续”和所需的局部状态,然后把控制权交还给调用方。任务完成后,后续代码会被安排继续执行。这里没有“每个 await 自动创建一个线程”的规则;异步 I/O 的价值恰恰是等待期间通常不需要占住线程。

常见返回类型可以这样选择:

场景返回类型说明
完成后无值Task成功、失败或取消
完成后有值Task<T>成功时携带 T
事件处理器async void仅限事件模式;调用方无法正常 await
高频且常同步完成ValueTask<T>先测量再用,消费规则更复杂

默认使用 Task。ValueTask<T> 是特定热点的分配优化,不是更“高级”的 Task;它不应被随意多次等待或缓存。如果没有性能数据,增加这种复杂度通常得不偿失。

小节测试

3
await 一个尚未完成的 I/O 任务时,当前线程必须一直停在原地等待任务完成。
4
普通异步业务方法不应返回 async void 的主要原因是什么?

并发组合:先启动,再等待

如果三个读取彼此独立,逐个 await 会把等待时间相加:

csharp
Profile profile = await LoadProfileAsync(userId, token);
Course[] courses = await LoadCoursesAsync(userId, token);
Notice[] notices = await LoadNoticesAsync(userId, token);

更合适的做法是先保存任务,再统一等待:

csharp
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 没有让单个请求变快,只是消除了不必要的顺序。

三个独立 I/O 任务并发启动,在 WhenAll 汇合,并共享取消与异常边界。

并发也有成本。一次性对十万条记录启动十万个请求,可能压垮连接池或下游服务。应根据资源容量限流,例如用 SemaphoreSlim 控制同时进行的请求数量,而不是把 WhenAll 当成无限并发开关。

小节测试

5
对于调用后立即启动的热任务,把三次调用直接传入 Task.WhenAll 与先保存三个任务再传入,其核心并发语义相同。
6
为什么不能把海量任务不加限制地全部交给 Task.WhenAll?

取消与异常:区分“不需要”和“做失败”

取消是合作式协议,不是强行终止线程。上层创建或接收 token,下层把它继续传给所有支持取消的异步 API:

csharp
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");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"upstream failed: {ex.Message}");
}

输出:

console
dashboard timed out

一个 Task 的结束状态必须被正确解释:

状态含义边界做法
RanToCompletion得到有效结果继续处理
Canceled调用方不再需要结果或超时通常记录为取消,不伪装成服务器错误
Faulted操作执行失败保留上下文并在合适层处理

await Task.WhenAll(...) 只要有子任务失败,组合任务就会失败;多个子任务可能同时失败。业务只需要“整体失败”时,直接 await 并捕获最符合直觉。如果诊断必须保留所有失败,应保存组合任务,并在 catch 中检查各子任务的 Exception,不要只记录一条消息后丢掉其余上下文。

csharp
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) 只需要在不依赖调用方上下文的可复用库边界审慎使用,不应作为业务代码的机械后缀。

小节测试

7
为什么超时通常应按 OperationCanceledException 处理,而不是归为一般失败?
8
CancellationToken 到达服务方法后,即使不再传给底层异步 API,上层取消也能立即终止底层等待。
9
Task.WhenAll 中多个子任务同时失败时,诊断日志应关注哪些信息?

异步流:数据到一项就处理一项

Task<List<T>> 表示“最终一次拿到全部数据”,IAsyncEnumerable<T> 表示“数据会异步地逐项到达”。分页接口、日志订阅或大型查询适合后者:

csharp
using System.Runtime.CompilerServices;
 
static async IAsyncEnumerable<Notice> StreamNoticesAsync(
    [EnumeratorCancellation] CancellationToken token = default)
{
    for (int page = 1; page <= 3; page++)
    {
        await Task.Delay(40, token);
        yield return new Notice(page, $"第 {page} 页到达");
    }
}
 
await foreach (Notice notice in
    StreamNoticesAsync(token).WithCancellation(token))
{
    Console.WriteLine($"notice={notice.Id}:{notice.Message}");
}

输出:

console
notice=1:第 1 页到达
notice=2:第 2 页到达
notice=3:第 3 页到达

IAsyncEnumerable 让分页数据逐项到达并立即处理,与一次性缓冲形成对比。

异步流减少首项延迟与一次性内存占用,但不会自动解决生产速度与消费速度不匹配的问题。消费者很慢时,总耗时仍会增加;如果生产端在后台无界缓存,还可能继续占用大量内存。设计时要明确取消、背压和每项错误的处理位置。

小节测试

10
哪些场景更适合使用 Task<List<T>> 一次返回全部数据?
11
异步迭代器参数上的 [EnumeratorCancellation] 有什么作用?

System.Text.Json:先定义契约,再转换数据

序列化不是“保存内存中的对象本身”,而是按约定把对象投影成 JSON;反序列化则按相同约定重建一个新对象。先定义公开契约:

csharp
using System.Text.Json.Serialization;
 
public sealed record DashboardDto(
    [property: JsonPropertyName("user")] string DisplayName,
    IReadOnlyList<CourseDto> Courses,
    DateTimeOffset GeneratedAt,
    [property: JsonIgnore] string InternalTraceId);
 
public sealed record CourseDto(int Id, string Title);

统一复用 options,避免系统不同角落生成不同形状:

csharp
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 dto = new DashboardDto(
    "小岚",
    [new CourseDto(101, "异步基础")],
    DateTimeOffset.Parse("2026-07-12T08:00:00+08:00"),
    "trace-7");
 
string json = JsonSerializer.Serialize(dto, JsonOptions);
Console.WriteLine(json);

输出:

json
{
  "user": "小岚",
  "courses": [
    { "id": 101, "title": "异步基础" }
  ],
  "generatedAt": "2026-07-12T08:00:00+08:00"
}

C# 对象与 JSON 双向转换时,全局选项、局部特性和验证边界各司其职。

PropertyNamingPolicy 是全局规则,JsonPropertyName 是局部覆盖,JsonIgnore 用于明确不公开的成员。不要把密码、访问令牌或内部追踪信息先放进 DTO 再指望调用处记得删除;让契约从类型定义开始就排除敏感字段。

反序列化面对的是外部输入:

csharp
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)
{
    Console.WriteLine($"invalid json at {ex.Path}");
}

JSON 能被解析,不代表业务数据有效。类型、必填项、数值范围、字符串长度与跨字段规则仍需在反序列化之后验证。

小节测试

12
JsonPropertyName("user") 与全局 camelCase 命名策略冲突时,哪一项优先?
13
成功反序列化后,通常还需要验证哪些业务约束?
14
设置 PropertyNameCaseInsensitive = true 会提高字段大小写兼容性,但也可能掩盖发送方的字段命名错误。

流式 JSON 与源生成:大型或高频边界

大 JSON 不必先完整读成字符串。DeserializeAsync 可以直接读 UTF-8 流;顶层数组还可以逐项反序列化:

csharp
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);
}

这与异步流自然衔接:数据一项项解析,一项项验证,一项项处理。它可以降低峰值内存与首项延迟,但要注意流只能按当前位置继续读取,失败后的重试也需要重新建立可靠输入。

流式 JSON 从 UTF-8 输入逐项形成强类型对象,源生成在构建期准备序列化元数据。

源生成器把部分序列化元数据提前到构建期,适用于启动时间、裁剪/AOT 或高频序列化敏感的应用:

csharp
[JsonSerializable(typeof(DashboardDto))]
[JsonSerializable(typeof(CourseDto[]))]
internal partial class AppJsonContext : JsonSerializerContext
{
}
 
string json = JsonSerializer.Serialize(
    dto,
    AppJsonContext.Default.DashboardDto);

它改变的是元数据准备方式,不改变 DTO 契约,也不能替代输入验证。普通应用先从清晰的 options 和模型开始,确认性能或部署约束后再引入。

小节测试

15
DeserializeAsyncEnumerable<T> 相比先读取完整 JSON 字符串有哪些主要收益?
16
System.Text.Json 源生成会自动验证课程编号必须为正数。

边界清单:把常见陷阱挡在评审前

下面这张表适合作为代码评审清单:

陷阱后果更稳妥的做法
在异步链中使用 .Result/.Wait()阻塞、死锁风险、异常包装变化调用链保持 async/await
忘记传递 token上层取消了,底层仍继续耗资源token 一路传到真正的 I/O
对海量任务直接 WhenAll连接池、内存或下游过载限流、分批、队列化
用 async void 写业务方法无法等待、组合和常规捕获异常返回 Task/Task<T>
吞掉 OperationCanceledException 后继续上层误判成功能处理才捕获,否则让取消传播
每次 new 不同 JSON options输出契约漂移、重复元数据成本集中注册与复用
把实体直接作为外部 DTO内部字段意外泄露、版本耦合定义专用传输模型
认为反序列化成功即有效非法业务数据进入核心逻辑解析后做领域验证

小节测试

17
捕获所有异常并返回空数组可能造成哪些问题?
18
数据库实体不宜直接作为公开 JSON 契约的主要原因是什么?

综合实践:完成可取消的课程导出器

把本章能力组合成一个小项目:输入用户编号,并发读取资料与课程;课程量大时使用异步流;每一项映射为 DTO;最终把 JSON 异步写入文件。最低要求如下:

csharp
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 DashboardDto(
        (await profileTask).DisplayName,
        (await coursesTask)
            .Select(c => new CourseDto(c.Id, c.Title))
            .ToArray(),
        DateTimeOffset.UtcNow,
        "not-exported");
 
    await JsonSerializer.SerializeAsync(
        destination,
        dto,
        JsonOptions,
        token);
}

完成后再做三次故障注入:让资料请求失败、让超时先发生、让输入 JSON 某字段类型错误。观察三者是否分别进入失败、取消和解析错误边界,而不是都变成同一个“导出失败”。

小节测试

19
让调用方传入 destination Stream 有哪些好处?
20
哪种测试最能证明两个各耗时约 100 ms 的独立读取确实并发执行?
21
为了避免导出中途取消后留下半成品文件,哪种做法更稳妥?

总结:两种边界,一条可靠数据流

异步边界回答“工作何时完成”:Task 表达状态,await 表达暂停与继续,WhenAll 组合独立等待,CancellationToken 传播不再需要结果的信号,IAsyncEnumerable<T> 让数据逐项到达。异常与取消必须保持各自语义。

JSON 边界回答“数据以什么形状跨出去”:DTO 定义公开面,options 定义全局规则,特性处理局部例外,反序列化后的验证守住业务约束。大型数据可使用异步流式 API,高频或 AOT 场景再考虑源生成。

真正可靠的代码不是堆叠更多关键字,而是让每个边界都有清晰契约:谁启动、谁等待、谁能取消、失败如何传播、数据允许什么形状。

小节测试

22
Task.WhenAll 与 IAsyncEnumerable<T> 的核心差别是什么?
23
一段 JSON 能被成功解析,就说明其中所有字段都满足业务规则。
上一章C# 接口与抽象:从契约到可测试设计下一章C# 错误处理:从异常传播到可靠边界