并发为什么难:数据竞争与不确定性

并发程序的 bug 之所以难查,是因为它们具有不确定性:同一份代码跑十次,崩溃可能只出现一次。最常见的元凶是数据竞争(data race)——两个或更多线程同时访问同一块内存,其中至少一个在写,又没有任何同步机制。在 C++ 里,这属于“未定义行为”,可能跑很久才在生产环境炸掉。

Rust 的卖点在于:它把“会不会数据竞争”这个问题提前到编译期回答。如果一段代码能编译通过,基本就可以认定它没有数据竞争。这不是靠你记得加锁,而是靠所有权与借用规则——编译器在类型层面就禁止了你把“既能被多个线程同时改、又没加保护”的数据到处传递。

记住这句话:在 Rust 里,并发错误首先是类型错误。要么编译通过、安全;要么根本编不过。你很少在运行时才第一次发现数据竞争。
本文核心知识点
  • thread::spawn创建操作系统线程,返回 JoinHandle 用来等待它结束。
  • channel多生产者单消费者的消息通道,在线程间安全地传递数据所有权。
  • Arc+Mutex原子引用计数共享只读壳,Mutex 提供同一时刻唯一的可变访问。
  • Send/Sync两个标记 trait,规定类型能不能跨线程安全转移或共享。
RUST 并发编程配图:深蓝渐变背景上多条平行发光流线从左侧分叉并排流动,流线之间由拱形管道连接传递光点小球,右侧半透明盾牌与挂锁守护汇合点
多条线程像并行的河流:要么用管道(channel)彼此传话,要么在盾牌(锁)的保护下共享同一块地。

起一个线程:thread::spawn 与 JoinHandle

在 Rust 里开线程用 std::thread::spawn,传入一个闭包,闭包就是这个线程要跑的活儿。spawn 立刻返回一个 JoinHandle,你可以稍后用它来等待线程结束、取回闭包的返回值。

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("子线程:{}", i);
        }
        "子线程干完了"   // 闭包的最后一行就是返回值
    });

    println!("主线程:继续做自己的事");

    // join() 会阻塞直到子线程结束,并取出它的返回值
    let result = handle.join().unwrap();
    println!("子线程返回:{}", result);
}

注意一个编译期的硬规则:闭包如果要借用主线程里的变量,编译器会要求这些变量的生命周期长于线程。可你 spawn 出去的线程可能比当前作用域活得久,编译器无法保证这一点,于是直接报错。这正是所有权系统在保护你——它替你想到了“主线程先结束、子线程还在引用已销毁数据”这种悬垂场景。

等线程结束:join 与返回值

join 返回一个 Result:如果子线程正常结束,里面是 Ok(闭包返回值);如果子线程 panic 了,join 会拿到 Err。也就是说,Rust 把“一个线程是否崩溃”显式地交到了你手里,而不是任其无声地拖垮整个进程。

use std::thread;

fn main() {
    let mut handles = Vec::new();

    for n in 1..=4 {
        handles.push(thread::spawn(move || {
            n * n            // 每个线程算出一个平方
        }));
    }

    let mut sum = 0;
    for h in handles {
        sum += h.join().unwrap();   // 依次收集结果
    }
    println!("平方和 = {}", sum);   // 1+4+9+16=30
}

这里用了 move ||:它告诉闭包“把捕获的变量(这里是循环变量 n)的所有权直接搬进线程”。因为 n 是 Copy 类型,每个线程拿到自己那一份副本,互不干扰。move 是你处理线程与数据所有权关系时最常用的一个关键字。

线程间传消息:channel 与 mpsc

Rust 推崇的并发范式是“通过消息传递共享所有权,而不是通过共享内存来传递消息”。标准库提供了信道(channel),它是多生产者、单消费者(mpsc)的:可以有多个发送端 Sender,但只有一个接收端 Receiver。

use std::sync::mpsc::channel;
use std::thread;

fn main() {
    let (tx, rx) = channel();

    for i in 0..3 {
        let tx = tx.clone();          // 每个线程克隆一个发送端
        thread::spawn(move || {
            tx.send(format("任务{}完成", i)).unwrap();
        });
    }

    drop(tx);   // 原始发送端丢掉,否则 recv 永远等不到结束

    for received in rx {             // 自动迭代直到信道关闭
        println!("收到:{}", received);
    }
}

消息传递的妙处在于:数据连同它的所有权一起被送过信道。接收方拿到消息后独占这份数据,发送方不再碰它——于是“同时读写同一份数据”这件事在结构上就不可能发生,根本不需要锁。recv() 是阻塞的,而 try_recv() 会立刻返回一个结果,适合你想顺手检查一下、不想卡住的时候。

共享可变状态:Arc 与 Mutex

有些场景就是要多个线程访问同一份数据——比如一个共享的计数器。这时组合是固定的两件套:Arc(原子引用计数,让一份数据被多处共享所有权)负责“共享”,Mutex(互斥锁)负责“同一时刻只允许一个线程进去改”。

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();

    for _ in 0..10 {
        let counter = Arc::clone(&counter);   // 共享同一份的句柄
        handles.push(thread::spawn(move || {
            let mut num = counter.lock().unwrap();  // 拿到锁,离开作用域自动释放
            *num += 1;
        }));
    }

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

几个关键认知:第一,Arc 本身只提供共享只读,它不能让你改内部值——能改,全靠 Mutex 包在里面;第二,lock() 返回的守卫实现了 Deref,你可以像操作普通引用一样用它,守卫一旦离开作用域,锁就自动释放,无需手写 unlock;第三,unwrap() 是因为锁可能被一个持锁又 panic 的线程弄成“中毒”状态,实战里要按你的错误策略决定怎么处理。

Send 与 Sync:编译期的并发安全契约

Send 和 Sync 是 Rust 里两个没有方法的“标记 trait”,它们不改变运行时行为,只用于告诉编译器这个类型能不能安全地跨线程使用。理解它们,就理解了 Rust 并发安全的底层契约。

标记 trait含义例子
Send这种类型的所有权可以安全地转移到另一个线程绝大多数普通类型;Rc 不是 Send
Sync这种类型可以安全地被多个线程同时引用(&T 是 Send)Mutex<T>、Arc<T>;裸指针不是 Sync

好消息是:你几乎不需要手动实现它们。如果一个类型的所有字段都是 Send / Sync,编译器会自动把它也标记为 Send / Sync。只有当你用了裸指针等“不安全”东西、需要人为承诺线程安全时,才会用 unsafe impl Send。这就形成了一个闭环:标准库的并发原语(Mutex、Arc、channel)被标成 Send+Sync,所以用它们搭出来的组合类型自动也线程安全;你一旦试图把一个非 Send 的值 move 进线程,编译器立刻拒绝。