ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

100-exercises-to-learn-rust 第 7 章:用 Rust 无畏并发重构多线程 Ticket Store

100-exercises-to-learn-rust 第 7 章:用 Rust 无畏并发重构多线程 Ticket Store 示例工程教程【免费下载链接】100-exercises-to-learn-rustA self-paced course to learn Rust, one exercise at a time.项目地址https://gitcode.com/GitHub_Trending/10/100-exercises-to-learn-rust点击查看免费下载导读本章是《100-exercises-to-learn-rust》课程中正式迈入并发编程的转折点在此之前整个课程从基础计算器、Ticket v1/v2 到 Ticket Management的代码都是单线程的而本章将把上一章构建的 ticket store 逐步改造成一个多线程系统让你在真实项目演进中系统掌握 Rust 的线程、通道channel、共享状态与Send/Sync两大并发核心 trait并理解多种多线程设计模式各自的取舍。从无畏并发承诺到实战Rust 最著名的承诺之一是fearless concurrency无畏并发让编写安全、正确的并发程序变得更简单。这个承诺建立在一套严格的类型系统与所有权规则之上——编译器在编译期就能拦截数据竞争、悬垂引用等并发 bug而不是等到运行时崩溃。但在课程的前六个章节中你几乎没有体会到这一点所有练习都是单线程的并发的威力被完全隔离在视野之外。本章就是来改变这一现状的——第 07_threads/00_intro.md 明确指出In this chapter well make our ticket store multithreaded.这一章将触及 Rust 核心并发特性的方方面面对应仓库中的 14 个练习目录见 book/src/SUMMARY.md练习目录核心技术点01_threadsstd::thread的spawn与join02_staticstatic生命周期约束03_leak内存泄漏Box::leak与线程的关系04_scoped_threadsstd::thread::scope作用域线程05_channelsmpsc 通道与消息传递06_interior_mutability内部可变性RefCell/Cell07_ackAck确认响应模式08_client客户端封装与请求/响应通道09_bounded有界通道bounded channel与背压10_patch补丁Patch模式11_locksMutex、Send与Arc12_rw_lockRwLock读写锁13_without_channels不用通道的共享状态方案14_syncSynctrait 回顾除了上述核心原语本章还会讨论多线程系统的各种设计模式消息传递、共享状态、客户端封装等以及它们各自的权衡trade-offs——这正是从会写并发走向会设计并发系统的关键一步。练习环境如何在仓库中动手每个练习都是独立的 Cargo crate位于 exercises/07_threads/ 目录下结构与课程前几章完全一致每个子目录包含独立的Cargo.toml与src/lib.rs源码中留有todo!()占位符和详细的 TODO 注释指明练习目标与提示部分练习带tests/目录包含集成测试如 05_channels/tests/insert.rs部分练习依赖课程提供的共享 helper crate例如channels练习依赖ticket_fields见 05_channels/Cargo.toml。典型的完成流程是进入某个练习目录如cd exercises/07_threads/01_threads阅读src/lib.rs中的 TODO 注释用cargo test运行单元测试与集成测试验证实现测试通过后即可进入下一练习。线程spawn与join第一个练习 01_threads/src/lib.rs 的任务非常直观给定一个整数向量把它拆成两半在两个独立线程里分别求和再汇总结果。use std::thread; pub fn sum(v: Veci32) - i32 { // 拆分 v分别在两个线程中求和最后 join 汇总 todo!() }练习的提示里埋了两个重要伏笔测试只验证结果不验证实现方式——tests模块中的empty、one、five、nine、ten等用例01_threads/src/lib.rs只断言sum的返回值因此你完全可以靠v.iter().sum()直接蒙混过关但那样就失去了练习的意义。课程明确提醒youcouldpass this test by just returningv.iter().sum(), but that would defeat the purpose of the exercise.新线程不能直接借用原向量的切片——提示指出you wont be able to get the spawned threads toborrowslices of the vector directly. Youll need to allocate new vectors for each half. 原因就是std::thread::spawn要求闭包满足static生命周期约束新线程可能比当前作用域活得更久借用本地切片会导致悬垂引用风险因此必须为每一半分配新的堆内存所有权转移进线程。这正是下一个练习的主题。这是理解 Rust 线程模型的关键一步spawn得到的句柄JoinHandle通过join()等待线程结束并取回返回值线程与创建它的作用域之间默认不存在借用关系只能传递拥有所有权的数据。static生命周期、内存泄漏与作用域线程紧接着的两个练习探讨了spawn的static约束带来的连锁问题static与线程练习 02_static 聚焦static生命周期spawn的闭包要求捕获的所有引用都必须活得和程序一样长。这就解释了上一练习为什么要为每半向量单独分配堆内存——Veci32通过所有权转移满足static而切片引用无法做到。内存泄漏Box::leak练习 03_leak 引入Box::leak把一个值泄漏leak掉让它获得static生命周期从而可以安全地送进新线程。这是用显式泄漏换取static约束满足的一种实用技巧代价是该内存永远不会被回收。作用域线程std::thread::scope练习 04_scoped_threads/src/lib.rs 提出了更高的要求同样是拆分向量并行求和但**Dont perform any heap allocation. Dont leak any memory.**——既不许堆分配也不许泄漏内存。答案就是std::thread::scope。作用域线程允许新线程借用栈上的数据因为scope会保证所有子线程在作用域结束前全部join完毕借用关系的安全性由编译器静态确认。于是你不需要分配新向量直接让两个线程借用原Vec的两个切片即可pub fn sum(v: Veci32) - i32 { std::thread::scope(|s| { let mid v.len() / 2; let left s.spawn(|| v[..mid].iter().sum::i32()); let right s.spawn(|| v[mid..].iter().sum::i32()); left.join().unwrap() right.join().unwrap() }) }这里的演进脉络非常清晰spawn需所有权 /static→Box::leak显式泄漏换static→scope借用安全、零分配零泄漏三种方案展示了 Rust 在线程与生命周期问题上的完整光谱。消息传递用通道让 ticket store 多线程化从练习 05_channels 开始章节主线回到课程的核心项目——ticket store开始真正的多线程改造。服务器线程 命令通道核心思路是把 ticket store 的所有权移交给一个常驻的服务器线程客户端通过通道发送命令与它交互。看 05_channels/src/lib.rspub enum Command { Insert(todo!()), } // Start the system by spawning the server thread. // It returns a Sender instance which can then be used // by one or more clients to interact with the server. pub fn launch() - SenderCommand { let (sender, receiver) std::sync::mpsc::channel(); std::thread::spawn(move || server(receiver)); sender } // TODO: The server task should **never** stop. // Enter a loop: wait for a command to show up in // the channel, then execute it, then start waiting // for the next command. pub fn server(receiver: ReceiverCommand) {}关键点std::sync::mpsc::channel()创建一对(Sender, Receiver)这是 Rust 标准库的多生产者、单消费者通道mpsc multiple producer, single consumerlaunch()把Receiver连同服务器逻辑整体move进新线程对外只暴露Sender因此可以有多个客户端共享同一个Sender向服务器发命令服务器的核心是一个loopreceiver.recv()阻塞等待下一条命令处理完继续等待——这正是练习注释强调的服务器线程永远不要停的循环结构。数据与存储数据层沿用课程既有的领域模型05_channels/src/data.rs 定义了Ticket含TicketId、TicketTitle、TicketDescription、Status、TicketDraft和Status枚举ToDo/InProgress/Done存储层 05_channels/src/store.rs 用BTreeMapTicketId, Ticket加自增counter实现TicketStoreadd_ticket生成新 ID 并插入。集成测试如何验证05_channels/tests/insert.rs 的测试思路很巧妙#[test] fn a_thread_is_spawned() { let sender launch(); std::thread::sleep(Duration::from_millis(200)); sender .send(Command::Insert(TicketDraft { ... })) // If the thread is no longer running, this will panic // because the channel will be closed. .expect(Did you actually spawn a thread? The channel is closed!); }它通过先等 200ms、再向通道发送消息来验证服务器线程确实存活如果线程没有被 spawn 或提前退出Sender会被销毁、通道关闭send就会返回错误并触发expectpanic。测试还诚实地点出本练习的局限our server doesnt expose anyreadactions. We have no way to know if the inserts are actually happening——这也为后续练习引入读取能力埋下伏笔。响应通道Ack 模式与客户端封装纯发命令的单向通道有个明显短板服务器处理完命令后客户端无从得知结果。后续练习依次补齐了这条反馈链路。Ack 模式练习 07_ack 引入Ackacknowledgement确认模式每个命令不仅包含数据还携带一个响应通道。服务器处理完命令后把结果如新生成的TicketId通过响应通道回传给请求方。这是消息传递系统里最常见的请求/响应request/reply变体。Client 模式练习 08_client 把这套交互封装成客户端类型隐藏通道细节提供类似普通 API 的调用体验。到练习 11_locks/src/lib.rs 时客户端设计已经相当成熟use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError}; use std::sync::{Arc, Mutex}; #[derive(Clone)] pub struct TicketStoreClient { sender: SyncSenderCommand, } impl TicketStoreClient { pub fn insert(self, draft: TicketDraft) - ResultTicketId, OverloadedError { let (response_sender, response_receiver) sync_channel(1); self.sender .try_send(Command::Insert { draft, response_channel: response_sender }) .map_err(|_| OverloadedError)?; Ok(response_receiver.recv().unwrap()) } }这段代码展示了完整的请求/响应流程为每次调用临时创建一对同步通道把response_channel打包进命令try_send发送后立即recv()阻塞等待服务器回传结果。服务器端11_locks/src/lib.rs则在loop中match receiver.recv()分发Insert/Get两种命令处理完通过response_channel.send(...)应答当所有发送端都关闭recv返回Err时服务器安全退出——这是通道自动携带的关闭即停止信号。有界通道与背压练习 09_bounded 要求把实现改成有界通道bounded channel对应 API 是std::sync::mpsc::sync_channel(capacity)与SyncSender、TrySendError参见 09_bounded/src/lib.rs。有界通道的意义在于背压backpressure管理无界通道channel()的发送永远不会阻塞生产速度远超消费速度时消息会无限堆积最终耗尽内存有界通道sync_channel(n)的容量固定为n队列满时发送方要么阻塞send要么立即返回TrySendError错误try_send。在 11_locks 的客户端实现中try_send失败被映射为OverloadedErrorThe store is overloaded一个用thiserror派生Error的错误类型客户端据此优雅地拒绝新请求而不是无限堆积——这正是生产级系统常用的过载保护模式。共享状态Arc、Mutex、RwLock与Send/Sync消息传递之外Rust 并发的另一条主线是共享状态。本章后半部分围绕Arc、Mutex、RwLock展开并最终落到Send/Sync两大 trait 上。从拷贝返回到共享句柄早期通道版本中Get命令把Ticket整体拷贝回客户端见 09_bounded/src/lib.rs 中ResultOptionTicket, ...。练习 [11_locks] 开始改为返回共享句柄存储层 12_rw_lock/src/store.rs 中TicketStore的字段变成tickets: BTreeMapTicketId, ArcMutexTicket,add_ticket把每个Ticket包装成ArcMutexTicket后插入get直接返回OptionArcMutexTicket克隆Arc只增加引用计数不拷贝数据。客户端拿到句柄后既能读也能改同一个Ticket——如练习注释所说Getnow returns a handle to the ticket which allows the caller to both modify and read the ticket不再需要单独的 update 命令。ArcT原子引用计数让多个线程安全共享同一个堆上值的只读引用通过原子操作维护引用计数Arc本身实现了Send可跨线程传递。MutexT互斥锁提供内部可变性——即使ArcMutexT本身不可变也能通过lock()获取独占的可变访问权。Mutex保证同一时刻只有一个线程能访问内部数据从而杜绝数据竞争。RwLock读写分离练习 [12_rw_lock] 进一步引入RwLockT读写锁它允许多个读者并发读取但写者独占。对于读多写少的 ticket store 场景RwLock通常比Mutex提供更高的读并发度代价是实现更复杂、写操作可能因读者占锁而等待。课程正是通过让读者体会Mutex→RwLock的演进理解并发度 vs 复杂度的权衡。Send与Sync并发安全性的编码贯穿全章的两大 trait 是Send和Sync练习 14_sync/src/lib.rs 用一个回顾填空收尾I have a good understanding ofSend and Sync!其测试断言outro()的返回值。Send类型可被安全地转移所有权到另一个线程。几乎所有拥有所有权的类型Vec、String、Sender等都自动SendArc、Mutex也实现了Send像RcT非原子引用计数就不Send因为引用计数在多线程下会失真。Sync类型可被安全地从多个线程同时引用T可跨线程共享。T满足Sync当且仅当T: SyncMutexT、RwLockT在T满足相应约束时都是Sync的而Cell/RefCell不是。这两个 trait 是编译期并发安全的总开关编译器依据它们决定哪些类型能进出线程、能在线程间共享——这正是无畏并发的底层机制。从 11_locks/src/lib.rs 的Command::Get返回SyncSenderOptionArcMutexTicket可以看到Arc、Mutex、Sender/Receiver这些类型之所以能自由跨线程传输正是因为它们都满足Send且往往也Sync。无通道方案回到共享状态练习 [13_without_channels] 提供了一个对照视角不借助通道纯粹用共享状态Arc 锁实现同样的并发 ticket store。与消息传递方案相比消息传递职责单一、数据流清晰、天然适合一个服务器 多个客户端的模型代价是需要设计命令/响应协议有一定样板代码共享状态直接访问数据结构实现直接代价是需要精心管理锁的粒度与生命周期容易引入死锁或锁竞争。课程鼓励读者同时掌握两种范式并理解其取舍——这也是本章引言所说discuss various design patterns for multithreaded systems and some of their trade-offs的具体落点。本章小结与下一步回顾整个 07_threads 章节你完成了从单线程到多线程的完整跃迁覆盖了 Rust 并发编程的四大支柱线程std::thread::spawn/join、static约束、Box::leak、std::thread::scope作用域线程消息传递mpsc::channel、sync_channel有界通道、请求/响应与 Ack 模式、客户端封装、背压与过载保护共享状态Arc、Mutex、RwLock从拷贝返回演进到共享句柄并发安全类型系统Send与Sync如何编码什么可以跨线程移动、什么可以跨线程共享。完成本章后ticket store 已经从单线程的内存数据结构进化为具备多客户端交互能力的多线程系统。而这一切仍是异步并发async之前的前奏——下一章 08_futures 将在这个多线程基础之上引入async/await、Future 与运行时把并发推向更高的抽象层次。赞分享示例工程教程【免费下载链接】100-exercises-to-learn-rustA self-paced course to learn Rust, one exercise at a time.项目地址https://gitcode.com/GitHub_Trending/10/100-exercises-to-learn-rust点击查看免费下载相关推荐GitHub Desktop 开发工具链与调试指南编辑器配置、构建流程与 DevTools 实战GitHub Desktop 开发工具链与调试指南编辑器配置、构建流程与 DevTools 实战 本篇指南面向 GitHub Desktop 开源仓库的贡献者示例工程教程effect-smol结构化日志完全指南Logger配置与级别过滤effect smol结构化日志完全指南Logger配置与级别过滤 在 effect smol 中做 结构化日志 核心就是两件事用 Logger 模块配置示例工程教程Linux Housekeeping 机制全解析CPU 隔离、RCU 同步与 cpumask 管理Linux Housekeeping 机制全解析CPU 隔离、RCU 同步与 cpumask 管理 Housekeeping 是 Linux 内核 CPU 隔示例工程教程上一篇Anki间隔重复记忆工具掌握科学学习的终极指南下一篇VLC播放器美化完全指南5款VeLoCity皮肤让你的播放器焕然一新创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

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