18、测试多线程代码:使用CUnit测试线程安全函数、处理竞态条件

多线程测试,说实话,是单元测试里最让人头疼的一块。我早年做嵌入式项目时,就吃过竞态条件的大亏——一个全局变量被两个线程同时读写,bug 时隐时现,折腾了两周才定位到。从那以后,我养成了一个习惯:凡是涉及多线程的代码,必须用测试工具把竞态条件“逼”出来。

这一章,我们就聊聊怎么用 CUnit 来测试线程安全函数,以及如何设计用例来暴露竞态条件。

18.1 线程安全函数长什么样?

先明确一个概念:线程安全函数,指的是多个线程同时调用它,不会产生数据竞争或逻辑错误。常见的实现手段有:

  • 加锁保护:用互斥锁(mutex)保护共享资源
  • 无锁设计:使用原子操作或线程局部存储
  • 不可变数据:函数内部不修改全局状态

举个例子,一个简单的计数器:

// counter.h
typedef struct {
    int value;
    pthread_mutex_t lock;
} SafeCounter;

void counter_init(SafeCounter *c);
void counter_increment(SafeCounter *c);
int counter_get(SafeCounter *c);
// counter.c
void counter_init(SafeCounter *c) {
    c->value = 0;
    pthread_mutex_init(&c->lock, NULL);
}

void counter_increment(SafeCounter *c) {
    pthread_mutex_lock(&c->lock);
    c->value++;
    pthread_mutex_unlock(&c->lock);
}

int counter_get(SafeCounter *c) {
    pthread_mutex_lock(&c->lock);
    int val = c->value;
    pthread_mutex_unlock(&c->lock);
    return val;
}

这个实现加了锁,理论上线程安全。但理论归理论,我们得用测试来验证。

18.2 用 CUnit 测试线程安全函数

CUnit 本身不直接提供多线程测试框架,但我们可以利用 POSIX 线程(pthread)来创建多个线程,在测试函数中并发调用目标函数。

核心思路:

  • 启动 N 个线程,每个线程循环调用目标函数 M 次
  • 主线程等待所有线程结束
  • 检查最终结果是否符合预期(比如计数器值是否等于 N * M)

来看一个完整的测试用例:

#include <CUnit/CUnit.h>
#include <pthread.h>
#include "counter.h"

#define THREAD_NUM 4
#define ITER_PER_THREAD 10000

SafeCounter g_counter;

typedef struct {
    int thread_id;
    int result;
} ThreadArg;

void* thread_func(void *arg) {
    ThreadArg *targ = (ThreadArg*)arg;
    for (int i = 0; i < ITER_PER_THREAD; i++) {
        counter_increment(&g_counter);
    }
    targ->result = counter_get(&g_counter);
    return NULL;
}

void test_counter_thread_safe(void) {
    counter_init(&g_counter);

    pthread_t threads[THREAD_NUM];
    ThreadArg args[THREAD_NUM];

    // 创建线程
    for (int i = 0; i < THREAD_NUM; i++) {
        args[i].thread_id = i;
        pthread_create(&threads[i], NULL, thread_func, &args[i]);
    }

    // 等待所有线程结束
    for (int i = 0; i < THREAD_NUM; i++) {
        pthread_join(threads[i], NULL);
    }

    // 验证最终结果
    int final_val = counter_get(&g_counter);
    CU_ASSERT_EQUAL(final_val, THREAD_NUM * ITER_PER_THREAD);
}

关键点:这里我们用了 4 个线程,每个线程执行 10000 次递增。如果计数器实现有竞态条件,最终值大概率会小于 40000。我建议迭代次数设大一些,太小了可能碰不到竞态。

18.3 处理竞态条件:如何设计“破坏性”测试

竞态条件有个特点:它不一定每次都能复现。你跑 10 次可能只出现 1 次。所以测试设计要刻意制造“碰撞”。

我个人习惯用以下几种手法:

  1. 高并发 + 短操作:让线程密集执行短小操作,增加锁竞争概率
  2. 线程数多于 CPU 核心数:迫使线程频繁切换,暴露时序问题
  3. 随机延迟:在线程内部插入 usleep(rand()%10),打乱执行顺序
  4. 重复运行:把测试用例放在循环里跑 100 次,直到出现失败

举个例子,给上面的测试加上随机延迟:

void* thread_func_with_delay(void *arg) {
    ThreadArg *targ = (ThreadArg*)arg;
    for (int i = 0; i < ITER_PER_THREAD; i++) {
        counter_increment(&g_counter);
        // 随机延迟 0~5 微秒,增加竞态暴露概率
        usleep(rand() % 5);
    }
    targ->result = counter_get(&g_counter);
    return NULL;
}

注意:随机延迟会显著增加测试执行时间。我曾经在一个项目中加了 1 毫秒的延迟,结果测试跑了 20 分钟才完成。建议只在调试阶段使用,或者把迭代次数调低。

18.4 测试无锁实现:一个反面教材

有些同学喜欢用无锁编程来提升性能。但无锁代码的测试更难。来看一个“看似安全”的无锁计数器:

// 错误示例:无锁但非原子操作
void unsafe_increment(int *counter) {
    int temp = *counter;  // 读
    temp++;               // 改
    *counter = temp;      // 写
    // 这三步不是原子的,线程切换时数据会丢失
}

用我们上面的多线程测试去跑这个函数,几乎每次都会失败。但如果你只跑 2 个线程、每个线程 100 次,可能偶尔能通过。这就是竞态条件的狡猾之处。

正确的无锁实现应该使用原子操作(C11 的 atomic_fetch_add 或 GCC 的 __sync_fetch_and_add):

#include <stdatomic.h>

atomic_int g_atomic_counter = 0;

void atomic_increment() {
    atomic_fetch_add(&g_atomic_counter, 1);
}

用 CUnit 测试原子操作时,同样可以用多线程并发调用的方式验证。

18.5 测试中的常见陷阱

嗯,这里要提醒几个容易踩的坑:

陷阱 说明 建议
测试本身不是线程安全的 多个线程同时调用 CU_ASSERT 宏,可能导致断言结果混乱 只在主线程中做断言检查,工作线程只负责执行操作
忘记初始化互斥锁 静态分配的 mutex 如果没有初始化,行为未定义 统一用 pthread_mutex_init 初始化
测试时间过长 高并发测试可能跑几秒甚至几分钟 设置超时机制,或者把压力测试单独放在一个 suite 里
忽略线程创建失败 pthread_create 可能返回错误 检查返回值,失败时跳过测试或报错

小技巧:我曾经在测试一个队列模块时,发现测试偶尔失败但代码看起来没问题。后来加了线程 ID 日志,才发现是测试用例里两个线程共用了同一个局部变量作为参数。记住:每个线程的参数必须是独立的。

18.6 知识体系图

下面这张图总结了多线程测试的核心流程和关键点:

多线程测试知识体系 测试准备 初始化共享资源 并发执行 创建N个线程 结果验证 主线程断言检查 关键设计要素 线程数 > CPU核心数 增加切换概率 迭代次数足够大 暴露竞态条件 随机延迟插入 打乱执行顺序 重复运行测试 提高复现概率 常见陷阱 ❌ 工作线程中做断言 ❌ 忽略线程创建失败 ❌ 共享参数未独立 目标:让竞态条件从“偶发”变成“必现”

18.7 总结

测试多线程代码,说白了就是和“不确定性”做斗争。你没法保证一次测试就能覆盖所有时序,但可以通过精心设计的并发场景,把竞态条件逼到墙角。

我个人总结了三句话:

  • 加锁的代码不一定安全——锁的粒度、顺序、遗漏都可能引入 bug
  • 无锁的代码更难测——原子操作虽然快,但心智负担大
  • 测试本身也要线程安全——断言留在主线程,工作线程只管干活

最后送大家一句话:多线程测试不是一次性的工作,而是持续的过程。每次修改共享资源的访问逻辑,都应该跑一遍并发测试。别问我怎么知道的——我曾经因为少跑一次测试,在线上环境排查了三天。