为什么要异步:别让界面卡死

桌面程序最怕一件事:点了个按钮去下载文件,整个窗口就"未响应"了。原因很简单——界面和下载跑在同一个线程上,下载没干完,界面就没空重绘。异步编程就是把"等网络/等文件/等数据库"这段时间让出来,让线程去做别的事,等结果回来再接着算。

C# 从 5.0 起用 async/await 两个关键字把这件事做得特别顺手:写出来的代码几乎和同步代码一样顺,但底层并不占着线程干等。

异步不等于多线程。它的核心是"在等待 I/O 时别白占着线程",省下的资源可以同时处理成千上万个请求。
本文核心知识点
  • async标记一个方法是异步的,内部才能用 await。
  • await等待一个 Task,等待期间线程不被卡住。
  • Task代表一个将来会完成的操作,返回结果用 Task<T>。
  • WhenAll同时等一批任务都完成。
  • CancellationToken给任务一个"取消"的入口。
C# 异步编程配图:蓝紫渐变背景,中央一个发光的沙漏形异步调度核心,上方落下发光任务粒子流,中间由多个发光齿轮状并发单元串联,底部流出整齐结果数据流,旁边有时钟与盾牌装饰
沙漏上方落下任务、下方流出结果——异步就是把"等待时间"用在别的任务上。

async / await:把异步写成同步的样子

下面这个方法标记了 async,内部用 await 等一次网络请求。看起来像顺序执行,其实 await 那一行会立刻把线程让出去,请求回来再接着跑。

using System.Net.Http;

static async Task<string> FetchAsync(string url)
{
    using var client = new HttpClient();
    string body = await client.GetStringAsync(url);
    Console.WriteLine("拿到了 " + body.Length + " 个字符");
    return body;
}

方法返回类型写 Task<string>(将来会给一个 string),调用方再用 await 去取。注意 async 方法尽量不要返回 void,除非真是事件处理函数。

Task:异步操作的"未来凭证"

Task 就是一张"以后会兑现"的凭证。它一开始没结果,但你可以先把它存着、再 await。这意味着:你可以先把几个请求都发出去(不等结果),让它们在后台同时跑,最后一起等。

Task<string> t1 = FetchAsync("https://example.com/a");
Task<string> t2 = FetchAsync("https://example.com/b");

// 两个请求已经同时在跑了
string a = await t1;
string b = await t2;

这就是异步和同步最直观的差别:同步写法是"发一个、等完、再发下一个",异步写法是"先全发出去,再一起收"。

一次并发多个:Task.WhenAll

要并发等待一批任务,用 Task.WhenAll:它返回一个新 Task,等列表里全部完成后才完成,结果是一个数组。

string[] urls = {
    "https://example.com/a",
    "https://example.com/b",
    "https://example.com/c"
};

Task<string>[] tasks = urls.Select(FetchAsync).ToArray();
string[] bodies = await Task.WhenAll(tasks);

Console.WriteLine($"一共拿到 {bodies.Length} 个页面");

三个请求并发跑,总耗时约等于最慢那个,而不是三个相加。这正是 Web 服务和爬虫里最常用的并发姿势。

取消令牌:让任务能被叫停

用户在界面上点了"取消",或者服务端收到关闭信号,正在跑的异步任务就得能干净地停下。C# 用 CancellationToken 把这个信号一路传进异步方法里。

using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(3));  // 3 秒后自动取消

try
{
    string body = await client.GetStringAsync(url, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("请求被取消了");
}

把 token 一路传下去,每一层都能在合适的点检查它,避免任务白跑、资源白占。

常见坑:async void 与死锁

新手最容易踩两个坑:

  • async void:除了事件处理函数,异步方法都应返回 Task;返回 void 的方法没法被 await、出错也难捕获。
  • 在 UI 线程上用 .Result 或 .Wait():这等于"等异步结果的时候占着 UI 线程不放",而异步完成后又想回到 UI 线程——两边互相等,直接死锁。正确做法是从头到尾 await,别混合同步阻塞。
// 错误:UI 线程上这样写会死锁
// string bad = FetchAsync(url).Result;

// 正确:一路 await 到底
string good = await FetchAsync(url);