外观
第 8 节:逻辑竞态——没有数据竞争,结果照样错
症状
程序编译通过、没有崩溃、也没有"数据竞争"的报错——但结果就是不对:计数器该是 3,出来是 10;限流该拒绝 7 个,结果全放行了。
诊断
数据竞争(data race):多个线程同时读写同一块内存(第 15 章,被编译器/锁拦下)。
逻辑竞态(race condition):没有数据竞争,但"操作顺序"出了问题——多线程交错执行,破坏了"检查→执行"的逻辑。经典模式是 check-then-act(先检查,再动手):检查通过不代表动手时还通过。
实测(限流场景:最多 3 个连接,10 个线程同时来):
运行输出
text
连接成功!(当前 3 个)
连接成功!(当前 2 个)
连接成功!(当前 6 个) ← 超过 3 了!
连接成功!(当前 1 个)
连接成功!(当前 4 个)
连接成功!(当前 5 个)
连接成功!(当前 7 个)
连接成功!(当前 9 个)
连接成功!(当前 10 个)
连接成功!(当前 8 个)
最终活跃连接:10 ← 应该最多 3!代码用了 AtomicUsize(原子操作,没有数据竞争),但 "检查当前数 < 3"和"计数 +1"是两步——10 个线程全在检查时看到"还不到 3",全部通过检查,然后全部 +1。原子保证了每一步不坏,没保证"检查+动手"是一件事。
解药
解药一:把"检查+修改"变成一个原子操作(fetch_update/compare_exchange):
rust
use std::sync::atomic::{AtomicUsize, Ordering};
let active = AtomicUsize::new(0);
// 原子地:"如果还不到 max,就 +1 并返回 Ok"——检查和修改是一件事
let result = active.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
if current < max {
Some(current + 1) // 返回新值
} else {
None // 拒绝:返回 None 表示不改
}
});
match result {
Ok(_) => println!("连接成功"),
Err(_) => println!("连接被拒(已满)"),
}解药二:用 Mutex 把"检查+修改"锁成一段——简单直观,性能够用:
rust
let active = Arc::new(Mutex::new(0));
{
let mut guard = active.lock().unwrap();
if *guard < max {
*guard += 1; // 检查和修改在锁里,一个原子块
println!("连接成功");
} else {
println!("连接被拒(已满)");
}
}解药三:改设计,不做 check-then-act——比如"固定 3 个槽位,抢到才算数"(信号量思想):
rust
// 用"槽位"信号量:try_acquire 原子地抢一个名额
let semaphore = Arc::new(tokio::sync::Semaphore::new(3));
// 每个请求里:
match semaphore.try_acquire() {
Ok(permit) => {
// permit 拿在手里 = 槽位被占用
handle_connection().await;
drop(permit); // 用完归还,别人才能抢
}
Err(_) => println!("没有空位,被拒"),
}permit 的寿命 = 占用的时长
信号量的名额抢到就要拿住:permit 在手里,名额才算占用;permit 一 drop(或离开作用域),名额自动归还。
实测教训:把 Ok(permit) 的 permit 用 _permit 忽略(抢到就丢),10 个请求 8 个都"成功"了——因为前几个抢完立刻释放,后面的接着抢。想限流,permit 要持有到资源用完。
预防
- 看到"先读再写"的两行代码,先问:这两步必须是一件事吗?——是,就原子化(锁或 fetch_update)
AtomicUsize只保证"单步原子":load是原子的,fetch_add是原子的,但"load 然后判断然后 fetch_add"不是——组合操作要组合原语- 逻辑竞态没有编译器提示:它藏在"看起来都对"的代码里——并发逻辑要写测试,多线程多跑几遍(第 10 章测试 + 第 8 节测量)
- 真实项目的限流/配额/抢购,全部用信号量/原子更新:手写 check-then-act 是通病,
Semaphore/fetch_update是标准解药