示例工程教程【免费下载链接】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》的 07_threads 章节中基于 actor 模型服务端线程 通道通信的TicketStore在引入更新操作后暴露出严重的竞态条件问题。本文以book/src/07_threads/11_locks.md为核心系统讲解如何从「补丁patch策略」演进到「锁lock策略」先剖析竞态根源与版本号方案乐观并发控制再深入MutexT的用法与锁粒度设计并重点解析为什么MutexGuard不能跨线程发送、Send标记 trait 的语义以及最终用ArcMutexT构建「可克隆、可发送、可加锁」的共享句柄的完整方案。读完本文你将掌握 Rust 标准库中Mutex、Send、Arc三个并发核心概念的配合方式并能看懂并完成exercises/07_threads/11_locks这个练习。背景补丁策略的竞态缺陷在前一练习见 book/src/07_threads/10_patch.md中我们通过向服务端发送TicketPatch来实现票务更新客户端无法把mut Ticket跨线程发送不满足static约束于是改为告诉服务端「要改什么」由服务端线程在持有mut TicketStore的情况下完成修改。但这种策略存在一个致命缺陷它是 racy 的存在竞态。如果两个客户端几乎同时为同一张票发送补丁服务端会以任意顺序应用它们——谁最后入队谁就覆盖另一方的修改。两个客户端各自基于自己看到的旧状态做出修改后应用的补丁会把先应用的补丁改掉导致更新的丢失lost update。这其实是典型的「检查与使用之间存在时间窗口」问题两个客户端都先读取了状态version 0都基于该状态构造补丁而服务端无法分辨哪个补丁基于的是最新状态。方案一版本号与乐观并发控制文档给出的第一个修复思路是引入版本号version number每张票在创建时分配一个版本号初始为0客户端发送补丁时必须同时携带当前票的版本号与期望的变更服务端仅当补丁携带的版本号与自身存储的版本号一致时才应用该补丁每应用一次补丁版本号递增。回到上述竞态场景第一个补丁被应用后版本号从0变成1第二个客户端补丁携带的版本号仍是0服务端因此拒绝它。这种方案在分布式系统中非常常见尤其是客户端与服务端不共享内存的场景学名叫做乐观并发控制optimistic concurrency control。其核心假设是大多数时候冲突不会发生所以我们可以针对常见情况优化——不做加锁、不加同步只在提交时校验版本号。乐观并发控制的特点是「冲突后失败、由客户端重试」实现代价低但在高冲突场景下会频繁失败。由于这个练习的目标是讲解锁文档明确说明基于你已经掌握的 Rust 知识完全可以把它作为**加分练习bonus exercise**自行实现。方案二引入锁悲观并发控制与版本号方案相对的思路是加锁lock客户端要更新某张票之前必须先获取该票的锁锁激活期间其他客户端无法修改这张票。这是典型的悲观并发控制——提前阻止并发访问冲突根本不会发生。Rust 标准库提供了两种锁原语MutexT与RwLockT。本文先从MutexT讲起。MutexT互斥锁Mutex是mutualexclusion互斥的缩写是最简单的一种锁无论读还是写同一时刻只允许一个线程访问被保护的数据。关键设计MutexT**包裹wrap**它保护的数据因此对数据类型T是泛型的你无法直接访问被包裹的数据——类型系统强制你先用Mutex::lock或Mutex::try_lock获取锁lock会阻塞直到成功获取锁try_lock若当前无法获取锁则立即返回错误不会阻塞两个方法都返回一个守卫对象guard守卫通过Deref解引用到数据本身因此可以像操作mut T一样修改数据锁在守卫被 drop 时释放——无论是显式drop(guard)还是守卫离开作用域自然析构。文档中的最小示例use std::sync::Mutex; // An integer protected by a mutex lock let lock Mutex::new(0); // Acquire a lock on the mutex let mut guard lock.lock().unwrap(); // Modify the data through the guard, // leveraging its Deref implementation *guard 1; // The lock is released when data goes out of scope // This can be done explicitly by dropping the guard // or happen implicitly when the guard goes out of scope drop(guard)值得注意的细节lock().unwrap()中的unwrap处理的是PoisonError情形——如果持锁线程在持有锁期间 panicMutex会被标记为「中毒poisoned」后续lock返回Errunwrap会直接 panic。在示例这类简单场景中可以接受生产代码中通常需要更细致的错误处理。锁粒度粗粒度 vs 细粒度下一个设计问题是我们的Mutex应该包裹什么最简方案粗粒度锁把整个TicketStore包进一个Mutex。它一定能工作但会严重限制系统性能任何一次读都必须等待锁释放无法并行读取多张票。这就是粗粒度锁定coarse-grained locking——锁的保护范围大、并发度低实现简单但吞吐量受限。更优方案细粒度锁让每张票拥有自己的锁。这样只要客户端访问的不是同一张票就可以并行操作系统的并发能力显著提升。这就是细粒度锁定fine-grained locking。文档给出的新结构// The new structure, with a lock for each ticket struct TicketStore { tickets: BTreeMapTicketId, MutexTicket, }细粒度方案更高效但也有代价TicketStore必须开始感知系统的多线程本质——此前它一直「天真地」忽略线程的存在非线程版本的get_mut直接返回mut Ticket见 book/src/07_threads/10_patch.md。不过文档明确表态我们决定采用细粒度方案。谁持有锁跨线程传递的难题加锁方案要成立锁必须能到达想要修改票的客户端手中客户端拿到锁后可以像持有mut Ticket一样直接修改票改完释放锁。问题随之而来锁怎么送到客户端Mutex不是Clone的而且不能把它从TicketStore中 move 出来那能否直接把MutexGuard通过通道发给客户端文档用一个小实验验证了「发送MutexGuard」的可行性use std::thread::spawn; use std::sync::Mutex; use std::sync::mpsc::sync_channel; fn main() { let lock Mutex::new(0); let (sender, receiver) sync_channel(1); let guard lock.lock().unwrap(); spawn(move || { receiver.recv().unwrap(); }); // Try to send the guard over the channel // to another thread sender.send(guard); }编译器立刻报错error[E0277]: MutexGuard_, i32 cannot be sent between threads safely -- src/main.rs:10:7 | 10 | spawn(move || { | _-----_^ | | | | | required by a bound introduced by this call 11 | | receiver.recv().unwrap(); 12 | | }); | |_^ MutexGuard_, i32 cannot be sent between threads safely | help: the trait Send is not implemented for MutexGuard_, i32, which is required by {closuresrc/main.rs:10:7: 10:14}: Send note: required for ReceiverMutexGuard_, i32 to implement Send note: required because its used within this closure错误信息的核心MutexGuard_, i32未实现Send而std::thread::spawn要求闭包是Send闭包捕获的Receiver要Send又要求其元素类型MutexGuard是Send。这引出了本节的主题——Send到底是什么深入理解Send标记 traitSend是一个标记 traitmarker trait它表示一个类型的值可以安全地从当前线程转移到另一个线程。Send是自动 trait与课程中遇到过的Sized一样Send也是一个auto-trait自动 trait编译器根据类型定义自动决定它是否实现Send无需开发者手动标注。如果一个类型的所有字段都是Send那么这个类型自动就是Send反之只要任何一个字段不是Send整个类型就不是Send。你也可以手动实现Send但这需要unsafe关键字——因为一旦手动声明你就必须向编译器保证「这个类型在线程间转移是安全的」而编译器无法自动验证这种安全性责任完全在开发者身上。这是 Rust 中典型「unsafe 换取能力、约束换成责任」的设计。通道类型对Send的要求SenderT、SyncSenderT和ReceiverT当且仅当T是Send时才是Send。原因很直接这些类型的职责就是在线程间搬运值如果值本身不能在线程间安全转移那用通道搬运它就是不安全的。ReceiverT被spawn的闭包捕获时要求Send于是T MutexGuard也必须Send——这就是上面编译错误产生的完整链条。为什么MutexGuard不是SendMutexGuard不是Send的根本原因在于底层实现Mutex依赖的操作系统同步原语在某些平台上要求「解锁必须由加锁的同一线程执行」。如果允许把MutexGuard发送到另一个线程锁就会由不同线程释放这属于未定义行为undefined behavior。Rust 通过不给MutexGuard实现Send从类型层面彻底堵死了这种误用。我们的困境总结当前的矛盾不能把MutexGuard通过通道发送——因此无法「服务端加锁、客户端改票」可以把Mutex通过通道发送——只要被保护的数据是Send即可Ticket恰好满足但与此同时我们既不能把Mutex从TicketStore中移出也不能克隆它Mutex不是Clone。看似无解文档给出的突破口是换个角度看问题要锁住一个Mutex并不需要拥有它——一个共享引用就够了。因为Mutex内部采用内部可变性interior mutability其lock方法签名是self而非selfimplT MutexT { // self, not self! pub fn lock(self) - LockResultMutexGuard_, T { // Implementation details } }所以理论上只要把「对Mutex的共享引用」发给客户端即可。但直接发引用行不通引用必须满足static生命周期才能被spawn的闭包捕获而这里的引用显然不满足。我们需要一种「拥有所有权的共享引用owned shared reference」。Rust 标准库里恰好有这种类型——Arc。Arc登场原子引用计数Arc是atomic reference counting原子引用计数的缩写。工作方式Arc包裹一个值并跟踪指向该值的引用数量每当Arc被克隆引用计数 1当最后一个引用被 drop计数归零堆上的值被释放deallocate被Arc包裹的值是不可变的你只能获得对它的共享引用。基本用法use std::sync::Arc; let data: Arcu32 Arc::new(0); let data_clone Arc::clone(data); // ArcT implements DerefT, so can convert // a ArcT to a T using deref coercion let data_ref: u32 data;Arc与Rc的区别线程安全如果你有「似曾相识」的感觉那是对的Arc听起来很像课程在讨论内部可变性时介绍过的Rc引用计数指针。两者的唯一关键区别是线程安全Rc不是SendArc是Send。差异的根源在于引用计数的实现方式Rc用普通的整数plain integer维护计数非原子操作在多线程下会产生数据竞争而Arc用原子整数atomic integer维护计数原子操作可以安全地跨线程共享与修改。正是这一点让Arc可以在线程间自由克隆与传递。ArcMutexT最终组合把Arc和Mutex配对我们终于得到一个满足全部需求的目标类型ArcMutexTicket可以跨线程发送因为Arc在T是Send时是SendMutex在T是Send时是Send而T Ticket恰好是Send其字段TicketId、TicketTitle、TicketDescription、Status均为简单值类型或可安全转移的类型见 exercises/07_threads/11_locks/src/data.rs可以克隆因为Arc无论T是什么都实现Clone。克隆Arc只是让引用计数 1数据本身不会被拷贝可以用来修改数据因为Arc允许你获得MutexT共享引用进而调用lock()获取写权限。三个能力叠加ArcMutexTicket就是我们要找的「可发送、可克隆、可加锁」的共享票务句柄。源码实现锁策略落地的完整证据TicketStore的结构变更练习exercises/07_threads/11_locks中的 store.rs 展示了最终结构——文档中的MutexTicket被升级为ArcMutexTicket#[derive(Clone)] pub struct TicketStore { tickets: BTreeMapTicketId, ArcMutexTicket, counter: u64, }注意TicketStore本身派生Clone克隆它只是复制Arc句柄的集合代价很低且add_ticket的签名仍为mut self——服务端线程持有TicketStore的所有权插入操作依然独占进行。add_ticket中预留的todo!()正是需要练习者补全的位置把新建的Ticket包进ArcMutex_并插入BTreeMap。通道中传递的不再是Ticket而是句柄对比 10_patch 的 lib.rsCommand::Get返回OptionTicket与 11_locks 的 lib.rs可以看到 API 层面的关键演变pub fn get(self, id: TicketId) - ResultOptionArcMutexTicket, OverloadedError { // ... } enum Command { Insert { draft: TicketDraft, response_channel: SyncSenderTicketId, }, Get { id: TicketId, response_channel: SyncSenderOptionArcMutexTicket, }, }Get命令现在把ArcMutexTicket句柄本身作为响应返回——客户端拿到句柄后既可以读、也可以改不再需要单独的 update 命令这正是 lib.rs 顶部注释强调的设计变化「Getnow returns a handle to the ticket which allows the caller to both modify and read the ticket」。服务端线程在 server 循环 中只是把句柄克隆一份发回给客户端自己仍保留一份从而保证状态始终由服务端托管。客户端如何使用锁测试用例 tests/check.rs 完整演示了客户端侧的加锁读写模式是理解本练习的最佳入口let ticket client.get(ticket_id).unwrap().unwrap(); { let mut ticket ticket.lock().unwrap(); assert_eq!(ticket_id, ticket.id); assert_eq!(ticket.status, Status::ToDo); // ... ticket.status Status::InProgress; } // 作用域结束guard 被 drop锁自动释放 let ticket client.get(ticket_id).unwrap().unwrap(); { let ticket ticket.lock().unwrap(); assert_eq!(ticket.status, Status::InProgress); }两个关键点第一次get后通过ticket.lock().unwrap()获得MutexGuard在块作用域内修改status离开块时守卫析构、锁自动释放第二次get再次取回同一个句柄Arc克隆加锁后读到第一次修改的结果InProgress验证了修改确实持久生效。这正对应文档中「客户端拿到锁后就像持有mut Ticket一样直接修改改完释放」的描述——而guard的DerefMut让ticket.status Status::InProgress这种赋值直接作用于被保护的数据。并发环境约束为什么通道必须是有界整个方案建立在通道通信之上。本练习沿用了上一节引入的有界通道bounded channellaunch(capacity: usize)通过sync_channel(capacity)创建insert/get均使用try_send通道满时返回OverloadedError实现背压backpressure防止生产者过快压垮服务端线程详见 book/src/07_threads/09_bounded.md。响应通道也采用容量为 1 的有界通道保证每个请求有独立的响应接收端。小结与延伸至此我们完成了从「竞态补丁」到「细粒度锁」的演进闭环需求方案关键机制检测/避免竞态版本号乐观或锁悲观乐观提交时校验悲观访问前独占互斥保护单张票MutexTicketlock/try_lock返回守卫drop 释放减少锁争用细粒度每票一锁BTreeMapTicketId, ArcMutexTicket跨线程传递锁不能传MutexGuard非Send发送ArcMutexTicket句柄共享 克隆 线程安全Arc原子引用计数Rc非SendArc是SendArcMutexT是 Rust 并发编程中最经典的组合拳之一其模式可以概括为Arc解决「多线程共享所有权」Mutex解决「共享下的可变性」Send保证「跨线程转移的类型安全」。如果你希望进一步优化读性能可以继续学习下一练习 book/src/07_threads/12_rw_lock.mdRwLockT区分读者与写者允许多个读者并发读、写者独占写适合本场景这种读多写少的负载但要注意它比Mutex锁定开销更大且存在写者饥饿writer starvation的潜在风险。原文档的「Further reading」部分还建议对原子操作感兴趣的读者查阅 Rust 标准库的std::sync::atomic模块文档以及《Rust Atomics and Locks》一书——Arc的计数机制正是建立在原子操作之上理解AtomicUsize等原语能让你对Arc的线程安全有更底层的认识。你也可以在练习目录exercises/07_threads/11_locks下运行cargo test验证实现或对照tests/check.rs的断言自行补全store.rs中的todo!()。赞分享示例工程教程【免费下载链接】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_Trending/10/100-exercises-to-learn-rust安全审计实践如何发现并修复Rust代码中的安全漏洞GitHub_Trending/10/100 exercises to learn rust安全审计实践如何发现并修复Rust代码中的安全漏洞 在Rust开发示例工程教程Linux Housekeeping 机制全解析CPU 隔离、RCU 同步与 cpumask 管理Linux Housekeeping 机制全解析CPU 隔离、RCU 同步与 cpumask 管理 Housekeeping 是 Linux 内核 CPU 隔示例工程教程C 开发者视角Rust 并发安全实战——Send/Sync、Arc\Mutex\T\\ 与消息传递C 开发者视角Rust 并发安全实战——Send/Sync、Arc\Mutex\T\ \ 与消息传递 本指南以 C 开发者的知识背景为起点系统对比两种语文档教程上一篇Cpp2IL终极指南掌握Unity IL2CPP逆向工程的完整解决方案下一篇Apache Druid Kerberos 认证扩展实战指南为 Druid 节点启用 SPNEGO 安全认证创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?