竞态条件排查

把「谁先谁后」写清楚:共享可变状态上的非原子复合操作,在交错执行下会破坏不变量。本技能对齐 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("库存不足或竞态失败");
Heisenbug 提示:加日志可能改变时序使问题消失。需配合采样、硬件计数器、无 printf 的检测器,或先写出可重复的「压力 + 断言」再缩小日志范围。

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/awaitawait 前后若触摸同一可变状态,等价于在间隙插入其他任务;用队列、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 是最后防线
  }
}
乐观并发(版本号、CAS)失败重试时须检查业务语义:无限自旋、ABA、或「错误重试导致重复副作用」都是常见二次事故。

测试与放大手段

目标是把低概率调度变成可稳定触发的失败,再对照时序假设收敛修复。

  • 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 并发测试 + 断言不变量
- 属性测试:洗牌操作序列,验证最终状态

# 文档化
- 写清合法调度假设与不变量
- 修复后保留回归测试,禁止「修完即删测」

返回技能库 更多技能入口