多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Catch2 线程安全指南:在派生线程中使用断言与消息宏

Catch2 线程安全指南:在派生线程中使用断言与消息宏 Catch2 线程安全指南在派生线程中使用断言与消息宏【免费下载链接】Catch2A modern, C-native, test framework for unit-tests, TDD and BDD - using C14, C17 and later (C11 support is in v2.x branch, and C03 on the Catch1.x branch)项目地址: https://gitcode.com/GitHub_Trending/ca/Catch2Catch2 自 3.9.0 起为断言宏提供了可选的线程安全支持允许测试在用户自行派生的多个线程中同时执行断言与消息宏。本文以 docs/thread-safety.md 为骨架结合仓库源码深入讲解哪些宏可以在派生线程中使用、哪些宏会终止进程以及线程安全开关的配置方式与性能代价帮助你写出真正可运行的多线程测试用例。线程安全的边界什么可以做什么不可以Catch2 的线程安全目前仅限于全部运行时断言宏以及消息类/消息邻近类宏如INFO、WARN。而以下几类宏不是线程安全的不应当从用户派生的线程中调用基准测试宏benchmark macros节section宏生成器generator宏测试用例宏test case macros其中section 宏的不兼容性是结构性的、长期存在的section 通过测试内部的状态机定义穿过测试的路径这与用户任意派生线程的方式天然冲突因此这一限制短期内不会改变见 docs/thread-safety.md 开篇说明。最重要的一点Catch2 的线程安全是选择加入opt-in的默认情况下断言并不是线程安全的。开启方式见下文如何开启线程安全一节。如何在编译期开启线程安全线程安全支持通过编译期配置宏控制相关宏定义于 src/catch2/catch_user_config.hpp.inCATCH_CONFIG_THREAD_SAFE_ASSERTIONS // 开启线程安全断言 CATCH_CONFIG_NO_THREAD_SAFE_ASSERTIONS // 强制关闭线程安全断言CATCH_CONFIG_THREAD_SAFE_ASSERTIONS在 Catch2 3.9.0 引入当时属于实验特性在 3.12.0 起转为正式非实验特性见 docs/configuration.md。由于线程安全会给即使是单线程使用的场景也带来性能开销Catch2 默认采用非线程安全断言。两个宏同时定义时catch_user_config.hpp.in 会直接触发编译错误Cannot force THREAD_SAFE_ASSERTIONS to both ON and OFF。若使用 CMake 集成这一宏通常由构建配置生成并写入catch_user_config.hpp。开启后底层同步原语也会随之切换。在 src/catch2/internal/catch_thread_support.hpp 中可以看到两种实现#if defined( CATCH_CONFIG_THREAD_SAFE_ASSERTIONS ) using Mutex std::mutex; using LockGuard std::lock_guardstd::mutex; struct AtomicCounts { std::atomicstd::uint64_t passed{ 0 }; std::atomicstd::uint64_t failed{ 0 }; std::atomicstd::uint64_t failedButOk{ 0 }; std::atomicstd::uint64_t skipped{ 0 }; }; #else // 单线程性能优化的空实现 struct Mutex { void lock() {} void unlock() {} }; struct LockGuard { LockGuard( Mutex ) {} }; using AtomicCounts Counts; #endif也就是说未开启线程安全时锁是空操作、计数是普通结构体不产生任何同步开销开启后断言计数使用std::atomic向 reporter 上报断言结果前会加std::mutex锁。这正解释了为什么线程安全会有额外性能代价。在派生线程中使用断言宏Catch2 的全部运行时断言宏在开启线程安全后均可从派生线程调用但语义上并非全部适用REQUIRE系列REQUIRE、REQUIRE_FALSE等的语义是失败即停止测试执行。其实现方式是抛出异常但用户派生的线程没有测试级的 try-catch 块来捕获这个测试失败异常因此在派生线程中失败一个REQUIRE会直接终止进程。CHECK系列不存在此问题因为它不尝试停止测试执行可以从任何线程安全使用。CHECKED_IF/CHECKED_ELSE同样是线程安全的——它们在内部就是一个断言宏加一个 if。在 src/catch2/internal/catch_run_context.cpp 中可以看到支撑CHECKED_IF/CHECKED_ELSE的上一次断言是否通过标志被声明为static CATCH_INTERNAL_THREAD_LOCAL bool g_lastAssertionPassed即线程局部存储各线程互不干扰。断言相关的线程局部状态开启线程安全后catch_thread_local.hpp 会把CATCH_INTERNAL_THREAD_LOCAL展开为thread_local未开启时展开为空#if defined( CATCH_CONFIG_THREAD_SAFE_ASSERTIONS ) #define CATCH_INTERNAL_THREAD_LOCAL thread_local #else #define CATCH_INTERNAL_THREAD_LOCAL #endif这些线程局部变量被用于存储上次断言语义状态、最近一次宏的源码位置用于异常/致命错误时的精确定位以及消息作用域清理标志见 catch_run_context.cpp。同时注释也说明这些变量刻意使用线程局部魔数静态量避免为非触及 Catch2 的线程付出初始化代价。共享状态的加锁上报断言结果汇总到 reporter 的路径需要触碰共享状态因此被互斥锁保护。在 catch_run_context.cpp 中auto msgHolder Detail::g_messageHolder(); msgHolder.repairUnscopedMessageInvariant(); // From here, we are touching shared state and need mutex. Detail::LockGuard lock( m_assertionMutex ); { auto _ scopedDeactivate( *m_outputRedirect ); updateTotalsFromAtomics(); m_reporter-assertionEnded( AssertionStats( result, msgHolder.getMessages(), m_totals ) ); }而断言计数在锁外先行通过原子操作累加m_atomicAssertionCount.passed/failed等见 catch_run_context.cpp 与 catch_run_context.hpp 中的mutable Detail::Mutex m_assertionMutex;和Detail::AtomicCounts m_atomicAssertionCount;。这种原子计数 上报加锁的设计正是多线程下断言总数依然精确的底层保证。断言类消息宏与派生线程断言类消息宏assertion-like messages在 Catch2 3.10.0 起线程安全。与断言宏类似并非所有断言类消息宏都能在派生线程中使用SKIP与FAIL会停止测试执行与REQUIRE同理不能在用户派生线程中使用否则会终止进程。SUCCEED、FAIL_CHECK、WARN不会尝试停止测试执行可以从任何线程使用。消息宏与派生线程消息宏message macros在 Catch2 3.10.0 起线程安全。为后续断言附加额外消息的宏如INFO、UNSCOPED_INFO、CAPTURE全部线程安全可在任意线程使用。但请注意这些消息是每线程per-thread的——在用户派生线程中的INFO不会被主线程看到反之亦然。从实现上看消息持有者MessageHolder存放于Detail::g_messageHolder()它被声明为static CATCH_INTERNAL_THREAD_LOCAL MessageHolder value;见 catch_run_context.cpp各线程持有独立的消息集合。完整示例代码示例一主线程REQUIRE派生线程CHECKTEST_CASE( Failed REQUIRE in the main thread is fine ) { std::vectorstd::jthread threads; for ( size_t t 0; t 16; t) { threads.emplace_back( []() { for (size_t i 0; i 10000; i) { CHECK( true ); CHECK( false ); } } ); } REQUIRE( false ); }这会按预期工作进程正常运行完毕测试用例失败并通过/失败断言计数正确16 个线程各 20,000 条断言共 160,000 条通过、160,000 条失败加主线程 1 条失败的REQUIRE失败合计 160,001。但需要理解当主线程失败其断言时已派生的线程会继续运行std::jthread析构时才 join。示例二派生线程中的REQUIRETEST_CASE( Successful REQUIRE in spawned thread is fine ) { std::vectorstd::jthread threads; for ( size_t t 0; t 16; t) { threads.emplace_back( []() { for (size_t i 0; i 10000; i) { REQUIRE( true ); } } ); } }REQUIRE成功时没有问题进程正常结束。TEST_CASE( Failed REQUIRE in spawned thread kills the process ) { std::vectorstd::jthread threads; for ( size_t t 0; t 16; t) { threads.emplace_back( []() { for (size_t i 0; i 10000; i) { REQUIRE( false ); } } ); } }这个用例会灾难性地失败并终止进程——派生线程中的失败REQUIRE抛出测试失败异常而该线程没有测试级 catch 块来捕获它。示例三消息跨线程隔离TEST_CASE( messages dont cross threads ) { std::jthread t1( []() { for ( size_t i 0; i 100; i ) { INFO( spawned thread #1 ); CHECK( 1 1 ); } } ); std::thread t2( []() { for (size_t i 0; i 100; i) { UNSCOPED_INFO( spawned thread #2 ); } } ); for (size_t i 0; i 100; i) { CHECK( 1 2 ); } }主线程中任何失败的CHECK( 1 2 )都不会显示 spawned thread #1 消息因为该消息属于t1线程。如果 reporter 展示通过的断言例如以-s运行测试你会看到 spawned thread #1 消息伴随t1中通过的CHECK( 1 1 )出现。spawned thread #2 永远不会显示因为t2中没有任何断言UNSCOPED_INFO的消息只挂接到同线程后续的断言上。示例四主线程中的FAIL/SKIPTEST_CASE( FAIL in the main thread is fine ) { std::vectorstd::jthread threads; for ( size_t t 0; t 16; t) { threads.emplace_back( []() { for (size_t i 0; i 10; i) { CHECK( true ); CHECK( false ); } } ); } FAIL(); }结果符合预期进程正常结束测试失败总计 321 条断言160 通过、161 失败FAIL本身计为一条失败断言。注意主线程命中FAIL时会因std::jthread析构 join 等待其他线程结束。因此一旦派生了多个线程不推荐在主线程使用SKIP——主线程虽会退出测试执行但派生线程仍会继续运行可能反过来导致测试失败。示例五派生线程中的FAIL/SKIPTEST_CASE( FAIL/SKIP in spawned thread kills the process ) { std::vectorstd::jthread threads; for ( size_t t 0; t 16; t) { threads.emplace_back( []() { for (size_t i 0; i 10000; i) { FAIL(); } } ); } }与失败的REQUIRE相同派生线程中的FAIL和SKIP都会终止进程。仓库自带的线程安全测试仓库在 tests/ExtraTests/X94-ThreadSafetyTests.cpp 中提供了专门的线程安全回归测试其文件头注释说明通过在多个子线程中大量触发断言与消息若链接的是非线程安全版本会可靠地触发段错误CTest 定义还会校验最终断言计数是否正确。测试用例包含主线程失败REQUIRE 子线程CHECK/CAPTURE以及兄弟线程中使用UNSCOPED_INFO两类场景均标记[!shouldfail]与本文示例一、示例三相互印证。你可以用X94的构建目标配合线程安全配置验证自己编译的 Catch2 是否符合预期。STATIC_REQUIRE与STATIC_CHECKSTATIC_REQUIRE、STATIC_REQUIRE_FALSE、STATIC_CHECK、STATIC_CHECK_FALSE这四者在**延迟求值配置delayed evaluation configuration**下全部线程安全。需要注意的是静态断言本身是编译期求值的所谓线程安全更多是指其求值后的记录与上报路径与其他断言宏保持一致。致命错误与多线程默认情况下Catch2 会尝试捕获致命错误POSIX 信号 / Windows 结构化异常 SEH并向用户报告有用信息。这一直是尽力而为best-effort的行为但在存在多线程与锁的场景下捕获成功的概率会下降。如果这开始影响你的项目可以通过 docs/configuration.md 中的other-toggles相关配置将其禁用例如关闭致命错误处理的相关宏开关。性能开销开启线程安全要付出多少线程安全的代价取决于构建与断言路径最坏情况优化构建 走成功断言快速路径时线程安全断言实现的性能开销可达40%。其他情况开销更小介于4% ~ 20%之间。这也是为什么 Catch2 默认不开启线程安全即使你的测试完全单线程运行也会为这份可能的多线程安全性买单。从源码看开销主要来自两处断言计数的原子操作AtomicCounts的std::atomic累加以及上报 reporter 前的互斥锁m_assertionMutex。开启前建议先评估测试中并发断言的收益与单测整体运行时间的损失。总结线程安全是编译期 opt-in特性定义CATCH_CONFIG_THREAD_SAFE_ASSERTIONS开启Catch2 3.9.03.12.0 起非实验CATCH_CONFIG_NO_THREAD_SAFE_ASSERTIONS强制关闭。可用范围全部运行时断言宏、INFO/CAPTURE等消息宏、SUCCEED/FAIL_CHECK/WARN以及STATIC_*系列延迟求值配置下。不可用范围section、generator、benchmark、test case 宏以及在派生线程中使用会终止进程的REQUIRE失败、FAIL、SKIP。消息是每线程的跨线程不共享。性能代价最坏约 40%通常 4% ~ 20%只有确实需要多线程并发断言时才值得开启。进一步配置细节见 docs/configuration.md 的 Thread safety in assertions (and messages) 一节运行期命令行参数如展示通过断言的-s见 docs/command-line.md。【免费下载链接】Catch2A modern, C-native, test framework for unit-tests, TDD and BDD - using C14, C17 and later (C11 support is in v2.x branch, and C03 on the Catch1.x branch)项目地址: https://gitcode.com/GitHub_Trending/ca/Catch2创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表