ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

comprehensive-rust 异步并发实战:用 Tokio 实现哲学家就餐问题(Dining Philosophers) 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::Mutex、tokio::sync::mpsc、tokio::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、共享状态Arc与Mutex见 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安装 Rust 工具链rustccargo。课程文档写于 Rust 1.69 时代但由于 Rust 向后兼容任何更新的稳定版本都可以使用。用cargo new dining-philosophers-async-dine创建新项目或直接新建Cargo.toml与src/main.rs。将练习代码复制到src/main.rs填好空缺后运行cargo run验证。开发过程中可用cargo check快速检查编译错误用cargo build --release生成优化版本。配置 Cargo.tomltokio 依赖与特性解析与同步版本不同同步版只需要空的[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::Mutex与tokio::sync::mpsc通道use tokio::sync::{Mutex, mpsc};time提供tokio::time::sleep模拟进食耗时且不阻塞线程time::sleep(time::Duration::from_millis(5)).awaitmacros提供#[tokio::main]属性宏将普通fn main变成异步运行时入口#[tokio::main] async fn main()rt-multi-thread启用默认的多线程工作窃取work-stealing运行时练习运行时的默认 flavor值得一提的细节是练习文档强调「这次你必须使用tokio crate 中的Mutex和mpsc模块」。这不是为了换 API 而换 API——异步代码中锁与通道的行为和同步版本有本质区别这也是本练习的核心教学点详见下文「核心 API 深度解读」一节。顺带一提仓库中同一章节的进阶练习 chat-async/Cargo.toml 使用了tokio { version 1.52.3, features [full] }全量特性并额外引入futures-util、http、tokio-websockets来实现广播聊天应用——说明 tokio 特性可按需裁剪从最小集[sync, time, macros, rt-multi-thread]到full都可以。练习骨架需要补全的四处空缺练习文档通过 mdbook 的 ANCHOR 锚点机制从源码文件 dining-philosophers.rs 中抽取代码片段拼出带空缺的骨架因此代码块标注为compile_fail因为空缺处无法通过编译。骨架共四处需要补全Philosopher结构体字段struct Philosopher { name: String, // left_chopstick: ... // right_chopstick: ... // thoughts: ... }think方法骨架已给出方法签名需要填实现impl Philosopher { async fn think(self) { // ... }eat方法骨架给出async fn eat(self)开头与结尾中间「Pick up chopsticks...」需要填async fn eat(self) { // Pick up chopsticks... } }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: ArcMutexChopstick, right_chopstick: ArcMutexChopstick, thoughts: mpsc::SenderString, } 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 doesnt 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 dont 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: ArcMutexChopstick, right_chopstick: ArcMutexChopstick, thoughts: mpsc::SenderString, }ArcMutexChopstick每根筷子被多个哲学家共享邻居之间共享一根筷子因此需要Arc引用计数共享所有权Mutex保证同一时刻只有一位哲学家能拿到某根筷子。Arc与Mutex的组合正是课程「Shared State」一节的经典用法。mpsc::SenderString这是本练习与同步版最关键的区别之一——同步版使用std::sync::mpsc::SyncSenderString同步通道的发送端而这里必须使用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]标记为异步入口内部按练习骨架的四步组织创建筷子PHILOSOPHERS.iter().for_each(...)为每位哲学家分配一根ArcMutexChopstick。注意这是「每位哲学家一根」的简化索引i的哲学家其右筷子是(i 1) % N那根从而在环上形成 N 根筷子被 N 位哲学家两两共享的拓扑。创建哲学家mpsc::channel(10)创建容量为 10 的异步有界通道每位哲学家持有tx.clone()发送端克隆。这里有一个值得留意的细节tx在代码块结束处被 drop注释明确说明「tx is dropped here, so we dont need to explicitly drop it later」——因为所有哲学家任务结束时其克隆的发送端也会被 drop主任务while let Some(thought) rx.recv().await循环会在所有发送端关闭后自然终止这也是为什么程序最终能正常退出而非永远阻塞在接收端。让哲学家思考并进食tokio::spawn为每位哲学家创建一个异步任务每个任务循环 100 轮「思考 → 进食」。tokio::spawn要求闭包返回的 future 是Send static这再次说明Philosopher中所有字段包括tokio::sync::Mutex守卫都必须支持跨任务发送。输出想法主任务循环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必须.awaitstd 版lock()是阻塞调用返回LockResultMutexGuard。能否跨.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 看起来像一个只含一个元素的集合」的比喻同样适用于异步版MutexT上调用.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同样返回 futurestd 版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 doesnt 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点交错执行因此Mutex与mpsc依然不可或缺——单线程化不等于「不需要同步」死锁风险来自锁的获取顺序而非线程数量即便单线程运行若两个哲学家按相反顺序拿筷子双方都会在第二个.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-websockets、futures-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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进