竞态条件排查
把「谁先谁后」写清楚:共享可变状态上的非原子复合操作,在交错执行下会破坏不变量。本技能对齐 happens-before、锁与测试三条主线,并用手动调度示例体会丢失更新。
SKILL 指导 Agent 先列出参与者(线程、协程、进程、副本)与共享资源(内存单元、缓存行、行记录、文档版本),再追问:若没有互斥或同步,是否存在两种合法调度给出不同终态?若「不能形式化回答」,就仍有竞态风险待验证。
概述与典型模式
竞态指多个执行流对共享可变状态的访问结果依赖于调度顺序;data race在语言内存模型里通常有更窄定义(如无同步的非原子冲突访问)。排查时两者常一起出现,但修复策略可能不同:有时只需可见性(volatile / 发布-订阅),有时必须互斥或改写为单写者。
- check-then-act:先读条件再改状态,中间可被插队(如「若库存>0 则扣减」)。
- read-modify-write 非原子:计数器、位图、JSON 补丁读改写。
- 双重检查锁定:构造未完成就被其他线程读到半初始化对象。
- 跨 await / I/O:临界区在异步边界被撕开,中间状态暴露。
check-then-act 竞态与数据库修复示例:
// ❌ check-then-act:库存扣减竞态(Node.js)
async function deductStock(productId, qty) {
const stock = await db.query(
"SELECT stock FROM products WHERE id = ?", [productId]);
// ← 两个并发请求都读到 stock=1,都通过了检查
if (stock.rows[0].stock >= qty) {
await db.query(
"UPDATE products SET stock = stock - ? WHERE id = ?",
[qty, productId]); // 两个都扣减,stock 变 -1
}
}
// ✅ 数据库级修复:SELECT FOR UPDATE(悲观锁)
async function deductStockSafe(productId, qty) {
await db.query("BEGIN");
try {
const result = await db.query(
"SELECT stock FROM products WHERE id = ? FOR UPDATE",
[productId] // 行级锁,其他事务必须等待
);
if (result.rows[0].stock >= qty) {
await db.query(
"UPDATE products SET stock = stock - ? WHERE id = ?",
[qty, productId]);
}
await db.query("COMMIT");
} catch (e) {
await db.query("ROLLBACK");
throw e;
}
}
// ✅ 乐观锁:UPDATE ... WHERE stock >= qty(利用原子性)
const res = await db.query(
"UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?",
[qty, productId, qty]
);
if (res.rowCount === 0) throw new Error("库存不足或竞态失败");
Happens-before 与可见性
Happens-before(及其在分布式中的类比:先于关系 / 因果序)回答:线程 B 是否「必定看到」线程 A 的写入?没有 hb 边时,编译器、CPU、缓存可能重排,读者可见过期值或撕裂读。
- 语言级:锁释放-获取、线程启动/join、
volatile(因语言而异)、原子操作的 order。 - 并发集合:队列的「生产 happens-before 消费」常用于传递可见性。
- 分布式:单调版本向量、lease、fence;客户端重试与幂等键与「逻辑上重放同一操作」相关,需单独建模。
Agent 在文档化时应画出必须成立的 hb 边:例如「写配置 → 发布标志」之间是否缺一道同步。缺边即候选根因,而不是先默认「加锁万能」。
锁、临界区与异步间隙
互斥锁将 check-then-act 包进临界区,代价是粒度与死锁风险。读写锁适合读多写少,但要警惕读者升级、写饥饿与缓存行乒乓。无锁结构(CAS、RCU)把正确性押在内存序上,排查更难,应用检测器与形式化注释约束使用方式。
- 临界区应尽可能短;锁内避免 I/O、RPC、慢哈希。
- 锁顺序全局一致,避免 AB-BA;超时锁要定义失败策略。
- async/await:
await前后若触摸同一可变状态,等价于在间隙插入其他任务;用队列、actor、或「单线程事件循环内修改」收窄交错面。
Redis 分布式锁实现(SET NX PX + Lua 原子释放):
// Redis 分布式锁(Node.js + ioredis)
const Redis = require("ioredis");
const { randomUUID } = require("crypto");
const redis = new Redis();
// 获取锁:SET key value NX PX ttl
async function acquireLock(key, ttlMs = 5000) {
const token = randomUUID(); // 唯一 token,防止误释放他人锁
const ok = await redis.set(key, token, "NX", "PX", ttlMs);
return ok === "OK" ? token : null;
}
// 释放锁:Lua 脚本保证 check-and-delete 的原子性
const RELEASE_SCRIPT = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end`;
async function releaseLock(key, token) {
return redis.eval(RELEASE_SCRIPT, 1, key, token);
}
// 使用模式
async function criticalSection(resourceId) {
const lockKey = `lock:${resourceId}`;
const token = await acquireLock(lockKey, 3000);
if (!token) throw new Error("获取锁失败,请重试");
try {
// 执行临界区操作
await doWork(resourceId);
} finally {
await releaseLock(lockKey, token); // 必须释放,TTL 是最后防线
}
}
测试与放大手段
目标是把低概率调度变成可稳定触发的失败,再对照时序假设收敛修复。
- ThreadSanitizer / 竞态检测:C/C++、Go 等;Java 可用特定工具与 strict 模式配合代码审查。
- 压力与并发测试:多线程循环、固定种子随机调度、缩短 sleep 放大交错。
- 属性测试 / 模型检验:对小状态机声明不变量,由框架洗牌操作序列。
- 分布式:jepsen 风格分区与时钟偏移;数据库用隔离级别 + 唯一约束作最后防线。
Go -race 检测器与并发测试框架示例:
// Go race detector 使用
// go test -race ./... # 所有包跑测试时启用 race detector
// go run -race main.go # 运行时启用(开销约 5-10x,仅用于测试)
// go build -race -o app_race # 构建带 race 的可执行文件
// Go 并发安全性测试示例
func TestCounter_Concurrent(t *testing.T) {
c := &Counter{}
var wg sync.WaitGroup
const goroutines = 100
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
c.Increment()
}()
}
wg.Wait()
// go test -race 会捕获 c.value 上的 data race
if c.Value() != goroutines {
t.Errorf("got %d, want %d", c.Value(), goroutines)
}
}
// Node.js 并发测试(多个 Promise 同时触发)
test("库存扣减无超卖", async () => {
await db.query("UPDATE products SET stock = 1 WHERE id = 1");
// 同时发起 10 个并发扣减请求,只有 1 个应该成功
const results = await Promise.allSettled(
Array.from({ length: 10 }, () => deductStockSafe(1, 1))
);
const succeeded = results.filter(r => r.status === "fulfilled").length;
expect(succeeded).toBe(1); // 断言只有 1 次成功
const stock = await db.query("SELECT stock FROM products WHERE id = 1");
expect(stock.rows[0].stock).toBe(0); // 不能为负数
});
每项修复应附带回归策略:要么保留压力测试,要么保留静态/检测器门禁,避免「修完即删测」导致复发。
排查流程
建议固定顺序,避免在未理清共享状态前就改锁层次,造成死锁或掩盖真实交错。
[ 列出共享可变状态 + 所有读者/写者 ]
│
▼
[ 标出非原子复合:check-then-act / RMW / 跨 await ]
│
▼
[ 推导 happens-before:哪些写应对哪些读可见?缺哪条边? ]
│
┌────────┴────────┐
▼ ▼
[ 选同步原语 ] [ 结构消除:不可变 / 单写者 / 队列 ]
│ │
└────────┬────────┘
▼
[ TSAN / 压力 / 属性测试 放大并门禁 ]
│
▼
[ 文档化不变量与合法调度假设 ]
交错轨迹小实验与清单
下面示例中初始 x = 0。线程 A 执行「读 x → 写 x 为读值+1」;线程 B 执行「读 x → 写 x 为读值+2」。三步串行与一种典型交错会得到不同终态,可借此向非并发背景同事说明「丢失更新」。
点击预设查看逐步轨迹与最终 x(仅前端演示,无网络请求)。
现场排查勾选(勾选状态保存在本机浏览器):
---
name: race-condition
description: 竞态条件排查:时序、happens-before、锁与测试方法
---
# 排查步骤
1. 列出共享可变状态与所有读者/写者(含跨 await 间隙)
2. 标出非原子复合操作:check-then-act / read-modify-write / 跨 await
3. 推导 happens-before;缺边则设计同步或结构消除
# 数据库级保护
- 悲观锁:SELECT ... FOR UPDATE(适合高冲突写场景)
- 乐观锁:UPDATE ... WHERE version = ? 或 WHERE stock >= ?
- 唯一约束作最后防线(防超卖兜底)
# 应用层锁
- Redis 分布式锁:SET key token NX PX ttl
- 释放:Lua check-and-delete 保证原子性
- 超时续期:Redlock 或手动 watchdog
# 无锁设计
- CAS 操作:compare-and-swap(Java AtomicInteger / Go sync/atomic)
- 注意 ABA 问题与自旋退避策略
# 测试方法
- Go:go test -race ./... 检测 data race
- Node.js:Promise.allSettled 并发测试 + 断言不变量
- 属性测试:洗牌操作序列,验证最终状态
# 文档化
- 写清合法调度假设与不变量
- 修复后保留回归测试,禁止「修完即删测」