2026.09.1313 分钟

从 AZULC 中学 Rust 01:所有权和共享

从“下载一个整合包”开始,理解所有权和共享

learn-rust-from-azulc-01

1 前言

本系列为 从 AZULC 中学 Rust 第一章。这个项目是我 vibe coding 出的一个以轻量级、高性能、高度模块化为标杆的新一代 Minecraft 启动器技术验证平台。

话虽如此,我作为“项目总监”,总应该掌握其中的实际代码逻辑的。但面对 Coding Agent 的强大代码功底,此前从未系统性学习过 Rust,只是会依葫芦画瓢的我并不能做到这一点。因此,我决定以该项目为起点与工程样例,向 AI 学习 Rust 的各大重难点。

2 从下载一个整合包开始,理解所有权和共享

假设你在 AZULC 中安装一个整合包。下载器同时处理几个文件,界面定期显示总进度。文件 A 下载完了,负责它的任务可以结束;文件 B 还在下载,界面也还要读取进度。那么,保存“总下载量”的对象应该由谁保留,什么时候释放?

本篇从普通字符串讲起,再到共享进度,完整走一遍路径锁和下载额度机制。读完后,应当能说清楚:谁拥有对象、谁只是借用、谁结束时负责释放。

源码入口:下载与 ProgressTracker · 路径锁注册表 · 下载额度

2.1 只有一个变量时,对象随谁离开?

fn main() {
    let name = String::from("Forge");
    println!("{name}");
}

String::from 创建拥有字符串内容的值,name 是它的所有者。执行到 main 的右花括号时,name 离开作用域,Rust 释放它拥有的资源。

作用域可以先理解为变量所在的一对花括号;后面“守卫活到作用域结束”说的也是这种关系。

多加一个变量试试:

fn main() {
    let first = String::from("Forge");
    let second = first;
    println!("{second}");
    // println!("{first}"); // 取消注释会报 moved value 错误。
}

let second = first 把 String 的所有权交给 second,没有再复制字符串,因此 first 不能继续使用。这叫 move,移动所有权。整数等实现 Copy 的值可以直接复制;本例讨论 String,不要把结论套到所有赋值上。

2.2 如果只想读名字,不接管它呢?

界面显示实例名,不需要把名字从安装任务手里拿走。引用表示这种临时访问:

fn print_name(name: &str) {
    println!("Installing {name}");
}

fn main() {
    let name = String::from("Forge");
    print_name(&name);
    println!("Still available: {name}");
}

&name 借用字符串,不转移所有权。参数 &str 表示借用一段字符串内容;调用结束后,name 仍归 main 所有。

写法先这样理解
&T共享借用某种 T,不接管它的所有权
&mut T独占地借用 T,允许修改,不能同时保留冲突借用

T 是“某种类型”的占位写法,例如 &String。普通共享引用不允许任意修改;锁和原子变量会提供受控制的修改方式,后面再解释。

2.3 一个进度对象,多个使用者!

如果只有 A 拥有总进度,A 结束时对象被释放,B 和上报任务怎么办?只借用也需要安排所有者的寿命,而这些任务的结束顺序并不固定。

在项目中,AZULC 让它们共同拥有对象,类型叫 Arc<ProgressTracker>:

// 源码节选,周边定义在下载模块中。
let tracker = Arc::new(ProgressTracker::new(total, files_total));
let report_tracker = Arc::clone(&tracker);

Arc::new 建立可以共同拥有的数据;Arc::clone 增加一个指向同一对象的拥有者。这里 clone 的是 Arc,不是里面整份进度。

先用一个不涉及并发的教学例子观察:

use std::sync::Arc;

fn main() {
    let download = Arc::new(String::from("shared progress"));
    let ui = Arc::clone(&download);
    assert!(Arc::ptr_eq(&download, &ui));
    drop(download);
    println!("{ui}");
}

ptr_eq 检查它们是否指向同一份分配。drop(download) 提前结束 download 这份所有权;ui 还在,所以内容仍然能读。

Arc 内部记录拥有者数量,叫强引用计数。最后一个强引用释放时,内部值才销毁。对应下载器,就是所有文件任务和上报任务都不再需要 tracker 后,才可以释放它。

Arc 解决“共同拥有多久”。多个任务如何同时累加,需要另外使用原子类型,第 2 篇会从读取和写入讲起。Arc 的计数安全,不会让里面任意一种数据都自动具备安全并发修改的能力。

2.4 任务拿到的到底是什么?

下载调度中有这样的结构:

// 源码节选
let tracker = Arc::clone(&tracker);
async move { download_spec(&client, &spec, &tracker).await }

暂时把 async 代码块看成“稍后由运行时推进的一份工作”,它的正式类型叫 Future,第 5 篇会讲执行过程。这里看所有权就够了。

外层 clone 一个 Arc,给工作自己的拥有权;move 把它交给工作内部保存;工作进行时,再用 &tracker 借给函数。

顺序是:增加共同拥有者 → 交给任务 → 暂时借给函数。move 不会自动克隆对象;如果移进去的本来是引用,它也仍然是引用。

2.5 两个整合包碰到同一个文件怎么办?

现在同时安装整合包 A、B,它们都需要同一份依赖,目标都是 libraries/example.jar。如果两个任务都先检查“文件不存在”,再各自下载,就会重复工作,还可能争相发布同一个文件。

项目希望同一个目标的任务依次取得操作权限,无关文件仍能并发。锁用于安排权限:拿到锁才能进入受保护操作,其他竞争者等待。此处先理解拿到和归还,第 3 篇再展开读写规则。

但给 A 一把锁、给 B 另一把锁没有用,它们都能拿到自己的锁。关键是同一目标必须找到同一把锁。

因此项目建立了“路径 → 对应锁”的查询表:

libraries/example.jar → 锁 X
libraries/another.jar → 锁 Y

A 查 example.jar 得到 X,B 查同一路径也得到 X。另一文件的任务取得 Y,不受 X 的占用影响。HashMap 就是做这类键值查询的容器;此处路径是键,锁的登记信息是值。这张表就是路径锁注册表。

2.5.1 一次查询从头到尾做什么

打开 path_locks.rs 的 for_path,按顺序读:

  1. 把路径整理成查询用的 key,减少相对路径、.、.. 等写法差异。
  2. 暂时锁住注册表,避免两个调用者同时认定“没有登记”,各建一把锁。
  3. 如果存在仍然活着的锁,取得它的 Arc 并返回。
  4. 否则创建新锁,登记到表里,再返回它的 Arc。
  5. 函数返回时,注册表的锁释放。调用者之后等待的是目标路径的锁。

这里有两种锁:注册表的锁只保护查询和插入;目标路径的锁保护实际工作。不会拿着整个注册表的锁一直等待网络下载。

路径整理不能识别所有符号链接和硬链接别名;表也只在当前启动器进程里共享。它不是跨进程文件锁。

2.5.2 没有任务使用的锁,还要永久留着吗

如果表的值直接是 Arc,表就成为永久拥有者。A、B 都结束了,只要条目还在,锁就仍然活着。

项目在表里保存 Weak,弱引用。可以通过它尝试找到对象,但它不保证对象继续存活:

use std::sync::Arc;

fn main() {
    let task = Arc::new(String::from("lock X"));
    let registry = Arc::downgrade(&task);
    assert!(registry.upgrade().is_some());
    drop(task);
    assert!(registry.upgrade().is_none());
}

downgrade 创建 Weak,不消耗原 Arc。upgrade 尝试取得新强引用:对象活着就返回 Some(Arc),否则返回 None。

时刻强引用情况注册表的 Weak
A 首次申请A 持有 X可以 upgrade
B 申请同一路径A、B 都持有 X可以 upgrade
A 完成B 仍持有 X可以 upgrade
B 及相关拥有型守卫都释放没有强引用,X 的值销毁upgrade 返回 None
C 再申请创建并持有新锁更新为新锁的 Weak

其实 Weak 仍占有一些控制信息,直到弱引用也释放才能完全回收,并非不占内存。

2.5.3 长类型里是什么?

OnceLock<Mutex<HashMap<PathBuf, Weak<RwLock<()>>>>>

前面每个角色都在这里:

从外到内对应的工作
OnceLock首次访问时创建注册表,以后复用
Mutex一次只让一个调用者查询或修改表
HashMap通过路径找到条目
PathBuf拥有路径内容的类型,用作查询键
Weak登记锁,但不永久拥有它
RwLock<()>提供路径读写权限,() 表示内部不放业务数据

OnceLock 的“一次”说的是初始化,不是以后只能访问一次。

为什么 RwLock 锁住空值、读锁和写锁怎么配合,第 3 篇继续讲。

use std::sync::OnceLock;

static NAME: OnceLock<String> = OnceLock::new();

fn main() {
    let first = NAME.get_or_init(|| String::from("registry"));
    let second = NAME.get_or_init(|| String::from("replacement"));
    assert_eq!(first, "registry");
    assert_eq!(second, "registry");
}

NAME 在这里充当全局存放位置,初始没有内部值。get_or_init 接受一段初始化代码,|| 开头的写法是无参数闭包:只有需要初始化时才调用。第二次已经有值,因此不会把 registry 换成 replacement。项目采用同样的方式创建注册表,只是内部值更复杂。

2.6 不同文件也不能无限同时下载!

路径锁解决相同目标的冲突。但 3 个包各下载 8 个不同文件,仍可能同时占用 24 个传输。目标不同,路径锁不会限制这个总数。

项目因此还有全局下载额度,类型叫 Budget,记录两个数字:正在占用的数量 running,以及上限 limit。假设上限是 2:

A 申请 → 占用 1 → 开始下载
B 申请 → 占用 2 → 开始下载
C 申请 → 已满   → 等待
A 结束 → 占用 1 → C 有机会取得空位

申请成功返回 Permit,表示这份额度正在被持有。Permit 也称为守卫:只要它在,额度就属于当前工作;它销毁时归还额度。因此提前返回或取消时,不必在每个分支手动写“减一”。

2.6.1 Permit 为什么借用 Budget?

源码是:

pub(super) struct Permit<'a>(&'a Budget);

这是一种只有一个未命名字段的结构体。Permit 保存 &Budget,没有复制整个 Budget。'a 给这份借用关系起名字:守卫释放时需要访问 Budget,把 running 减一,所以 Budget 必须比守卫活得久。

这就是此处的生命周期约束。它让编译器拒绝“Budget 已经销毁,Permit 还在访问”的情况,不会延长对象的实际寿命。

归还动作定义在 impl Drop for Permit 中。Drop 是类型定义“销毁时做什么”的机制,第 8 篇再讲错误、取消和强制退出时的区别。Budget 自己通过 OnceLock<Budget> 全局初始化并复用。

到这里可以区分两项限制:路径注册表让同一目标找到同一把锁;Budget 让不同目标也遵守总额度。

C 怎么等待、谁叫醒它,以及如何安全地检查 running,第 3、4 篇会接着这条时间线解释。

2.7 总结一下?

  • A 完成了,B 仍占用着总进度,对象会释放吗? 不会,B 的 Arc 仍然是强引用。但对象活着不代表可以任意并发修改,修改方式要另外设计。

  • Weak 会在下载到一半时回收锁吗? 不会,任务或拥有型守卫仍持有强引用。Weak 不延长寿命,也不会主动销毁别人仍拥有的对象。

  • 路径锁能代替 Budget 吗? 不能。不同路径各拿各的锁,仍然可能产生过多并发下载。