1. 介绍
在写代码之前,先搞清楚动机,想象一个典型场景。假设你的程序从互联网下载文件:
// 伪代码
var data = DownloadFile("https://example.com/file");
ProcessData(data);
问题是:在下载的时候,程序“卡住”了。其它操作都不能进行 — 用户既不能移动鼠标,也不能点击按钮,甚至只能面对一个死掉的界面无奈等待。
以前(以及在其他语言里)为了解决这个问题需要使用线程 (Thread)、任务 (Task)、委托、计时器 —— 这些会让代码堆成一团,很难读也难维护。C# 团队想把事情简单化:引入了用关键字 async 和 await 的异步。现在可以像写普通代码一样简单地写异步代码。
经典的“异步痛点”没有 async/await
为了对比,看看如果用线程来做耗时操作以避免阻塞界面,会是什么样子:
// 无 async/await 的例子,手工实现
var thread = new Thread(() =>
{
var data = DownloadFile("https://example.com/file");
Console.WriteLine("文件已下载!");
});
thread.Start();
这种方式挺粗糙的:你得手动管理线程,没办法方便地“等待”结果,错误处理也很麻烦。
2. C# 异步:语法
定义:什么是 async 和 await?
async — 是一个修饰符,它让方法不再普通并把它变成异步。这样的方法通常返回 Task(或 Task<T>),或者 ValueTask。它是一种承诺,结果会在稍后到来(像网购下单 —— 下单然后等着收货)。
await — 是一个操作符,它的意思是:“到这行时暂停。等操作完成,但不要阻塞线程!其余的事情可以继续做。”
异步方法长什么样?
public async Task MyAsyncMethod()
{
Console.WriteLine("正在下载文件...");
var data = await DownloadFileAsync("https://example.com/file"); // 异步等待结果!
Console.WriteLine("完成!");
}
注意:
- 方法上加了修饰符 async。
- 方法内部使用了 await 来等待异步操作。
- 方法返回 Task(如果有返回值则返回 Task<T>)。
可视化:调用 async 方法时发生了什么?
graph LR
A[调用 MyAsyncMethod] --> B[执行到 await 前]
B --> C[调用 DownloadFileAsync]
C --> D{等待}
D --> |文件未加载| E[释放线程]
D --> |文件已加载| F[await 后继续执行]
F --> G[方法完成]
- 在第一个 await 之前,方法是同步执行的。
- 到 await 时方法会被挂起,控制权返回给调用者。
- 当异步操作完成后,执行会在 await 之后继续 —— 就像什么都没发生一样。
3. 示例:使用 async/await 的异步下载
假设我们的教学用程序从网络下载文本并打印它的长度,同时不阻塞其它代码。
using System;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
// 异步方法(返回 Task)
public static async Task DownloadAndPrintLengthAsync(string url)
{
Console.WriteLine("开始下载...");
// 使用 HttpClient — 它支持异步方法
using (var client = new HttpClient())
{
string data = await client.GetStringAsync(url);
Console.WriteLine($"下载完成!文本长度:{data.Length} 个字符。");
}
Console.WriteLine("方法执行完毕。");
}
static void Main()
{
// 启动异步操作并等待其完成
var task = DownloadAndPrintLengthAsync("https://www.example.com");
task.Wait(); // 在简单的控制台例子里可以这样做。在真实的 UI 或 web 应用里,这个调用会导致阻塞并可能引起死锁。
}
}
说明:
- DownloadAndPrintLengthAsync — 完全是异步的,得益于 async 和 await。
- 在方法内部我们通过 await 等待异步下载字符串的完成。
- 在 Main() 中我们启动任务并通过 Wait() 显式等待。在现代 C# 中你也可以让 Main 本身是异步的(async Task Main),然后直接用 await。
4. 它是怎么工作的?
同步代码 vs 异步代码的区别
同步示例
Console.WriteLine("开始");
string data = client.GetStringAsync(url).Result; // .Result 会阻塞线程!
Console.WriteLine("操作完成");
异步示例
Console.WriteLine("开始");
string data = await client.GetStringAsync(url); // 不会阻塞线程
Console.WriteLine("操作完成");
await 在“引擎”里是怎么工作的?
当你使用 await 时,C# 会自动把你的方法“拆成”两部分(或更多):await 之前的部分和之后的部分。当被调用的异步方法返回 Task 时,你的方法会先返回给调用者;当该 Task 完成时,执行会在 await 之后继续。所有这些都是自动完成的;你不需要手动管理线程和切换。
有趣的事实: C# 会把你的异步方法变成一个状态机,每个 await 都是一个新的“恢复点”。
在实际应用里使用 async/await
static async Task Main(string[] args)
{
var downloadTask = DownloadAndPrintLengthAsync("https://www.example.com");
// 在下载进行时,做点别的事情
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"正在工作... 迭代 {i}");
await Task.Delay(500); // 暂停 0.5 秒,模拟工作
}
await downloadTask; // 等待下载完成
}
现在程序既在下载,又不会被阻塞:是真正的“活”多任务,就像一只既睡着又盯着饭碗的猫。
异步 ≠ 多线程
一个常被初学者混淆的重要区别:异步代码 可以在同一个线程上执行!异步的本质是不要阻塞线程,而不是必须创建新线程。操作系统的线程是昂贵的资源!异步允许“释放”线程去做别的事,而不是等待耗时操作(网络、文件、定时器等)。
比较表:什么时候选择哪个?
| 场景 | Thread/Task | async/await |
|---|---|---|
| CPU 密集型任务 | 是 | 是(通过 Task.Run) |
| I/O 密集型任务 | 效率低 | 理想 |
| 大量并行 | 复杂 | 简单 |
| 代码简洁性 | 很难 | 易读 |
5. 有用的细节
异步 Main
在现代 C# 中,Main 也可以是异步的!
static async Task Main(string[] args)
{
// 你可以在 Main 里直接 await 所有异步代码
await DownloadAndAnalyzeFileAsync("https://example.com/file");
}
典型错误:忘记 await — 任务“泄漏”
SomeAsyncFunction(); // 没有 await,没人等它完成!
结果这个任务会在“无人治理”的情况下继续执行 —— 如果出错,你可能根本不会知道!
什么时候不应该写 async 方法
- 如果方法内部没有任何异步操作(没有任何 await),就不要把它标记为 async。
- 不要写 async void 的方法(除非你是在写事件处理器)。
简要规则和常见问题
- 能在 async 方法外使用 await 吗? 不能!必须在标记为 async 的方法内部使用 await。
- 一个方法里可以有多个 await 吗? 可以,数量不限——每个都是一个等待点。
- 想返回结果怎么办? 使用 Task<T> 并用 return 返回。
- 能组合多个异步任务吗? 当然可以!可以并行启动多个任务并通过 Task.WhenAll 等待它们。
6. 常见错误 使用 async/await
错误 №1:忘记等待 — 在调用异步任务后没用 await。
DownloadAndPrintLengthAsync("https://www.example.com");
Console.WriteLine("全部完成!"); // 实际上下载还在进行中!
代码不会等待异步操作完成。所有异步任务要么被 await,要么被显式地用 Wait() 等待(但后者危险,可能导致阻塞)。
错误 №2:在同步和异步代码之间混用 .Result 或 .Wait()。
这是个反模式,会把异步的好处全部抹掉。在 UI 或 ASP.NET 应用中几乎肯定会导致死锁(异步任务等着线程释放,而线程被等待任务阻塞)。记住口头禅: "async all the way"(从底到顶都异步)。
Ошибка №3: 使用 async void.
带有 async void 的方法无法被等待(不能用 await),而且从它们抛出的异常不能被常规的 try-catch 捕获,通常会导致应用崩溃。唯一合理的场景是事件处理器(例如 async void Button_Click(...)),因为事件签名要求如此。其他场合请使用 async Task。
Ошибка №4: 多余的 async 在没有 await 的方法里。
如果你把方法标记为 async,但内部没有使用任何 await,编译器会给出警告。这样的代码会完全同步执行,但会多出不必要的状态机开销,既令人误解又降低性能。
GO TO FULL VERSION