并发测试与验证:压力测试设计、确定性测试、模型检查、模糊测试

并发代码的测试,说实话,比单线程难了不止一个量级。你想想看,单线程程序跑一遍,结果对就是对,错就是错。但并发程序呢?这次跑对了,下次可能就崩了。我在项目中遇到过好几次这样的场景:测试环境跑了一整天都没问题,一上线就出故障。嗯,这就是并发测试的痛点——非确定性

今天我们就来聊聊,怎么系统地验证并发代码的正确性。我会从四个维度展开:压力测试、确定性测试、模型检查、模糊测试。每个方法都有自己的适用场景,咱们一个一个说。

核心观点:并发测试不是「跑一跑看会不会崩」,而是一套系统性的验证方法。压力测试找性能瓶颈,确定性测试复现bug,模型检查证明正确性,模糊测试探索未知边界。

并发测试与验证方法体系 并发代码验证 压力测试 确定性测试 模型检查 模糊测试 高并发负载 资源争抢模拟 性能瓶颈定位 固定线程调度 可复现执行 精确时序控制 状态空间穷举 形式化验证 死锁/活锁检测 随机输入生成 边界条件探索 异常路径覆盖 四种方法互补,覆盖并发验证的完整生命周期

一、压力测试设计:把系统逼到极限

压力测试,说白了就是「使劲造」。你让系统在远超正常负载的条件下运行,看看它会不会崩溃。我个人习惯把压力测试分成两类:负载测试耐久测试

负载测试关注的是峰值性能。比如一个消息队列,正常情况每秒处理1万条消息,你压到10万条,看看会不会丢消息、会不会死锁。耐久测试则是长时间运行,我曾经有一个项目,压力测试跑了8小时都没事,但跑到第9小时突然内存暴涨——嗯,内存泄漏了。

我的经验:压力测试不要只盯着「通过/不通过」。要记录关键指标:吞吐量、延迟分布、CPU/内存使用率、上下文切换次数。这些数据能帮你定位瓶颈在哪。

写压力测试代码时,有几个要点:

  • 线程数要可配置:别写死,方便调整并发度
  • 要有预热阶段:JIT编译、缓存预热都需要时间
  • 统计要准确:用原子变量或累加器,别用普通int
// 一个简单的压力测试框架示例
#include <atomic>
#include <thread>
#include <vector>
#include <chrono>
#include <iostream>

class StressTest {
public:
    StressTest(int threads, int duration_sec) 
        : num_threads_(threads), duration_(duration_sec) {}
    
    void run() {
        std::atomic<long long> counter{0};
        std::atomic<bool> stop{false};
        
        auto worker = [&]() {
            while (!stop.load()) {
                // 模拟业务操作
                do_work();
                counter.fetch_add(1, std::memory_order_relaxed);
            }
        };
        
        std::vector<std::thread> threads;
        for (int i = 0; i < num_threads_; ++i) {
            threads.emplace_back(worker);
        }
        
        std::this_thread::sleep_for(std::chrono::seconds(duration_));
        stop.store(true);
        
        for (auto& t : threads) t.join();
        
        std::cout << "Total ops: " << counter.load() << "\n";
        std::cout << "Throughput: " << counter.load() / duration_ << " ops/s\n";
    }
    
private:
    void do_work() {
        // 实际业务逻辑
    }
    
    int num_threads_;
    int duration_;
};

二、确定性测试:让bug无处遁形

并发程序最烦人的是什么?就是bug不能稳定复现。你写了个死锁,它可能跑100次才出现一次。这时候就需要确定性测试了。

确定性测试的核心思想是:控制线程的调度顺序。你手动指定线程A先执行到某一行,然后线程B再执行,这样就能精确复现特定的交错场景。

我在项目中用过几种方法:

  • 使用屏障(barrier):让线程在特定点等待,然后按指定顺序释放
  • 使用条件变量:精确控制线程的唤醒时机
  • 使用测试框架:比如Google Test的线程安全测试工具

注意:确定性测试只能覆盖你「想到」的场景。你设计了多少种交错顺序,就只能验证多少种。对于复杂的并发系统,光靠确定性测试是不够的。

// 确定性测试示例:精确控制线程交错
#include <mutex>
#include <condition_variable>
#include <thread>
#include <cassert>

std::mutex mtx;
std::condition_variable cv;
int step = 0;

void thread_a() {
    std::unique_lock<std::mutex> lock(mtx);
    // 等待主线程允许执行
    cv.wait(lock, []{ return step == 1; });
    
    // 执行关键操作
    // ...
    
    step = 2;
    cv.notify_all();
}

void thread_b() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return step == 2; });
    
    // 执行关键操作
    // ...
    
    step = 3;
    cv.notify_all();
}

void test_deadlock_scenario() {
    std::thread t1(thread_a);
    std::thread t2(thread_b);
    
    {
        std::lock_guard<std::mutex> lock(mtx);
        step = 1;
    }
    cv.notify_all();
    
    t1.join();
    t2.join();
    
    // 验证结果
    assert(step == 3);
}

三、模型检查:用数学证明正确性

模型检查,听起来很高大上对吧?说白了就是穷举所有可能的状态,看看有没有违反你设定的规则。它不像测试那样「抽样检查」,而是「全面检查」。

但代价也很明显——状态空间爆炸。一个简单的并发程序,状态数可能就上亿了。所以模型检查通常用于核心逻辑,而不是整个系统。

常用的模型检查工具有:

  • Spin:用Promela语言建模,检查LTL性质
  • TLA+:亚马逊在用的形式化验证工具
  • CBMC:C语言的模型检查器,可以直接检查C代码

实际案例:我曾经用TLA+验证过一个分布式锁的实现。建模花了3天,但检查出了一个只有在5个节点同时故障时才会触发的活锁bug。这种bug靠普通测试根本测不出来。

模型检查的典型流程:

  1. 将并发系统抽象为状态机模型
  2. 定义需要验证的性质(无死锁、无活锁、最终一致性等)
  3. 运行模型检查器,穷举所有可达状态
  4. 如果发现反例,分析并修复

四、模糊测试:让计算机帮你找bug

模糊测试(fuzzing)的思路很暴力:生成大量随机输入,看看程序会不会崩溃。对于并发代码,模糊测试不仅要随机化输入数据,还要随机化线程调度。

我常用的模糊测试策略:

  • 随机延迟注入:在关键操作前后插入随机的sleep
  • 线程数随机化:每次测试用不同的线程数
  • 操作顺序随机化:随机打乱操作的执行顺序

小技巧:模糊测试发现bug后,一定要记录下触发bug的「种子」。这样你可以用同一个种子反复复现,方便调试。我习惯把种子值打印到日志里。

// 并发模糊测试示例:随机延迟注入
#include <random>
#include <thread>
#include <vector>

class FuzzTester {
public:
    FuzzTester(unsigned seed) : rng_(seed) {}
    
    void inject_random_delay() {
        std::uniform_int_distribution<int> dist(0, 100);
        int delay_us = dist(rng_);
        if (delay_us > 0) {
            std::this_thread::sleep_for(std::chrono::microseconds(delay_us));
        }
    }
    
    void run_fuzz_test(int num_threads) {
        std::vector<std::thread> threads;
        for (int i = 0; i < num_threads; ++i) {
            threads.emplace_back([this, i]() {
                for (int j = 0; j < 1000; ++j) {
                    inject_random_delay();
                    // 执行被测试的操作
                    do_operation(i, j);
                }
            });
        }
        for (auto& t : threads) t.join();
    }
    
private:
    void do_operation(int thread_id, int op_id) {
        // 被测试的并发操作
    }
    
    std::mt19937 rng_;
};

五、四种方法的对比与选择

说了这么多,到底该用哪种?我整理了一个表格,方便你对比:

方法 覆盖范围 自动化程度 适用阶段 主要成本
压力测试 性能问题、资源泄漏 集成测试、上线前 运行时间长
确定性测试 特定交错场景 单元测试 人工设计成本高
模型检查 所有可能状态 低(需建模) 设计阶段 状态空间爆炸
模糊测试 未知边界 持续集成 需要种子管理

我个人建议的实践顺序是:

  1. 设计阶段:用模型检查验证核心逻辑的正确性
  2. 开发阶段:写确定性测试覆盖关键的交错场景
  3. 集成阶段:跑压力测试,看性能是否达标
  4. 持续集成:加入模糊测试,探索未知的bug

曾经踩过的坑:我有一回只做了压力测试,觉得系统很稳。结果上线后,在特定的业务高峰期,出现了数据不一致。后来用模型检查才发现,是某个边界条件下的竞态条件。从那以后,我再也不敢只用一种测试方法了。

并发测试没有银弹。每种方法都有自己的盲区,只有组合使用,才能最大程度地保证并发代码的正确性。你想想看,一个系统如果能在压力测试下不崩溃、在确定性测试下逻辑正确、在模型检查下没有死锁、在模糊测试下不出现未定义行为——那它基本上就是可靠的。


公众号:蓝海资料掘金营,微信deep3321