单核心CPU场景下互斥锁必要性及Rust unsafe代码访问共享变量的安全性疑问
这个问题问得特别戳中很多Rust异步开发者的误区——单核心CPU上,两个任务看起来是串行跑的,为啥直接用unsafe访问共享HashMap还会不安全?我来给你掰扯清楚本质原因,绝对不整虚的。
1. 先搞懂:Rust的unsafe不安全,从来不是只看“硬件是否多核心”
Rust的内存安全规则(比如&mut T必须独占访问)是语言层面的契约,和硬件核心数没关系。你写unsafe代码的时候,相当于手动向编译器承诺:“我会自己遵守所有内存安全规则”。但你这段代码里,明显打破了这个承诺:
你在两个不同的异步任务里,都通过&mut UNSAFE_MAP获取了可变引用——哪怕在单核心上任务是串行执行的,Rust的编译器也无法验证这一点(而且你也没法保证未来不会改代码加入await导致任务切换)。编译器一旦发现你违反了引用规则,就会触发未定义行为——这可不是闹着玩的,它可能让你的HashMap内部结构彻底损坏,导致程序崩溃、数据乱码,甚至出现完全不可预测的结果。
2. 再说说单核心下的“隐藏风险”:你以为的串行,未必真的串行
你用的是Tokio的current_thread调度器,它是协作式的——理论上,只有当任务调用await或者主动让出CPU时,才会切换到另一个任务。但你这段代码里的循环真的不会触发切换吗?
- 首先,
HashMap::insert会涉及内存分配,而很多内存分配器(比如Tokio默认的分配器)在分配内存时可能会主动让出CPU(比如处理内存碎片时),这时候调度器就会切到另一个任务,导致两个任务的insert操作交错执行。 - 其次,就算这次运行时没切换,你能保证未来不会给循环里加个
await吗?比如加个日志输出或者短暂睡眠?一旦加了,任务切换就会立刻发生,两个任务同时操作HashMap的概率直接拉满。
而HashMap本身根本不是为并发访问设计的——它的insert操作涉及修改内部的桶结构、链表,这些操作都是非原子的。如果在操作的中途被切换到另一个任务,HashMap的内部状态会处于“半完成”的损坏状态,后续任何操作都会触发未定义行为。
3. 那单核心下,互斥锁真的有必要吗?
答案是必须要,但不是为了防硬件并发,而是为了:
- 遵守Rust的内存安全契约:互斥锁(比如
tokio::sync::Mutex)能保证同一时刻只有一个任务能获取可变引用,完美符合Rust的&mut T独占规则。 - 保证数据结构操作的原子性:哪怕是单核心,只要有任务切换的可能,互斥锁就能确保整个
insert操作是“原子”完成的,不会暴露中间的损坏状态。 - 代码的健壮性:你不用再担心未来改代码加
await会引入bug,也不用手动维护unsafe的脆弱契约。
4. 给你改个正确的写法(不用unsafe)
把静态变量改成带异步互斥锁的:
use std::collections::HashMap; use tokio::sync::Mutex; static SAFE_MAP: Mutex<HashMap<u32, u32>> = Mutex::const_new(HashMap::new()); #[tokio::main(flavor = "current_thread")] async fn main() { let task1 = tokio::spawn(async { for i in 0..10_000_000u32 { let mut map = SAFE_MAP.lock().await; map.insert(i, i); } }); let task2 = tokio::spawn(async { for i in 0..10_000_000u32 { let mut map = SAFE_MAP.lock().await; map.insert(i + 10_000_000, i); } }); task1.await.unwrap(); task2.await.unwrap(); }
这个写法完全符合Rust的安全规则,不管是单核心还是多核心,都不会有任何内存安全问题,而且代码可读性和健壮性拉满。
备注:内容来源于stack exchange,提问作者Tono Nam

