【AI开发之Rust】第 12 课:并发模型与同步原语

12.1 这节课解决什么问题

多线程编程最大的噩梦是数据竞争 :两个线程同时读写同一块内存,行为未定义。其他语言的解决方案靠程序员自律 + 各种加锁约定,依然防不胜防;Rust 的方案是把"能不能安全地跨线程共享/传递"变成类型系统的一部分Send/Sync),在编译期就把绝大多数并发 bug 关进笼子里。

本课先讲线程级并发的两大流派,再把"为什么 Rust 敢说 fearless concurrency"的机制讲透:

rust 复制代码
流派一:消息传递(channel)------ 通过"传值"同步,无共享即无竞争(Go 风格)
流派二:共享状态(Arc<Mutex<T>>)------ 通过"加锁"同步,锁里锁外都要守规矩
底层保障:Send / Sync 两个 marker trait ------ 类型能不能跨线程,编译器替你查

💡 用这张图建立心理模型:Send = 这个值可以"搬家"到别的线程;Sync = 这个值可以同时被多个线程共享引用。绝大多数类型自动满足,少数"不满足的名单"(Rc、RefCell、裸指针)恰好就是你想跨线程用会踩坑的那几个。

12.2 创建线程:thread::spawn + move

rust 复制代码
use std::thread;
use std::time::Duration;

fn main() {
    // 新线程:闭包在子线程执行
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("子线程 {i}");
            thread::sleep(Duration::from_millis(50));
        }
    });

    for i in 1..=3 {
        println!("主线程 {i}");
        thread::sleep(Duration::from_millis(50));
    }

    handle.join().unwrap();     // 等待子线程结束(join 返回 Result)
}

关键点:

  • spawn 返回 JoinHandle<T>.join() 阻塞等待并取回子线程闭包的返回值(Result<T, ...>);
  • 主线程结束则整个进程结束------忘了 join 可能看到子线程没跑完
  • 闭包捕获外部变量必须 move(第 9 课讲过 move 闭包),因为子线程可能比 main 活得久,编译器要求它拥有捕获的数据:
rust 复制代码
use std::thread;

fn main() {
    let data = vec![1, 2, 3];

    let handle = thread::spawn(move || {      // 必须 move:data 被搬进子线程
        println!("子线程看到 {:?}", data);
    });

    // println!("{:?}", data);   // ❌ data 已被 move 进子线程
    handle.join().unwrap();
}

💡 从所有权视角看线程:spawn(move || ...) 就是把一批数据的 owner 转交给新线程;线程结束、闭包被丢弃,数据随之释放------和普通作用域完全同构,只是"作用域"变成了"另一个线程的生命周期"。理解这一点,线程就不再神秘。

12.3 消息传递:std::sync::mpsc(channel)

"生产者---消费者"式同步,值从一端传另一端,传递即移交所有权

rust 复制代码
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();        // 发送端 tx,接收端 rx(mpsc: 多生产者单消费者)

    let tx2 = tx.clone();                  // 克隆出发送端,供第二个生产者用

    thread::spawn(move || {                // 生产者 1
        for i in 0..3 {
            tx.send(format!("来自线程A: {i}")).unwrap();
            thread::sleep(Duration::from_millis(30));
        }
    });

    thread::spawn(move || {                // 生产者 2
        for i in 0..3 {
            tx2.send(format!("来自线程B: {i}")).unwrap();
            thread::sleep(Duration::from_millis(30));
        }
    });

    // 消费者:迭代 rx,直到所有发送端都 drop
    for msg in rx {
        println!("收到: {msg}");
    }
}

要点:

  • send 把值move 进通道 ,发送后原变量不可用------无竞争的核心:值同一时刻只在一个线程手里
  • rx.iter() / for msg in rx 在所有发送端被 drop 后自动结束(所以发送端要 move 进线程、别留在 main 里挡住结束);
  • recv() 阻塞等待,try_recv() 不阻塞;send 返回 Result(接收端已 drop 会 Err);
  • 设计偏好:能用 channel 表达的并发优先用 channel------它强制"值只在一处",心智负担最小。

12.4 共享状态:Arc<Mutex> / Arc<RwLock>

12.4.1 为什么是 Arc<Mutex> 组合

swift 复制代码
线程间共享            → 需要"引用计数 + 可跨线程" → Arc<T>(Rc 的多线程版)
共享且要修改         → 需要"互斥锁"              → Mutex<T>
组合:Arc<Mutex<T>>  每个线程持有一个 Arc 句柄,通过锁访问内部 T
rust 复制代码
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..5 {
        let c = Arc::clone(&counter);          // 每个线程一个句柄
        handles.push(thread::spawn(move || {
            for _ in 0..100 {
                let mut guard = c.lock().unwrap();   // 拿锁,guard 解引用 = &mut i32
                *guard += 1;                         // 临界区内修改
            }                                        // guard 在这里 drop,自动解锁
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
    println!("最终计数: {}", *counter.lock().unwrap());   // 500
}

三个必须讲透的细节:

  1. lock() 返回 MutexGuardguard 就是"被锁住的引用",*guard 等价于 &mut T;guard 离开作用域自动解锁(Drop),不需要手动 unlock
  2. lock() 返回 Result :持锁线程 panic 会导致锁"中毒"(poisoned),后续 lock 返回 Err------.unwrap() 在示例里够用,生产代码要处理(通常把 lock().expect("...") 写清楚,或 .lock().unwrap_or_else(|e| e.into_inner()) 强行拿回锁内容);
  3. Arc::clone 仍只是 +1 引用计数 ,不拷贝 Mutex 内部数据。

12.4.2 读多写少:RwLock

rust 复制代码
use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let config = Arc::new(RwLock::new(String::from("version=1")));

    // 多个读线程可以同时拿读锁
    let mut readers = vec![];
    for _ in 0..3 {
        let c = Arc::clone(&config);
        readers.push(thread::spawn(move || {
            let guard = c.read().unwrap();          // 读锁:可并发
            println!("读到: {}", guard.len());
        }));
    }
    // 写线程拿写锁(独占)
    let c = Arc::clone(&config);
    let writer = thread::spawn(move || {
        let mut guard = c.write().unwrap();          // 写锁:独占
        guard.push_str("\nversion=2");
    });

    for r in readers { r.join().unwrap(); }
    writer.join().unwrap();
    println!("最终: {}", *config.read().unwrap());
}
Mutex<T> RwLock<T>
语义 独占锁(写写/读写都互斥) 读锁共享 + 写锁独占
适用 写多、简单场景 读多写少(配置、缓存)
风险 持锁太久拖慢全局 写锁可能被大量读锁"饿死"(fairness 问题)

⚠️ 两个锁都要注意别在持锁期间调用可能再次拿同一把锁的代码(会死锁或 panic),也别做耗时 IO(把整条线程堵住)。持锁时间 = 最短临界区。

12.5 Send 与 Sync:把并发安全写进类型

12.5.1 两个 marker trait 的定义

rust 复制代码
// 语义(不是方法,是标记):
unsafe trait Send {}     // 该类型的值可以安全地移动到另一个线程(转移所有权)
unsafe trait Sync {}     // 该类型的引用可以安全地被多个线程共享(&T 是 Send)
  • 几乎所有类型自动 实现 Send/Synci32StringVecBoxArc......;
  • 例外名单(编译器自动不给它们实现,因为不安全):
r 复制代码
!Send:裸指针 *mut T、Rc<T>(计数非原子)、RefCell<T> 的某些使用......
!Sync:RefCell<T>、Cell<T>(内部可变性无锁保护)

12.5.2 它如何"在编译期拦 bug"

rust 复制代码
use std::rc::Rc;
use std::thread;

fn main() {
    let rc = Rc::new(5);
    // thread::spawn(move || println!("{rc}"));
    // ❌ error[E0277]: `Rc<i32>` cannot be sent between threads safely
    //    因为 Rc 的引用计数不是原子的,跨线程并发增减会数据竞争。
    //    编译器直接拒绝 spawn ------ 你不用等运行期崩。
}

反过来,Arc<T> 通过原子计数实现 Send + Sync,所以能进线程。把你想跨线程的类型往编译报错里一扔,编译器会告诉你它是不是 Send/Sync------这是 Rust 并发安全最优雅的地方。

12.5.3 组合法则

rust 复制代码
Send + Sync 组合的自动推导:
  T: Send 且 T: Sync    → Arc<T> 可共享可发送(Mutex/RwLock 内置保证)
  自定义 struct 自动 Send/Sync,当且仅当所有字段都 Send/Sync

💡 实战记忆:要跨线程的共享可变状态 ,配方是 Arc<Mutex<T>>Arc<RwLock<T>>;要跨线程的配置/不可变大对象Arc<T> 即可;纯消息流用 mpsc channel。12.8 的综合题会把三者串起来。

12.6 读报错专项:并发三兄弟

报错 含义 修法
E0277: Rc<i32> cannot be sent between threads 用了非 Send 类型 Arc(或不用跨线程)
cannot borrow data in an Arc as mutable Arc 内容默认不可变 Mutex/RwLock 再锁
lock() 返回 Err(运行期) 持锁线程 panic,锁中毒 .expect 说明 / into_inner() 恢复

12.7 心智升级:共享内存 vs 消息传递,怎么选

markdown 复制代码
选消息传递(channel):
  - 流程天然是"一条流水线"(一个线程算完交给下一个)
  - 想避免锁/死锁的思考
  - 数据只在两线程间来回(一对一)

选共享状态(Arc<Mutex>):
  - 多线程要反复读同一份状态(配置、连接池、统计)
  - 结构本身是"中心的表",各线程只是改字段
  - 需要随机访问(不是顺序管道)

工程混合也很常见:线程 A 通过 channel 把任务发给线程 B,
B 再把结果写进共享的 Arc<RwLock<Vec<...>>> 供主线程汇总。

💡 Rust 的 channel 自带所有权转移,天然实现"消息传递即同步";而 Go 谚语"不要通过共享内存通信,要通过通信共享内存"在 Rust 里同样适用------channel 往往是更不容易错的起点。

12.8 📝 动手练习

参考实现放 code/12-concurrency/(写作时同步给出)。

  1. join 与返回值 :spawn 一个线程计算 1..=10_000 的和返回,主线程 join 后打印结果。
  2. move 捕获 :把第 4 课的 String move 例子改造成跨线程版(spawn(move || ...)),记录"不用 move"时的报错并解释。
  3. channel 流水线 :生产者线程生成 0..100 的平方,消费者求和,经 channel 传值,打印最终和;再增加第二个生产者(用 tx.clone())。
  4. Arc 计数器 :复刻 12.4.1,把线程数调到 8、每线程累加 10_000,断言结果 = 80_000(体会无竞争)。再故意改成 Rc,记录 E0277。
  5. 读多写少 :用 Arc<RwLock<Vec<String>>> 模拟"共享日志":3 个写线程各 append 若干行,2 个读线程各自打印当前行数,主线程汇总验证行数正确。
  6. 综合练习(重点) :做一个"并发单词统计":主线程把一段长文本按行切成 chunk 交给 channel;N 个工作线程各自统计 chunk 的字频写入 Arc<Mutex<HashMap<String, usize>>>;最后主线程汇总输出 Top3。(提示:任务用 channel 分发时是"分发后 drop 发送端"以结束循环。)
  7. Send/Sync 判断表 :写出 Rc<T>Arc<T>Box<T>RefCell<T>Mutex<T>String 各自 Send/Sync 组合,并解释 Rc/RefCell 为什么不是。

验收门禁 :不查资料能解释------channel 为什么天然防数据竞争;ArcMutex 各自解决什么问题;MutexGuard 的生命周期如何保证自动解锁;Send/Sync 是什么、为什么 Rc/RefCell 不在其中。

✅ 本节小结

  • spawn + move :新线程拥有闭包捕获的数据;join 取回结果;
  • channel(mpsc) :多生产者单消费者;send 移交所有权 → 值同时只在一处;rx 迭代到发送端全部 drop;
  • Arc<Mutex>:原子引用计数共享 + 锁内可变;guard 自动解锁;注意锁中毒与持锁时间;
  • Arc<RwLock>:读锁共享、写锁独占,读多写少时用;
  • Send/Sync:类型系统的并发安全标记,自动实现;Rc/RefCell/裸指针等例外在编译期拦截;
  • 设计决策:流水线用 channel,中心共享状态用 Arc<锁>,两者可混合;
  • 记忆配方 :跨线程不可变 → Arc<T>;共享可变 → Arc<Mutex/RwLock<T>>;传递数据 → channel。

下一课预告 :第 13 课《async/await 异步运行时》------线程阻塞太浪费,Rust 用 async 表达 IO 密集任务:Future 是什么、tokio 如何驱动它、spawn/select!/超时/取消如何编排,以及"异步和线程怎么各司其职"。AI 流式问答的整条 IO 链(第 17-19 课)全靠本课打底。

相关推荐
音符犹如代码19 小时前
RustFS 1.0.0 深度解析:GA 之后,它值得替代 MinIO 吗?
rust
名字还没想好☜1 天前
Python sqlite3 实战:事务提交、参数化防注入、WAL 并发与 row_factory 取字典
后端·python·编程语言
qq_452396231 天前
第十三篇:《unsafe Rust 与 FFI:与 C 和系统调用交互》
rust
传奇开心果编程1 天前
【Rust入门练中学】 第1课:从零开始
开发语言·学习·rust
对象存储与RustFS1 天前
跨实现迁移 MinIO→RustFS:mc diff 静默返回才是最容易翻车的一步
后端·rust·开源
doiito(Do It Together)1 天前
【Agent Harness】Gliding Horse 最新进化:从“能学习”到“可验证的自主进化”
人工智能·rust·系统架构·开源
传奇开心果编程2 天前
【Rust入门练中学】第2课:变量、可变性与常量
开发语言·学习·rust