想象一间老式餐厅:顾客站在柜台前,厨师放下手里的勺,先把眼前这位的菜做完,顾客端着菜走了,才轮到下一位。看起来公平,可一旦客人多起来,柜台挤成一团,厨师连喘口气的功夫都没有。这就是传统的“同步阻塞”模型——每个连接配一个线程,线程一多,光切换上下文就耗光了精力。Rust 给出的解法,是把经营方式改成“点餐制”:顾客下单拿到取餐号,回座位等着,厨师按订单分步推进,一个灶台同时照顾几十桌。这份“订单”就是 Future,负责统筹的“大堂经理”是 Tokio 运行时,“菜好了按铃叫号”是 Waker 唤醒机制。整套 async/await 语法,就是让你像写一条龙服务一样写异步代码,既不阻塞线程,也不用掉进一堆回调嵌套的泥潭。

岗位关联

掌握 Rust 异步模型,你就能用极少的线程支撑海量并发 IO 请求,写出像同步代码一样清晰、却拥有极致吞吐的网络服务,成为高并发后端与基础设施方向的核心人才。

目录
  1. 可视化演示
  2. 异步与Future
  3. 运行时Tokio
  4. Pin与自引用
  5. Send与生命周期
  6. 并发任务处理
  7. 阻塞任务隔离
  8. 异步trait
  9. 实践与生态
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示Rust async/await核心机制:Future状态机(Pending→Poll→Ready)、select!并发竞争与Tokio多线程调度,动态对比异步执行流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Future状态机:Pending→Poll→Ready 橙色为Pending(挂起等待) 青色为Poll(轮询执行) 绿色为Ready(完成就绪) Future: poll() -> Pending | Ready(T) async fn编译为状态机,每次poll推进到下一个await点,Pending时挂起让出执行权

异步与Future

一间老式餐厅,顾客站在柜台前,厨师必须把这位的菜做完才能接待下一位。顾客倒是“专注”了,但老板心里清楚:每多一个顾客就得雇一个厨师,厨师多了,光在灶台间轮换就累得够呛。对应到程序里,这就是“一连接一线程”的同步阻塞模型。线程要占栈空间,切换要花时间,连接数一上来,服务器先被自己累垮了。

Rust 换了个思路,改成了“点餐制”:顾客来了,厨师先写订单(构造 Future),订单送进后厨,顾客拿取餐号回座位。一个厨师可以同时管几十桌菜,只要记得每桌做到哪一步。关键在于:订单本身不会自动做菜——Future 是惰性的,必须有人照着订单动手(poll)才会推进。

怎么动手?执行器(后厨调度)会反复调用 poll 来问“这道菜能出锅了吗”。Future 没准备好就返回 Pending,并留下一个“叫号铃”(Waker);准备好就返回 Ready(结果)。而 fut.await 是任务级的语法糖:当前协程把自己挂起,把线程让出来,等 Waker 摇铃唤醒后再从挂起点继续。

所以 async/await 的第一课是:它不神奇,只是把“一桌一厨师”改成“一厨师管多桌,到点翻菜”。Future 是一沓订单,await 是“这桌先放放,我去炒下一桌”的那句吩咐。

poll: Pin⟨&mut Self⟩ → Poll⟨Output⟩,Poll⟨T⟩=Ready(T)∣Pending\mathrm{poll}:\ \mathrm{Pin}\langle\&\mathrm{mut}\ \mathrm{Self}\rangle\ \rightarrow\ \mathrm{Poll}\langle\mathrm{Output}\rangle,\quad \mathrm{Poll}\langle T\rangle=\mathrm{Ready}(T)\mid\mathrm{Pending}
这个公式说明:Future 的轮询需要“钉住”的可变引用,返回要么“好了”要么“还没好”,绝不阻塞等待。
  1. poll 是唯一驱动手段Future 接口里最核心的方法,执行器每次调用它就是在“照订单做一步菜”。
    poll: Pin⟨&mut Self⟩ → Poll⟨Output⟩\mathrm{poll}:\ \mathrm{Pin}\langle\&\mathrm{mut}\ \mathrm{Self}\rangle\ \rightarrow\ \mathrm{Poll}\langle\mathrm{Output}\rangle
  2. 第一参数 Pin 引用Pin<&mut Self> 保证轮询期间 Future 不会在内存里被挪走,防止自引用悬空——托盘固定才不会洒菜。
    Pin⟨&mut Self⟩\mathrm{Pin}\langle\&\mathrm{mut}\ \mathrm{Self}\rangle
  3. 返回值 Poll 枚举Ready(T) 表示菜已出锅,Pending 表示还没好,但无论如何 poll 必须立刻返回,不能占着线程干等。
    Poll⟨T⟩=Ready(T)∣Pending\mathrm{Poll}\langle T\rangle=\mathrm{Ready}(T)\mid\mathrm{Pending}

运行时Tokio

订单攒了一堆,总得有个大堂经理统筹全局。Tokio 就是 Rust 异步世界里最流行的大堂经理——一套成熟的异步运行时(Runtime)。#[tokio::main] 是它的开工仪式:启动多线程执行器,把主入口跑起来。tokio::spawn 则是你把新订单递进后厨窗口的那只手。

tokio::spawn 接受一个 Future,把它提交给执行器去调度,返回一个 JoinHandle(取餐凭证)。你需要结果时,对凭证 await 一下,就能等到那道菜端出来。任务运行在后台,不阻塞当前流程。

Tokio 的多线程执行器采用工作窃取调度:厨房里若干固定灶台(worker 线程),每个灶台前有自己的排队订单;哪个灶台空了,不等自己队伍,直接去别的队伍“偷”一单来做。这样一个 OS 线程上能挂成千上万个任务,每个任务只是一小段状态机,切换成本极低,远小于线程切换。

有了 Tokio,Rust 的“点餐制”才能真正运转起来:大堂经理负责分配灶台、偷单补位、按铃叫号,整个餐厅在高客流下依然井井有条。

spawn(f): f:Future⟨Output=()⟩ ⇒ JoinHandle⟨()⟩,f:Send+′static\mathrm{spawn}(f):\ f:\mathrm{Future}\langle\mathrm{Output}=()\rangle\ \Rightarrow\ \mathrm{JoinHandle}\langle()\rangle,\quad f:\mathrm{Send}+'static
这条签名说明:交出 Future 时通常要求它能安全转移到任意线程,并且不借用临时数据。
  1. spawn 接收 Future 输出单元任务常常是“做了不回报结果”的后厨订单,输出为 ()。
    f:Future⟨Output=()⟩f:\mathrm{Future}\langle\mathrm{Output}=()\rangle
  2. 返回 JoinHandle这是取餐凭证,handle.await 可以等待任务完成并取走返回值。
    ⇒ JoinHandle⟨()⟩\Rightarrow\ \mathrm{JoinHandle}\langle()\rangle
  3. Send 与 'static 约束任务可能被丢到任意 worker 线程执行,订单必须能安全传递,且不能依赖会过期的便签(借用)。
    f:Send+′staticf:\mathrm{Send}+'static

Pin与自引用

有些菜必须放在固定托盘上,托盘一抖菜就洒——比如盖浇饭。Future 内部也藏着类似的“不能抖”的秘密:自引用。当你写一个 async 块,对局部变量取引用,并且跨过 .await 继续使用时,编译器生成的 Future 结构体里可能藏着一个指向自己成员区的裸指针。

问题来了:Future 一旦被 move(比如放进 Box、被 mem::swap 交换位置),内部指针还指着旧地址,就成了悬空指针。这是未定义行为,Rust 必须在编译期拦住。于是 poll 的接收者特意设计成 Pin<&mut Self>:Pin 就是那张“固定托盘”。

有了 Pin,在 Pin 的有效期内,T 不会被移动。栈上可以用 pin! 宏把变量临时钉住;堆上则用 Box::pin 分配并固定。Pin 的实现底层绕不开 unsafe,但对外暴露的 API 是安全的——普通开发者只需要遵守“钉住的东西别去 move 它”这条规矩。

你不需要天天手写自引用类型,但理解 Pin 才能读懂编译器的报错——就像服务员知道哪盘菜不能端,才不容易当众打翻。

Pin⟨P⟩: P:!Unpin ⇒ P 的目標在記憶體中不移動\mathrm{Pin}\langle P\rangle:\ P:\mathrm{!Unpin}\ \Rightarrow\ P\ \text{的目標在記憶體中不移動}
这句表达式说明:对不支持移动的类型(!Unpin),Pin 保证目标内存地址固定不动。
  1. !Unpin 类型自引用 Future 属于 !Unpin,编译器默认它移动会出问题,所以需要 Pin 来保护。
    P:!UnpinP:\mathrm{!Unpin}
  2. 目标不可移动只要 Pin<P> 还活着,P::Target 就不会被移动,内部自引用始终有效。
    ⇒ P 的目標不移動\Rightarrow\ P\ \text{的目標不移動}

Send与生命周期

订单是可以跨窗口传递的,但有一种订单不能传:上面写着“借用一下门口寄存的雨伞”。雨伞还在别人手里,订单传过去就失效了。Rust 中的 Send 标记 trait,就是用来声明“这东西能安全搬到别的线程”;而 'static 生命周期,则是“订单内容完整独立,不依赖任何临时存在的便签”。

tokio::spawn 的任务会被丢进工作窃取线程池里的任意线程,因此它必须同时满足 Send + 'static。这两条约束在编译期执行:Rc 是非原子的引用计数,搬家到别的线程会导致计数错乱,所以不是 Send;改为 Arc 原子引用计数后就能 Send。RefCell 不是 Send,因为它的借用检查不是线程安全的;Mutex 则通过加锁保证安全,可以 Send。

跨 .await 持有一个非 Send 变量也会触发编译错误:因为整个 Future 可能在挂起期间被移动到别的线程上继续执行。借用非 'static 的数据同样会被拒绝——任务可能活得比那些数据久得多。

遵守“外卖可以全城送、但订单别夹带借条”的规则,编译器就会放行;一旦夹带,报错信息会明确告诉你:这个 Future 无法在线程间安全传递。

T:Send+′static ⇔ T 可安全傳遞到任意執行緒,且不攜帶借用T:\mathrm{Send}+'static\ \Leftrightarrow\ T\ \text{可安全傳遞到任意執行緒,且不攜帶借用}
这句话是对跨线程约束的提炼:既能安全搬家,又不欠着会过期的债。
  1. Send 标记表示 T 的所有权可以被移动(move)到另一个线程,不会产生数据竞争。
    T:SendT:\mathrm{Send}
  2. 'static 生命周期表示 T 不借用任何临时数据,可以安全地存活任意长时间。
    +′static+'static
  3. 组合适用场景spawn 一个任务时,这两个条件必须同时成立,编译器才能放心让任务跨线程流浪。
    T:Send+′staticT:\mathrm{Send}+'static

并发任务处理

顾客点了一个套餐,后厨分成好几道菜同时做。join! 就像点套餐的规矩:所有菜都上齐,这桌才算齐活。而 select! 则像同时排了几家不同的队:哪家先叫到号,就先吃哪家,其他队的号作废。

tokio::join!(a, b) 同时等待两个 Future,两个都完成才返回元组;try_join! 更严格,其中一个出错就整体失败。tokio::select! 则是事件竞速的利器:宏里写多个分支,每个分支是“模式 = future => 处理代码”,谁先 Ready 就执行谁的分支体,其余未完成的 future 会被直接丢弃——也就是自动取消。

select! 默认做伪随机的分支分配,避免每次都偏向第一个分支造成“饿死”。想按顺序优先,就在宏开头写 biased;。tokio::time::timeout(dur, fut) 正是 select! 的经典封装:等待 future 和超时时钟赛跑,超时先到就返回 Err,同时取消掉原任务。

一个 select! 就像排队攻略:谁先叫到号就先服务谁,服务完就走,不恋战。利用它,你可以轻松实现超时、取消、竞速等高级并发控制。

join!(a,b): 兩者都 Ready 才返回,select!{a⇒A, b⇒B}: 先 Ready 者觸發\mathrm{join!}(a,b):\ \text{兩者都}\ \mathrm{Ready}\ \text{才返回},\qquad \mathrm{select!}\{a\Rightarrow A,\ b\Rightarrow B\}:\ \text{先}\ \mathrm{Ready}\ \text{者觸發}
这组式子对比了两种组合策略:join 是“等到齐”,select 是“先到先得”。
  1. join 的合流语义join! 并发驱动两个 future,直到两者都 Ready 才打包返回。
    join!(a,b): 兩者都 Ready 才返回\mathrm{join!}(a,b):\ \text{兩者都}\ \mathrm{Ready}\ \text{才返回}
  2. select 的选择语义select! 同时轮询多个分支,第一个 Ready 的分支触发后整体退出。
    select!{a⇒A, b⇒B}: 先 Ready 者觸發\mathrm{select!}\{a\Rightarrow A,\ b\Rightarrow B\}:\ \text{先}\ \mathrm{Ready}\ \text{者觸發}
  3. 未选中分支被取消触发一个分支后,其他尚未完成的 future 被 drop,任务终止。
    未觸發分支 ⇒ drop 取消\text{未觸發分支}\ \Rightarrow\ \text{drop 取消}

阻塞任务隔离

有些菜要慢炖两小时。如果它占着炒锅,其他所有菜都得等着——整个后厨瘫痪。聪明的后厨会把它送进专门的炖锅间,让炒锅手继续快炒。Rust 里的 tokio::task::spawn_blocking 就是那间炖锅间。

在 async 代码里做 CPU 密集计算或同步阻塞 IO,等于让炒锅手守着炖锅发呆:整个 worker 线程被卡死,挂在这个线程上的几千个任务全部遭殃。更糟的是,工作窃取调度也无法挽救,因为线程根本没机会去偷别人的活。所以这两类“慢活”必须隔离。

spawn_blocking 接受一个普通闭包(FnOnce),把它交给独立的阻塞线程池执行,返回 JoinHandle。它适合解析大 JSON、图片处理、调用同步数据库驱动等场景。原则是:等着 IO(网络/磁盘)用 async,死磕 CPU(计算密集)用专用线程池。Tokio 还提供 block_in_place 把当前任务转移到阻塞线程池后继续执行,并从运行时层面做阻塞检测,协助定位卡顿点。

把慢活扔进炖锅间,炒锅手才能持续翻菜。高并发服务的“翻台率”,正是靠这种精细的后厨分工撑起来的。

spawn_blocking(f): f:FnOnce()→R ⇒ JoinHandle⟨R⟩\mathrm{spawn\_blocking}(f):\ f:\mathrm{FnOnce}()\rightarrow R\ \Rightarrow\ \mathrm{JoinHandle}\langle R\rangle
这个签名表示:把一个“重活”闭包交给独立线程池,返回一个等待结果的凭证。
  1. 普通闭包类型FnOnce() 表示只需执行一次的闭包,适合与外部环境交互后返回结果。
    f:FnOnce()→Rf:\mathrm{FnOnce}()\rightarrow R
  2. 返回值 JoinHandle与 spawn 一致,返回取餐凭证,await 后得到闭包的产出 R。
    ⇒ JoinHandle⟨R⟩\Rightarrow\ \mathrm{JoinHandle}\langle R\rangle
  3. 专用线程池该闭包运行在独立于 async worker 的阻塞线程池中,炒锅不背炖锅的锅。
    阻塞任務 → 專用線程池\text{阻塞任務 }\rightarrow\ \text{專用線程池}

异步trait

不同菜系的厨师,都需要遵守统一的“出菜流程”。Rust 用 trait 定义这种流程。可一旦流程里写着“做完自己按铃”(async fn),就遇到一个尴尬:这个方法返回的是“未来会响的铃”(impl Future),并不是一个能直接塞进 dyn 的具体类型。

当你在 trait 里写 async fn fetch(&self) -> String,编译器会把它解糖为 fn fetch(&self) -> impl Future<Output = String> + Send。问题在于:返回 impl Trait 的方法不是对象安全(object safe)的,因此不能直接用 &dyn AsyncTrait 做多态。早期经典解法是 async-trait crate:用过程宏把方法改写成返回 Pin<Box<dyn Future>>,牺牲一次堆分配换取 dyn 兼容。

Rust 1.75 起,返回位置 impl Trait 在 trait 中(RPITIT)稳定,原生 async fn in trait 的一部分场景已经可用。追求极致性能时,也可以绕开 trait 的 async 方法,手动实现 poll,自己管理内部状态机,但代价是代码复杂度显著上升。

统一流程规范能落地以后,不同“厨师”(实现者)才能在同一套 trait 下高效配合。虽然语法上绕了点路,但生态的演进正让这条路越走越宽。

async fn f(x) ⇒ fn f(x)→ impl Future⟨Output=R⟩\mathrm{async\ fn}\ f(x)\ \Rightarrow\ \mathrm{fn}\ f(x)\rightarrow\ \mathrm{impl}\ \mathrm{Future}\langle\mathrm{Output}=R\rangle
这个式子揭示了 async fn 的本质:它只是普通函数,返回一个“将来会有结果”的 Future 对象。
  1. async fn 语法糖写 async fn 时就隐含了 Future 的构造方式,方法体在调用时并不执行。
    async fn f(x)\mathrm{async\ fn}\ f(x)
  2. 解糖为返回 impl Future实际签名是一个返回 impl Future<Output=R> 的普通函数。
    ⇒ fn f(x)→ impl Future⟨Output=R⟩\Rightarrow\ \mathrm{fn}\ f(x)\rightarrow\ \mathrm{impl}\ \mathrm{Future}\langle\mathrm{Output}=R\rangle
  3. 对象安全冲突impl Future 是具体隐藏类型,dyn 无法处理,所以需要装箱或等 RPITIT。
    dyn 無法承載 impl Future\mathrm{dyn}\ \text{無法承載}\ \mathrm{impl}\ \mathrm{Future}

实践与生态

餐厅终究要看翻台率。Rust 异步在高并发 IO 场景的“翻台率”,是传统线程模型难以企及的:一个 8 核机器,线程模型撑住几千并发连接就已吃力;Tokio 异步模型动辄支撑几十万连接,因为任务体量极小、调度轻盈。

生态方面,tokio 是运行时基石,axum 是广受欢迎的 Web 框架,hyper 是底层 HTTP 引擎,tokio-postgres 提供异步数据库驱动,tracing 负责结构化日志。典型实践陷阱有三个:一是 async 中调用 Mutex::lock().await 时让 MutexGuard 跨过 .await 悬浮,容易导致死锁或非 Send 错误;二是在 select! 分支里做大量计算,拖慢事件循环;三是忘记检查 Future 是否 Send,等部署到多线程运行时才爆错。性能观测上,tokio-console 能实时查看任务调度、资源占用,benchmark 用 criterion 做量化验证。

后厨流畅,顾客满意,这就是 async 的终极目标:高并发、低延迟、资源一点不浪费。把 Tokio 生态用好,你的网络服务也能拥有顶级餐厅的“翻台率”。

Throughput ≈ ConcurrencyLatency\mathrm{Throughput}\ \approx\ \frac{\mathrm{Concurrency}}{\mathrm{Latency}}
吞吐量大致等于并发数除以平均延迟:同延迟下并发越高,单位时间完成的请求越多。
  1. Concurrency 并发任务数同一时刻在途的请求数量,async 模型能以极低开销抬升它。
    Concurrency\mathrm{Concurrency}
  2. Latency 平均延迟单个请求从发出到完成的平均耗时,应尽量压低。
    Latency\mathrm{Latency}
  3. 近似正比关系想提高吞吐要么增大并发,要么降低延迟——这正是异步模型的用武之地。
    Throughput ≈ ConcurrencyLatency\mathrm{Throughput}\ \approx\ \frac{\mathrm{Concurrency}}{\mathrm{Latency}}

AI 应用场景

实时消息推送网关

阶段:系统架构设计 / 后端开发

解决的问题:IM 通知、模型推理结果流式推送这类场景,需要保持海量 WebSocket 长连接。传统一连接一线程的模型,在 5 万在线时线程数爆炸、上下文切换耗尽 CPU;Rust async 将每个连接建模为一个轻量 Future,一个 worker 线程即可复用管理数千连接,大幅降低内存与调度开销。

具体案例:某智能客服平台的消息网关,单实例 4 核 8G,用 tokio + axum 支撑 3 万在线会话。每个会话一个 select! 循环监听用户消息、超时心跳和后台通知,三路事件互不阻塞。

AI 训练数据预取管道

阶段:数据工程 / MLOps

解决的问题:训练数据往往要从对象存储下载海量图片,同步下载会让 GPU 长期饿死;用 async 并发预取数据,再把图片解码这类 CPU 密集操作丢进 spawn_blocking,实现下载与解码流水线并行。

具体案例:某 CV 团队的数据管道用 tokio 并发拉取 10 万张图片,spawn_blocking 线程池负责 JPEG 解码与数据增强,预取队列始终饱满,GPU 利用率从 40% 提升到 90%。

高并发数据采集器

阶段:数据采集 / 爬虫工程

解决的问题:爬取大量 URL 时,同步等待网络响应会浪费大量线程;Rust 异步配合 select! 实现批量请求、超时熔断和任务取消,避免某个慢接口拖垮整个采集任务。

具体案例:舆情分析团队用 reqwest + tokio 并发抓取新闻页面,每个请求设置 3 秒超时,失败自动重试,单机小时级抓取 50 万页面,CPU 占用仅 2 核。

Python 演示

python31 行
import asyncio async def cook(dish: str, delay: float) -> str:    """模拟后厨做菜, delay 秒后完成"""    await asyncio.sleep(delay)    return dish async def serve() -> None:    """select 竞速: 先上先吃, 其余取消"""    a = asyncio.create_task(cook("炒饭", 1.0))    b = asyncio.create_task(cook("红烧肉", 2.0))    done, pending = await asyncio.wait(        [a, b], timeout=1.5, return_when=asyncio.FIRST_COMPLETED    )    for t in pending:        t.cancel()    for t in done:        print(f"先上菜: {t.result()}") async def heavy_parse() -> None:    """spawn_blocking 类比: 阻塞任务交给线程池"""    loop = asyncio.get_running_loop()    result = await loop.run_in_executor(None, lambda: "解析大 JSON 完成")    print(result) async def main() -> None:    """join 并发: 两件事一起等"""    await asyncio.gather(serve(), heavy_parse()) if __name__ == "__main__":    asyncio.run(main())
目的:用 Python asyncio 演示文章中描述的三类异步机制——await 挂起、select 竞速与取消、阻塞任务隔离,帮助理解 Rust async 的核心思想。
  1. async def cook(...)定义协程函数,调用时不执行,返回协程对象,对应 Rust 中惰性的 Future。
  2. await asyncio.sleep(delay)模拟 IO 等待,挂起当前任务并让出事件循环,对标 .await 的本质。
  3. asyncio.create_task(...)把协程包装成任务提交给事件循环,类似 tokio::spawn。
  4. asyncio.wait(..., return_when=FIRST_COMPLETED)竞速等待多个任务,先完成者胜出,对应 select!。
  5. t.cancel()取消未完成的任务,对应 async 任务取消语义。
  6. loop.run_in_executor(None, ...)把阻塞函数丢进线程池执行,类比 spawn_blocking 的隔离策略。
  7. asyncio.gather(...)并发等待多个任务全部完成,对应 join!。

常见误区

误区我以为 `async fn` 被调用后,里面的代码就开始在后台跑了,所以经常在调用后立刻去读共享变量,结果读到的还是旧值。

正解Future 是惰性的。调用 async fn 只是“写好订单”,什么都没做;必须交给执行器(spawn)或直接 await,它才会被 poll 并推进。就像你写好菜单递给后厨,厨师不照单做菜,菜永远不会自己熟。

为何常见:很多语言的 async 调用后立即执行(如 Go 的 go 关键字),初学者容易把 Rust 的惰性 Future 与它们混淆。

误区我写了个异步任务,里面调用了 `std::thread::sleep(2s)` 处理数据,以为它只阻塞自己,不影响别人。

正解worker 线程是共享的,一个任务的阻塞操作会冻结整个线程上所有其他任务。必须把 CPU 密集或同步阻塞的工作交给 spawn_blocking,就像慢炖菜不能占着炒锅。

为何常见:单线程理解根深蒂固,没意识到 async worker 是“多人共用一个灶台”,一个占住大家全饿着。

误区既然 `select!` 是“先到先得”,那分支触发顺序应该完全随机,写业务逻辑时不用管顺序。

正解默认确实做了伪随机化以避免饥饿,但可用 biased; 开启按书写顺序优先的确定性模式。且同一时刻多个分支都 Ready 时,选择结果非常依赖 poll 顺序。写超时或优先级业务时,必须明确这条语义。

为何常见:把“随机化”误解为“不可控”,没有意识到这只是默认策略,而不是语言限制。

误区`Rc` 和 `Arc` 都是引用计数指针,应该都能随便跨线程传递,我直接把 `Rc` 放进了 spawn 的任务里。

正解Rc 的计数器是非原子的,多线程并发增减计数会产生数据竞争,所以不是 Send;Arc 使用原子计数,才能跨线程。同理 RefCell 非 Send 而 Mutex 是 Send。编译器在这些类型上卡得非常死,报错正是保护。

为何常见:只看到“都是引用计数”的相似 API,忽略了内部实现是否原子这一关键差异。

误区我平时写业务代码从不手写 Pin,所以它只是进阶概念,等遇到再学也不迟。

正解虽然你不直接写 Pin,但每个带 .await 的 async 块都可能生成自引用 Future,一旦跨线程移动就会悬空。编译器报 E0277 或要求 Send 时,如果不理解 Pin,往往一脸茫然。理解它,才能读懂 poll 签名的深意。

为何常见:Pin 藏在 rust 标准库 API 签名背后,表面透明,但一旦编译冲突爆发,它就是拦路虎。

面试题

Q1. 以下哪项准确描述 Rust 中 Future 的行为?

单选题 · easy

  • A.创建 async fn 后立即开始执行
  • B.只有被 poll 才会推进执行← 正确答案
  • C.Future 自动在后台线程池运行
  • D.Future 只能运行在主线程上

答案:B。Future 是惰性订单,构造它只是“写好菜单”,必须由执行器 poll 或在 await 中驱动才会推进。选 A 是把它当成了 Go 的 goroutine;C 错在“自动”——没有执行器它就静止不动;D 没有这种限制。

Q2. tokio::spawn 返回什么类型的值?

单选题 · easy

  • A.一个新的 OS 线程对象
  • B.一个 JoinHandle 取餐凭证← 正确答案
  • C.一个 Mutex 互斥锁
  • D.一个原始 Future 本身

答案:B。tokio::spawn 把 Future 提交给执行器后返回 JoinHandle,用来等待结果或取消任务,它不会给你线程对象,也不会把你传的 Future 原样返回。

Q3. 下列哪个类型能安全传给 tokio::spawn 的任务?

单选题 · mid

  • A.Rc\<String\> 智能指针
  • B.RefCell\<String\> 盒子
  • C.Arc\<Mutex\<String\>\> 原子互斥← 正确答案
  • D.&String 临时借用

答案:C。spawn 任务要求 Send + 'static。Rc 非原子计数不是 Send,RefCell 非线程安全,&String 是借用不满足 'static;只有 Arc\<Mutex\<String\>\> 既原子安全,又拥有完整所有权。

Q4. 在 async 块中使用 std::thread::sleep 有什么风险?

单选题 · mid

  • A.编译器会报语法错误
  • B.阻塞整个 worker 线程← 正确答案
  • C.自动切到阻塞线程池
  • D.直接导致程序崩溃

答案:B。std::thread::sleep 是同步阻塞操作,占着 worker 线程不放,会让该线程上所有其他 async 任务全部停滞。应当使用 tokio::time::sleep,它是异步的,只挂起当前任务。

Q5. 关于 tokio::select! 的正确理解有哪些?

多选题 · mid

  • A.同时等待多个 Future← 正确答案
  • B.先完成的触发对应分支← 正确答案
  • C.未选中分支任务被取消← 正确答案
  • D.所有分支必须全部完成

答案:ABC。select! 是事件竞速:多个 future 并发等待,第一个 Ready 的触发分支体,其余未完成的 future 被 drop 取消。D 描述的是 join! 的语义。

Q6. Pin 主要解决了什么核心问题?

单选题 · hard

  • A.防止线程间的数据竞争
  • B.防止自引用移动后指针悬空← 正确答案
  • C.加速 Future 调度的速度
  • D.自动回收不再使用的内存

答案:B。自引用 Future 若被 move,内部指针指向旧地址形成悬空指针,造成未定义行为。Pin 像固定托盘一样保证目标不被移动,这正是 poll 签名中 Pin<&mut Self> 存在的原因。

Q7. 为什么早期 trait 里不能直接写 async fn 做多态?

单选题 · mid

  • A.返回 impl Future 非对象安全← 正确答案
  • B.编译器版本实在是太旧
  • C.async 关键字在 trait 中禁用
  • D.Future 类型无法作为返回值

答案:A。trait 方法写 async fn 会被解糖为返回 impl Future,而 impl Trait 返回类型不是对象安全类型,无法被 dyn 使用。async-trait 通过装箱成 Pin<Box<dyn Future>> 绕过这个限制,RPITIT 稳定后又逐步原生支持。

Q8. async/await 模型的最大优势是什么?

单选题 · easy

  • A.让每行代码运行得更快
  • B.用少量线程管理海量 IO 任务← 正确答案
  • C.彻底替代所有多线程编程
  • D.自动消除所有并发 bug

答案:B。async/await 的核心收益是“少线程扛大并发”:任务极轻、切换极快,一个 worker 线程挂成千上万个 Future。它并不让单条代码更快,也无法取代 CPU 密集场景下的多线程。

Q9. 关于 spawn_blocking 的正确说法有哪些?

多选题 · hard

  • A.在专用线程池运行阻塞任务← 正确答案
  • B.避免阻塞 async worker 线程← 正确答案
  • C.返回值是 JoinHandle← 正确答案
  • D.只能在主线程中使用

答案:ABC。spawn_blocking 把阻塞闭包送到独立阻塞线程池,返回 JoinHandle,可 await 获取结果。它用于隔离 CPU 密集或同步 IO,不限于主线程,且正是为了不拖累 async worker。

Q10. 自引用 Future 被 mem::swap 到新位置后会发生什么?

单选题 · extreme

  • A.内部指针自动跟随更新
  • B.悬空指针导致未定义行为← 正确答案
  • C.编译器插入代码自动修复
  • D.运行时会捕获这个异常

答案:B。自引用结构的内部指针不随 move 更新,swap 后它仍指向旧地址,访问即未定义行为。这正是编译器强制使用 Pin 的原因——阻止这类移动操作,从根源上杜绝悬空。