22、并发测试:并发测试的挑战、确定性测试、压力测试、模糊测试

并发编程里,最让人头疼的是什么?我个人觉得,不是死锁,也不是数据竞争,而是——测不出来

你写了个多线程模块,跑了一遍,没问题。跑十遍,也没问题。结果上线第一天,用户量一上来,啪,崩了。这种经历,我遇到过不止一次。说白了,并发 bug 就像幽灵,你越是想抓它,它越躲着走。

为什么会这样?因为线程调度是不确定的。每次运行,操作系统都可能把线程安排到不同的 CPU 核心上,执行顺序也千差万别。你测试时没触发那个特定的交错顺序,bug 就藏起来了。

所以,并发测试不能靠「跑一跑试试」。我们需要系统的方法。今天我就把这几年积累的并发测试经验,拆成四个部分讲给你听。

并发测试的核心挑战

先说说并发测试到底难在哪。我总结了三座大山:

  • 不确定性:同样的代码,每次执行结果可能不同。你没法像单线程那样「复现」一个 bug。
  • 状态爆炸:N 个线程,M 个共享变量,可能的交错路径是天文数字。你不可能穷举。
  • 时序敏感:很多 bug 只在特定时间窗口内触发,比如刚好在某个线程读变量时,另一个线程在写。

核心观点:并发测试的目标不是「证明没有 bug」,而是「尽可能多地暴露 bug」。承认这一点,你的测试策略才会务实。

我记得有一次,一个同事信誓旦旦地说他的队列实现是线程安全的。我让他跑了一晚上压力测试,第二天一看日志——第 3 亿次操作时,数据错乱了。你看,这就是并发测试的残酷之处:没出问题不代表没问题

确定性测试:让并发变得可预测

既然并发 bug 难复现,那我们就想办法让它变得可复现。这就是确定性测试的思路。

说白了,就是控制线程的执行顺序。你手动指定:线程 A 先执行到某一行,然后线程 B 执行,再切回线程 A。这样,每次测试的路径都是一样的。

在 C 语言里,怎么做?我常用的方法是用条件变量和屏障(barrier)来「编排」线程。

// 确定性测试示例:手动控制线程交错
#include <pthread.h>
#include <stdio.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int step = 0;

void* thread_a(void* arg) {
    pthread_mutex_lock(&lock);
    // 等待 step == 0
    while (step != 0) {
        pthread_cond_wait(&cond, &lock);
    }
    // 执行关键操作 1
    printf("Thread A: step 1\n");
    step = 1;
    pthread_cond_broadcast(&cond);
    pthread_mutex_unlock(&lock);
    return NULL;
}

void* thread_b(void* arg) {
    pthread_mutex_lock(&lock);
    // 等待 step == 1
    while (step != 1) {
        pthread_cond_wait(&cond, &lock);
    }
    // 执行关键操作 2
    printf("Thread B: step 2\n");
    step = 2;
    pthread_cond_broadcast(&cond);
    pthread_mutex_unlock(&lock);
    return NULL;
}

我的习惯:在写确定性测试时,我会把「线程交错点」用宏封装起来。比如 WAIT_FOR_STEP(n)SET_STEP(n),这样测试代码读起来更清晰。

确定性测试的优点是:一旦发现 bug,你可以在调试器里精确复现。缺点是:写起来费劲,而且只能覆盖你手动指定的路径。所以它适合用来验证核心逻辑,不适合做大规模覆盖。

压力测试:用暴力暴露问题

压力测试的思路正好相反——我不控制你,我让你疯狂跑。用大量的线程、大量的操作、长时间的运行,来「撞」出那些罕见的交错。

我见过最狠的一次,是让 64 个线程同时往一个哈希表里插入数据,跑了整整 48 小时。结果抓到了三个之前从未见过的死锁。

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

  • 线程数要够多:至少是 CPU 核心数的 2-4 倍,让线程切换更频繁。
  • 操作要随机:读、写、删除混合,模拟真实负载。
  • 要有校验点:跑完之后,检查数据结构的一致性。
// 压力测试框架示例
#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

#define NUM_THREADS 16
#define OPS_PER_THREAD 1000000

typedef struct {
    int* shared_data;
    int thread_id;
} thread_arg_t;

void* stress_test(void* arg) {
    thread_arg_t* targ = (thread_arg_t*)arg;
    for (int i = 0; i < OPS_PER_THREAD; i++) {
        // 随机选择操作
        int op = rand() % 3;
        int idx = rand() % 100;
        switch (op) {
            case 0: // 读
                volatile int val = targ->shared_data[idx];
                break;
            case 1: // 写
                targ->shared_data[idx] = rand();
                break;
            case 2: // 读写
                targ->shared_data[idx] += 1;
                break;
        }
    }
    return NULL;
}

int main() {
    int data[100] = {0};
    pthread_t threads[NUM_THREADS];
    thread_arg_t args[NUM_THREADS];

    for (int i = 0; i < NUM_THREADS; i++) {
        args[i].shared_data = data;
        args[i].thread_id = i;
        pthread_create(&threads[i], NULL, stress_test, &args[i]);
    }

    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }

    // 校验:这里应该检查 data 的一致性
    printf("Stress test completed.\n");
    return 0;
}

注意:压力测试跑出来的 bug,往往很难定位。因为你不知道是第几次操作、哪个线程触发的。我的建议是:在压力测试代码里埋日志点,记录每次操作的关键信息。这样出问题时,至少能缩小范围。

模糊测试:让随机性帮你找 bug

模糊测试(Fuzzing)是压力测试的升级版。它不只是随机操作,而是随机生成输入数据,甚至随机注入系统调用失败、内存分配失败等异常情况。

在并发场景下,模糊测试特别适合用来检测:

  • 对空指针、越界访问的处理
  • 资源泄漏(比如锁没释放)
  • 异常路径下的死锁

我曾经用 libFuzzer 配合线程消毒器(ThreadSanitizer)跑过一个并发队列的实现。结果发现了一个只有在 malloc 返回 NULL 时才会触发的死锁。这种 bug,你靠常规测试根本测不出来。

// 模糊测试思路示例(配合 ThreadSanitizer)
// 编译时加 -fsanitize=thread -fsanitize=fuzzer

#include <stdint.h>
#include <stddef.h>

// 假设这是你的并发队列
extern void queue_push(int value);
extern int queue_pop(void);

int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
    // 用模糊数据驱动操作
    for (size_t i = 0; i < size; i++) {
        if (data[i] % 2 == 0) {
            queue_push(data[i]);
        } else {
            queue_pop();
        }
    }
    return 0;
}

小技巧:模糊测试最好和 AddressSanitizer、ThreadSanitizer 一起用。这些工具能在运行时检测到内存错误和数据竞争,比你自己肉眼查日志高效得多。

三种测试策略的对比

我把这三种方法放在一起对比一下,方便你根据场景选择:

测试类型 优点 缺点 适用场景
确定性测试 可复现、易调试 覆盖路径少、编写成本高 核心逻辑验证、边界条件
压力测试 覆盖广、能发现罕见 bug 难定位、耗时长 集成测试、上线前回归
模糊测试 自动化程度高、能发现异常路径 需要工具链支持、结果分析复杂 安全敏感模块、资源管理

知识体系总览

下面这张图,把并发测试的核心脉络梳理了一下。你可以把它当作一个检查清单:

并发测试 确定性测试 手动控制线程交错 条件变量 + 屏障 压力测试 大量线程 + 长时间 随机操作 + 校验点 模糊测试 随机输入 + 异常注入 libFuzzer + TSan 三者结合,覆盖从单元到集成的并发测试

嗯,到这里,并发测试的三种核心方法就讲完了。我个人觉得,没有哪种方法是银弹。实际项目中,我通常是:核心逻辑用确定性测试,集成阶段跑压力测试,长期维护靠模糊测试。三者配合,才能把并发 bug 的生存空间压缩到最小。

最后说一句:并发测试不是一次性的工作。每次修改代码,都要重新跑一遍。我曾经因为改了一行日志打印,引入了一个锁顺序不一致的问题——这种坑,踩过一次就记住了。


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