Rust 异步:从 Future 到 Runtime
From Future to Runtime
目录
先从一个简单的异步函数开始:
async fn hello() {
println!("hello");
}
fn main() {
let future = hello();
println!("future created");
drop(future);
}
运行以后,只会看到:
future created
hello 明明已经被调用,函数体为什么没有执行?
这就是整个 Rust 异步模型的入口。调用 async fn 不会创建线程,也不会把任务自动交给某个后台调度器。它只返回一个 Future。只要没人继续轮询这个 Future,里面的代码就一步也不会向前走。
接下来问题会一个接一个出现:
Future 里保存了什么?
谁来轮询 Future?
它返回 Pending 以后,谁知道什么时候再试一次?
.await 为什么能在原来的位置继续?
tokio::spawn 为什么要求 Send + 'static?
Pin 又是在防止什么?
这其实是在问:一段代码暂停以后,它的状态放在哪里,又由谁负责让它继续运行。
调用 async fn 得到了什么
普通函数在调用时立刻进入函数体:
fn hello() {
println!("hello");
}
let value = hello();
异步函数则不同:
async fn load_user(id: u64) -> Result<User, Error> {
// ...
}
let future = load_user(42);
虽然函数签名里写的是 Result<User, Error>,调用表达式得到的并不是 Result,而是一个实现了 Future<Output = Result<User, Error>> 的匿名类型。
可以粗略地把它理解成:
fn load_user(id: u64) -> impl Future<Output = Result<User, Error>> {
async move {
// ...
}
}
Future 只是对“一项将来可能完成的计算”的描述。创建它和执行它是两件事。
下面的 Tokio 程序之所以会打印 hello,不是因为 hello() 自己启动了,而是因为 main 返回的 Future 被 Tokio Runtime 驱动,执行到 .await 时又开始驱动 hello() 返回的 Future:
#[tokio::main]
async fn main() {
hello().await;
}
Rust 选择这种惰性模型有一个直接结果:Future 可以在执行前被组合、包装或丢弃,单纯创建它不会悄悄产生网络请求和其他副作用。不过,这也意味着我们不能只凭一行 async fn 判断任务何时开始。还要继续看它在哪里被 .await、spawn,或者交给了其他组合器。
Future 是一台状态机
看一个更接近生产代码的例子。创建订单时,服务需要读取请求体、验证用户、向计价服务询价、写入订单,最后记录一份审计日志:
async fn create_order(
&self,
request: Request,
) -> Result<OrderId, Error> {
let request_id = request_id(&request);
let raw_body = read_body(request).await?;
let command: CreateOrder = serde_json::from_slice(&raw_body)?;
let user = self
.auth_client
.verify(&command.access_token)
.await?;
let quote = self
.pricing_client
.quote(&command.items, user.member_level)
.await?;
let order = Order::new(
request_id.clone(),
user.id,
command.items,
quote,
);
self.order_repo.insert(&order).await?;
self.audit_log.write(&request_id, &raw_body).await?;
Ok(order.id)
}
普通同步函数把执行位置和局部变量保存在调用栈里。线程被操作系统挂起后,调用栈还在,因此恢复时知道该从哪里继续。
异步函数不能一直占着一条线程。它等待请求体、鉴权服务或计价服务时,需要把工作线程让给其他任务。可代码恢复以后还要用到的局部变量,总得有地方保存。
编译器会把异步函数转换为一个状态机。上面的代码可以大致想象成:
ReadingBody {
self,
request_id,
body_future
}
Authenticating {
self,
request_id,
raw_body,
command,
auth_future
}
Quoting {
self,
request_id,
raw_body,
command,
user,
quote_future
}
SavingOrder {
self,
request_id,
raw_body,
order,
insert_future
}
WritingAuditLog {
self,
request_id,
raw_body,
order,
audit_future
}
Done
这不是编译器实际生成的 Rust 代码,只是一个方便理解的模型。每遇到一个可能暂停的 .await,状态机就多了一个需要恢复的位置。一个变量是否进入某个状态,取决于它在这次 .await 之后还会不会被使用。
例如 user 在计价时还要提供会员等级,所以它必须跨过鉴权之后的 .await。计价完成并取出 user.id 后,后面的状态就不再需要保存整个 user。
order 则要跨过两次等待:订单落库时需要借用它,审计完成以后还要读取 order.id 作为返回值。任务暂停期间,order 不能留在已经让出去的线程栈上,只能成为 Future 状态的一部分。
这里还有一个更隐蔽的问题。raw_body 只在解析命令和最后写审计日志时使用,但因为最后一行还需要它,它会跨过鉴权、计价和订单落库三个暂停点。假设一次批量下单的请求体有 2 MB,这 2 MB 数据就会在几个远程调用期间一直被任务持有。
严格来说,Vec<u8> 或 Bytes 的内容通常位于堆上,Future 本体保存的是指针、长度等字段;但只要这些字段仍属于 Future,那块堆内存就不能释放。大量请求同时停在计价服务上时,这种生命周期差异会直接反映在进程内存里。
如果审计只需要验证请求内容,而不需要保存完整原文,可以提前计算摘要,让大块请求数据尽早离开作用域:
let (command, body_hash) = {
let raw_body = read_body(request).await?;
let command = serde_json::from_slice::<CreateOrder>(&raw_body)?;
let body_hash = sha256(&raw_body);
(command, body_hash)
}; // raw_body 在这里释放
let user = self
.auth_client
.verify(&command.access_token)
.await?;
// 后续状态只需要保存 command 和一个很小的 body_hash
生成的 Future 可以看作一个枚举,它自身的大小大致由占用空间最大的状态决定,而不是把所有状态简单相加。计算一个任务的实际内存成本时,还要算上这些状态所持有的 String、Vec、Bytes 等堆内存。
这也是为什么 .await 不只是调度标记。它还是一条变量生命周期的边界:哪些值要带到下一个阶段,编译器会把它们写进 Future,直到后续代码不再需要为止。
谁在推动状态机
Future 的核心接口不长:
pub trait Future {
type Output;
fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Self::Output>;
}
一次 poll 只回答当前这一刻的状态:
enum Poll<T> {
Ready(T),
Pending,
}
Ready(value) 表示计算已经结束,可以取走结果。Pending 表示现在还无法继续,例如:
socket 还没有收到数据
定时器还没有到期
channel 里暂时没有消息
Semaphore 还没有空闲许可
调用 poll 的一方叫作 Executor。它拿到 Ready 后收集结果,拿到 Pending 后则先去执行其他任务。
poll 有一条很重要的约定:不要在里面长时间阻塞。
如果数据尚未就绪,Future 应该尽快返回 Pending。它不能在 poll 里写一个循环,不停检查 socket 是否有数据:
// 错误的思路
while !socket.is_ready() {
// 一直占用 Executor 的工作线程
}
否则看起来使用了 async,工作线程实际上仍然被当前任务占住了。
Pending 之后谁来通知
如果 Executor 每隔几毫秒重新检查所有 Future,会造成大量没有意义的轮询:
Task A 有结果了吗?没有
Task B 到时间了吗?没有
Task C 收到消息了吗?没有
再从 Task A 问一遍……
Rust 使用 Waker 避免这种忙等。
Future 在发现当前无法继续时,会把 Context 里的 Waker 登记到对应资源上。例如,一个网络 Future 会让 I/O Driver 知道:这个 socket 就绪以后,请唤醒当前任务。
完整过程大致如下:
Executor poll Future
│
▼
Pending
│
▼
Future 登记当前任务的 Waker
│
▼
I/O Driver 等待 socket / timer / event
│
▼
资源就绪,调用 Waker
│
▼
任务重新进入 Executor 的可运行队列
│
▼
Executor 再次 poll Future
Waker 不是“继续执行”按钮。它通常只负责告诉 Executor:这个任务现在值得再 poll 一次。至于任务什么时候真正获得工作线程,仍由调度器决定。
Future 自己也没有能力监听操作系统事件。网络 socket、定时器和操作系统事件之间的联系,需要 Runtime 的 I/O Driver 维护。Linux 上常见的底层机制是 epoll,macOS 是 kqueue,Windows 则有 IOCP。应用通常不会直接接触它们,Tokio 等 Runtime 已经完成了适配。
.await 做了什么
现在可以回头看这一行:
let profile = fetch_profile(user_id).await?;
.await 会尝试推进它左边的 Future。
如果子 Future 返回 Ready(profile),当前异步函数取到结果,继续执行下一行。如果子 Future 返回 Pending,当前函数也无法继续,于是把自己的执行状态保存下来,并向外层返回 Pending。
可以用一段不严格的伪代码表示:
loop {
match poll(child_future) {
Ready(value) => break value,
Pending => suspend current future,
}
}
这里的 suspend 不是让线程睡眠。当前 Future 已经把恢复所需的数据保存在自身状态里,工作线程可以马上去运行别的任务。
等子 Future 的 Waker 被触发以后,外层 Future 会再次被 poll。状态机根据之前保存的状态,跳回相应位置继续执行。
所以 .await 同时做了两件事:
向内:轮询正在等待的子 Future
向外:必要时暂停当前 Future,把 Pending 继续传出去
一层 Future 等待另一层 Future,最终形成一棵嵌套的状态机。最外层 Future 被 Runtime 驱动,唤醒信号则沿着组合关系让相应任务重新进入调度队列。
Runtime 里有什么
Rust 标准库定义了 Future、Poll、Waker 等基础协议,却没有附带一个完整的异步 Runtime。
这是因为“异步计算如何表达”和“应用如何调度任务、监听 I/O”是两个不同层次的问题。前者可以放在标准库里保持稳定,后者则会面对工作线程数量、调度策略、网络驱动、计时器和平台差异。
以 Tokio 为例,一个 Runtime 大致包含:
Tokio Runtime
│
├── Scheduler / Executor
│ 管理任务队列,把可运行任务交给工作线程
│
├── I/O Driver
│ 监听 socket 等系统资源
│
├── Timer Driver
│ 管理 sleep、interval 和 timeout
│
└── Blocking Thread Pool
隔离无法异步化的阻塞调用
#[tokio::main] 是一个方便的入口宏。它创建 Runtime,再用这个 Runtime 执行 main 返回的 Future。可以把它粗略理解为:
fn main() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
// 原来 async main 中的代码
});
}
实际展开方式会受 Runtime 配置和宏参数影响,但职责差不多:总得先有一个同步入口创建执行环境,最外层 Future 才有人负责第一次 poll。
这也解释了为什么在普通测试函数或者同步 main 中直接写 .await 不行。.await 只能出现在异步上下文里,而整个异步调用链最外面还需要 Runtime 接住。
join!、select! 和 spawn
理解了谁在 poll Future,几个常用并发工具的差别就容易看清了。
join!:在当前任务里一起推进
回到用户首页接口。如果依次等待两个下游:
let profile = fetch_profile(user_id).await?;
let orders = fetch_orders(user_id).await?;
假设两次请求分别需要 80 ms 和 120 ms,总时间接近 200 ms。它们互不依赖时,可以同时开始:
let (profile, orders) = tokio::try_join!(
fetch_profile(user_id),
fetch_orders(user_id),
)?;
两个 Future 仍然属于当前任务。try_join! 在当前任务每次被 poll 时,轮流推进各个分支;只要有一个分支返回错误,就返回这个错误。
它带来的是并发等待,不是把两个分支分别放到两条线程上并行计算。分支里的同步代码仍然由当前任务所在的工作线程执行。
这种方式有一个很实用的优点:分支没有脱离当前作用域,因此可以借用当前栈上的数据。
let request_id = String::from("req-42");
let (profile, orders) = tokio::join!(
fetch_profile(&request_id),
fetch_orders(&request_id),
);
select!:等待先发生的事情
有时我们不想等待所有分支,只关心哪个先完成:
use tokio::time::{sleep, Duration};
tokio::select! {
result = load_home(user_id) => {
result
}
_ = sleep(Duration::from_millis(300)) => {
Err(Error::Timeout)
}
}
select! 同样在当前任务中轮询多个 Future。某个分支完成后,执行对应代码,其他尚未完成的分支会被丢弃。
这正是它和 join! 的区别:
join! 等待全部分支
select! 选择先完成的分支
“丢弃未完成分支”不是无关紧要的实现细节。它意味着 select! 与超时、取消安全紧密相关,后面还会回来讨论。
spawn:创建独立任务
如果一项工作需要独立于当前调用继续运行,可以把 Future 提交给 Runtime:
let handle = tokio::spawn(async move {
refresh_cache(user_id).await
});
do_something_else().await?;
handle.await??;
tokio::spawn 会创建一个可独立调度的任务。当前任务不需要先 poll 到某个 .await,新任务就有机会开始执行。
代价是,两边的生命周期已经分开。新任务可能在当前函数返回以后仍然存在,因此不能随意借用当前栈上的数据。它还可能在 Tokio 的多线程 Runtime 中被不同工作线程轮流执行,因此会引出 Send + 'static 的要求。
JoinHandle 用来观察任务结果。这里出现两个 ?,是因为第一层处理任务是否被取消或发生 panic,第二层才是 refresh_cache 自己返回的业务错误。
需要特别注意:直接丢弃 JoinHandle 不会取消任务,而是让它在后台继续运行。需要停止任务时,应当显式调用 abort,或者建立一套协作式取消机制。
为什么 spawn 要求 Send + 'static
下面的代码无法通过编译:
async fn run() {
let user_id = String::from("42");
tokio::spawn(async {
fetch_user(&user_id).await;
});
}
新任务借用了 run 的局部变量,但它可能比 run 活得更久:
run 返回,user_id 被释放
│
└── spawned task 可能还在等待网络
最常见的修改是把所有权移入任务:
async fn run() {
let user_id = String::from("42");
let handle = tokio::spawn(async move {
fetch_user(&user_id).await
});
handle.await.unwrap();
}
'static 在这里并不表示任务必须运行到程序结束。它表示任务持有的数据不能依赖一个更短命的借用。拥有自己的 String 可以满足这个条件,即使任务只运行十毫秒。
Send 解决的是另一个问题。多线程 Runtime 可能这样调度任务:
Worker 1: poll → Pending
│
│ I/O ready
▼
Worker 3: poll → Ready
任务在两次 .await 之间可能换一条工作线程继续被 poll。这里移动的是任务的调度权和持有它的句柄,被 Pin 的 Future 本体仍然可以保持稳定地址;但 Future 内部的数据会先后由不同线程访问,所以整个 Future 必须能够安全地在线程间转移。
Rc 就是一个常见反例:
use std::rc::Rc;
tokio::spawn(async {
let value = Rc::new("hello");
tokio::task::yield_now().await;
println!("{value}");
});
value 在 .await 之后还会使用,因此它成为 Future 状态的一部分。Rc 不是 Send,整个 Future 也就不能被提交到可能跨线程调度的 tokio::spawn。
如果某个非 Send 值只在 .await 之前使用,并且确定已经离开作用域,它就不需要跟着任务跨越暂停点:
tokio::spawn(async {
{
let value = Rc::new("hello");
println!("{value}");
}
tokio::task::yield_now().await;
});
确实需要运行非 Send Future 时,Tokio 还提供 LocalSet 和 spawn_local,把这些任务限制在同一条线程上。它不是绕过线程安全,而是明确承诺任务不会被移动到其他线程。
Pin 在防止什么
现在再看 Future::poll 的接收者:
self: Pin<&mut Self>
为什么不是普通的 &mut self?
考虑一个跨越 .await 的借用:
async fn inspect() {
let text = String::from("hello");
let first = &text[..1];
tokio::task::yield_now().await;
println!("{first}");
}
从源码看,first 借用了局部变量 text。编译成状态机以后,二者都需要保存在同一个 Future 里。可以把它想象成某个状态同时保存了数据和指向这份数据的引用。
如果 Future 在开始执行以后还能被随意移动到另一个内存位置,其中依赖原位置的内部关系就可能失效。Rust 需要一种方式向 poll 保证:对于不允许移动的 Future,从开始轮询以后,不会再通过这个引用把它挪走。
这就是 Pin 提供的约束。
Future 创建以后
可以先移动、组合、放入容器
Future 被 Pin 以后
对于 !Unpin 的值,不能再安全地移动其本体
Pin 不等于“这个值一定在堆上”。值可以被固定在栈上,也可以通过 Box::pin 固定在堆上。它限制的是移动行为,不是指定某种内存分配方式。
多数业务代码不需要手动实现 Future,也很少直接操作 Pin<&mut T>。编译器生成的 Future、.await 和 Tokio 宏已经处理了大部分细节。通常在以下场景里才会明显遇到它:
手动实现 Future 或 Stream
在 select! 里重复借用同一个 Future
保存不同类型的 Future
处理 !Unpin 类型
因此,理解 Pin 的目标比记住一组调用方式更重要:它在保护已经开始运行的状态机,避免状态机保存的内部关系因移动而失效。
async 里混入阻塞代码
异步任务很轻,但执行任务的工作线程并不多。假设 Runtime 有四条 Worker,每条都被一个阻塞调用占住:
Worker 1 ── blocking read ─────────────
Worker 2 ── thread::sleep ─────────────
Worker 3 ── legacy SDK call ───────────
Worker 4 ── heavy calculation ─────────
其他 async tasks:全部等待 Worker
问题不在于这四个任务耗时,而在于它们没有在等待时返回 Pending。Executor 没有机会把工作线程交给别的任务。
最直观的例子是休眠:
// 会阻塞当前工作线程
std::thread::sleep(Duration::from_secs(1));
// 注册定时器并让出当前任务
tokio::time::sleep(Duration::from_secs(1)).await;
无法改造成异步接口的同步库,可以放进专门的阻塞线程池:
let bytes = tokio::task::spawn_blocking(move || {
legacy_client.read(path)
})
.await??;
spawn_blocking 适合文件操作、同步 SDK 和其他不可避免的阻塞调用。它不会把同步代码变成非阻塞 I/O,只是把它从 async Worker 上隔离出去。
大量 CPU 计算则更适合 Rayon 或单独控制规模的计算线程池。CPU 任务即使写进 async fn,也必须真实占用核心:
异步 I/O 等待时让出线程
阻塞 I/O 放入 blocking pool 隔离
CPU 密集计算 使用受控的计算线程池
锁也要看它跨不跨 .await。短时间修改内存数据,普通 std::sync::Mutex 完全可能是合适的:
{
let mut state = state.lock().unwrap();
state.requests += 1;
} // guard 在 await 前释放
send_request().await?;
把 Guard 一直留到网络请求完成则通常有问题:
let mut state = state.lock().unwrap();
state.requests += 1;
send_request().await?; // 等待期间仍然占着锁
这不只是可能让 Future 失去 Send,还会让其他任务在一段不可预测的网络等待期间都拿不到锁。tokio::sync::Mutex 允许跨 .await 等锁,但并不意味着应该把远程调用放进临界区。多数时候,更好的做法仍然是缩短锁的持有范围。
取消是丢弃 Future
Rust Future 没有一个通用的 cancel() 方法。对于还没有独立 spawn 的 Future,取消通常表现为:不再 poll,并将它丢弃。
select! 超时就是一个例子:
tokio::select! {
result = reserve_inventory(order_id) => result,
_ = tokio::time::sleep(Duration::from_millis(300)) => {
Err(Error::Timeout)
}
}
如果定时器先完成,reserve_inventory 对应的 Future 会被丢弃,后面的代码不再执行。
但“Future 不再执行”和“外部世界恢复原状”完全是两回事。假设库存服务已经完成预留,只是响应还没有传回来:
发送预留请求
│
▼
库存服务已经落库
│
├── 响应仍在网络中
│
└── 本地 timeout,Future 被丢弃
本地取消无法撤销对方已经提交的数据库事务。调用方甚至无法确定请求究竟没有到达,还是已经成功但响应丢失。
因此,异步取消往往还要配合:
幂等键
可查询的操作状态
补偿操作
明确的超时边界
下游可以识别的 Cancellation Token
还有一些 Future 在被取消后重新创建,可能丢失内部已经读取但尚未交付的数据。Tokio 会在 API 文档里标注某个异步操作是否 cancellation safe。使用 select! 编写循环时,这类说明尤其重要。
独立任务的取消又有所不同:
let handle = tokio::spawn(run_job());
// 丢弃 handle:任务继续运行
drop(handle);
如果要停止它,需要保留 Handle 并调用 abort,或者让任务主动监听取消信号。取消仍然主要发生在任务下一次能够让出执行权的位置;一段不返回的同步代码不会因为外部发出取消信号就立刻消失。
任务很轻,资源并没有变多
Future 通常比操作系统线程便宜得多,于是很容易写出这样的程序:
for user_id in user_ids {
tokio::spawn(refresh_user(user_id));
}
如果 user_ids 有一百万个,程序就会迅速创建一百万个任务。它们不会各占一条线程,却仍然会持有请求参数、Future 状态、计时器、日志上下文和调度节点。
更现实的瓶颈通常在下游:
1,000,000 tasks
│
▼
100 HTTP connections
│
▼
20 database connections
异步让等待变得便宜,没有让连接池和数据库容量增长。
一个常见做法是用 Semaphore 限制同时访问某个下游的任务数:
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
async fn refresh_all(user_ids: Vec<u64>) -> Result<(), Error> {
let limit = Arc::new(Semaphore::new(64));
let mut tasks = JoinSet::new();
for user_id in user_ids {
let permit = limit.clone().acquire_owned().await.unwrap();
tasks.spawn(async move {
let _permit = permit;
refresh_user(user_id).await
});
}
while let Some(result) = tasks.join_next().await {
result??;
}
Ok(())
}
许可跟随任务一起释放,因此最多只有 64 个刷新操作同时进行。JoinSet 则把一组动态创建的任务集中管理,避免创建后完全失去踪迹。
如果任务由生产者持续产生,有界 mpsc 往往比无界队列更重要。当消费者跟不上时,生产者在发送处等待,压力才不会无限转化成内存占用。
Semaphore 限制同时执行的操作
bounded channel 限制等待处理的任务
connection pool 限制下游连接
timeout 限制一次等待持续多久
这些限制不应该随手写成同一个数字。每一层保护的资源不同:HTTP 服务也许能并发 200 个请求,数据库连接池只有 30 条,第三方接口还可能规定每秒最多调用 50 次。
回到一次完整请求
现在把前面的概念放进一个接口。用户首页需要并发查询资料和订单,整个下游调用不能超过 300 ms,同时最多允许 64 个请求占用这组后端资源:
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration};
struct HomeService {
downstream_limit: Arc<Semaphore>,
}
impl HomeService {
async fn load_home(&self, user_id: u64) -> Result<Home, Error> {
let permit = self
.downstream_limit
.acquire()
.await
.map_err(|_| Error::ShuttingDown)?;
let request = async {
let (profile, orders) = tokio::try_join!(
fetch_profile(user_id),
fetch_orders(user_id),
)?;
Ok(Home { profile, orders })
};
let result = timeout(Duration::from_millis(300), request)
.await
.map_err(|_| Error::Timeout)?;
drop(permit);
result
}
}
这段代码里没有显式操作 Waker,也没有手写 poll,但那条执行链一直存在:
load_home 被调用
│
▼
返回 Future
│
▼
外层任务 poll 它
│
├── Semaphore 有许可:继续
│
└── 没有许可:登记 Waker,返回 Pending
│
▼
try_join! 推进两个网络 Future
│
├── socket 未就绪:返回 Pending
│
└── I/O Driver 收到事件后唤醒任务
│
▼
两个结果都 Ready,组装 Home
timeout 也不是另一套机制。它只是同时等待业务 Future 和计时器 Future;计时器先完成时,业务 Future 被丢弃。
这里还可以继续追问工程细节:等待 Semaphore 的时间是否应该计入 300 ms?下游已经成功但本地超时时怎么办?订单接口是否支持幂等?服务关闭时,排队中的请求如何退出?
Rust async 负责让代码能够暂停和恢复,却不会替应用回答这些问题。理解执行模型的意义,正是知道 Runtime 已经保证了什么,剩下的边界又在哪里。
从 Future 到 Runtime
回到开头:
let future = hello();
它没有执行,因为 Rust 的异步函数首先是一份可以被驱动的计算。Future 保存执行状态,Executor 调用 poll,I/O Driver 监听资源,Waker 把就绪任务送回调度队列,.await 则把这一套机制嵌进了接近顺序执行的代码里。
沿着这条路径再看那些常见约束,就不再显得突然:
Send
Future 可能在不同工作线程之间移动
'static
独立任务不能借用已经离开的调用栈
Pin
已开始轮询的状态机不能被随意移动
spawn_blocking
阻塞代码不能长期占用 async Worker
cancellation safety
丢弃 Future 不等于撤销已经发生的副作用
backpressure
任务调度变便宜,不代表下游资源无限
Rust 异步最初让人费解,通常不是因为 .await 的语法,而是同步代码里由线程调用栈默默承担的工作,在这里被拆开摆到了面前。
任务停下来以后,Future 保存它;条件满足以后,Waker 通知它;Runtime 再次调度它。看清这三步,async 代码才真正从语法变成了执行过程。
References
- Rust Standard Library:
asyncKeyword - Rust Standard Library:
Future - The Rust Reference: Await Expressions
- The Rust Programming Language: Fundamentals of Asynchronous Programming
- Rust Standard Library:
Pin - Tokio Tutorial: Spawning
- Tokio Tutorial:
select! - Tokio Tutorial: Shared State
- Tokio Documentation: CPU-bound Tasks and Blocking Code
- Tokio Documentation:
JoinHandle - Tokio Documentation:
JoinSet - Tokio Documentation:
Semaphore