0%

Windows C++ 并发与工程问题:多线程竞争、SIOF 与跨模块内存分配

小问题,大后果

上一篇讲的是单线程场景下的常见崩溃。这一篇进入更”工程”的领域:多线程竞争、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++;
}
});
// ... join ...
}

加锁后结果始终正确。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++;
}
});
// ... join ...
}

链表竞争:复合操作必须整体保护

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; // 步骤 1
g_head = node; // 步骤 2
}

多个线程同时执行这两步,可能读到不一致的状态,导致节点丢失或链表断裂。对于这类复合操作,不能依赖原子变量,必须用互斥锁保护整个操作序列。

为什么多线程竞争难排查

  • 结果依赖线程调度时机。
  • 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); // 前置条件:调用者应保证 b 不为零
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
// 错误:Release 下这行不会执行
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
// a.cpp
class B;
B& GetB();

class A {
public:
A() {
GetB().DoSomething(); // 依赖 B
}
void DoSomething() {}
};

A g_a;
A& GetA() { return g_a; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// b.cpp
class A;
A& GetA();

class B {
public:
B() {
GetA().DoSomething(); // 依赖 A
}
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(); // 安全,即使 SafeA 还没初始化,也会先初始化
}
};

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
// DLL 中
extern "C" __declspec(dllexport) char* AllocBuffer(int size) {
return new char[size]; // 在 DLL 的 CRT 堆中分配
}

// 主程序中
char* buffer = AllocBuffer(100);
// ... 使用 buffer ...
delete[] buffer; // 危险:在主程序的 CRT 堆中释放

当 DLL 和主程序都使用 /MTd 时,Debug 下几乎立刻崩溃;Release 下可能暂时不崩,但已造成堆结构损坏。

正确做法 1:谁分配谁释放

DLL 提供配套的释放函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// DLL 中
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);
// ... 使用 buffer ...
FreeBuffer(buffer); // 回到 DLL 中释放

正确做法 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
// DLL 中
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);
// ... 使用 buffer ...
HeapFreeToProcess(buffer);

进程默认堆是整个进程共享的,不依赖 CRT,跨模块安全。

容易踩坑的跨模块对象

不要跨模块传递 std::stringstd::vector 等内部管理堆内存的对象。如果 DLL 返回一个 std::string,主程序析构它时就会在主程序的 CRT 堆中释放 DLL 分配的内存:

1
2
3
4
// 危险:DLL 返回 string,主程序析构时跨模块释放
__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 符号服务器的搭建。到那时候,这套知识就真正能从”看懂”变成”定位”。