Rust 的设计取向

取向

含义

性能不打折

零成本抽象:高级写法编译后和手写底层代码一样快,没有运行时开销

正确性优先

只要编译通过,一整类 bug(空指针、数据竞争、释放后使用)就不可能出现

显式胜于隐式

没有隐藏的行为,没有隐式的类型转换

所有权

每份资源——内存、文件、网络连接——同一时刻只有一个主人

无畏并发

类型系统在编译期就阻止数据竞争

最核心的是所有权:一个值同一时刻只属于一个变量,赋值或传参会把所有权「移走」,原来的变量就不能再用了。值的主人离开作用域时,值被自动释放,所以 Rust 既不需要垃圾回收,也不需要手动 free。

rust
fn main() {
    let data = vec![1, 2, 3];
    let moved = data; // 所有权转移给 moved
    // println!("{:?}", data); // 去掉注释会编译失败:data 已经被移走
    println!("{:?}", moved);
}
在 Playground 打开
输出
[1, 2, 3]

为什么数据与 AI 基础设施在用 Rust

越来越多的数据和 AI 基础设施用 Rust 写核心,比如 Arrow 的 Rust 实现、Lance 文件格式、Daft 多模态引擎、Polars。原因可以对应到 Rust 的具体特性上:

Rust 的特性

在数据与 AI 基础设施里的用处

所有权与借用检查

管理大块的内存缓冲区,既安全又不需要垃圾回收

零拷贝

减少图片、向量、Arrow 缓冲区在各环节之间的复制

Send / Sync

在编译期保证数据在线程之间传递和共享是安全的

async 与 Tokio

高并发地访问对象存储和网络

trait 与泛型

搭出可扩展的数据源、算子和索引

Result / Option

显式处理脏数据和失败的样本,不会悄悄崩溃

SIMD 与原生性能

向量距离计算、解码、过滤、表达式执行

PyO3

用 Rust 写内核、对外提供 Python API,符合 AI 工程师的使用习惯

以 Result 为例,处理脏数据时,每一种失败都必须被显式处理:

rust
fn parse_age(raw: &str) -> Result<u8, String> {
    raw.trim()
        .parse::<u8>()
        .map_err(|e| format!("无法解析 {raw:?}:{e}"))
}

fn main() {
    for raw in ["18", " 42 ", "abc", "300"] {
        match parse_age(raw) {
            Ok(age) => println!("有效:{age}"),
            Err(err) => println!("跳过:{err}"),
        }
    }
}
在 Playground 打开
输出
有效:18
有效:42
跳过:无法解析 "abc":invalid digit found in string
跳过:无法解析 "300":number too large to fit in target type

这些项目的具体用法,可以看 多模态数据闭环 系列教程。

多线程:编译器能挡住什么

并发编程大致有三种形态:

  • 多线程:多个线程共享内存,同时执行;

  • 异步:少量线程在大量 I/O 任务之间切换,适合「大部分时间在等」的场景;

  • 并行:把一个大计算拆开,同时在多个核上算。

多线程最容易出错的是共享数据。下面分三类问题来看。

数据竞争:编译期就挡住了

数据竞争是指多个线程同时访问同一块内存,其中至少一个在写,而且没有同步。在 Rust 里,这样的代码根本编译不过。想在线程之间共享可变数据,必须显式地说清楚怎么共享:

做法

什么时候用

Arc + Mutex

多个线程共享同一份数据,每次只有一个线程能修改

原子类型 + 内存序(Ordering)

共享一个简单的计数器或标志,不想加锁

Channel(消息传递)

不共享数据,而是把数据从一个线程发给另一个线程

Arc 让多个线程共同拥有一份数据,Mutex 保证同一时刻只有一个线程能修改它:

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..8 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                *counter.lock().unwrap() += 1;
            }
        }));
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("{}", *counter.lock().unwrap()); // 8 个线程各加 1000 次
}
在 Playground 打开
输出
8000

不想共享,就用 Channel 传消息。这个例子的输出顺序每次可能不同,所以没有保存结果:

rust
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    for id in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || {
            tx.send(format!("worker {id} 完成")).unwrap();
        });
    }
    // 丢掉最初的发送端:所有发送端都结束后,接收循环才会退出
    drop(tx);
    for message in rx {
        println!("{message}");
    }
}
在 Playground 打开

竞态条件:编译器挡不住

竞态条件是逻辑上的问题:每一步单独看都是安全的,但多个步骤之间被别的线程插了进来。比如取款时「先检查余额、再扣款」,如果两步分别加锁,两个线程可能都检查通过,余额就被扣成负数。规则是:需要原子完成的业务逻辑,要放在同一个临界区里。

rust
// 错误:检查和扣款分别加锁,中间可能被别的线程插入
if *balance.lock().unwrap() >= 100 {
    *balance.lock().unwrap() -= 100;
}

// 正确:在同一个临界区里完成检查和扣款
let mut balance = balance.lock().unwrap();
if *balance >= 100 {
    *balance -= 100;
}

锁带来的问题

问题

现象

怎么避免

死锁

两个线程各持有一把锁,又都在等对方的锁

所有线程按固定的顺序加锁;缩小锁的作用范围;用 try_lock 拿不到就先放弃

活锁

线程没有阻塞,却一直在互相让步,谁也推进不了

让步时加入随机的等待时间

饥饿

某个线程一直抢不到锁

使用公平锁,或减少持锁时间

锁竞争

很多线程抢同一把锁,性能退化成串行

拆分锁的粒度,或改用消息传递、无锁结构

异步:不阻塞地等待

异步任务(Future)不是一直占着线程等待,而是「能做就做,做不了就让出线程,好了再叫我」:

  • .await 不是阻塞等待,而是去 poll 一次内部的状态机,没准备好就让出线程;

  • 执行器是一个循环,不断 poll 那些已经被唤醒的任务;

  • Waker 是唤醒通知:I/O 准备好了,告诉执行器可以再来 poll 一次;

  • Pin 保证异步任务在内存中不会被移动,因为状态机里可能有指向自身的引用。

参考资料


全文评论

0

正在加载评论…