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 | 难定位、耗时长 | 集成测试、上线前回归 |
| 模糊测试 | 自动化程度高、能发现异常路径 | 需要工具链支持、结果分析复杂 | 安全敏感模块、资源管理 |
知识体系总览
下面这张图,把并发测试的核心脉络梳理了一下。你可以把它当作一个检查清单:
嗯,到这里,并发测试的三种核心方法就讲完了。我个人觉得,没有哪种方法是银弹。实际项目中,我通常是:核心逻辑用确定性测试,集成阶段跑压力测试,长期维护靠模糊测试。三者配合,才能把并发 bug 的生存空间压缩到最小。
最后说一句:并发测试不是一次性的工作。每次修改代码,都要重新跑一遍。我曾经因为改了一行日志打印,引入了一个锁顺序不一致的问题——这种坑,踩过一次就记住了。