Skip to content
Go back

[Rust] Send & Sync 03 - 好在哪?

100% 本人原创文章,非 AI 创作

Table of Contents

Open Table of Contents

Introduction

这是一个系列文章,从浅到深记录我对这两个 Trait 的学习:

前两篇文章回答了”Send 和 Sync 是什么”的问题。本篇回答在第一篇文章的 Introduction 中提出的剩下两个问题:

  1. 这东西解决了什么问题?
  2. 为什么选择它,不选择别的?

同时也回应第一篇中埋下的一个伏笔:为什么说 Send 和 Sync “让写出烂代码变难了”。

Send & Sync 解决了什么问题

多线程编程中有两类极其常见又极其难调试的 bug:

  1. Data Race(数据竞争):多个线程同时访问同一块内存,且至少有一个是写操作,没有同步机制保护
  2. Use-After-Free / Dangling Pointer:一个线程释放了内存,另一个线程还在用指向那块内存的指针

这两类 bug 的共同特点是:它们不一定每次都会出问题。可能跑一百次里有一次 crash,可能只在高负载下出现,可能换台机器就好了。这让它们在实际开发中成为最让人头痛的问题——bug 确实存在,但你不一定能复现它。

Send 和 Sync 正是针对这两类问题的。回顾前两篇文章中的例子:

  • 第一篇中,我们尝试通过通道发送一个裸指针给另一个线程。裸指针没有实现 Send,编译器直接拒绝了这段代码。如果放在 C 语言中,这段代码会编译通过,然后在运行时产生 use-after-free 的问题。

  • 第二篇中,我们尝试用 Arc<Cell<i32>> 在多个线程间共享一个 CellCell 没有实现 Sync,编译器同样拒绝了。如果这段代码能跑起来,多个线程同时对同一个 Cell 进行读写就是标准的 data race。

这两个例子说明了同一件事:Send 和 Sync 把本来要在运行时才会暴露(还不一定暴露)的并发 bug,变成了编译时直接报错。

编译时 vs 运行时

这是 Send 和 Sync 设计中我觉得最精妙的地方:它们是零成本的编译时检查

第一篇文章中提到过,Send 和 Sync 作为 Marker Trait 是零开销的——最终编译出来的机器代码中,不会因为实现了这两个 Trait 而多出任何指令。没有锁,没有原子操作,没有运行时检查。所有的检查都发生在编译阶段。

编译通过就意味着这一类并发错误不可能发生。

这和运行时检查工具有本质的区别。运行时检查工具(比如后面会提到的 Go 的 race detector)只能发现程序实际跑到的代码路径中的问题。如果某个有 bug 的代码路径在测试中没被执行到,运行时工具就发现不了它。而编译时检查覆盖的是所有代码——不管跑不跑得到,只要类型不对,就过不了编译。

其他语言是怎么处理的

说完了 Rust 的做法,来看看其他语言面对同样的问题是怎么处理的。

C/C++

C 和 C++ 对线程安全基本不提供语言层面的保护。写出 data race 的代码完全合法,编译器不会有任何抱怨。

#include <stdio.h>
#include <pthread.h>

int counter = 0;

void* increment(void* arg) {
    for (int i = 0; i < 100000; i++) {
        counter++;  // data race:多个线程同时读写 counter
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %d\n", counter);  // 结果是 undefined behavior
    return 0;
}

这段代码能编译通过,也能运行。但 counter 的最终值是不确定的——两个线程同时对 counter 进行 ++ 操作,这个操作本身不是原子的(读取→加1→写回),线程之间会互相覆盖对方的写入。

跑十次可能有九次输出 200000,让你觉得没问题。但这是 undefined behavior,它就是一颗定时炸弹。

C/C++ 中避免这类问题的方式是使用 mutex 或原子操作,但用不用完全靠程序员自觉。编译器不强制,也不检查。

Go

Go 的并发模型以 goroutine 和 channel 为核心,官方也鼓励”通过通信来共享内存,而不是通过共享内存来通信”。但 Go 并不阻止你写出 data race 的代码。

package main

import (
    "fmt"
    "sync"
)

func main() {
    m := make(map[string]int)
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            // data race:多个 goroutine 同时写入 map
            m[fmt.Sprintf("key%d", n)] = n
        }(i)
    }

    wg.Wait()
    fmt.Println(m)
}

这段代码编译完全没问题。但 Go 的 map 不是并发安全的,多个 goroutine 同时写 map 会导致 fatal error: concurrent map writes,程序直接 crash。

Go 提供了一个 race detector 工具(go run -race),可以在运行时检测 data race。但它有两个局限:

  1. 它是运行时工具,只能发现程序实际执行到的代码路径中的 data race。如果有 bug 的路径在测试中没跑到,race detector 就发现不了。
  2. 它有性能开销,官方文档说典型开销是内存增加 5-10 倍,执行时间增加 2-20 倍。这意味着它不适合在生产环境中常开,通常只在测试时使用。

Java

Java 有 synchronized 关键字和 volatile 修饰符来处理并发问题,但它们都是可选的——用不用全凭程序员判断。

import java.util.ArrayList;
import java.util.List;

public class DataRaceExample {
    public static void main(String[] args) throws InterruptedException {
        List<Integer> list = new ArrayList<>();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(i);  // ArrayList 不是线程安全的
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(i);
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        // 期望 20000,实际结果不确定
        // 可能抛出 ArrayIndexOutOfBoundsException
        // 可能数据丢失
        System.out.println("size = " + list.size());
    }
}

ArrayList 不是线程安全的,但 Java 编译器不会因为你在多线程中用了 ArrayList 而报错。运行时可能抛异常,可能数据丢失,也可能看起来一切正常——取决于线程调度的时序。

Java 的解决方案是用 Collections.synchronizedList() 或者 ConcurrentHashMap 之类的线程安全容器,但选择使用哪个完全是程序员的责任。编译器不管你用的是 ArrayList 还是 ConcurrentHashMap


对比这三种语言,一个共同的特点是:线程安全靠的是程序员的自律和代码审查。编译器不强制,不检查,写出有 data race 的代码不会有任何编译时的警告。

Rust 的 Send 和 Sync 做的事情就是把这个责任从程序员转移到了编译器。你不需要记住”这个类型不能跨线程使用”或者”这个容器要加锁”——如果你搞错了,编译器会告诉你。

Send & Sync 并不彻底解决所有问题

第一篇文章中我提过一句:“这两个 Trait 本身并不彻底解决并发代码中的线程安全问题。”

现在来兑现这个伏笔。

Send 和 Sync 解决的是类型层面的线程安全——确保不该跨线程发送的数据不会被发送,不该被多线程共享的数据不会被共享。但并发编程中还有一整类问题是它们管不了的。

最典型的就是死锁(Deadlock):

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

fn main() {
    let a = Arc::new(Mutex::new(1));
    let b = Arc::new(Mutex::new(2));

    let a1 = Arc::clone(&a);
    let b1 = Arc::clone(&b);

    let t1 = thread::spawn(move || {
        let _lock_a = a1.lock().unwrap();
        // 假设这里有一些耗时操作
        let _lock_b = b1.lock().unwrap();  // 等 b 的锁
    });

    let a2 = Arc::clone(&a);
    let b2 = Arc::clone(&b);

    let t2 = thread::spawn(move || {
        let _lock_b = b2.lock().unwrap();
        // 假设这里有一些耗时操作
        let _lock_a = a2.lock().unwrap();  // 等 a 的锁
    });

    // 两个线程交叉加锁,可能永远等下去
    t1.join().unwrap();
    t2.join().unwrap();
}

这段代码中所有的类型都是 SendSync 的,编译完全没问题。但运行时,两个线程有可能各持一把锁,互相等对方释放,永远等下去。这就是死锁。

除了死锁之外,活锁(Livelock)、饥饿(Starvation)、优先级反转(Priority Inversion)等问题也不在 Send 和 Sync 的管辖范围内。这些是逻辑层面的并发问题,涉及的是程序的行为和调度策略,不是类型系统能解决的。

但这不影响 Send 和 Sync 的价值。它们消灭了多线程编程中最难调试的一大类 bug——data race 和跨线程的内存安全问题。剩下的那些逻辑层面的并发问题(死锁、活锁等),虽然也很麻烦,但至少它们的表现是确定性的(比如程序卡住不动了),比起 data race 那种”跑一百次偶尔 crash 一次”的情况要好排查得多。


到这里,关于 Send 和 Sync 这个系列的三个问题都回答完了:

  1. 它们是什么?——两个 Marker Trait,Send 表示”可以安全发送到另一个线程”,Sync 表示”可以被多线程安全地共享”。
  2. 解决了什么问题?——把 data race 和跨线程内存安全问题从运行时 bug 变成编译时错误。
  3. 好在哪?——零成本的编译时检查,把线程安全的责任从程序员转移到编译器,其他主流语言做不到这一点。