12. Windows多线程编程:从CreateThread到临界区与事件对象
说实话,很多从Linux转过来的朋友,第一次看到Windows的线程API都会有点懵。我当年也是这样——明明在pthreads里一个pthread_create就搞定的事,到了Windows这边怎么冒出个CreateThread,还带一堆参数?
别急,这一章我们就来彻底搞懂Windows的多线程编程。我会带着你从最基础的API开始,一步步深入到临界区、事件对象,最后再跟pthreads做个对比。嗯,这些都是我在实际项目中踩过的坑,希望能帮你少走弯路。
12.1 Windows线程API:CreateThread
Windows创建线程的核心函数就是CreateThread。它的原型长这样:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
参数看着多,其实常用的就三个:线程函数地址、传给函数的参数、创建标志。其他两个填NULL和0就行。
核心要点:CreateThread返回的是一个线程句柄(HANDLE),不是线程ID。句柄才是你操作线程的钥匙。
来看个最简单的例子:
#include <windows.h>
#include <stdio.h>
DWORD WINAPI ThreadFunc(LPVOID lpParam) {
int threadNum = (int)lpParam;
printf("线程 %d 正在运行\n", threadNum);
return 0;
}
int main() {
HANDLE hThread;
DWORD threadId;
hThread = CreateThread(
NULL, // 默认安全属性
0, // 默认栈大小
ThreadFunc, // 线程函数
(LPVOID)1, // 传给线程的参数
0, // 立即运行
&threadId // 接收线程ID
);
if (hThread == NULL) {
printf("创建线程失败,错误码: %d\n", GetLastError());
return 1;
}
// 等待线程结束
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
return 0;
}
这里有个细节我特别想提醒你:线程函数必须是DWORD WINAPI类型的。我在项目中见过有人写成普通函数,编译能过,但运行起来各种诡异崩溃。为什么?因为Windows内部对线程函数的调用约定有严格要求。
注意:CreateThread创建的线程,一定要用CloseHandle关闭句柄。否则会造成句柄泄漏。我曾经在一个服务程序里忘了关句柄,跑了三天后系统资源耗尽...那教训太深刻了。
12.2 临界区对象(Critical Section)
多线程编程绕不开的一个问题就是同步。Windows提供了临界区对象,说白了就是一把「只能在一个进程内使用的锁」。
临界区的使用分四步:
- 初始化:InitializeCriticalSection
- 进入:EnterCriticalSection
- 离开:LeaveCriticalSection
- 销毁:DeleteCriticalSection
看个实际例子:
#include <windows.h>
#include <stdio.h>
CRITICAL_SECTION g_cs;
int g_counter = 0;
DWORD WINAPI IncrementThread(LPVOID lpParam) {
for (int i = 0; i < 100000; i++) {
EnterCriticalSection(&g_cs);
g_counter++;
LeaveCriticalSection(&g_cs);
}
return 0;
}
int main() {
HANDLE threads[2];
DWORD threadIds[2];
// 初始化临界区
InitializeCriticalSection(&g_cs);
// 创建两个线程
for (int i = 0; i < 2; i++) {
threads[i] = CreateThread(NULL, 0, IncrementThread, NULL, 0, &threadIds[i]);
}
// 等待所有线程结束
WaitForMultipleObjects(2, threads, TRUE, INFINITE);
// 清理
for (int i = 0; i < 2; i++) {
CloseHandle(threads[i]);
}
DeleteCriticalSection(&g_cs);
printf("最终计数: %d\n", g_counter);
return 0;
}
个人经验:临界区的Enter和Leave一定要成对出现。我见过有人写了个if分支,一个分支里Enter了但没Leave,结果整个程序就卡死了。建议用RAII思想封装一下,或者至少保证Leave在函数的出口处。
临界区有个特点:它是用户态对象,不是内核态对象。这意味着它的性能比互斥量(Mutex)要好得多。但代价是——它不能跨进程使用。
12.3 事件对象(Event Object)
事件对象是Windows里非常灵活的同步机制。它有两种状态:有信号(signaled)和无信号(non-signaled)。说白了,事件就是一个「开关」。
事件分为两类:
- 自动重置事件:当一个等待线程被释放后,事件自动变回无信号状态
- 手动重置事件:需要手动调用SetEvent或ResetEvent来改变状态
来看一个典型的生产者-消费者场景:
#include <windows.h>
#include <stdio.h>
HANDLE g_hEvent;
int g_dataReady = 0;
DWORD WINAPI Producer(LPVOID lpParam) {
printf("生产者: 准备数据...\n");
Sleep(1000); // 模拟数据准备
g_dataReady = 1;
printf("生产者: 数据就绪,发送信号\n");
SetEvent(g_hEvent); // 触发事件
return 0;
}
DWORD WINAPI Consumer(LPVOID lpParam) {
printf("消费者: 等待数据...\n");
WaitForSingleObject(g_hEvent, INFINITE); // 等待事件
printf("消费者: 收到信号,处理数据\n");
return 0;
}
int main() {
HANDLE hProducer, hConsumer;
DWORD producerId, consumerId;
// 创建自动重置事件,初始为无信号
g_hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
hConsumer = CreateThread(NULL, 0, Consumer, NULL, 0, &consumerId);
hProducer = CreateThread(NULL, 0, Producer, NULL, 0, &producerId);
WaitForSingleObject(hConsumer, INFINITE);
WaitForSingleObject(hProducer, INFINITE);
CloseHandle(hConsumer);
CloseHandle(hProducer);
CloseHandle(g_hEvent);
return 0;
}
关键区别:CreateEvent的第二个参数bManualReset决定事件类型。FALSE=自动重置,TRUE=手动重置。选错了类型,程序行为会完全不一样。
我记得有一次调试一个网络服务程序,多个工作线程等待同一个事件。我用了自动重置事件,结果只有一个线程能收到信号,其他线程永远在等。后来改成手动重置事件,配合ResetEvent手动控制,问题就解决了。
12.4 与pthreads的对比
如果你用过pthreads,你会发现Windows的API虽然名字不同,但概念上有很多相似之处。我整理了一张对比表:
| 功能 | Windows API | pthreads |
|---|---|---|
| 创建线程 | CreateThread | pthread_create |
| 等待线程结束 | WaitForSingleObject | pthread_join |
| 互斥锁 | CRITICAL_SECTION / Mutex | pthread_mutex_t |
| 条件变量 | Event Object | pthread_cond_t |
| 读写锁 | SRWLOCK (Vista+) | pthread_rwlock_t |
| 线程局部存储 | TlsAlloc / TlsGetValue | pthread_key_t |
这里有几个关键差异点:
- 线程函数签名不同:Windows用
DWORD WINAPI,pthreads用void*。这个别搞混了。 - 等待机制不同:Windows的WaitForSingleObject可以等待任何内核对象(线程、事件、互斥量等),而pthreads的pthread_join只能等待线程。
- 临界区 vs 互斥量:Windows的CRITICAL_SECTION是用户态锁,性能好但不能跨进程。pthreads的pthread_mutex_t默认也是进程内,但可以配置为跨进程。
我的建议:如果你在Windows上写纯用户态程序,优先用临界区而不是互斥量。性能差距在低竞争场景下可能不明显,但在高并发场景下,临界区能快一个数量级。
说到事件对象和条件变量的对比,这里有个有意思的点。pthreads的条件变量需要配合互斥锁使用,而Windows的事件对象是独立的。这意味着事件对象用起来更简单,但灵活性不如条件变量。条件变量可以精确控制「等待某个条件成立」,而事件只能表达「有信号/无信号」两种状态。
嗯,这张图把Windows三种同步机制的核心特点都列出来了。你想想看,临界区就像一把只能在自己家里用的锁,事件对象像个门铃,互斥量则像一把可以借给邻居用的锁。选哪个,完全看你的使用场景。
避坑指南:我曾经在写一个Windows服务程序时,用临界区保护了一个全局链表。结果服务需要跨进程通信,临界区根本不管用。后来改成命名互斥量才解决问题。所以,先搞清楚你的同步范围是进程内还是跨进程,再选工具。
最后说一句,Windows的线程API虽然跟pthreads长得不一样,但核心思想是相通的。理解了互斥、同步、信号通知这些概念,换平台只是换个API名字而已。好了,这一章的内容就到这里,希望对你有所帮助。
公众号:蓝海资料掘金营,微信deep3321