The Essence of async/await: A 5-Language Comparison
很多初学者把 async/await 当成”开新线程的语法糖”。这是最危险的概念错误。真相是:
await 不会帮你开任何线程。async 关键字本身不创建线程——它只是告诉编译器”这个方法要被改写成状态机”。SynchronizationContext / TaskScheduler,不是取决于 await。一句话:在需要等待的地方暂停当前方法,把线程交还出去。
var data = await client.GetAsync(url); // ← 在这里
Parse(data); // ← 后半段
执行到 await,如果结果还没好:
return(不是阻塞),返回一个未完成的 Task 给调用方。await 那行往下跑。注意第 2 步:是 return,不是阻塞。线程没被占住,只是”暂时不要这段代码了”。这是异步相对于多线程的本质区别——多线程是”雇新工人来等”,异步是”工人不等了,先去做别的”。
异步方法里真正在干活的,是底层 I/O:
Task.Run 丢到线程池——await 本身不会。所以一个 await client.GetAsync(...) 在等待期间,没有托管线程在等。这正是异步省资源的根本——不是开了多少线程,而是让已有的线程不空等。
async 不是 Thread.Start 的语法糖。看到 async 就以为开了线程是经典误解。await 只暂停方法,不暂停线程。线程被交还,可以服务其它工作。Task.Run 或 Parallel。纯 I/O 异步不会让 CPU 并行。await 真正神奇的地方是:它把一个方法从中间切成两半。
async Task<string> Fetch()
{
Log("before"); // ← 半段 A
var data = await Download(); // ← 切点
return Parse(data); // ← 半段 B
}
await 之前的代码,正常同步执行。await:如果结果还没好,方法在此暂停并 return——注意是 return,不是阻塞,线程被交还。await 那行继续往下跑。C# 编译器把整个异步方法改写成一台状态机(async state machine)。每个 await 是一个 存档点:
这套机制的本质是 续体(continuation)——把”await 之后要执行的代码”打包成一个对象,等条件满足再接回去执行。C# 用状态机实现续体,是历史原因(早期没有 first-class continuation)。其它语言(JS 的 Promise then、Rust 的 Future)实现细节不同,但都在做同一件事:把”后续”打包保存,等事件来了再接上。
把上面三行展开(简化伪代码),大致是这样:
// 编译器生成的状态机类(简化)
struct FetchStateMachine : IAsyncStateMachine
{
public int state; // 当前停在哪个存档点:-1=初始, 0=等Download, ...
public string data; // 局部变量被提升为字段保存
public TaskAwaiter<string> awaiter;
public Task<string> builder;
public void MoveNext()
{
try
{
switch (state)
{
case -1: // 第一次进来
Log("before");
awaiter = Download().GetAwaiter();
if (!awaiter.IsCompleted)
{
state = 0;
// 注册回调:好了就再调一次 MoveNext
AwaitUnsafeOnCompleted(ref awaiter, ref this);
return; // ← 关键:直接 return,线程交还
}
goto case 0;
case 0: // Download 完成后被回调进来
data = awaiter.GetResult(); // 读出结果
builder.SetResult(Parse(data));
return;
}
}
catch (Exception e)
{
builder.SetException(e); // 异常存进 Task
}
}
}
读这台机器的几个关键点:
state 字段记着当前停在第几个 await。data 被提升为字段(否则方法返回后变量就没了)。MoveNext”,然后直接 return——线程就此被交还。MoveNext 再次进入,这次走 case 0 分支,读出结果,继续往下。async/await 的全部魔法,就是这台状态机加上”注册回调、return、被回调再进”的循环。
async 会制造 GC 压力——这是 .NET 后续引入 ValueTask、池化状态机的原因。异步方法的后半段,到底在哪个线程上恢复?这是 C# 异步最容易栽跟头的地方,也是和 JS 异步最大的差别。
答案:默认情况下,await 会捕获当前的 SynchronizationContext,恢复时回到这个上下文代表的线程。
SynchronizationContext(同步上下文)是一个抽象——“把一段代码排到某条线程上执行”的通用接口。不同宿主提供不同实现:
| 宿主 | 上下文 | 代表的线程 |
|---|---|---|
| WPF / WinForms | DispatcherSynchronizationContext | UI 线程 |
| ASP.NET (classic) | AspNetSynchronizationContext | 请求上下文 |
| 控制台 / ASP.NET Core | null | 线程池(任意) |
核心方法 Post(callback) 的含义是:“请把这个回调安排到我所代表的线程/队列里跑”。
await task 在暂停前,做了一件事:
var capturedContext = SynchronizationContext.Current;
// 注册:task 完成时,把"继续 MoveNext"这个回调 Post 到 capturedContext
所以恢复时:
SynchronizationContext.Current 是 UI 上下文 → 后半段被 Post 回 UI 线程。SynchronizationContext.Current 是 null → 后半段在线程池里随便哪个线程恢复。在 UI 程序里,后半段常常要更新界面:
var data = await client.GetAsync(url);
label.Text = data; // ← 必须 UI 线程,否则跨线程异常
默认捕获 UI 上下文,让这行自动在 UI 线程跑——开发者不用手动 Invoke,写起来像同步代码。这是 async/await 在 UI 场景最大的便利。
.Result),后半段永远等不到 UI 线程,死锁。详见 B13。SynchronizationContext.Current 是 per-thread / per-async-flow 的。不能假设到处都是同一个。ConfigureAwait(false) 告诉 await:别捕获同步上下文,结果一好就直接在线程池里恢复,省掉”Post 回原上下文”这一步。
// 默认:捕获上下文,恢复回 UI 线程
var data = await client.GetAsync(url);
// ConfigureAwait(false):不捕获,在线程池恢复
var data = await client.GetAsync(url).ConfigureAwait(false);
参数 true(默认)和 false:
| 参数 | 行为 |
|---|---|
ConfigureAwait(true) | 捕获 SynchronizationContext,恢复 Post 回它 |
ConfigureAwait(false) | 不捕获,直接在完成该 task 的线程上继续 |
库代码里默认加 ConfigureAwait(false)。理由:
经典例子:HTTP 客户端、JSON 序列化库、配置读取——这些操作的后半段根本不碰 UI,多一道回 UI 线程的绕路纯属浪费。
应用层 UI 代码里,后半段要更新界面时,绝对不能加 ConfigureAwait(false)。
var data = await client.GetAsync(url).ConfigureAwait(false);
label.Text = data; // ← 现在可能在后台线程,碰 UI 直接抛异常
加了 false 之后,后半段可能跑在线程池线程上,碰 Control / DependencyObject 会触发”跨线程访问 UI”异常。要更新界面,必须回 UI 线程。
| 代码类型 | 默认策略 |
|---|---|
| 库(library) | 每处 await 加 ConfigureAwait(false) |
| 应用 UI 层 | 不加,让默认捕获上下文带你回 UI 线程 |
| 控制台 / ASP.NET Core | 加不加没差别(本来就没上下文),但加了是惯例 |
ASP.NET Core 和控制台程序里 SynchronizationContext.Current 本来就是 null,ConfigureAwait(false) 在那里主要是冗余但无害的好习惯。
ConfigureAwait 是 per-await 的,不是 per-method 的。每个 await 都要单独写,没法在方法头开个开关。ConfigureAwait(false) 不等于”运行在后台线程”。它只是”别回原上下文”,恢复线程仍是完成 task 的那个线程(通常是线程池,但不保证)。await 到底,别用 .Result。详见 B13。异步方法的返回类型有三种:
| 返回类型 | 用途 | 可 await | 可观察异常 |
|---|---|---|---|
Task | 无返回值的异步 | 是 | 是 |
Task<T> | 有返回值的异步 | 是 | 是 |
void | 只用于事件处理器 | 否 | 否 |
ValueTask<T> | 高频零分配优化 | 是 | 是 |
绝大多数情况应该用 Task 或 Task<T>。async void 是个陷阱。
async void 方法无法被 await,调用方拿不到 Task,也就无法知道它何时结束、有没有抛异常:
async void DoWork() // ← async void
{
await Task.Delay(100);
throw new Exception("boom"); // ← 谁来接?
}
DoWork(); // 调用方无法 await,也无法 try-catch
async void 方法抛出的异常直接进到 SynchronizationContext(如果有的话),在 UI 程序里通常意味着未处理异常 → 进程崩溃。它不会进调用方的 try-catch,因为调用方根本没有”等它”的能力。
async void 之所以存在,是为了事件处理器签名。.NET 事件要求处理器签名是 void Handler(object, EventArgs),没有 Task:
// 事件处理器:签名必须是 void,这里 async void 合法
private async void Button_Click(object sender, EventArgs e)
{
await DoWorkAsync();
}
这是 async void 唯一被社区认可的用法。即使如此,也要在方法内 try-catch 把异常兜住,不让它逃出去。
异步方法里 throw,异常不会在抛出点立刻冒泡——它被存进返回的 Task:
async Task<string> Fetch()
{
var data = await Download();
if (data == null) throw new InvalidOperationException("empty"); // 存进 Task
return Parse(data);
}
try
{
var s = await Fetch(); // ← 异常在这一行被重新抛出
}
catch (InvalidOperationException ex)
{
// 精准落进这个 catch
}
异常在 await 那一行原样重抛(带原堆栈),所以 try-catch 能精准捕获到正确的位置。但前提是:你真的去 await 它了。
如果忘了 await(fire-and-forget),异常进了 Task 没人取,最终在某次 GC 时变成 UnobservedTaskException——这是另一个典型的”忘了 await 静默吞异常”的坑。
async void 的异常没人能接。除非是事件处理器,否则一律改 async Task。Task 异常只有在被观察(await / .Result / 等待)时才会重抛。async void 和 async Task 都不会”开线程”——这点不要混淆。最常见的浪费写法——以为 await 很快,其实是串行的:
// ✗ 串行:三个任务一个等完才发下一个
var a = await FetchAsync("a"); // 等 1s
var b = await FetchAsync("b"); // 等 1s
var c = await FetchAsync("c"); // 等 1s
// 总耗时 = 1+1+1 = 3s
await 在第一行就把方法暂停了,直到 a 回来才会执行第二行发起 b。三个彼此独立的网络请求被生生串成一条线,时间累加。
// ✓ 并发:先启动三个任务,拿到三个 Task,再一次性等
Task<string> ta = FetchAsync("a");
Task<string> tb = FetchAsync("b");
Task<string> tc = FetchAsync("c");
string[] results = await Task.WhenAll(ta, tb, tc);
// 总耗时 = max(1s, 1s, 1s) = 1s
关键两步:
FetchAsync——不 await,只拿到 Task。这一步是”派活”,三个请求同时发出去了。await Task.WhenAll(...)——一次性等它们全部完成。总耗时只取决于最慢的那个,而不是三者之和。
Task.WhenAll(tasks) 返回一个 Task,它只有当所有输入都完成时才完成:
AggregateException 包含所有失败任务的异常(.NET Core 之后默认抛第一个,但内部异常集合齐全)。Task.WhenAny(tasks) 返回最先完成的那个任务,不等其它:
Task<string> first = await Task.WhenAny(t1, t2, t3);
string result = await first; // 已经完成了,立刻拿结果
典型场景:冗余请求(同时问多个服务器,谁先回用谁)、带超时(和 Task.Delay 赛跑)。
// 超时模式:fetch 和 5 秒计时赛跑
Task completed = await Task.WhenAny(
FetchAsync(url),
Task.Delay(TimeSpan.FromSeconds(5))
);
if (completed != fetchTask) throw new TimeoutException();
(现代 .NET 已有 CancellationTokenSource.CancelAfter 做这件事,更优雅。)
WhenAll 不会取消未完成的其它任务。一个失败,其它还在跑——如果不想浪费,要自己传 CancellationToken。WhenAny 之后剩下的任务仍在运行,没被取消。用完别忘了处理(取消或忽略)。await FetchAsync("a") 等于立刻暂停,没法并发。WhenAll 返回数组顺序与输入顺序一致,不是完成顺序。在 UI 线程(或带 SynchronizationContext 的环境)上,用 .Result 或 .Wait() 同步阻塞一个异步任务,会触发死锁:
// UI 线程上
private void Button_Click(object sender, EventArgs e)
{
string data = GetDataAsync().Result; // ← 同步等,死锁!
}
private async Task<string> GetDataAsync()
{
var data = await httpClient.GetStringAsync(url); // 默认捕获 UI 上下文
return Parse(data); // ← 后半段要回 UI 线程才能跑
}
两条互相等待的链:
[UI 线程] [异步任务]
│ │
├─ 调用 GetDataAsync() │
├─ 进 await,发起 HTTP │
├─ await 暂停方法 return │
├─ .Result 阻塞 UI 线程 │
│ 等待 Task 完成 ◄───────────── │ HTTP 回来了
│ ├─ 想恢复方法后半段
│ UI 线程被占着 ◄───────────────── ├─ 但默认要 Post 回 UI 线程
▼ ▼
等 Task 等 UI 线程
互相等待,死锁
.Result 把 UI 线程阻塞在这里,等 Task 完成。Task 的后半段默认要 Post 回 UI 线程才能跑(B09 讲的上下文捕获)。.Result 占着,永远腾不出来。关键在 SynchronizationContext.Current 是否为 null:
| 环境 | 上下文 | .Result 行为 |
|---|---|---|
| WPF / WinForms | UI 上下文 | 死锁 |
| 经典 ASP.NET | 请求上下文 | 死锁 |
| 控制台 / ASP.NET Core | null | 不死锁(恢复到线程池) |
控制台程序没有捕获上下文,Task 后半段直接在线程池恢复,不需要等 UI 线程——所以不会死锁。这也是”在我电脑上没事,部署到 ASP.NET 就挂”的典型原因。
把方法签名也改成 async,不要 .Result:
private async void Button_Click(object sender, EventArgs e)
{
string data = await GetDataAsync(); // ✓ 不阻塞,UI 线程释放
}
async 是会传染的(“函数染色”),但这是最干净的解法。
在 GetDataAsync 内部加 ConfigureAwait(false),后半段不再非要 UI 线程:
private async Task<string> GetDataAsync()
{
var data = await httpClient.GetStringAsync(url).ConfigureAwait(false);
return Parse(data);
}
现在即便 .Result 卡住 UI 线程,Task 后半段能在线程池恢复并完成——死锁解开。这就是为什么库代码必须加 ConfigureAwait(false) 的另一个理由。
Task.Run 包一层string data = Task.Run(() => GetDataAsync()).Result;
Task.Run 把整个异步任务丢到线程池,那里的上下文是 null,没有”等 UI 线程”问题。但代价是占用线程池线程、增加延迟,只在改不了库代码时才用。
.Wait() 和 .Result 行为一样,都会引发同样的死锁。看到同步等异步就要警觉。SynchronizationContext 的环境发生。控制台/ASP.NET Core 不死锁,不代表这是好代码——只是侥幸。Task.Run 包一层是 workaround 不是治本。能改 async 就改。异步操作常常需要能中途取消——用户点了”取消”、超时了、或者上游已经不需要这个结果了。但 .NET 没有”强制 kill 任务”的机制(线程池任务不能硬杀),而是采用协作式取消(cooperative cancellation):取消信号通过一个对象传下去,由任务自己检查并优雅退出。
// 发起方:创建源,控制取消
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5)); // 5 秒后自动取消
cts.Cancel(); // 立即取消
// 接收方:拿着 token 检查
CancellationToken token = cts.Token;
CancellationTokenSource(发号施令者):调用方持有,调用 Cancel() / CancelAfter() 发出取消信号。CancellationToken(监听者):任务持有,只能读取消状态、注册回调,不能自己取消。这种单向分离是设计要点:接收方拿不到 source,无法”自己取消自己”或”取消别人”,只能响应发来的信号。
CancellationToken 要传给每一个支持取消的异步调用,整条调用链共享同一个:
async Task<string> DownloadAllAsync(CancellationToken token)
{
var list = await httpClient.GetStringAsync(url1, token); // ← 传给 BCL
token.ThrowIfCancellationRequested(); // ← 自己检查
var data = await httpClient.GetStringAsync(url2, token);
return Process(list, data);
}
ThrowIfCancellationRequested() 是惯用法:检查到已取消就抛 OperationCanceledException,调用方在 await 处捕获它就知道”是取消,不是错误”。
HttpClient、文件流、Task.Delay 等)都内置支持取消——传 token 给它们,取消时底层会中止 socket 读取/定时器,立即抛异常。这是 Task.Delay(1000, token) 比 Thread.Sleep(1000) 强的地方——前者可取消,后者不可。
// 用法 A:手动取消
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
if (用户点了取消) cts.Cancel();
// 用法 B:超时自动取消
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { await DoWorkAsync(cts.Token); }
catch (OperationCanceledException) { /* 超时或取消 */ }
OperationCanceledException 不是错误,是协议。代码在收到这个异常时不应记录成错误日志,而应视为”正常的中止”。把它和 NullReferenceException 同等对待会污染告警。
CancellationToken 不传等于无法取消。每个 await 调用都要带 token,漏一个就断链。ThrowIfCancellationRequested 要主动调。CPU 密集循环里 BCL 不会替你检查,要自己在循环里插。CancellationTokenSource 实现了 IDisposable,长生命周期场景要 dispose(短链路一般可忽略)。JavaScript 是 单线程的——它只有一个主线程(worker thread 除外),没有像 C# 那样的线程池。所有 JS 代码都在这个主线程上跑,靠**事件循环(event loop)**实现”同时做很多事”的假象。
异步操作完成后,回调被丢进**任务队列(task queue / macrotask queue)**排队。主线程跑完手头的同步代码后,从队列里取下一个回调执行,循环往复:
┌──────────────────────────────────────┐
│ 调用栈(call stack) │ ← 当前在跑的同步代码
└──────────────────────────────────────┘
▲
│ 取下一个
┌──────────────────────────────────────┐
│ 宏任务队列 [ cb1, cb2, cb3, ... ] │ ← setTimeout / IO / 事件
└──────────────────────────────────────┘
▲
┌──────────────────────────────────────┐
│ 微任务队列 [ then1, then2, ... ] │ ← Promise 回调
└──────────────────────────────────────┘
每次循环(tick):
.then / queueMicrotask),清完才走下一步。所以 JS 的”异步”从来不是真并行——所有回调串成一个队列,主线程一次处理一个。它只是快速轮转:
如果某段 JS 代码 CPU 密集(比如排序大数组),它会霸占主线程,整页卡死——所有 setTimeout、点击事件、动画都进不了队列。这就是为什么 JS 写重计算必须开 Web Worker(真并行)。
console.log(1);
setTimeout(() => console.log(2)); // 宏任务
Promise.resolve().then(() => console.log(3)); // 微任务
console.log(4);
// 输出:1 4 3 2
微任务永远比宏任务先跑,且每次清空全部微任务。这意味着一个 .then 链里如果不停 then,可以无限饿死宏任务(用户点击永远进不来)。这是 JS 事件循环的隐藏陷阱。
.then 会让 UI 卡死。setTimeout(fn, 0) 不是 0ms。它至少要等当前任务和所有微任务跑完,实际延迟可能几毫秒。JS 里”未来的值”叫 Promise,地位等同于 C# 的 Task。它代表一个尚未完成的异步操作,三种状态:
pending(进行中)fulfilled(已完成,有值)rejected(已失败,有原因)状态一旦从 pending 变成 fulfilled/rejected 就不可逆——这正是 Task 的语义。
Promise 出现前用裸回调,嵌套深了就成了”回调地狱”。Promise 用 .then 链摊平:
// 回调地狱
getData(url, function (a) {
getMore(a, function (b) {
getMore(b, function (c) {
// 越缩越深
});
});
});
// Promise 链
fetch(url)
.then(res => res.json())
.then(data => process(data))
.then(result => render(result))
.catch(err => console.error(err));
链式比嵌套好读,但 .then 一长仍是横向延伸的”管道”,且每一步都要包函数。
ES2017 引入 async/await,让异步代码写得像同步:
async function fetchAll() {
try {
const res = await fetch(url); // ← await Promise
const data = await res.json();
return process(data);
} catch (err) {
console.error(err);
}
}
它不是新机制——await 等价于把后续代码包进 .then,try-catch 等价于 .catch。但读起来线性、可读性碾压 then 链。
// JS
async function f() {
const x = await fetch(url);
return parse(x);
}
// C#
async Task<string> F() {
var x = await httpClient.GetStringAsync(url);
return Parse(x);
}
async、await、try-catch、返回类型——几乎逐字对应。这套语法已经成了跨语言的通用异步语法:JS、C#、Python、Rust、Dart、Kotlin 都这么写。
别被表象骗了:
Task 跑在多线程线程池,await 之后可能换线程(B18 详谈)。Promise 跑在单线程事件循环,await 之后还是同一个主线程。同样的 await,背后的世界观完全不同。
await 后面只能跟 Promise(或 thenable)。await 5 不会报错但等价于 await Promise.resolve(5)。async function 永远返回 Promise。return 5 会被包成 Promise.resolve(5),throw 会被包成 Promise.reject(err)。await。const x = fetch(url) 拿到的是 Promise 而不是结果,新手最常踩。C# 和 JS 表面语法一样,底层线程模型完全不同——这决定了你写异步代码时要操心什么。
| 维度 | C# (Task) | JS (Promise) |
|---|---|---|
| 线程模型 | 多线程(线程池) | 单线程(事件循环) |
| await 之后线程 | 可能换另一个线程池线程 | 永远是同一个主线程 |
| 数据竞争 | 有,要加锁 | 几乎没有 |
| 真并行 | 可以(多核) | 不行(除非 Worker) |
| CPU 密集 | 直接在线程池跑 | 阻塞主线程,要开 Worker |
C# 的 await 后半段可能落在任意线程池线程上:
int counter = 0;
async Task Increment()
{
await Task.Delay(100);
counter++; // ← 多个并发任务可能同时跑这行,counter++ 不是原子
}
counter++ 实际是”读-改-写”三步,多线程并发时可能丢失更新。C# 程序员写异步必须关心:
lock / SemaphoreSlim)。ConcurrentDictionary、Interlocked 等并发原语。JS 的 await 后半段永远在主线程:
let counter = 0;
async function increment() {
await delay(100);
counter++; // ← 永远主线程,没有竞争
}
主线程一次只跑一段代码,counter++ 不会被中途打断。几乎不用担心数据竞争,这是 JS 异步最舒服的地方。
但代价是不能真并行:
// 两个 await 不会并行计算
const a = await heavyCompute(); // 阻塞主线程 1 秒
const b = await heavyCompute(); // 再阻塞 1 秒
// 总 2 秒
CPU 密集任务必须开 Web Worker(真线程)才能并行,否则永远卡主线程。
// C#:后半段可能换线程
var x = await Fetch();
UpdateSharedState(); // 要考虑并发安全
// JS:后半段一定主线程
const x = await fetch();
updateSharedState(); // 主线程独占,安全
同样一行 await,C# 让你操心锁,JS 让你操心不阻塞。这是从 C# 转 JS(或反过来)时最容易踩的认知盲区。
await 后线程不确定。不能假设”await 前后是同一个线程”,依赖 ThreadLocal 会失效。Python 的 asyncio 沿用同一套 async/await 语法:
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text()
# 必须显式启动事件循环
async def main():
data = await fetch(session, "https://...")
print(data)
asyncio.run(main()) # ← 这里才真正开事件循环
async def 定义协程(coroutine),调用它返回一个协程对象,不会自动跑。asyncio.run() 或 await 来驱动它,否则什么也不发生。async 方法调用即开始执行;Python 的协程惰性,需要事件循环推它。和 JS 一样,asyncio 跑在单线程事件循环上。同一时刻只有一个协程在执行,靠 await 让出控制权轮转。
所以 Python 的 await 后半段也永远在同一个线程,没有 C# 那种数据竞争问题。这部分心智模型可以复用 JS。
Python(CPython)有个特殊存在:GIL(Global Interpreter Lock)。它规定同一时刻,只有一个线程能执行 Python 字节码。
┌──────────────────────────┐
线程 A │ 持有 GIL,跑字节码 │
└──────────────────────────┘
线程 B │ 等 GIL,干瞪眼 │ ← 即使多核 CPU 也白搭
└──────────────────────────┘
后果:Python 多线程无法真正并行 CPU 计算。开 8 个线程算同一个矩阵,速度和单线程差不多——因为 GIL 把它们锁成了串行。
multiprocessing 每个进程有自己的 GIL,能绕开。GIL 让 Python 的”异步”和”并行”彻底分家:
| 写法 | 真并行 | 适合 |
|---|---|---|
asyncio(单线程协程) | 否 | I/O 密集 |
threading(多线程) | 否(GIL) | I/O 密集,旧代码 |
multiprocessing(多进程) | 是 | CPU 密集 |
| C 扩展 + 释放 GIL | 是 | 数值计算 |
异步 ≠ 并行这一点,Python 体现得最明显。asyncio 解决的是”等待时不空等”,不是”同时算得更快”。
asyncio.run 不会跑。coroutine = fetch() 只创建对象,必须被 await 或调度。asyncio 不能和阻塞调用混用。time.sleep(1) 或 requests.get() 会卡住整个事件循环,要用 asyncio.sleep / aiohttp。Rust 也有 async/await,但哲学和前几种语言很不一样:
async fn fetch(url: &str) -> Result<String, Error> {
let resp = client.get(url).await?; // ← .await 是后缀,不是前缀
let body = resp.text().await?;
Ok(body)
}
注意 .await 写在表达式后面(x.await,不是 await x)。这是 Rust 的设计——后缀让链式调用更顺,且宏展开更简单。
Rust 的 async fn 调用后,返回一个 Future 对象,但不会开始执行任何代码:
let f = fetch("https://..."); // ← 只造了个 Future,HTTP 还没发!
// Future 是惰性的,必须有东西来"推"它
这和 Python 协程类似,和 C#/JS 不同——后两者调用即开始。Rust 的 Future 是纯描述:“我要做什么,请谁来执行”。
Rust 的 Future trait 只有一个核心方法:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
enum Poll<T> {
Ready(T),
Pending,
}
poll 的契约:
Ready(v) → Future 完成,结果是 v。Pending → 没好,但必须通过 cx.waker() 告诉执行器”我好了再 poll 我”。关键设计:Future 自己不会主动跑,是被 poll 推着走的。 每个被 await 的 async 函数都被编译成一台 poll 状态机(和 C# 的状态机思路一致,但靠 poll 显式驱动而非回调)。
Rust 标准库不提供执行器(runtime)。你必须自己选一个:
#[tokio::main]
async fn main() {
let result = fetch("https://...").await; // tokio 提供 poll 循环
}
#[tokio::main] 宏展开后是一个同步 main,里面开 tokio 运行时,把 async main 推到完成。
这是 Rust 的核心哲学——零成本抽象(zero-cost abstraction):
C#/JS/Python 把运行时和语言绑死,所有 async 程序都为运行时付出内存与启动开销。Rust 让你按需付费:嵌入式设备可以选 embassy(无堆、无分配),服务器选 tokio(多线程)。
代价是认知负担——新手要理解 “Future 不会自己跑”、“要执行器 poll”、“选哪个 runtime”。这是 Rust 一贯的”显式优于隐式”。
async fn 返回 Future,不调 .await 不执行。let _ = fetch(url); 看似调了,其实啥也没干——这是经典新手坑(编译器会警告 unused future)。Pin 保证 Future 内部的自引用不会失效——这是 Rust 异步最绕的概念。Go 走了一条完全不同的路:没有 async/await 关键字。所有代码看起来都是同步阻塞的写法:
func fetch(url string) string {
resp, err := http.Get(url) // ← 看起来是阻塞调用
// ...
return body
}
func main() {
go fetch("https://a") // ← go 关键字开 goroutine
go fetch("https://b")
// 两个并发跑
}
func 调用就是普通同步调用。go 开个 goroutine,调度器自己处理”阻塞”。go 调用,不需要标 async。goroutine 不是操作系统线程,是 Go 运行时在用户态调度的”绿色线程”:
| 维度 | OS 线程 | goroutine |
|---|---|---|
| 栈大小 | 1-8 MB(固定) | 2 KB 起步,按需增长 |
| 切换成本 | 内核态切换,~1μs | 用户态切换,~100ns |
| 数量上限 | 几千就吃力 | 几十万很轻松 |
| 调度 | OS 抢占式 | Go 运行时(GMP 模型) |
2 KB 的栈让”每个连接一个 goroutine”变得可行。Go 服务器可以开百万 goroutine 而不爆内存——这是 C# Task 都做不到的密度。
Go 的魔法在于:一个看起来阻塞的调用,在 goroutine 里并不真的阻塞 OS 线程。
resp, err := http.Get(url) // 看起来阻塞
实际发生:
http.Get,底层进入网络 I/O。对程序员来说,http.Get 就是阻塞的——你写线性代码,不用关心状态机。对运行时来说,它做了和 async/await 一样的事:保存状态、让出线程、I/O 完了再恢复。
Go 用绿色线程把 async 的复杂性藏进了运行时——这是它和 async/await 派的根本分歧。
goroutine 之间不用共享变量加锁,Go 的口号是 “不要通过共享内存来通信,而要通过通信来共享内存”:
func worker(jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- compute(j) // 把结果发到 channel
}
}
jobs := make(chan int, 100)
results := make(chan int, 100)
go worker(jobs, results)
channel 是带类型的并发安全队列,goroutine 之间用它传递数据和同步。没有 await、没有 Promise,但有 select、有 buffered channel——一套独立的并发原语。
async 不代表没有异步。Go 的同步写法背后是运行时做的异步——只是对程序员透明。sync.Mutex。channel 是首选,但不是万能;高并发下共享 map 不加锁照样数据竞争。把五种语言放进一张表,一眼看清异同:
| 语言 | 底层并发模型 | 异步语法 | “未来值”类型 | 运行时 |
|---|---|---|---|---|
| C# | 多线程(线程池) | async/await | Task<T> | 内置(CLR + TaskScheduler) |
| JavaScript | 单线程事件循环 | async/await | Promise | 内置(V8 / 引擎 + 事件循环) |
| Python | 单线程事件循环(asyncio) | async/await | Coroutine | 内置(asyncio) |
| Rust | 无运行时(自选执行器) | async/await(后缀) | Future | 不内置,需 tokio 等 |
| Go | 绿色线程(goroutine + GMP) | 无(用 go 关键字) | 无(用 channel) | 内置(Go runtime) |
C#、JS、Python、Rust 都有 await,语法高度统一——这是这套语法能跨语言通行的原因。Go 是异类,它抛弃了 async/await,用 go + channel 代替。
注意 JS 和 Python 同属”单线程事件循环”——这是为什么它们的 await 后半段都回到同一个线程、几乎不用担心数据竞争。
await,C# 多线程、JS 单线程、Rust 需执行器,底层世界观完全不同。await 之后回到哪个线程是判断线程模型的试金石——这一条决定了你要不要操心数据竞争。掌握这张表,换任何语言都能快速上手它的异步:
| 你从 | 迁移到 | 最该注意的 |
|---|---|---|
| C# | JS | 单线程,无数据竞争,但 CPU 密集要开 Worker |
| JS | C# | 多线程,要加锁;不要 .Result 会死锁 |
| 任意 | Rust | Future 不自动跑,要执行器;.await 是后缀 |
| 任意 | Go | 没有色函数,写同步代码 + go + channel |
五种语言的 async/await 语法各异、底层天差地别,但撕开外衣,它们共享同一个本质——续体(continuation)。
在 await 这个点,把 “await 之后要执行的代码” 打包保存起来。这个被保存的”后续”,学名叫续体(英文 continuation)。
var data = await Download();
// ↓↓↓ 这一段就是续体 ↓↓↓
var result = Parse(data);
UpdateUI(result);
await 暂停方法时,编译器/运行时把”从 Parse 往下”这段代码打包成一个续体对象,挂在 Download 的完成回调上。事件一完成,运行时把续体重新接上、继续执行。
不管底层怎么实现:
case 分支(B07 讲过)。.then 回调(微任务)。poll 下一阶段。做的都是同一件事:剪断、保存、续接。
续体是计算机科学里更基础的概念——“给定一个执行点,之后所有要执行的代码”。它最初来自 Scheme 的 call/cc(call-with-current-continuation)——一流续体(first-class continuation)。
;; Scheme:续体是一等公民
(call/cc
(lambda (k)
;; k 就是"现在的续体"——一个可调用的函数
;; 调用它就把控制权"跳回"这里继续
(k 42)))
Scheme 让续体成为可传递、可多次调用的对象。async/await 借用了这个思想,但做了限定:
这种**受限续体(delimited continuation)**是 async/await 的理论根基——它让”暂停-恢复”既能实现,又不会让代码变得不可控。
| 语言 | 续体的实现 | 续体何时被”接回” |
|---|---|---|
| C# | 状态机 case 分支 | Task 完成回调触发 MoveNext |
| JS | Promise .then 微任务 | Promise resolve 时进微任务队列 |
| Python | 协程 .send() 推进 | 事件循环 select 到事件后 |
| Rust | Future poll 的下一阶段 | 执行器被 waker 唤醒后 poll |
| Go | goroutine 栈 | 运行时调度回这个 goroutine |
机制不同,本质相同:把”后续”打包,等条件来了再接上。理解了续体,你就抓住了所有异步语言的共同灵魂——剩下的只是各家用什么数据结构、什么调度策略实现它。
call/cc)。