在现代软件开发中,应用程序常常需要同时处理多项任务(如响应用户操作、进行网络通信等),此时“异步编程”能够显著提升系统的响应性和资源利用率,防止界面阻塞与线程浪费。 同时,在数据交换和持久化场景下,需要将对象转换为可存储或可传输的标准格式——这就是“序列化”,以 JSON 为代表格式,其关键诉求为简洁性、稳定性与可定制性。
这部分我们将介绍 C# 中的异步机制(基于 async/await 语法)及主流的序列化技术(以 JSON 为主),帮助开发者高效实现多任务处理与数据编码的最佳实践。

现实应用大量时间花在 IO:等待网络、磁盘、数据库。CPU 并没有忙着计算,却因为我们“堵着等”而闲在那里。异步的价值是:
异步不是让事情“更快完成”,而是让“等待过程”不占用宝贵的线程。把“等 IO”变成“让出线程、等结果再续上”。
|using System; using System.Net.Http; using System.Threading.Tasks; class Program { static async Task Main() { using var http = new HttpClient(); // await“挂起”:等待网络返回时,线程可去做别的事 string text = await http.GetStringAsync("https://example.com"); Console.WriteLine(text.Length); // 打印内容长度 } }
在这段代码里,await 就像我们在咖啡店点单时,点单员把“制作咖啡”的任务交给后厨,然后在单子上写下“做好了通知我”。
这时候,点单员不用一直站在原地等咖啡做好,他可以继续接待下一个顾客,做别的事情。等到后厨把咖啡做好了,就会通知点单员,点单员再把咖啡递给顾客。
在程序里,await 让当前线程在等待网络请求的过程中,不会被“卡住”在那里,而是可以去处理其他任务。
等到网络请求真的完成了,C# 运行时会自动把 Main 方法后面还没执行的代码“接着跑下去”。这样既不会浪费线程资源,也让我们的代码写起来像顺序执行一样自然,既直观又高效。
就像我们点了外卖,点单后不用一直盯着手机等外卖员送到,可以去做自己的事情。等外卖到了,手机响了,我们再去取外卖。await 就是帮我们“等外卖”的那个提醒器,让我们不用傻等,还能高效安排时间。
在 C# 里,Task 就像是我们许下的一个“承诺”:它代表着某个操作正在进行,将来某一刻会有结果。比如说,我们让朋友帮忙买奶茶,这个“买奶茶的过程”就是一个 Task。
等朋友买回来了,这个 Task 就完成了。如果朋友路上遇到堵车,可能会晚点,甚至有可能因为店铺关门而失败——Task 也能表示操作成功、失败或者被取消。
如果我们希望这个“承诺”最终能带回一个具体的结果,比如奶茶的口味、价格,那就用 Task<T>,这里的 <T> 就是结果的类型。比如 Task<string> 表示“将来会返回一个字符串”,就像朋友回来后告诉我们“买到的是草莓味奶茶”。
需要注意的是,await 其实可以等待很多种“可等待对象”,但在实际开发中,最常见、最主流的就是 Task 和 Task<T>。我们可以把 Task 理解为“异步世界的快递单”,而 await 就是“等快递到家再拆箱”。
这样,我们的程序就能一边下单,一边做别的事情,等快递到了再继续后面的流程,既高效又优雅。
|using System; using System.Threading.Tasks; class Demo { static async Task<int> ComputeAsync() { await Task.Delay(100); // 模拟耗时 IO return 42; // 结果在将来某一刻交付 } static async Task Main()
我们刚学异步编程时,常常会忍不住想“偷个懒”,比如直接写 var s = http.GetStringAsync(url).Result; 或者用 .Wait() 把异步方法“强行变同步”。这样看起来好像很方便,代码也能拿到结果,但其实这背后藏着不少坑。
举个例子,如果我们在桌面应用或者 ASP.NET 这样的有“同步上下文”的环境里这么写,程序很可能会卡住不动,甚至直接死锁。就像我们让朋友帮忙买奶茶,结果一直堵在门口不让朋友进来,大家都干不了活,场面一度十分尴尬。
为什么会这样呢?因为 .Result 和 .Wait() 这两个方法会让当前线程“原地等着”,直到异步操作完成。可如果异步操作又需要当前线程来“收尾”,那大家就互相等着,谁也动不了。这种情况在 UI 程序和 Web 服务器里尤其常见。
所以,最推荐的做法就是“异步到底”——只要用了 async/await,就让整个调用链都用 async/await,不要半路用 Result 或 Wait 把异步“掐断”。 这样我们的程序才能既高效又不会莫名其妙卡住,就像点了外卖后安心做自己的事,等外卖到了再去取,大家各忙各的,互不耽误。
|// 反例(可能死锁/卡顿) string text = http.GetStringAsync(url).Result; // 正例 string text = await http.GetStringAsync(url);
在库代码中,如果不需要回到捕获的上下文,可以使用 ConfigureAwait(false) 降低上下文切换开销:
|string text = await http.GetStringAsync(url).ConfigureAwait(false);
Async all the way——一旦用了 async,就把调用链都改成 async/await,不要在中间“掐断”用 Result/Wait。
|using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; class Program { static async Task Main() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(
其实,除了设置超时时间让取消自动发生,我们还可以自己在代码里“主动按下取消按钮”。比如说,某个操作如果用户点了“取消”按钮,我们就可以直接调用 cts.Cancel(),这样所有用这个 token 的异步任务都会收到“该停了”的信号,
就像我们在厨房做饭,突然接到电话说不用做了,立刻停手一样。
有时候,取消的理由可能不止一个来源,比如既想支持用户手动取消,也想支持超时自动取消,这时我们可以用 CancellationTokenSource.CreateLinkedTokenSource 把多个 token 合成一个。
这样只要有一个来源发出“取消”信号,所有监听这个合成 token 的任务都会响应,就像家里有好几个闹钟,只要有一个响了,我们就得起床一样。
await 会在出错时直接“把异常抛回来”,所以用 try/catch 即可。并发地 Task.WhenAll 时,可能有多个任务异常,会被包装在 AggregateException(或在 await 时抛首个,Exception.InnerExceptions 包含所有)。
|using System; using System.Linq; using System.Threading.Tasks; class Program { static async Task Main() { Task t1 = Task.Run(() => throw new InvalidOperationException("A")); Task t2 =
Task.WhenAll、Task.WhenAny、限流|using System; using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; class Program { static async Task Main() { var urls
有时候,我们需要同时处理很多个异步任务,比如批量下载网页或者批量请求接口。这个时候,如果我们让所有任务一起“蜂拥而上”,很可能会把网络、服务器或者本地资源压垮。 就像大家一起挤进电梯,电梯就会超载。所以,我们需要一种“限流”的办法,让同一时刻只有固定数量的任务在跑。
在 C# 里,SemaphoreSlim 就像是电梯的门卫,只允许一定数量的人(任务)同时进去。每当有一个任务开始,就“占用”一个名额;
任务结束后,名额就释放出来,其他等待的任务才能继续进来。这样,我们就能优雅地控制并发数量,既保证效率,又不会让系统崩溃。
|var sem = new SemaphoreSlim(5); // 同时跑 5 个 var results = new List<string>(); var tasks = new List<Task>(); foreach (var u in urls) { tasks.Add(Task.Run(async () => { await
IAsyncEnumerable<T> 与 await foreach有时候,我们处理的数据不是一下子全部到手,而是像快递分批送来一样,一点点地到达。比如说,我们要从网络上分段下载大文件,
或者实时接收消息流,这种“分批到货”的场景下,异步流(IAsyncEnumerable<T>)就特别合适。它就像一条流水线,每来一批数据,
我们就能立刻处理,不用等所有数据都准备好。这样既节省内存,又让程序响应更快,非常适合需要边等边处理的任务。
|using System; using System.Collections.Generic; using System.Threading.Tasks; class Program { static async IAsyncEnumerable<int> CountAsync() { for (int i = 1; i <= 3; i++)
Task.Run 把计算丢到线程池(适量)我们常说“异步”,其实它的本质是帮我们把“等待”的时间省下来,比如等网络、等磁盘、等数据库这些慢吞吞的操作。可如果我们遇到的是那种需要大量计算、让 CPU 满负荷转的任务,异步本身并不能让计算变快。这时候,我们可以用 Task.Run 把这些“重体力活”丢到线程池里去做,这样主线程就不会被卡住,还能继续响应用户操作。
不过,这里有个小故事值得我们注意:想象一下,如果我们把所有的计算任务都一股脑扔进线程池,就像把所有家务都推给一个人,结果只会让他累瘫,反而效率低下。所以,Task.Run 要用在合适的地方,别滥用。
一般来说,只有当我们的计算真的很重、会卡住主线程时,才考虑用它。否则,还是让主线程自己轻松地做点小事就好。
举个例子:假如我们要处理一张超大的图片,做复杂的滤镜运算,这种时候就可以用 Task.Run,让主线程继续陪用户聊天,后台慢慢算,等算完了再告诉主线程结果。这样,既不会让界面卡死,也能充分利用多核 CPU 的威力。
|int HeavyCalc() { /* 进行密集计算 */ return 123; } int result = await Task.Run(HeavyCalc);
IProgress<T>有时候,我们处理的任务需要很长时间,比如下载大文件、处理复杂计算。这时候,我们希望用户能知道进度,比如“已经下载了 30%”“还剩 10 秒”。在 C# 里,IProgress<T> 就像是一个进度条,可以帮我们实时更新进度。
|using System; using System.Threading.Tasks; async Task DownloadAsync(IProgress<int>? progress = null) { for (int i = 1; i <= 100; i++) { await Task.Delay(10);
我们在开发 C# 程序时,经常会遇到这样一个需求:把内存里的对象“打包”成一串字符串,方便我们把它发到网络上,或者存进文件里,这个过程就叫做“序列化”。 可以想象成我们把一只小猫装进盒子里,快递到远方,等到了地方再把它放出来,这样小猫(对象)就能安全地“旅行”了。
而反序列化,就是把这串字符串再还原成原来的对象,好比我们收到快递后,把小猫从盒子里放出来,继续陪我们玩耍。
在现代软件开发中,JSON 格式就像是大家都能听懂的“普通话”,不管是前端、后端,还是不同的系统之间,大家都喜欢用 JSON 来交流数据。 它既简洁又易读,非常适合做数据交换的“桥梁”。所以,我们在 C# 里最常用的序列化方式,就是把对象变成 JSON 字符串,然后再根据需要还原回来。
|using System; using System.Text.Json; public record User(string Name, int Age); class Program { static void Main() { var u = new User("小李", 18); string json
在我们实际开发中,序列化 JSON 时经常会遇到各种需求,比如让输出的 JSON 更加美观易读,或者让属性名变成前端喜欢的驼峰风格。这个时候,我们就需要用到 JsonSerializerOptions 这个“调味料”来调整序列化的口味。
我们可以把它想象成点菜时的备注,比如“少盐”“多糖”,而 JsonSerializerOptions 就是告诉序列化器:“请把 JSON 格式弄得漂亮一点,属性名用小写开头的驼峰风格。”
下面是一个简单的例子:
|var options = new JsonSerializerOptions { WriteIndented = true, // 漂亮格式 PropertyNamingPolicy = JsonNamingPolicy.CamelCase // 驼峰命名 }; string json = JsonSerializer.Serialize(u, options);
在实际开发中,我们经常需要对序列化过程进行一些定制,比如:
|using System.Text.Json.Serialization; public class Article { [JsonPropertyName("title")] // 重命名 public string Title { get; set; } = string.Empty; [JsonIgnore] // 完全忽略 public string? InternalNote { get
在 C# 里,我们如果直接用 JsonSerializer 来序列化 DateTime 或 DateTimeOffset,默认情况下,
生成的 JSON 里的日期时间会是 ISO-8601 标准格式的字符串,比如 "2024-06-01T15:30:00Z"。
JSON 天然不携带 .NET 类型信息。若需要多态,可以:
Type 字段作为“类型线索”;[JsonPolymorphic], [JsonDerivedType]);JsonConverter。示例(类型线索法):
|public abstract class ShapeDto { public string Kind { get; init; } = ""; // circle/rect } public class CircleDto : ShapeDto { public double R { get; init; } } public class RectDto : ShapeDto { public double W {
JsonConverter<T>在我们日常写 C# 程序的时候,光靠默认的序列化方式其实远远不够用。比如说,有时候我们希望把某个类型序列化成特殊的字符串格式,或者反过来,把一个很特别的 JSON 字符串还原成我们自定义的对象。 这个时候,光靠系统自带的序列化规则就有点捉襟见肘了。我们就得自己动手,给序列化和反序列化的过程加点“私房菜”,让它们能按照我们的想法来处理数据。 比如说,我们可能会遇到金额、日期、甚至一些业务上独有的类型,这些都需要我们自己来定制序列化的细节。
|using System; using System.Text.Json; using System.Text.Json.Serialization; public readonly struct Money { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount
4. 改造为异步一路到底练习
将以下同步写法改为异步写法,并说明为什么更安全:
var s = http.GetStringAsync(url).Result;.Result 会阻塞当前线程,可能导致死锁async/await 语法async/await 更安全(不会阻塞线程,避免死锁)|using System; using System.Net.Http; using System.Threading.Tasks; class Program { // 反例:使用 .Result 阻塞线程(可能导致死锁) // static void Main() // { // using var http = new HttpClient(); // var s = http.GetStringAsync("https://example.com").Result; // 危险! // Console.WriteLine(s.Length); // } // 正例:使用 async/await(推荐) static async Task Main()
5. 取消与超时练习
使用 CancellationTokenSource 实现一个超时取消机制:
Task.Delay(5000))CancellationTokenSource 在 100ms 后取消这个延迟OperationCanceledException 异常,输出"已取消"|using System; using System.Threading; using System.Threading.Tasks; class Program { static async Task Main() { using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); // 100ms 后自动取消 try
6. WhenAll 的异常处理练习
启动两个立即抛异常的任务,使用 Task.WhenAll 等待它们完成,并捕获并打印所有异常消息:
InvalidOperationException 和 ArgumentException)Task.WhenAll 等待所有任务完成AggregateException,遍历 InnerExceptions 打印所有内部异常的消息|using System; using System.Linq; using System.Threading.Tasks; class Program { static async Task Main() { Task t1 = Task.Run(() => throw new InvalidOperationException("任务1的异常")); Task t2
7. 限流爬取练习
使用 SemaphoreSlim 实现并发限流,限制同时只能有 3 个任务在执行:
SemaphoreSlim(3) 创建信号量,限制并发数为 3sem.WaitAsync(),结束时调用 sem.Release()Task.WhenAll 等待所有任务完成|using System; using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; class Program { static async Task Main() { var
8. 异步流练习
编写一个 IAsyncEnumerable<int> 方法,每 50ms 产出 1 到 5 的数字,并使用 await foreach 打印:
IAsyncEnumerable<int> 的异步方法yield return 在异步方法中产出值await Task.Delay(50))Main 方法中使用 await foreach 遍历并打印每个值|using System; using System.Collections.Generic; using System.Threading.Tasks; class Program { static async IAsyncEnumerable<int> CountAsync() { for (int i = 1; i <= 5; i++
9. 自定义 JsonConverter 练习
为 DateOnly 类型编写一个自定义的 JsonConverter,实现 "yyyy-MM-dd" 格式与 DateOnly 的相互转换:
JsonConverter<DateOnly> 的类Read 方法:从 JSON 字符串(格式 "yyyy-MM-dd")解析为 DateOnlyWrite 方法:将 DateOnly 序列化为 "yyyy-MM-dd" 格式的字符串JsonSerializerOptions 中注册这个转换器|using System; using System.Text.Json; using System.Text.Json.Serialization; public class DateOnlyConverter : JsonConverter<DateOnly> { public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions
|1256
说明:
.Result 的问题:
async/await 的优势:
|开始 5 秒延迟... 已取消:延迟在 100ms 后被取消
说明:
CancellationTokenSource(TimeSpan) 构造函数会在指定时间后自动取消Task.Delay(5000, cts.Token) 传入取消令牌,当令牌被取消时,延迟任务会抛出 OperationCanceledExceptiontry/catch 捕获取消异常,优雅处理取消情况|捕获到异常: InvalidOperationException 异常消息: 任务1的异常 所有内部异常: - InvalidOperationException: 任务1的异常 - ArgumentException: 任务2的异常
说明:
Task.WhenAll 会等待所有任务完成await 时会抛出第一个异常AggregateException 的 InnerExceptions 获取所有异常|开始处理: https://example.com/1 开始处理: https://example.com/2 开始处理: https://example.com/3 完成: https://example.com/1, 长度: 1256 开始处理: https://example.com/4 完成: https://example.com/2, 长度: 1256 开始处理: https://example.com/5 完成: https://example.com/3, 长度: 1256 完成: https://example.com/4, 长度: 1256 完成: https://example.com/5, 长度: 1256 所有任务完成
说明:
SemaphoreSlim(3) 创建信号量,初始允许 3 个并发WaitAsync() 获取信号量,如果已满则等待,直到有位置释放Release() 释放信号量,让等待的任务继续执行try/finally 确保即使出错也释放信号量|开始遍历异步流... 收到: 1 收到: 2 收到: 3 收到: 4 收到: 5 遍历完成
说明:
IAsyncEnumerable<T> 是异步流接口,用于逐步产出数据yield return 在异步方法中产出值await foreach 用于遍历异步流,每次等待下一个值|序列化结果: "2024-06-01" 反序列化结果: 2024-06-01
说明:
JsonConverter<T> 是自定义 JSON 转换器的基类Read 方法:从 Utf8JsonReader 读取 JSON 值,解析为 DateOnlyWrite 方法:将 DateOnly 写入 Utf8JsonWriter,格式化为字符串DateOnly.ParseExact 用于按指定格式解析日期字符串ToString("yyyy-MM-dd") 用于将日期格式化为指定格式JsonSerializerOptions.Converters 中注册转换器后,序列化/反序列化时会自动使用