comprehensive-rust 异步并发实战:用 Tokio 实现哲学家就餐问题(Dining Philosophers)
2026/9/10 18:30:47 网站建设 项目流程

comprehensive-rust 异步并发实战:用 Tokio 实现哲学家就餐问题(Dining Philosophers)

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

哲学家就餐(Dining Philosophers)是并发编程领域最经典的同步问题之一。Google Android 团队维护的 Rust 培训课程 comprehensive-rust 在「异步并发」阶段用它作为压轴练习,要求读者用 Tokio 的async/await生态重写一遍经典解法。本文以 src/concurrency/async-exercises/dining-philosophers.md 为骨架,结合仓库内的完整参考实现与相关课程章节,逐段拆解练习目标、Cargo.toml配置、参考实现、死锁成因与单线程化挑战。读完本文,你将掌握tokio::sync::Mutextokio::sync::mpsctokio::spawn#[tokio::main]的组合用法,并能独立分析「跨.await持有锁」与「对称破缺」这两个异步并发中的关键问题。

练习背景:经典哲学家就餐问题

该练习位于课程 src/concurrency/async-exercises/ 章节,是异步并发课程中「Async Exercises」板块的第一个练习(建议时长 20 分钟)。问题本身的描述在同步版本的 sync-exercises/dining-philosophers.md 中有完整阐述:

五位哲学家同桌进餐。每位哲学家在桌边有固定位置,每两个餐盘之间放着一根筷子。上桌的菜是意大利面,需要两根筷子才能吃。每位哲学家只能交替地思考与进食,而且只有同时拿到左右两根筷子才能开吃。也就是说,只有当相邻的两位邻居都在思考(而非进食)时,一位哲学家才可能凑齐两根筷子。吃完后,哲学家会把两根筷子都放下。

这是一个经典的资源竞争与死锁演练场景:哲学家需要同时持有两个资源才能完成操作(进食),而资源(筷子)是互斥共享的,且存在循环等待的可能。课程的同步练习已经用它展示了std::sync::{Arc, Mutex, mpsc}std::thread的用法(参考 sync-exercises/dining-philosophers.rs);本练习则要求用Async Rust + Tokio重写一遍,重点体会异步生态下同步原语的差异。

练习目标非常明确:把下面这份带空缺的代码复制到本地项目的src/main.rs填上空缺,并验证cargo run不会死锁。所有空白需要你补全的正是异步并发课上学过的核心概念:async/await语法(见 src/concurrency/async/async-await.md)、共享状态(ArcMutex,见 src/concurrency/shared-state/arc.md 与 src/concurrency/shared-state/mutex.md)、异步通道(见 src/concurrency/async-control-flow/channels.md)。

环境准备:本地 Cargo 安装与项目初始化

练习要求使用本地 Cargo 环境运行(无法在线编译),这与课程「Running Code Locally with Cargo」一节的指引一致(见 src/cargo/running-locally.md):

  1. 安装 Rust 工具链(rustc+cargo)。课程文档写于 Rust 1.69 时代,但由于 Rust 向后兼容,任何更新的稳定版本都可以使用。
  2. cargo new dining-philosophers-async-dine创建新项目(或直接新建Cargo.tomlsrc/main.rs)。
  3. 将练习代码复制到src/main.rs,填好空缺后运行cargo run验证。
  4. 开发过程中可用cargo check快速检查编译错误,用cargo build --release生成优化版本。

配置 Cargo.toml:tokio 依赖与特性解析

与同步版本不同(同步版只需要空的[dependencies],见 sync-exercises/dining-philosophers.md),异步版必须引入tokio运行时。练习文档给出了可直接使用的Cargo.toml

[package] name = "dining-philosophers-async-dine" version = "0.1.0" edition = "2024" [dependencies] tokio = { version = "1.26.0", features = ["sync", "time", "macros", "rt-multi-thread"] }

这个练习只用到 tokio 的四个 feature,每个都在代码中有对应落点,可以结合参考实现逐一对应:

Feature本练习中的用途对应参考实现位置
sync提供tokio::sync::Mutextokio::sync::mpsc通道use tokio::sync::{Mutex, mpsc};
time提供tokio::time::sleep,模拟进食耗时且不阻塞线程time::sleep(time::Duration::from_millis(5)).await
macros提供#[tokio::main]属性宏,将普通fn main变成异步运行时入口#[tokio::main] async fn main()
rt-multi-thread启用默认的多线程工作窃取(work-stealing)运行时练习运行时的默认 flavor

值得一提的细节是:练习文档强调「这次你必须使用tokio crate 中的Mutexmpsc模块」。这不是为了换 API 而换 API——异步代码中锁与通道的行为和同步版本有本质区别,这也是本练习的核心教学点,详见下文「核心 API 深度解读」一节。

顺带一提,仓库中同一章节的进阶练习 chat-async/Cargo.toml 使用了tokio = { version = "1.52.3", features = ["full"] }(全量特性),并额外引入futures-utilhttptokio-websockets来实现广播聊天应用——说明 tokio 特性可按需裁剪,从最小集["sync", "time", "macros", "rt-multi-thread"]"full"都可以。

练习骨架:需要补全的四处空缺

练习文档通过 mdbook 的 ANCHOR 锚点机制,从源码文件 dining-philosophers.rs 中抽取代码片段拼出带空缺的骨架(因此代码块标注为compile_fail,因为空缺处无法通过编译)。骨架共四处需要补全:

  1. Philosopher结构体字段
struct Philosopher { name: String, // left_chopstick: ... // right_chopstick: ... // thoughts: ... }
  1. think方法(骨架已给出方法签名,需要填实现):
impl Philosopher { async fn think(&self) { // ... }
  1. eat方法(骨架给出async fn eat(&self)开头与结尾,中间「Pick up chopsticks...」需要填):
async fn eat(&self) { // Pick up chopsticks... } }
  1. main函数:需要依次实现「创建筷子」「创建哲学家」「让哲学家思考并进食」「输出他们的想法」四步。

注意同步版骨架中eat需要「拿起筷子」,而异步版骨架则把eat方法体整个留空(Philosopher-eat锚点只包含方法签名),难度略高于同步版——这暗示异步版的锁获取方式(.lock().await)本身就是考察点之一。

参考实现:完整代码与逐段精解

参考答案位于 dining-philosophers.rs(以// ANCHOR: solution标注,同时在 solutions.md 中以{{#include dining-philosophers.rs:solution}}形式呈现)。完整实现如下:

use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; use tokio::time; struct Chopstick; struct Philosopher { name: String, left_chopstick: Arc<Mutex<Chopstick>>, right_chopstick: Arc<Mutex<Chopstick>>, thoughts: mpsc::Sender<String>, } impl Philosopher { async fn think(&self) { self.thoughts .send(format!("Eureka! {} has a new idea!", &self.name)) .await .unwrap(); } async fn eat(&self) { // Pick up chopsticks... let _left_chopstick = self.left_chopstick.lock().await; let _right_chopstick = self.right_chopstick.lock().await; println!("{} is eating...", &self.name); time::sleep(time::Duration::from_millis(5)).await; // The locks are dropped here } } // tokio scheduler doesn't deadlock with 5 philosophers, so have 2. static PHILOSOPHERS: &[&str] = &["Socrates", "Hypatia"]; #[tokio::main] async fn main() { // Create chopsticks let mut chopsticks = vec![]; PHILOSOPHERS .iter() .for_each(|_| chopsticks.push(Arc::new(Mutex::new(Chopstick)))); // Create philosophers let (philosophers, mut rx) = { let mut philosophers = vec![]; let (tx, rx) = mpsc::channel(10); for (i, name) in PHILOSOPHERS.iter().enumerate() { let mut left_chopstick = Arc::clone(&chopsticks[i]); let mut right_chopstick = Arc::clone(&chopsticks[(i + 1) % PHILOSOPHERS.len()]); if i == PHILOSOPHERS.len() - 1 { std::mem::swap(&mut left_chopstick, &mut right_chopstick); } philosophers.push(Philosopher { name: name.to_string(), left_chopstick, right_chopstick, thoughts: tx.clone(), }); } (philosophers, rx) // tx is dropped here, so we don't need to explicitly drop it later }; // Make them think and eat for phil in philosophers { tokio::spawn(async move { for _ in 0..100 { phil.think().await; phil.eat().await; } }); } // Output their thoughts while let Some(thought) = rx.recv().await { println!("Here is a thought: {thought}"); } }

下面逐段解读这个参考实现。

Philosopher 结构体:异步资源的三要素

struct Philosopher { name: String, left_chopstick: Arc<Mutex<Chopstick>>, right_chopstick: Arc<Mutex<Chopstick>>, thoughts: mpsc::Sender<String>, }
  • Arc<Mutex<Chopstick>>:每根筷子被多个哲学家共享(邻居之间共享一根筷子),因此需要Arc引用计数共享所有权;Mutex保证同一时刻只有一位哲学家能拿到某根筷子。ArcMutex的组合正是课程「Shared State」一节的经典用法。
  • mpsc::Sender<String>:这是本练习与同步版最关键的区别之一——同步版使用std::sync::mpsc::SyncSender<String>(同步通道的发送端),而这里必须使用tokio::sync::mpsc::Sender。哲学家通过发送端把想法(如"Eureka! Socrates has a new idea!")发往主任务,主任务在main中接收并打印。

Chopstick是一个空结构体(unit struct),仅作为「资源」的占位符,本身不携带数据——重点在于它的独占访问权Mutex保护。

think:异步发送消息

async fn think(&self) { self.thoughts .send(format!("Eureka! {} has a new idea!", &self.name)) .await .unwrap(); }

tokio::sync::mpsc::Sender::send异步方法,需要.await:当通道缓冲已满时,发送方会挂起等待,直到接收方腾出空间。.unwrap()处理接收端被关闭(哲学家任务结束时Sender被 drop)的错误情况。

eat:跨 await 持有两把锁

async fn eat(&self) { // Pick up chopsticks... let _left_chopstick = self.left_chopstick.lock().await; let _right_chopstick = self.right_chopstick.lock().await; println!("{} is eating...", &self.name); time::sleep(time::Duration::from_millis(5)).await; // The locks are dropped here }

这是全篇最值得细读的函数:

  • 两把锁通过tokio::sync::Mutex::lock().await异步获取
  • 锁守卫(guard)被刻意命名为带下划线的_left_chopstick/_right_chopstick,并在time::sleep(...).await期间继续持有——守卫的生命周期直到函数末尾作用域结束才结束,注释「The locks are dropped here」明确点出这一点;
  • 这正是异步版必须使用tokio::sync::Mutex的根本原因:它允许锁守卫跨越.await点持有(详见下一节)。

main:四条工作流

main#[tokio::main]标记为异步入口,内部按练习骨架的四步组织:

  1. 创建筷子PHILOSOPHERS.iter().for_each(...)为每位哲学家分配一根Arc<Mutex<Chopstick>>。注意这是「每位哲学家一根」的简化:索引i的哲学家其右筷子是(i + 1) % N那根,从而在环上形成 N 根筷子被 N 位哲学家两两共享的拓扑。

  2. 创建哲学家mpsc::channel(10)创建容量为 10 的异步有界通道;每位哲学家持有tx.clone()(发送端克隆)。这里有一个值得留意的细节:tx在代码块结束处被 drop,注释明确说明「tx is dropped here, so we don't need to explicitly drop it later」——因为所有哲学家任务结束时其克隆的发送端也会被 drop,主任务while let Some(thought) = rx.recv().await循环会在所有发送端关闭后自然终止,这也是为什么程序最终能正常退出而非永远阻塞在接收端。

  3. 让哲学家思考并进食tokio::spawn为每位哲学家创建一个异步任务,每个任务循环 100 轮「思考 → 进食」。tokio::spawn要求闭包返回的 future 是Send + 'static,这再次说明Philosopher中所有字段(包括tokio::sync::Mutex守卫)都必须支持跨任务发送。

  4. 输出想法:主任务循环rx.recv().await,把每位哲学家发出的想法逐条打印为Here is a thought: ...

运行后程序会交错输出类似Socrates is eating...Here is a thought: Eureka! Hypatia has a new idea!的内容,最终在 100 轮结束后所有任务完成、通道关闭、程序退出——全程不死锁。

核心 API 深度解读:异步同步原语与同步版的差异

tokio::sync::Mutex vs std::sync::Mutex

这是本练习最重要的知识点,也是练习文档特意强调「必须用 tokio 的 Mutex」的原因。两者的关键差异可以从参考实现中直接观察到:

  • 锁获取方式是异步的:tokio 版lock()返回 future,必须.await;std 版lock()是阻塞调用,返回LockResult<MutexGuard>
  • 能否跨.await持有守卫tokio::sync::Mutex的守卫可以安全地跨.await持有(如参考实现中持有两把锁的同时sleep(...).await)。而std::sync::MutexGuard不能.await持有——一旦守卫被跨 await 借用,整个 future 就不再是Send,无法通过tokio::spawn编译(tokio::spawn要求 future 为Send)。这是异步编程中非常经典的「编译期就能抓出来」的错误。
  • 错误模型不同std::sync::Mutex存在「中毒」(poisoning)机制——持锁线程 panic 后,后续lock()返回PoisonError(课程 src/concurrency/shared-state/mutex.md 有专门讲解);tokio::sync::Mutex不采用中毒模型,其lock()Result错误类型是AcquireError,只在通道被close()关闭后才会出现。

课程 src/concurrency/shared-state/mutex.md 中「Mutex 看起来像一个只含一个元素的集合」的比喻同样适用于异步版:&Mutex<T>上调用.lock().await即可获得&mut T的访问权,守卫保证该可变引用不会超过锁的持有期。

tokio::sync::mpsc vs std::sync::mpsc

  • 异步发送tokio::sync::mpsc::Sender::send(...).await在通道满时挂起等待而非阻塞线程;std 版要么用阻塞的send(有界sync_channel),要么用不阻塞的try_send
  • Receiver 的接收方式:tokio 版rx.recv().await同样返回 future;std 版rx.recv()是阻塞调用。本练习main中的while let Some(thought) = rx.recv().await正是 tokio mpsc 的标准消费模式,与课程 async-control-flow/channels.md 中 ping 处理器的while let Some(...) = input.recv().await写法一致。
  • 容量与背压mpsc::channel(10)创建容量为 10 的有界通道,配合异步send构成天然背压;同步版使用sync_channel(10)原理类似。

课程文档还指出,异步通道的额外优势在于可以与其他 future 组合(select!join!等)构造复杂控制流——这是本练习之后「Async Control Flow」与「Async Pitfalls」章节的内容。

time::sleep:异步休眠的关键

参考实现用time::sleep(time::Duration::from_millis(5)).await模拟进食耗时。与std::thread::sleep不同,tokio::time::sleep(...).await不会阻塞线程——当前任务挂起并把线程让给运行时上的其他任务,这正是异步并发「少量线程驱动大量任务」的基础。可以对比同步版(sync-exercises/dining-philosophers.rs)中thread::sleep(Duration::from_millis(10))的阻塞行为。

tokio::spawn 与 #[tokio::main]

  • tokio::spawn(async move { ... })把闭包中的 future 提交到运行时上独立调度,等价于线程版中的thread::spawn(move || ...)(见同步版参考实现)。由于#[tokio::main]默认启用rt-multi-thread,多个任务会被工作窃取调度器分散到多个 OS 线程上执行。
  • #[tokio::main]宏(依赖macrosfeature)把普通fn main改写为「创建运行时并 block_on 异步 main」的样板,对应课程 async/async-await.md 中「不能直接把main变成 async、需要一个 executor 来运行 future」的讲解。

死锁分析与对称破缺

为什么朴素实现会死锁

假设哲学家按固定顺序「先拿左筷、再拿右筷」:

  • 若有 5 位哲学家(同步版场景),每位哲学家都成功拿到左筷后,环上每一根筷子都被占用,所有人都在等待右筷——形成循环等待:哲学家 0 等哲学家 1 的筷子,哲学家 1 等哲学家 2 的筷子……哲学家 4 等哲学家 0 的筷子,谁也无法前进,程序永久挂起。
  • 课程在同步练习的讲解(sync-exercises/dining-philosophers.md 的<details>部分)明确指出:最朴素方案中的死锁是一般性的并发问题,Rust 不会自动阻止这类逻辑错误。这个练习的价值正在于此——编译器能保证内存安全,却无法替你设计资源获取顺序。

对称破缺:最后一位哲学家交换筷子

参考实现中的修复是经典的「对称破缺」技巧:

let mut left_chopstick = Arc::clone(&chopsticks[i]); let mut right_chopstick = Arc::clone(&chopsticks[(i + 1) % PHILOSOPHERS.len()]); if i == PHILOSOPHERS.len() - 1 { std::mem::swap(&mut left_chopstick, &mut right_chopstick); }

对于最后一位哲学家(i == N - 1),用std::mem::swap交换左右筷子的引用(注释强调该函数「不会反初始化其中任何一个」)。这样一来,最后一位哲学家与其他哲学家按相同的全局顺序(索引 0 → 索引 1 → …)获取筷子,环上不再存在「循环等待」,死锁被从根源上消除。同步版参考实现(sync-exercises/dining-philosophers.rs 第 72-77 行)使用了完全相同的技巧。

为什么异步版只有 2 位哲学家

参考实现中有一个值得注意的细节,源码注释原文是:

// tokio scheduler doesn't deadlock with 5 philosophers, so have 2. static PHILOSOPHERS: &[&str] = &["Socrates", "Hypatia"];

即异步版刻意只保留Socrates 与 Hypatia 两位哲学家,而同步版使用了 5 位(Socrates、Hypatia、Plato、Aristotle、Pythagoras)。原因是:Tokio 的调度器在 5 位哲学家场景下并不会复现死锁(多线程工作窃取 + 协作式调度的交错方式使得「全部拿到一把筷子」的极端状态难以出现),为了让练习能真实检验死锁问题,改为 2 位哲学家——此时两位哲学家都需要同时持有两根筷子,一旦获取顺序不当,死锁状态一触即发,修复前后的对比更加清晰。

挑战:你的实现可以单线程化吗

练习文档的<details>部分提出了一个思考题:「Can you make your implementation single-threaded?(你的实现能否单线程化?)」

答案是肯定的。#[tokio::main]宏支持指定运行时 flavor,将其改为#[tokio::main(flavor = "current_thread")]即可让整个程序运行在单线程的当前线程运行时上。这一挑战的深层含义是:

  • 即使只有一个线程,异步任务依然会在每个.await点交错执行,因此Mutexmpsc依然不可或缺——单线程化不等于「不需要同步」;
  • 死锁风险来自锁的获取顺序而非线程数量:即便单线程运行,若两个哲学家按相反顺序拿筷子,双方都会在第二个.lock().await处挂起等待,而调度器没有任何其他任务可以推进(没有任何任务持有资源释放的机会),程序依然会死锁。保留「对称破缺」的筷子交换逻辑,单线程版本同样可以正常运行。

验证运行与课程后续

把补全后的代码放入src/main.rs并运行cargo run,程序应在打印交错出现的X is eating...Here is a thought: Eureka! ...后正常退出(退出码 0),即验证「不死锁」。若删除筷子交换逻辑再运行,程序会挂起不动——这就是死锁的直接症状。

参考答案的完整形态可以在 dining-philosophers.rs(// ANCHOR: solution起)与 solutions.md 中对照查阅。

完成本练习后,课程「Async Exercises」章节还有进阶任务:chat-async广播聊天应用(服务器端与客户端实现,见 solutions.md 与 chat-async/),它进一步融合了tokio-websocketsfutures-util与多任务协作。若想深入异步陷阱(任务取消、阻塞 executor、Pin等),可继续学习 src/concurrency/async-pitfalls/ 章节——哲学家就餐练习中「跨 await 持有锁」的经历,正是理解这些陷阱的最佳铺垫。

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询