自在学
分类课程智能体订阅
分类课程AI导师价格
课程进度
7 / 11
上一节接口与抽象下一节错误处理
自在学

© 2025 - 2026 自在学,保留所有权利。

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

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

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1

编程C#异步与序列化

异步与序列化

在现代软件开发中,应用程序常常需要同时处理多项任务(如响应用户操作、进行网络通信等),此时“异步编程”能够显著提升系统的响应性和资源利用率,防止界面阻塞与线程浪费。 同时,在数据交换和持久化场景下,需要将对象转换为可存储或可传输的标准格式——这就是“序列化”,以 JSON 为代表格式,其关键诉求为简洁性、稳定性与可定制性。

这部分我们将介绍 C# 中的异步机制(基于 async/await 语法)及主流的序列化技术(以 JSON 为主),帮助开发者高效实现多任务处理与数据编码的最佳实践。

异步与序列化


为什么要异步

现实应用大量时间花在 IO:等待网络、磁盘、数据库。CPU 并没有忙着计算,却因为我们“堵着等”而闲在那里。异步的价值是:

  • 释放线程去做别的事(不白白阻塞);
  • 提升吞吐与响应性(界面不卡、服务更抗压);
  • 写法直观(async/await 让回调变顺叙)。

异步不是让事情“更快完成”,而是让“等待过程”不占用宝贵的线程。把“等 IO”变成“让出线程、等结果再续上”。


async/await

csharp
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 就是帮我们“等外卖”的那个提醒器,让我们不用傻等,还能高效安排时间。


Task 是什么

在 C# 里,Task 就像是我们许下的一个“承诺”:它代表着某个操作正在进行,将来某一刻会有结果。比如说,我们让朋友帮忙买奶茶,这个“买奶茶的过程”就是一个 Task。 等朋友买回来了,这个 Task 就完成了。如果朋友路上遇到堵车,可能会晚点,甚至有可能因为店铺关门而失败——Task 也能表示操作成功、失败或者被取消。

如果我们希望这个“承诺”最终能带回一个具体的结果,比如奶茶的口味、价格,那就用 Task<T>,这里的 <T> 就是结果的类型。比如 Task<string> 表示“将来会返回一个字符串”,就像朋友回来后告诉我们“买到的是草莓味奶茶”。

需要注意的是,await 其实可以等待很多种“可等待对象”,但在实际开发中,最常见、最主流的就是 Task 和 Task<T>。我们可以把 Task 理解为“异步世界的快递单”,而 await 就是“等快递到家再拆箱”。 这样,我们的程序就能一边下单,一边做别的事情,等快递到了再继续后面的流程,既高效又优雅。

csharp
using System;
using System.Threading.Tasks;
 
class Demo
{
    static async Task<int> ComputeAsync()
    {
        await Task.Delay(100); // 模拟耗时 IO
        return 42;             // 结果在将来某一刻交付
    }
 
    static async Task Main()
    {
        Task<int> promise = ComputeAsync();
        // 这里还未取结果,可以做别的事情
        int answer = await promise; // 在此处“取货”
        Console.WriteLine(answer);
    }
}

避免阻塞

我们刚学异步编程时,常常会忍不住想“偷个懒”,比如直接写 var s = http.GetStringAsync(url).Result; 或者用 .Wait() 把异步方法“强行变同步”。这样看起来好像很方便,代码也能拿到结果,但其实这背后藏着不少坑。
举个例子,如果我们在桌面应用或者 ASP.NET 这样的有“同步上下文”的环境里这么写,程序很可能会卡住不动,甚至直接死锁。就像我们让朋友帮忙买奶茶,结果一直堵在门口不让朋友进来,大家都干不了活,场面一度十分尴尬。

为什么会这样呢?因为 .Result 和 .Wait() 这两个方法会让当前线程“原地等着”,直到异步操作完成。可如果异步操作又需要当前线程来“收尾”,那大家就互相等着,谁也动不了。这种情况在 UI 程序和 Web 服务器里尤其常见。

所以,最推荐的做法就是“异步到底”——只要用了 async/await,就让整个调用链都用 async/await,不要半路用 Result 或 Wait 把异步“掐断”。 这样我们的程序才能既高效又不会莫名其妙卡住,就像点了外卖后安心做自己的事,等外卖到了再去取,大家各忙各的,互不耽误。

csharp
// 反例(可能死锁/卡顿)
string text = http.GetStringAsync(url).Result;
 
// 正例
string text = await http.GetStringAsync(url);

在库代码中,如果不需要回到捕获的上下文,可以使用 ConfigureAwait(false) 降低上下文切换开销:

csharp
string text = await http.GetStringAsync(url).ConfigureAwait(false);

Async all the way——一旦用了 async,就把调用链都改成 async/await,不要在中间“掐断”用 Result/Wait。


取消与超时

csharp
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(2)); // 2 秒后自动取消
        using var http = new HttpClient();
        try
        {
            string text = await http.GetStringAsync("https://example.com", cts.Token);
            Console.WriteLine(text.Length);
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("已取消或超时");
        }
    }
}

其实,除了设置超时时间让取消自动发生,我们还可以自己在代码里“主动按下取消按钮”。比如说,某个操作如果用户点了“取消”按钮,我们就可以直接调用 cts.Cancel(),这样所有用这个 token 的异步任务都会收到“该停了”的信号, 就像我们在厨房做饭,突然接到电话说不用做了,立刻停手一样。

有时候,取消的理由可能不止一个来源,比如既想支持用户手动取消,也想支持超时自动取消,这时我们可以用 CancellationTokenSource.CreateLinkedTokenSource 把多个 token 合成一个。 这样只要有一个来源发出“取消”信号,所有监听这个合成 token 的任务都会响应,就像家里有好几个闹钟,只要有一个响了,我们就得起床一样。


异常处理:try/catch、聚合异常与 WhenAll

await 会在出错时直接“把异常抛回来”,所以用 try/catch 即可。并发地 Task.WhenAll 时,可能有多个任务异常,会被包装在 AggregateException(或在 await 时抛首个,Exception.InnerExceptions 包含所有)。

csharp
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.Run(() => throw new ApplicationException("B"));
        try
        {
            await Task.WhenAll(t1, t2);
        }
        catch (Exception ex) // 捕获异常
        {
            Console.WriteLine(ex.Message);
            if (ex is AggregateException ag) // 如果是聚合异常
            {
                foreach (var e in ag.InnerExceptions) // 遍历内部异常
                    Console.WriteLine("inner: " + e.Message); // 打印内部异常信息
            }
        }
    }
}

同时做多件事:Task.WhenAll、Task.WhenAny、限流

csharp
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 = new []
        {
            "https://example.com/a",
            "https://example.com/b",
            "https://example.com/c"
        };
 
        using var http = new HttpClient();
        var tasks = new List<Task<string>>();
        foreach (var u in urls)
            tasks.Add(http.GetStringAsync(u));
 
        string[] all = await Task.WhenAll(tasks); // 并发拉取
        Console.WriteLine(string.Join(",", all.Length));
 
        // WhenAny:先处理最快完成的
        var t = await Task.WhenAny(tasks);
        Console.WriteLine((await t).Length);
    }
}

有时候,我们需要同时处理很多个异步任务,比如批量下载网页或者批量请求接口。这个时候,如果我们让所有任务一起“蜂拥而上”,很可能会把网络、服务器或者本地资源压垮。 就像大家一起挤进电梯,电梯就会超载。所以,我们需要一种“限流”的办法,让同一时刻只有固定数量的任务在跑。

在 C# 里,SemaphoreSlim 就像是电梯的门卫,只允许一定数量的人(任务)同时进去。每当有一个任务开始,就“占用”一个名额; 任务结束后,名额就释放出来,其他等待的任务才能继续进来。这样,我们就能优雅地控制并发数量,既保证效率,又不会让系统崩溃。

csharp
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 sem.WaitAsync();
        try { results.Add(await http.GetStringAsync(u)); }
        finally { sem.Release(); }
    }));
}
await Task.WhenAll(tasks);

异步流:IAsyncEnumerable<T> 与 await foreach

有时候,我们处理的数据不是一下子全部到手,而是像快递分批送来一样,一点点地到达。比如说,我们要从网络上分段下载大文件, 或者实时接收消息流,这种“分批到货”的场景下,异步流(IAsyncEnumerable<T>)就特别合适。它就像一条流水线,每来一批数据, 我们就能立刻处理,不用等所有数据都准备好。这样既节省内存,又让程序响应更快,非常适合需要边等边处理的任务。

csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
 
class Program
{
    static async IAsyncEnumerable<int> CountAsync()
    {
        for (int i = 1; i <= 3; i++)
        {
            await Task.Delay(100); // 模拟一批一批地来
            yield return i;
        }
    }
 
    static async Task Main()
    {
        await foreach (var x in CountAsync())
            Console.WriteLine(x); // 1,2,3(逐个到)
    }
}

CPU 密集:用 Task.Run 把计算丢到线程池(适量)

我们常说“异步”,其实它的本质是帮我们把“等待”的时间省下来,比如等网络、等磁盘、等数据库这些慢吞吞的操作。可如果我们遇到的是那种需要大量计算、让 CPU 满负荷转的任务,异步本身并不能让计算变快。这时候,我们可以用 Task.Run 把这些“重体力活”丢到线程池里去做,这样主线程就不会被卡住,还能继续响应用户操作。

不过,这里有个小故事值得我们注意:想象一下,如果我们把所有的计算任务都一股脑扔进线程池,就像把所有家务都推给一个人,结果只会让他累瘫,反而效率低下。所以,Task.Run 要用在合适的地方,别滥用。 一般来说,只有当我们的计算真的很重、会卡住主线程时,才考虑用它。否则,还是让主线程自己轻松地做点小事就好。

举个例子:假如我们要处理一张超大的图片,做复杂的滤镜运算,这种时候就可以用 Task.Run,让主线程继续陪用户聊天,后台慢慢算,等算完了再告诉主线程结果。这样,既不会让界面卡死,也能充分利用多核 CPU 的威力。

csharp
int HeavyCalc() { /* 进行密集计算 */ return 123; }
int result = await Task.Run(HeavyCalc);

进度回报:IProgress<T>

有时候,我们处理的任务需要很长时间,比如下载大文件、处理复杂计算。这时候,我们希望用户能知道进度,比如“已经下载了 30%”“还剩 10 秒”。在 C# 里,IProgress<T> 就像是一个进度条,可以帮我们实时更新进度。

csharp
using System;
using System.Threading.Tasks;
 
async Task DownloadAsync(IProgress<int>? progress = null)
{
    for (int i = 1; i <= 100; i++)
    {
        await Task.Delay(10);
        progress?.Report(i); // 报告 1..100%
    }
}
 
var p = new Progress<int>(v => Console.WriteLine($"进度:{v}%"));
await DownloadAsync(p);

序列化/反序列化

我们在开发 C# 程序时,经常会遇到这样一个需求:把内存里的对象“打包”成一串字符串,方便我们把它发到网络上,或者存进文件里,这个过程就叫做“序列化”。 可以想象成我们把一只小猫装进盒子里,快递到远方,等到了地方再把它放出来,这样小猫(对象)就能安全地“旅行”了。

而反序列化,就是把这串字符串再还原成原来的对象,好比我们收到快递后,把小猫从盒子里放出来,继续陪我们玩耍。

在现代软件开发中,JSON 格式就像是大家都能听懂的“普通话”,不管是前端、后端,还是不同的系统之间,大家都喜欢用 JSON 来交流数据。 它既简洁又易读,非常适合做数据交换的“桥梁”。所以,我们在 C# 里最常用的序列化方式,就是把对象变成 JSON 字符串,然后再根据需要还原回来。

csharp
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 = JsonSerializer.Serialize(u);
        Console.WriteLine(json); // {"Name":"小李","Age":18}
 
        var u2 = JsonSerializer.Deserialize<User>(json);
        Console.WriteLine(u2);
    }
}

在我们实际开发中,序列化 JSON 时经常会遇到各种需求,比如让输出的 JSON 更加美观易读,或者让属性名变成前端喜欢的驼峰风格。这个时候,我们就需要用到 JsonSerializerOptions 这个“调味料”来调整序列化的口味。 我们可以把它想象成点菜时的备注,比如“少盐”“多糖”,而 JsonSerializerOptions 就是告诉序列化器:“请把 JSON 格式弄得漂亮一点,属性名用小写开头的驼峰风格。”

下面是一个简单的例子:

csharp
var options = new JsonSerializerOptions
{
    WriteIndented = true, // 漂亮格式
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase // 驼峰命名
};
string json = JsonSerializer.Serialize(u, options);

命名、忽略、包含、枚举、默认值

在实际开发中,我们经常需要对序列化过程进行一些定制,比如:

csharp
using System.Text.Json.Serialization;
 
public class Article
{
    [JsonPropertyName("title")]      // 重命名
    public string Title { get; set; } = string.Empty;
 
    [JsonIgnore]                      // 完全忽略
    public string? InternalNote { get; set; }
 
    [JsonInclude]                     // 包含非公共 setter 的属性/字段
    public int Words { get; private set; }
 
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public Level Lv { get; set; } = Level.Normal;
}
 
public enum Level { Low, Normal, High }
 
// 选项层面:忽略默认/空值
var opts = new JsonSerializerOptions
{
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

在 C# 里,我们如果直接用 JsonSerializer 来序列化 DateTime 或 DateTimeOffset,默认情况下, 生成的 JSON 里的日期时间会是 ISO-8601 标准格式的字符串,比如 "2024-06-01T15:30:00Z"。


多态与派生类型

JSON 天然不携带 .NET 类型信息。若需要多态,可以:

  1. 在基类里加一个 Type 字段作为“类型线索”;
  2. 使用 .NET 7+ 的多态注解([JsonPolymorphic], [JsonDerivedType]);
  3. 自定义 JsonConverter。

示例(类型线索法):

csharp
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 { get; init; } public double H { get; init; } }
 
// 反序列化:先读 Kind 再分派
ShapeDto Parse(string json)
{
    using var doc = JsonDocument.Parse(json);
    string kind = doc.RootElement.GetProperty("kind").GetString()!;
    return kind switch
    {
        "circle" => JsonSerializer.Deserialize<CircleDto>(json)!,
        "rect"   => JsonSerializer.Deserialize<RectDto>(json)!,
        _         => throw new NotSupportedException(kind)
    };
}

自定义转换器:JsonConverter<T>

在我们日常写 C# 程序的时候,光靠默认的序列化方式其实远远不够用。比如说,有时候我们希望把某个类型序列化成特殊的字符串格式,或者反过来,把一个很特别的 JSON 字符串还原成我们自定义的对象。 这个时候,光靠系统自带的序列化规则就有点捉襟见肘了。我们就得自己动手,给序列化和反序列化的过程加点“私房菜”,让它们能按照我们的想法来处理数据。 比如说,我们可能会遇到金额、日期、甚至一些业务上独有的类型,这些都需要我们自己来定制序列化的细节。

csharp
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, string currency){ Amount = amount; Currency = currency; }
    public override string ToString() => $"{Amount} {Currency}";
}
 
public class MoneyConverter : JsonConverter<Money>
{
    public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // 自定义解析格式:"100 CNY"
        string s = reader.GetString()!;
        var parts = s.Split(' ');
        return new Money(decimal.Parse(parts[0]), parts[1]);
    }
 
    public override void Write(Utf8JsonWriter writer, Money value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }
}
 
var opts = new JsonSerializerOptions();
opts.Converters.Add(new MoneyConverter());
string j = JsonSerializer.Serialize(new Money(100m, "CNY"), opts); // "100 CNY"

小练习

  1. 在C#中,用于异步编程的关键字组合是?
  1. 等待异步操作完成,推荐使用哪个关键字?
  1. C#中用于取消异步操作的机制包括?

4. 改造为异步一路到底练习

将以下同步写法改为异步写法,并说明为什么更安全:

  • 原代码:var s = http.GetStringAsync(url).Result;
  • 问题:使用 .Result 会阻塞当前线程,可能导致死锁
  • 要求:改为使用 async/await 语法
  • 说明:为什么 async/await 更安全(不会阻塞线程,避免死锁)
csharp
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()
    {
        using var http = new HttpClient();
        string s = await http.GetStringAsync("https://example.com"); // 安全!
        Console.WriteLine(s.Length);
    }
}
console
1256

说明:

  • .Result 的问题:
    • 会阻塞当前线程,直到异步操作完成
    • 在 UI 线程或 ASP.NET 上下文中可能导致死锁
    • 因为异步操作可能需要回到原线程,但原线程被阻塞了
  • async/await 的优势:
    • 不会阻塞线程,等待时线程可以去做其他事情
    • 避免了死锁风险
    • 代码写起来像同步代码一样清晰
    • 遵循"异步一路到底"原则,整个调用链都用 async/await

5. 取消与超时练习

使用 CancellationTokenSource 实现一个超时取消机制:

  • 创建一个 5 秒的延迟任务(使用 Task.Delay(5000))
  • 使用 CancellationTokenSource 在 100ms 后取消这个延迟
  • 捕获 OperationCanceledException 异常,输出"已取消"
  • 验证延迟任务确实在 100ms 后被取消,而不是等待 5 秒
csharp
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
        {
            Console.WriteLine("开始 5 秒延迟...");
            await Task.Delay(5000, cts.Token); // 传入取消令牌
            Console.WriteLine("延迟完成");
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("已取消:延迟在 100ms 后被取消");
        }
    }
}
console
开始 5 秒延迟...
已取消:延迟在 100ms 后被取消

说明:

  • CancellationTokenSource(TimeSpan) 构造函数会在指定时间后自动取消
  • Task.Delay(5000, cts.Token) 传入取消令牌,当令牌被取消时,延迟任务会抛出 OperationCanceledException
  • 使用 try/catch 捕获取消异常,优雅处理取消情况
  • 这种方式可以避免长时间等待,提高程序的响应性

6. WhenAll 的异常处理练习

启动两个立即抛异常的任务,使用 Task.WhenAll 等待它们完成,并捕获并打印所有异常消息:

  • 创建两个任务,分别抛出不同的异常(如 InvalidOperationException 和 ArgumentException)
  • 使用 Task.WhenAll 等待所有任务完成
  • 捕获异常,如果是 AggregateException,遍历 InnerExceptions 打印所有内部异常的消息
  • 验证能够捕获到所有任务的异常
csharp
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 = Task.Run(() => throw new ArgumentException("任务2的异常"));
 
        try
        {
            await Task.WhenAll(t1, t2);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"捕获到异常: {ex.GetType().Name}");
            Console.WriteLine($"异常消息: {ex.Message}");
 
            // 如果是聚合异常,打印所有内部异常
            if (ex is AggregateException ag)
            {
                Console.WriteLine("\n所有内部异常:");
                foreach (var innerEx in ag.InnerExceptions)
                {
                    Console.WriteLine($"  - {innerEx.GetType().Name}: {innerEx.Message}");
                }
            }
        }
    }
}
console
捕获到异常: InvalidOperationException
异常消息: 任务1的异常
 
所有内部异常:
  - InvalidOperationException: 任务1的异常
  - ArgumentException: 任务2的异常

说明:

  • Task.WhenAll 会等待所有任务完成
  • 如果有多个任务抛出异常,await 时会抛出第一个异常
  • 但可以通过检查 AggregateException 的 InnerExceptions 获取所有异常
  • 这种方式可以让我们知道所有任务的执行情况,而不仅仅是第一个失败的

7. 限流爬取练习

使用 SemaphoreSlim 实现并发限流,限制同时只能有 3 个任务在执行:

  • 创建一个包含多个 URL 的列表(至少 5 个)
  • 使用 SemaphoreSlim(3) 创建信号量,限制并发数为 3
  • 为每个 URL 创建异步任务,在任务开始时调用 sem.WaitAsync(),结束时调用 sem.Release()
  • 使用 Task.WhenAll 等待所有任务完成
  • 验证同时只有 3 个任务在执行(可以通过打印日志观察)
csharp
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 = new[]
        {
            "https://example.com/1",
            "https://example.com/2",
            "https://example.com/3",
            "https://example.com/4",
            "https://example.com/5"
        };
 
        using var sem = new SemaphoreSlim(3); // 限制并发数为 3
        using var http = new HttpClient();
        var tasks = new List<Task>();
 
        foreach (var url in urls)
        {
            tasks.Add(Task.Run(async () =>
            {
                await sem.WaitAsync(); // 获取信号量(如果已满则等待)
                try
                {
                    Console.WriteLine($"开始处理: {url}");
                    string content = await http.GetStringAsync(url);
                    Console.WriteLine($"完成: {url}, 长度: {content.Length}");
                }
                finally
                {
                    sem.Release(); // 释放信号量
                }
            }));
        }
 
        await Task.WhenAll(tasks);
        Console.WriteLine("所有任务完成");
    }
}
console
开始处理: 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 确保即使出错也释放信号量
  • 这种方式可以控制并发数量,避免过多请求压垮服务器或网络

8. 异步流练习

编写一个 IAsyncEnumerable<int> 方法,每 50ms 产出 1 到 5 的数字,并使用 await foreach 打印:

  • 创建一个返回 IAsyncEnumerable<int> 的异步方法
  • 使用 yield return 在异步方法中产出值
  • 每次产出前等待 50ms(使用 await Task.Delay(50))
  • 在 Main 方法中使用 await foreach 遍历并打印每个值
  • 验证输出是 1, 2, 3, 4, 5,每个值之间有延迟
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
 
class Program
{
    static async IAsyncEnumerable<int> CountAsync()
    {
        for (int i = 1; i <= 5; i++)
        {
            await Task.Delay(50); // 每 50ms 产出一次
            yield return i;
        }
    }
 
    static async Task Main()
    {
        Console.WriteLine("开始遍历异步流...");
        await foreach (var num in CountAsync())
        {
            Console.WriteLine($"收到: {num}");
        }
        Console.WriteLine("遍历完成");
    }
}
console
开始遍历异步流...
收到: 1
收到: 2
收到: 3
收到: 4
收到: 5
遍历完成

说明:

  • IAsyncEnumerable<T> 是异步流接口,用于逐步产出数据
  • 使用 yield return 在异步方法中产出值
  • await foreach 用于遍历异步流,每次等待下一个值
  • 这种方式适合处理大量数据或需要逐步处理的数据流
  • 可以节省内存,不需要一次性加载所有数据

9. 自定义 JsonConverter 练习

为 DateOnly 类型编写一个自定义的 JsonConverter,实现 "yyyy-MM-dd" 格式与 DateOnly 的相互转换:

  • 创建一个继承自 JsonConverter<DateOnly> 的类
  • 实现 Read 方法:从 JSON 字符串(格式 "yyyy-MM-dd")解析为 DateOnly
  • 实现 Write 方法:将 DateOnly 序列化为 "yyyy-MM-dd" 格式的字符串
  • 在 JsonSerializerOptions 中注册这个转换器
  • 测试序列化和反序列化,验证格式正确
csharp
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 options)
    {
        // 从 JSON 字符串读取 "yyyy-MM-dd" 格式
        string dateString = reader.GetString() ?? throw new JsonException("日期字符串不能为空");
        return DateOnly.ParseExact(dateString, "yyyy-MM-dd");
    }
 
    public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
    {
        // 将 DateOnly 写入为 "yyyy-MM-dd" 格式的字符串
        writer.WriteStringValue(value.ToString("yyyy-MM-dd"));
    }
}
 
class Program
{
    static void Main()
    {
        var options = new JsonSerializerOptions();
        options.Converters.Add(new DateOnlyConverter());
 
        // 测试序列化
        var date = new DateOnly(2024, 6, 1);
        string json = JsonSerializer.Serialize(date, options);
        Console.WriteLine($"序列化结果: {json}"); // 输出: "2024-06-01"
 
        // 测试反序列化
        DateOnly deserialized = JsonSerializer.Deserialize<DateOnly>(json, options);
        Console.WriteLine($"反序列化结果: {deserialized}"); // 输出: 2024-06-01
    }
}
console
序列化结果: "2024-06-01"
反序列化结果: 2024-06-01

说明:

  • JsonConverter<T> 是自定义 JSON 转换器的基类
  • Read 方法:从 Utf8JsonReader 读取 JSON 值,解析为 DateOnly
  • Write 方法:将 DateOnly 写入 Utf8JsonWriter,格式化为字符串
  • DateOnly.ParseExact 用于按指定格式解析日期字符串
  • ToString("yyyy-MM-dd") 用于将日期格式化为指定格式
  • 在 JsonSerializerOptions.Converters 中注册转换器后,序列化/反序列化时会自动使用
  • 为什么要异步
  • async/await
  • Task 是什么
  • 避免阻塞
  • 取消与超时
  • 异常处理:try/catch、聚合异常与 WhenAll
  • 同时做多件事:`Task.WhenAll`、`Task.WhenAny`、限流
  • 异步流:`IAsyncEnumerable<T>` 与 `await foreach`
  • CPU 密集:用 `Task.Run` 把计算丢到线程池(适量)
  • 进度回报:`IProgress<T>`
  • 序列化/反序列化
  • 命名、忽略、包含、枚举、默认值
  • 多态与派生类型
  • 自定义转换器:`JsonConverter<T>`
  • 小练习

目录

  • 为什么要异步
  • async/await
  • Task 是什么
  • 避免阻塞
  • 取消与超时
  • 异常处理:try/catch、聚合异常与 WhenAll
  • 同时做多件事:`Task.WhenAll`、`Task.WhenAny`、限流
  • 异步流:`IAsyncEnumerable<T>` 与 `await foreach`
  • CPU 密集:用 `Task.Run` 把计算丢到线程池(适量)
  • 进度回报:`IProgress<T>`
  • 序列化/反序列化
  • 命名、忽略、包含、枚举、默认值
  • 多态与派生类型
  • 自定义转换器:`JsonConverter<T>`
  • 小练习