async/await 基础
本教程共 100 篇 · 第 83 篇 · 更新于 2026-07-31 · 约 7 分钟阅读
83. async/await 基础
本节目标:学完会写 async 方法、用 await 等待、并理解 XxxAsync 命名约定。
async 与 await 一对搭档
async 是修饰符,加在方法上,告诉编译器”这里面可能有异步操作”。await 是运算符,加在 Task 前,意思是”先挂起,等它完成再继续”。
两者通常成对出现:async 标记方法,await 在方法体内等待。
async Task BrewTeaAsync()
{
Console.WriteLine("烧水");
await Task.Delay(2000);
Console.WriteLine("泡茶");
}
await BrewTeaAsync();
async 让编译器把方法改写成状态机;await 标出”这里可以喘口气”的点。
方法怎么返回
带 async 的方法,返回类型一般写 Task(无值)或 Task<T>(有值)。编译器会自动把它包成异步操作。
async Task<int> SquareAsync(int n)
{
await Task.Delay(100); // 模拟耗时
return n * n;
}
int result = await SquareAsync(5);
Console.WriteLine(result); // 25
Note没有 async 也能返回 Task,但那是手写任务。新手先掌握 async/await 写法即可,简单直观。
命名约定 XxxAsync
行业习惯:异步方法名以 Async 结尾,如 FetchDataAsync、SaveAsync。这让人一眼看出”调用它可能不马上返回”。
async Task<string> FetchDataAsync()
{
await Task.Delay(1000);
return "数据来了";
}
string data = await FetchDataAsync();
Console.WriteLine(data);
Tip不是语法强制,但是团队约定。看到 Async 后缀,就记得用 await 接它,别忘写。
执行流程:从挂起到恢复
调 await SomeAsync() 时,若任务没完成,方法在此”让出”线程,返回 Task 给调用方。任务完成后再从 await 处继续。
关键点:让出期间线程没被占住,可以去处理别的事。这正是异步不卡顿的根本。
async Task DemoAsync()
{
Console.WriteLine("A");
await Task.Delay(500);
Console.WriteLine("B"); // 半秒后打印
}
await DemoAsync();
Console.WriteLine("C");
输出顺序:A → B → C。B 等了半秒,但没卡住 C 的线程。
await 能接什么
await 后面跟的是”可等待(awaitable)“对象,最常见是 Task 和 Task
Note你也能 await
ValueTask(第 84 章会讲),它是 Task 的轻量替代品,用于高频短任务,省一点分配开销。
同步方法里不能 await
await 只能出现在标了 async 的方法体内。若在普通方法里写 await,编译器会报错。
// 错误示例(仅示意,勿照搬)
// void Foo() { await Task.Delay(1); } // 编译错误:await 必须在 async 方法内
顶层语句里,可把逻辑包进一个 async 局部函数,再用 await 调用它,就像本教程示例所做。
控制流的直观模型
把 async 方法想成”带书签的故事”:读到 await,夹上书签把书合上(让出线程);任务好了,翻到书签处接着讲。书合着时,别人能读别的书。
这种”协作式”让出,比强制抢占更可控,也更好写、好读、好调试。
Tip一个方法里可以有多个 await,它们按顺序”挂起—恢复”,整体仍像顺序代码一样易读。
常见坑
坑一:忘了 await。任务在后台跑,方法却已结束,结果或异常都被悄悄丢弃。
坑二:用 async void 写普通逻辑。除事件处理外,async void 难以捕获异常,第 84 章细说。
坑三:在循环里逐个 await,误以为并发。其实那仍是串行等,要并发得先存 Task 再一起等。
Warning调用异步方法后务必 await(或妥善组合),否则异常可能被吞掉,调试时一脸懵。
多个 await 的时序
一个方法里可以有多个 await,它们按顺序”挂起—恢复”。前一个 await 完成,才走到下一个。
async Task SequenceAsync()
{
Console.WriteLine("1");
await Task.Delay(300);
Console.WriteLine("2");
await Task.Delay(300);
Console.WriteLine("3");
}
await SequenceAsync();
这段是串行的:2 等 1 后面的延迟,3 等 2 后面的延迟。若想并发,得把 Task 先存起来,再一起 await。
Tip想并发就先”启动”再”等待”:
var t1 = A(); var t2 = B(); await Task.WhenAll(t1, t2);。逐个 await 是串行。
async 如何层层传播
async 方法通常会调别的 async 方法。调用方 await 子方法,子方法内部的 await 同样让出线程,一层层往上传递”异步”属性。
async Task OuterAsync()
{
Console.WriteLine("外层开始");
await InnerAsync(); // 等内层,期间外层也让出线程
Console.WriteLine("外层结束");
}
async Task InnerAsync()
{
await Task.Delay(200);
Console.WriteLine("内层完成");
}
await OuterAsync();
只要链路上有 await,整条调用链就是异步的。任一层忘了 await,链条就断,后续变成”发火不灭火”。
Note行业说法:一处异步,处处异步。一旦底层用 async,上层基本都得跟着 async/await 一路传到顶。
await 的编译直觉
你写 await x 时,编译器在背后把方法切成”状态机”:await 之前是一段,await 之后是另一段,等任务好了再跳回去执行后半段。
这让”异步”在代码层面保持同步的阅读顺序,是 async/await 最大的价值。你不用自己拼回调,编译器替你拼。
Tip调试时仍可按”逐语句”走,IDE 会在 await 处跨线程帮你续上,体验接近同步代码。
await 在循环里:串行还是并发
新手常写出”看似并发、实则串行”的循环:
// 串行:一个接一个等
foreach (var url in urls)
await FetchAsync(url); // 上一个完成才发下一个
// 并发:先全部发起,再一起等
List<Task> tasks = urls.Select(u => FetchAsync(u)).ToList();
await Task.WhenAll(tasks); // 真正重叠进行
差别巨大:串行总耗时是各次之和,并发约等于最慢那次。IO 多的场景务必用并发写法。
Tip想并发就先”发起”(调用方法拿到 Task),暂存起来,最后统一 await。边循环边 await 等于放弃并发。
异常如何沿 await 传播
异步方法里抛的异常,被封进返回的 Task。调用方 await 时,异常以原类型重新抛出,try/catch 能正常接。
async Task RiskyAsync()
{
await Task.Delay(10);
throw new ArgumentException("参数不对");
}
try
{
await RiskyAsync(); // 这里接到 ArgumentException
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.Message);
}
Note这点和同步代码一致,是 async/await 的一大优点:异常处理模型没变,不用为异步重写 catch 逻辑。
ConfigureAwait 一句话
旧版框架里,ConfigureAwait(false) 用于避免回到原上下文(防死锁、提性能)。现代 .NET 多数场景不用管它。
// 库代码里偶尔见到,业务代码一般省略
// await FetchAsync().ConfigureAwait(false);
Warning在 UI/旧 ASP.NET 里混用
.Result或.Wait()又不开 ConfigureAwait,极易死锁。根因还是:别用 Wait,用 await。
一个完整小例子
把前面要点串起来:async 标记、await 等待、Task 返回、Async 命名、异常捕获,一个都不少。
async Task<int> FetchLengthAsync(string url)
{
await Task.Delay(200); // 模拟网络往返
return url.Length;
}
async Task RunAsync()
{
try
{
int len = await FetchLengthAsync("https://example.com");
Console.WriteLine($"长度 {len}");
}
catch (Exception ex)
{
Console.WriteLine($"出错:{ex.Message}");
}
}
await RunAsync();
这个骨架几乎就是真实异步代码的缩影:发起—等待—取结果—兜底异常。
Note注意
RunAsync本身也是 async Task,并被顶层await调用。异步像多米诺,一路 async 到最外层。
命名陷阱:Async 不等于”并行”
方法名叫 XxxAsync 只表示”它是异步的、可能不马上返回”,绝不表示”它内部并行跑了多份活”。一个 async 方法默认仍是顺序执行的。
async Task StepsAsync()
{
await A(); // 先 A
await B(); // 再 B,串行
}
想要并行,得主动用 Task.Run 或 WhenAll,名字里的 Async 不会替你做这件事。
Warning看到
Async就以为”快”是误解。异步关乎”不阻塞”,并行关乎”同时算”,两码事,别混。
为什么是 async/await,而不是回调
在 async/await 之前,异步靠回调(callback)或 ContinueWith 串起来:第一步完成触发第二步,第二步完成触发第三步……一旦链条长,代码向右缩进成”金字塔”,还散落在不同方法里,调试时调用栈支离破碎。
async/await 的价值,是让编译器把”异步等待”翻译成状态机,而你写的代码依然像同步一样自上而下。读起来是顺序的,跑起来却是异步让出线程。这正是它被称为”用同步的写法写异步”的原因。
Note类比:写文章时你标注”此处等素材到了再续”,其余段落照常写。编译器把标注处切成两半,素材到了自动接上后一半——你不用手动拼。
async/await 与 ContinueWith 对比
早期没有 await 时,链式异步只能靠 ContinueWith 把后续塞进回调;多层嵌套又回到割裂感,异常也要从 AggregateException 里拆。对比之下,await 版逻辑平铺、异常照常 try/catch、调试能逐语句走。结论很明确:只要能用 await,就别用 ContinueWith。
Warning仍有少量老代码用
ContinueWith,看到它要警惕异常处理与上下文切换的坑;新代码一律 await。
适用场景:什么时候该异步
值得异步的,通常是:
- IO 密集:网络请求、文件读写、数据库查询——等待期间线程本可去做别的事。
- 不想卡界面:桌面或移动端 UI 线程被占,界面就”假死”。
- 多个独立操作能重叠:并发发起,整体更快。
纯内存计算且很快返回的方法,异步只会增加状态机开销,保持同步更好。
Tip判断标准只有一个:方法里有没有”真正的等待点”?没有,就别 async。别为统一风格而异步化。
小结
async 标方法,await 等任务,返回写 Task/Task