17、多线程测试:使用Google Test测试多线程代码,检测竞态条件和死锁
多线程测试,说实话,是很多C++工程师的噩梦。
我见过太多项目,单测全绿,一上多线程就崩。崩得还很随机,今天跑没事,明天跑就挂。你盯着代码看半天,觉得逻辑没问题啊?嗯,问题就出在「你觉得」上。
多线程的bug,本质上是时序问题。两个线程谁先谁后,差几个纳秒,结果可能天差地别。这种bug靠人眼review很难发现,得靠测试工具来抓。
今天我们就聊聊,怎么用Google Test来对付这些「幽灵bug」。
17.1 多线程测试的难点在哪?
先说说痛点。单线程测试,输入输出是确定的。你给函数传1,它返回2,那就一定是2。多线程不一样,同样的输入,跑十次可能有十种结果。
为什么会这样?因为线程调度是操作系统决定的。你的代码里写了A->B的顺序,但实际执行可能是B->A,甚至A和B交错执行。这就是竞态条件(Race Condition)的根源。
死锁更恶心。两个线程互相等对方释放资源,程序卡死。单测跑的时候可能没事,生产环境压力一大就死给你看。
核心观点:多线程测试不是为了证明代码「能跑」,而是为了证明代码「在各种时序下都能跑」。
17.2 Google Test 的多线程支持
Google Test本身不提供多线程测试的专用宏,但它提供了几个关键能力:
- ASSERT_DEATH / EXPECT_DEATH:检测线程是否异常退出
- EXPECT_TRUE / EXPECT_FALSE:配合条件变量做时序验证
- ::testing::AssertionResult:自定义断言,封装多线程检查逻辑
- TestWithParam:参数化测试,模拟不同并发量
说白了,Google Test给了你一把「手术刀」,但怎么切,得你自己来。
17.3 实战:检测竞态条件
先看一个经典的竞态条件例子。一个计数器,两个线程同时加1,预期结果是2,但实际可能是1。
#include <gtest/gtest.h>
#include <thread>
#include <atomic>
// 有竞态的计数器
class UnsafeCounter {
public:
void increment() { ++count_; }
int get() const { return count_; }
private:
int count_ = 0;
};
// 无竞态的计数器
class SafeCounter {
public:
void increment() { count_.fetch_add(1, std::memory_order_relaxed); }
int get() const { return count_.load(std::memory_order_relaxed); }
private:
std::atomic<int> count_{0};
};
// 测试竞态条件
TEST(RaceConditionTest, UnsafeCounterShouldFail) {
UnsafeCounter counter;
std::thread t1([&]() {
for (int i = 0; i < 10000; ++i) counter.increment();
});
std::thread t2([&]() {
for (int i = 0; i < 10000; ++i) counter.increment();
});
t1.join();
t2.join();
// 预期是20000,但大概率不是
EXPECT_NE(counter.get(), 20000);
}
TEST(RaceConditionTest, SafeCounterShouldPass) {
SafeCounter counter;
std::thread t1([&]() {
for (int i = 0; i < 10000; ++i) counter.increment();
});
std::thread t2([&]() {
for (int i = 0; i < 10000; ++i) counter.increment();
});
t1.join();
t2.join();
EXPECT_EQ(counter.get(), 20000);
}
我的经验:写这种测试时,循环次数别太少。100次可能碰不到竞态,10000次基本必现。我习惯用100000次,跑个几十轮,心里才踏实。
17.4 检测死锁:超时策略
死锁的检测思路很简单:如果线程卡住了,那就超时报错。Google Test没有内置超时宏,但我们可以自己封装。
#include <gtest/gtest.h>
#include <thread>
#include <mutex>
#include <chrono>
// 死锁检测辅助函数
bool detect_deadlock(std::function<void()> func, int timeout_ms = 1000) {
std::thread t(func);
auto start = std::chrono::steady_clock::now();
while (std::chrono::steady_clock::now() - start < std::chrono::milliseconds(timeout_ms)) {
if (t.joinable()) {
// 线程还在跑,继续等
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
if (t.joinable()) {
// 超时了还没结束,判定为死锁
t.detach(); // 分离,避免程序崩溃
return true; // 检测到死锁
}
t.join();
return false; // 正常结束
}
// 一个会死锁的例子
std::mutex m1, m2;
void deadlock_func() {
std::lock_guard<std::mutex> lock1(m1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::lock_guard<std::mutex> lock2(m2); // 这里可能死锁
}
TEST(DeadlockTest, DetectDeadlock) {
std::thread t1(deadlock_func);
std::thread t2([&]() {
std::lock_guard<std::mutex> lock2(m2);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::lock_guard<std::mutex> lock1(m1); // 和t1形成死锁
});
bool deadlocked = detect_deadlock([&]() {
t1.join();
t2.join();
}, 2000);
EXPECT_TRUE(deadlocked);
}
注意:detach() 后的线程如果还在访问资源,可能导致未定义行为。实际项目中,我建议用 std::async 配合 future 来做超时,更安全。
17.5 压力测试:模拟高并发
单次测试不够,得压。我习惯用参数化测试,模拟不同线程数下的表现。
class ConcurrentTest : public ::testing::TestWithParam<int> {
protected:
void SetUp() override {
num_threads_ = GetParam();
}
int num_threads_;
};
TEST_P(ConcurrentTest, StressTestQueue) {
// 假设有一个线程安全队列
ThreadSafeQueue<int> queue;
std::vector<std::thread> threads;
for (int i = 0; i < num_threads_; ++i) {
threads.emplace_back([&queue, i]() {
for (int j = 0; j < 1000; ++j) {
queue.push(i * 1000 + j);
}
});
}
for (auto& t : threads) t.join();
EXPECT_EQ(queue.size(), num_threads_ * 1000);
}
INSTANTIATE_TEST_SUITE_P(
ThreadCount,
ConcurrentTest,
::testing::Values(2, 4, 8, 16, 32)
);
你看,这样一次测试就能覆盖从2线程到32线程的场景。我遇到过一个问题,2线程没事,16线程就崩。这种参数化测试能帮你快速定位并发瓶颈。
17.6 知识体系:多线程测试的核心逻辑
下面这张图,是我总结的多线程测试知识体系。你照着这个框架去设计测试,基本不会漏。
17.7 避坑指南
最后,分享几个我踩过的坑。
- 不要用 sleep 来同步:我曾经在测试里写 sleep(100ms) 等另一个线程就绪,结果换台机器就挂了。用条件变量或 barrier,别用 sleep。
- 测试要可重复:多线程测试最怕「偶发」。我习惯把随机种子固定下来,或者用 deterministic 的调度器来跑测试。
- 别忘了清理资源:线程跑完,mutex 要解锁,条件变量要 notify。否则下一个测试用例可能直接死锁。
- ThreadSanitizer 是你的好朋友:编译时加 -fsanitize=thread,它能自动检测数据竞争。我每次提交代码前都会跑一遍。
一句话总结:多线程测试不是玄学,是工程。用对工具,想好策略,那些「幽灵bug」也能被揪出来。
公众号:蓝海资料掘金营,微信deep3321