2026.09.1916 分钟

从 AZULC 中学 Rust 03:Mutex、RwLock 与锁的范围

learn-rust-from-azulc-03
上一章的 fetch_add 解决了“多个任务往一个数上累加”的问题。现在我们回到下载额度:只剩最后一个空位时,两个任务都想开始。它们在加一之前先要判断有没有空位。判断和增加的操作必须是一起完成的,否则上限仍会被突破。

这就是本篇从原子数字转向锁的原因。先看普通同步代码是如何取得一次锁的,再研究项目为什么同时使用注册表锁和路径读写锁。

源码入口:路径锁、安装阶段、下载额度、日志写入。

1 取得一次锁

下面的教学代码先没有多个线程,只是为了看清守卫的作用范围:

use std::sync::Mutex;

fn main() {
    let running = Mutex::new(0_usize);
    {
        let mut guard = running.lock().unwrap();
        *guard += 1;
        assert_eq!(*guard, 1);
    }
    let guard = running.lock().unwrap();
    assert_eq!(*guard, 1);
}

Mutex::new 把数字放进互斥锁。lock 申请独占访问,成功后返回守卫;*guard 通过守卫访问被保护的数字。第一个花括号结束,守卫销毁,锁才释放,所以之后可以再次拿锁。

示例用 unwrap 简化错误处理:它在成功时取出守卫,失败时 panic。项目使用的恢复方式和风险会在后面的 poisoning 小节解释。此刻只需分清:Mutex 长期存在,一次访问创建一个短期守卫。

2 锁保护的是一段约束

我们用一个二元组 (running, limit) 来维护下载额度。申请额度必须完成“检查 running < limit,然后 running += 1”。若检查和增加分开,两个任务可能同时看到最后一个空位。

项目用 Mutex<(usize, usize)> 保护这段操作。Mutex 的结果是一个守卫,守卫存在期间其他调用者不能同时获得同一把锁。

// 源码节选
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
if state.0 < state.1 {
    state.0 += 1;
    return Permit(self);
}

这里的 mut 允许通过守卫修改元组。不是 Mutex 本身必须声明成可变变量:锁利用内部可变性,通过 &self 提供受保护的访问。

“线程安全”不能只看是否用了锁,还要看所有竞争操作是否都遵守同一协议。有人绕开这把锁直接写文件,其他调用者的锁就保护不了它。

3 标准锁与异步锁

std::sync::Mutex::lock() 竞争时会阻塞当前线程,而 tokio::sync::Mutex::lock().await 等待时挂起任务,让执行器可以运行其他任务。那我们应该在什么情况下使用这二者呢?

为了理解两种锁为什么不同,先看看异步任务在哪里执行,再看锁的守卫允许怎样使用。

3.1 多线程执行器与 Send 能力

启动器可能同时下载许多文件,但不必为每个下载创建一个操作系统线程。Tokio 的多线程执行器用一组工作线程来推进这些异步任务:某个任务暂时无法继续时,线程可以去处理其他任务。

调用 async fn 会得到一个 Future。它表示这次异步计算,并保存执行进度和继续执行所需的状态。把 Future 交给 tokio::spawn 后,Tokio 就会把它作为独立任务调度。

执行到 .await 时,如果等待的操作还没有完成,任务可以暂停;如果已经完成,就可以直接往下执行。暂停后,任务不是从函数开头重来,而是等条件就绪后从等待处继续。例如:

执行器线程 A:
    推进任务
    创建 filename
    等待网络,任务暂停

执行器线程 A:
    转去处理其他任务

网络就绪,任务被唤醒

执行器线程 B:
    继续推进同一个任务
    使用之前保留的 filename
    完成任务

这个例子可以看出,在暂停前后,推进它的线程可以不同(当然也可能仍然是线程 A)。

这就产生一个要求:任务保存的状态必须允许交给另一个线程继续使用。 Rust 用 Send 表达这种能力。

3.2 标准守卫为什么不可以 Send?

{
    let guard = state.lock().unwrap();
}

lock 返回的 guard 代表当前这次持锁访问;guard 销毁时会释放锁。它不只是被保护数据的一个引用,还承担着解锁的责任。

标准库明确规定 std::sync::MutexGuard 不实现 Send。原因是要兼容某些平台的互斥锁要求:锁在哪个线程取得,就必须在哪个线程释放。因此,不能把标准守卫转移给另一个线程,让另一个线程负责销毁它。

这里要分清锁和守卫。比如 Arc<Mutex> 可以在线程间共享,各线程可以分别取得和释放自己的守卫;但某个线程已经取得的那份 MutexGuard,不能跟着任务转移到另一个线程。给锁加上 Arc,不会改变守卫的限制。

3.3 Tokio 守卫允许随任务继续执行

Tokio 的 Mutex 是为异步任务设计的。它满足 Send,可以随任务交给另一个线程继续使用,并在那里释放锁。当然,这也要求被保护的数据满足相应的 Send 条件。

它还有另一项独立的特性:取得锁要使用 lock().await 挂起任务,让执行器线程先去运行其他任务。

但是,滥用异步锁可能会导致死锁。下面是不要执行的反例:

// state 是 tokio::sync::Mutex。
let first = state.lock().await;
let second = state.lock().await; // 等待 first 释放。
drop(first);                    // 可任务到不了这一行。

第二次申请会让任务挂起,线程能去执行其他任务,但这个任务自己无法前进:它等第一份守卫释放,而释放又安排在等待之后。异步等待不会自动消除这样的依赖。遇到类似问题,应检查守卫何时释放、是否重复拿同一把锁,以及多把锁的取得顺序。

3.4 案例:带完成列表的多线程下载器

现在我们模拟一个带完成列表的多线程下载器:下载完成后,把文件名加入完成列表。用 sleep 模拟网络等待,不进行真实下载。示例需要 Tokio 的 macros、rt-multi-thread、time 和 sync 功能。

3.4.1 如果直接使用标准守卫……

这是一段无法编译的代码:

use std::sync::{Arc, Mutex};
use std::time::Duration;

#[tokio::main]
async fn main() {
    let completed = Arc::new(Mutex::new(Vec::<String>::new()));
    tokio::spawn(async move {
        let mut guard = completed.lock().unwrap();
        tokio::time::sleep(Duration::from_millis(100)).await;
        guard.push("forge.jar".to_owned());
    }).await.unwrap();
}

任务先取得 guard,再等待网络,最后通过 guard 修改列表;因此在等待过程中,guard 被执行器暂时保留在了 Future 中,等待唤醒后被其它执行器调用。

而标准 MutexGuard 不允许跨线程转移。因此整个 Future 不满足 Send,编译器报出类似 “future cannot be sent between threads safely” 的错误。

3.4.2 换成异步锁是否解决了问题呢?

现在只换锁类型和取得锁的方式,其余结构不变:

use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;

#[tokio::main]
async fn main() {
    let completed = Arc::new(Mutex::new(Vec::<String>::new()));
    tokio::spawn(async move {
        let mut guard = completed.lock().await;
        tokio::time::sleep(Duration::from_millis(100)).await;
        guard.push("forge.jar".to_owned());
    }).await.unwrap();
}

好消息是:这个例子可以编译。这里的 Tokio 守卫可以随任务在线程间转移;其他任务申请同一把锁时,通过 lock().await 挂起,执行器线程可以继续处理其他任务。

但如果有多个 spawn 呢?在 spawn A 等待网络的这 100 毫秒里,guard 始终存在。这时候如果有个 spawn B 即使只是想查看完成列表,也得等 A 释放。如果每个下载任务都按这个结构先拿同一把锁,再下载,下载就会一个接一个地执行。这显然也不符合我们的预期。

3.4.3 再改进:先下载,再短暂拿锁登记结果

在这个场景里,锁保护的只是完成列表。等待网络不需要访问列表,因此可以把它放在持锁范围之外,继续使用标准 Mutex:

use std::sync::{Arc, Mutex};
use std::time::Duration;

#[tokio::main]
async fn main() {
    let completed = Arc::new(Mutex::new(Vec::<String>::new()));
    let task_state = Arc::clone(&completed);
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(100)).await;
        {
            let mut guard = task_state.lock().unwrap();
            guard.push("forge.jar".to_owned());
        } // 登记完就释放,没有拿着守卫 await。
    }).await.unwrap();
    assert_eq!(completed.lock().unwrap().len(), 1);
}

这次任务等待网络时没有持锁;拿锁后只做一次短暂的列表更新,然后释放。多个下载任务可以分别等待自己的网络请求,登记结果时才短暂互斥。

不过,不能机械地把所有 await 都移到锁外。后面要讲的路径锁可能需要覆盖“检查文件、下载、发布”的整个过程,才能防止两个任务对同一路径重复操作。此时应保留必要的持锁范围,并用适合异步等待的锁;不同路径使用不同锁,让无关下载继续并行。

4 用 RwLock 保护配置

现在我们进入下一个案例:AZULC 启动器需要可持久化的维护配置文件中的 Java 版本,要求多线程操作读和写,不会互相干扰。

先从业务区分“看文件”和“改文件”:多个任务读取稳定的配置通常不会互相破坏,但一个任务重写配置时,其他任务不应把写到一半的内容当成完整结果。RwLock 提供两种许可,read 取得读守卫,write 取得写守卫。

先不碰磁盘,用一个整数模拟配置中的 Java 主版本。下面是完整的标准库示例:

use std::sync::RwLock;

fn main() {
    let java_version = RwLock::new(8_u32);

    let first_reader = java_version.read().unwrap();
    let second_reader = java_version.try_read().unwrap();
    assert_eq!(*first_reader, 8);
    assert_eq!(*second_reader, 8);
    assert!(java_version.try_write().is_err());

    drop(first_reader);
    drop(second_reader);

    {
        let mut writer = java_version.write().unwrap();
        *writer = 21;
        assert!(java_version.try_read().is_err());
    }
    assert_eq!(*java_version.read().unwrap(), 21);
}
  • first_reader 存在时,second_reader 也能进入;但 try_write 失败,因为“读者”们还没有退出。两份读守卫都 drop 后,write 才能取得写守卫。
  • 写守卫存在时,try_read 失败;写守卫离开作用域后,读者看到更新后的 21。

*reader 允许读取内存里的值;*writer = 21 通过可变写守卫修改。先理解这种最常见的“锁保护内部数据”,再看项目为什么把里面的数字换成了空值。

4.1 案例:路径锁注册表中的 RwLock

在 第一章 中,我们介绍了 AZULC 为多线程下载安装操作而创建的路径锁注册表,用来保护和标记文件状态,里面的核心部分就是 RwLock。

路径锁返回 Arc<RwLock<()>>。() 是单元类型,没有业务数据。锁只是标记访问路径的权限,而不需要标记内存里的文件内容。

由 RwLock 的特性,我们知道:

  • 多个读守卫可以同时存在。
  • 写守卫需要排除其他读写守卫。
  • 独立路径获得不同锁,因此不同实例可以继续并发。

安装器按阶段申请共享缓存的访问权限,并对目标实例或文件申请相应独占权限。 下面是独立的 Tokio 教学示例,模拟调用者从注册表取得同一把路径锁后的行为。为了只观察锁,没有真的写文件:

use std::sync::Arc;
use tokio::sync::RwLock;

#[tokio::main]
async fn main() {
    let path_lock = Arc::new(RwLock::new(()));
    let first = path_lock.read().await;
    let second = path_lock.read().await;
    assert!(path_lock.try_write().is_err());
    drop(first);
    drop(second);

    let task_lock = Arc::clone(&path_lock);
    let writer = task_lock.write_owned().await;
    let work = tokio::spawn(async move {
        let _permission = writer;
        // 实际工作在这里读写目标路径;权限持续到此任务结束。
        println!("Updating shared files");
    });
    work.await.unwrap();
    assert!(path_lock.try_read().is_ok());
}

这里介绍一下两个新的方法:read_owned() 和 write_owned() 能够取得拥有 Arc 的守卫,适合把守卫移动到独立任务里。普通 read() 的守卫只能借用锁,生命周期关系不同。

let _permission = writer 能够在工作线程中持有写守卫,当 move 结束后就自然释放,无需其他代码。

4.2 锁的粒度与顺序

锁的粒度值得深思,如果范围太大的话,无法实现高效并发。粒度太小的话,无法保证一个业务的完整性,其他业务可能会趁虚而入,锁失去意义。比如如果先检查文件不存在,释放锁,再下载写入,另一任务可以插进来重复下载。

还要统一多把锁的获取顺序:A 持有 X 等 Y,B 持有 Y 等 X,就会死锁。异步锁一样会死锁,只是等待期间不占用执行器线程。

4.3 小结

  1. 用两个 AtomicUsize 分别保存 running 和 limit,是否自然替代当前 Mutex?
  2. 同一个路径创建两把互不相关的锁,能否防止冲突?
  3. _guard 和 _ 是一样的吗?

答案:

  1. 不能自然替代,检查与增加仍需共同协调。
  2. 不能,必须共享同一把锁。
  3. 不一样;let _guard = ... 保留守卫到作用域结束,但是 _ 的语义是“这个位置不建立绑定”,let _ = ... 通常立即丢弃返回值。读写权限因此可能提前失效。

关于共享所有权与互斥的基础,可补读 Rust Book 的共享状态章节。