为什么要异步:别让界面卡死
桌面程序最怕一件事:点了个按钮去下载文件,整个窗口就"未响应"了。原因很简单——界面和下载跑在同一个线程上,下载没干完,界面就没空重绘。异步编程就是把"等网络/等文件/等数据库"这段时间让出来,让线程去做别的事,等结果回来再接着算。
C# 从 5.0 起用 async/await 两个关键字把这件事做得特别顺手:写出来的代码几乎和同步代码一样顺,但底层并不占着线程干等。
异步不等于多线程。它的核心是"在等待 I/O 时别白占着线程",省下的资源可以同时处理成千上万个请求。
- async标记一个方法是异步的,内部才能用 await。
- await等待一个 Task,等待期间线程不被卡住。
- Task代表一个将来会完成的操作,返回结果用 Task<T>。
- WhenAll同时等一批任务都完成。
- CancellationToken给任务一个"取消"的入口。
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);
实战:异步抓取并汇总数据
串起来:给一串网址,并发抓取,设一个总超时,最后把每个页面长度汇总打印。
static async Task RunAsync()
{
string[] urls = {
"https://example.com",
"https://golang.org",
"https://dotnet.microsoft.com"
};
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));
using var client = new HttpClient();
var tasks = urls.Select(async u =>
{
string body = await client.GetStringAsync(u, cts.Token);
Console.WriteLine($"{u} 长度 {body.Length}");
});
await Task.WhenAll(tasks);
Console.WriteLine("全部完成");
}
从发请求、并发、取消到汇总,整个流程用 async/await 一气呵成,没有一个线程是被干等占住的。这就是现代 C# 服务端和桌面程序处理 I/O 的标准写法。
async/await 的价值:把"等待 I/O"从阻塞线程变成让出线程,代码看起来是同步的,跑起来是异步的。