小问题,大后果
上一篇讲的是单线程场景下的常见崩溃。这一篇进入更”工程”的领域:多线程竞争、DLL 加载、断言、静态初始化顺序(SIOF)、跨模块内存分配。
这些问题在小项目里几乎不会出现,但在大型 C++ 工程中,它们是导致”启动就崩””客户环境崩””换编译器就崩”的典型原因。而且它们的共同特点是:崩溃点往往不是根因,真正的破坏发生在更早的某个时刻。
多线程竞争:本地不复现,线上偶发
一个简单的竞争示例
10 个线程各对全局计数器自增 10000 次,预期结果是 100000:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| #include <windows.h> #include <cstdio.h> #include <thread> #include <vector>
static int g_counter = 0;
void TestRaceCondition() { const int numThreads = 10; const int incrementsPerThread = 10000;
std::vector<std::thread> threads; for (int i = 0; i < numThreads; i++) { threads.emplace_back([]() { for (int j = 0; j < incrementsPerThread; j++) { g_counter++; } }); }
for (auto& t : threads) { t.join(); }
printf("Expected: %d\n", numThreads * incrementsPerThread); printf("Actual: %d\n", g_counter); }
|
实际输出通常小于 100000。原因:g_counter++ 在汇编层面分为三步:读内存到寄存器、寄存器加 1、写回内存。如果线程 A 读完旧值后被线程 B 打断,B 也读了旧值,那么两次自增最终只增加了 1。
加锁保护
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| #include <mutex>
static std::mutex g_mutex; static int g_counterLocked = 0;
void TestWithLock() { threads.emplace_back([]() { for (int j = 0; j < incrementsPerThread; j++) { std::lock_guard<std::mutex> lock(g_mutex); g_counterLocked++; } }); }
|
加锁后结果始终正确。std::lock_guard 在构造时加锁,析构时自动解锁,是 RAII 的典型应用。
原子变量
对于计数器、标志位这类简单场景,原子操作比锁更高效:
1 2 3 4 5 6 7 8 9 10 11 12 13
| #include <atomic>
static std::atomic<int> g_atomicCounter{0};
void TestAtomic() { threads.emplace_back([]() { for (int j = 0; j < incrementsPerThread; j++) { g_atomicCounter++; } }); }
|
链表竞争:复合操作必须整体保护
1 2 3 4 5 6 7 8 9 10 11 12
| struct Node { int value; Node* next; };
Node* g_head = nullptr;
void PushNodeUnsafe(int value) { Node* node = new Node{value, nullptr}; node->next = g_head; g_head = node; }
|
多个线程同时执行这两步,可能读到不一致的状态,导致节点丢失或链表断裂。对于这类复合操作,不能依赖原子变量,必须用互斥锁保护整个操作序列。
为什么多线程竞争难排查
- 结果依赖线程调度时机。
- Debug 模式的额外调试代码可能掩盖竞争窗口。
- Release 模式下优化更激进,反而更容易暴露问题。
- dump 中的崩溃点通常不是根因。
治理方向: 共享可变数据必须同步;优先使用 std::atomic;复杂数据结构整体加锁;设计阶段尽量消除共享。
DLL 加载失败:不要假设文件一定在
客户端程序经常依赖外部 DLL,比如插件、编解码器、显卡相关库。DLL 加载失败处理不好,会直接让程序退出,给用户很差的体验。
错误处理示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| #include <windows.h> #include <cstdio.h>
void TestLoadDll() { HMODULE hMod = LoadLibraryW(L"this_dll_does_not_exist.dll"); if (hMod == nullptr) { DWORD error = GetLastError(); printf("LoadLibrary failed, error: %lu\n", error);
LPWSTR msgBuffer = nullptr; FormatMessageW( FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, nullptr, error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPWSTR)&msgBuffer, 0, nullptr );
if (msgBuffer) { printf("Message: %S\n", msgBuffer); LocalFree(msgBuffer); } return; }
FARPROC proc = GetProcAddress(hMod, "SomeFunction"); if (proc == nullptr) { printf("GetProcAddress failed.\n"); FreeLibrary(hMod); return; }
FreeLibrary(hMod); }
|
延迟加载
对于可选功能依赖的 DLL,可以使用延迟加载:
1
| 项目属性 → 链接器 → 输入 → 延迟加载的 DLL:optional_feature.dll
|
延迟加载的好处:程序启动时不加载这些 DLL,第一次调用其中的函数时才加载。这样可以加快启动速度,也能在 DLL 缺失时给出更友好的提示。
工程建议
- 动态加载必须检查返回值。
- 关键 DLL 加载前预判文件是否存在。
- 给用户正确的错误信息和解决指引。
- 可选 DLL 使用延迟加载,避免启动失败。
- 商业软件可以用 Authenticode 签名验证 DLL 完整性。
断言:用来检测不变量,不是用来处理运行时错误
断言(assert)是开发阶段发现内部逻辑错误的工具。它的正确用法是检查”不应该发生的情况”,而不是处理用户输入或外部条件。
基本用法
1 2 3 4 5 6 7
| #include <cassert>
void Divide(int a, int b) { assert(b != 0); int result = a / b; printf("%d / %d = %d\n", a, b, result); }
|
自定义断言
1 2 3 4 5 6 7 8 9 10 11 12
| #define MY_ASSERT(cond, msg) \ do { \ if (!(cond)) { \ printf("\n========== ASSERTION FAILED ==========\n"); \ printf("File: %s\n", __FILE__); \ printf("Line: %d\n", __LINE__); \ printf("Condition: %s\n", #cond); \ printf("Message: %s\n", msg); \ printf("======================================\n\n"); \ assert(cond); \ } \ } while(0)
|
断言 vs 运行时错误处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| class Counter { int count_ = 0; public: void Increment() { assert(count_ >= 0); count_++; assert(count_ > 0); }
void Decrement() { assert(count_ > 0); count_--; assert(count_ >= 0); } };
bool SafeDivide(int a, int b, int* out) { if (b == 0) { printf("Error: divide by zero\n"); return false; } *out = a / b; return true; }
|
Release 模式下断言会被移除
定义了 NDEBUG 宏时,assert 不会生成任何代码。所以不要把副作用放在 assert 表达式里:
1 2 3 4 5 6
| assert(InitializeSubsystem() == true);
bool ok = InitializeSubsystem(); assert(ok);
|
静态初始化顺序(SIOF):main 之前就崩溃
SIOF(Static Initialization Order Fiasco)是大型 C++ 工程中的经典噩梦。C++ 只保证同一编译单元内全局对象的初始化顺序,跨编译单元的顺序由链接器决定,不确定。
问题场景
假设有两个源文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class B; B& GetB();
class A { public: A() { GetB().DoSomething(); } void DoSomething() {} };
A g_a; A& GetA() { return g_a; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class A; A& GetA();
class B { public: B() { GetA().DoSomething(); } void DoSomething() {} };
B g_b; B& GetB() { return g_b; }
|
如果链接器先初始化 g_a,此时 g_b 还没构造,GetB() 返回的是一个未初始化的对象。一旦 B::DoSomething() 访问成员变量,就会触发未定义行为,崩溃发生在 main() 之前的 _initterm() 中。
崩溃特征
- 崩溃发生在
main() 之前。
- 调用栈在 CRT 的
static initializer 中,不在业务代码里。
- 同一项目换编译器、换链接顺序、加新模块后,可能从”正常”变成”崩溃”。
解法:Meyers’ Singleton
把全局对象改成函数内的 static 局部对象,第一次访问时才构造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| class SafeA { public: SafeA() { printf("SafeA constructed\n"); } void DoWork() { printf("SafeA::DoWork()\n"); } };
SafeA& GetSafeA() { static SafeA instance; return instance; }
class SafeB { public: SafeB() { printf("SafeB constructed\n"); } void DoWork() { printf("SafeB::DoWork() -> calling SafeA...\n"); GetSafeA().DoWork(); } };
SafeB& GetSafeB() { static SafeB instance; return instance; }
|
C++11 起,函数内 static 局部对象的初始化是线程安全的。核心思想是:把”程序启动时初始化”改成”第一次用到时初始化”,顺序问题自然消失。
另一种解法:显式初始化顺序
如果确实需要全局对象,可以在 main() 入口显式控制初始化顺序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| class ResourceManager { public: void Init() { printf("ResourceManager initialized\n"); } };
class Logger { ResourceManager* res_ = nullptr; public: void Init(ResourceManager* res) { res_ = res; printf("Logger initialized with ResourceManager\n"); } };
ResourceManager* g_resourceManager = nullptr; Logger* g_logger = nullptr;
void InitAllGlobals() { g_resourceManager = new ResourceManager(); g_resourceManager->Init();
g_logger = new Logger(); g_logger->Init(g_resourceManager); }
void CleanupAllGlobals() { delete g_logger; delete g_resourceManager; }
|
跨模块内存分配:谁分配谁释放
在 Windows 下,如果模块使用 /MT 静态链接 CRT,每个 DLL/EXE 都会有自己独立的 CRT 堆。DLL 中 new 的内存,在主程序中 delete 会崩溃,因为两个堆管理器不是同一个。
错误做法
1 2 3 4 5 6 7 8 9
| extern "C" __declspec(dllexport) char* AllocBuffer(int size) { return new char[size]; }
char* buffer = AllocBuffer(100);
delete[] buffer;
|
当 DLL 和主程序都使用 /MTd 时,Debug 下几乎立刻崩溃;Release 下可能暂时不崩,但已造成堆结构损坏。
正确做法 1:谁分配谁释放
DLL 提供配套的释放函数:
1 2 3 4 5 6 7 8 9 10 11 12 13
| extern "C" __declspec(dllexport) char* AllocBuffer(int size) { return new char[size]; }
extern "C" __declspec(dllexport) void FreeBuffer(char* ptr) { delete[] ptr; }
char* buffer = AllocBuffer(100);
FreeBuffer(buffer);
|
正确做法 2:统一使用 /MD
所有模块都使用 /MD 或 /MDd 动态链接 CRT,共享同一个 CRT 堆:
1
| 项目属性 → C/C++ → 代码生成 → 运行库 → 多线程 DLL (/MD)
|
这样跨模块 new/delete 就是安全的,像单模块一样写代码。缺点:目标机器上需要安装对应的 Visual C++ Redistributable。
正确做法 3:使用进程默认堆
绕过 CRT 堆,直接使用 Windows API:
1 2 3 4 5 6 7 8 9 10 11 12 13
| extern "C" __declspec(dllexport) void* HeapAllocFromProcess(int size) { return HeapAlloc(GetProcessHeap(), 0, size); }
extern "C" __declspec(dllexport) void HeapFreeToProcess(void* ptr) { HeapFree(GetProcessHeap(), 0, ptr); }
void* buffer = HeapAllocFromProcess(100);
HeapFreeToProcess(buffer);
|
进程默认堆是整个进程共享的,不依赖 CRT,跨模块安全。
容易踩坑的跨模块对象
不要跨模块传递 std::string、std::vector 等内部管理堆内存的对象。如果 DLL 返回一个 std::string,主程序析构它时就会在主程序的 CRT 堆中释放 DLL 分配的内存:
1 2 3 4
| __declspec(dllexport) std::string GetVersion() { return std::string("v1.0"); }
|
如果必须传递,约定明确的内存管理责任:用原始指针 + 长度、提供配套释放函数、或使用 CoTaskMemAlloc/CoTaskMemFree 等标准约定。
工程原则总结
| 问题 |
核心原则 |
| 多线程竞争 |
共享可变数据必须同步;复杂操作整体加锁;优先无共享设计 |
| DLL 加载失败 |
检查返回值;给用户友好提示;可选 DLL 延迟加载 |
| 断言 |
检查内部不变量;不处理运行时错误;Release 下被移除 |
| SIOF |
不要让全局对象互相依赖;用 Meyers’ Singleton 或显式初始化 |
| 跨模块内存分配 |
谁分配谁释放;或统一 /MD;或使用进程默认堆 |
下篇预告
下一篇也是本系列的最后一篇,会进入工具链实战:用 WinDbg 分析 MiniDump、用 AddressSanitizer 和 gflags 抓堆破坏、用 Crashpad 做崩溃上报、以及 PDB 符号服务器的搭建。到那时候,这套知识就真正能从”看懂”变成”定位”。