使用内核对象进行线程同步
在多线程编程中,同步机制帮助线程协调彼此的工作。Windows提供了两套不同的方案:用户模式同步和内核对象同步,每种方案都有其适用的场景。
内核对象同步与用户模式同步
理解这两种同步方式的差异很重要。用户模式同步,比如关键段,在进程内部工作,不涉及系统内核。它们通过原子操作实现同步,通常速度很快。但这种方法只能在同一进程内使用,缺少超时机制,而且如果持有锁的线程意外终止,其他线程可能无法继续执行。
内核对象同步采用不同的方式。事件、互斥量和信号量等对象由操作系统内核管理,可以在不同进程间共享,可以设置等待时间,也可以命名以便识别。更重要的是,系统能管理这些对象的生命周期,当持有互斥量的线程意外结束时,系统会自动释放这个互斥量,避免其他线程永久等待。
这种功能上的增强是有代价的。每次线程等待内核对象时,都需要从用户模式切换到内核模式,这种切换需要时间。因此,在需要频繁同步的场景中,用户模式同步通常更有效率;而需要跨进程协作或更复杂控制时,内核对象同步是必要的选择。
事件内核对象
事件是Windows中最基础的同步对象之一,它的行为很简单:线程可以等待某个事件发生,其他线程可以触发这个事件。根据事件被触发后的行为,Windows提供两种类型的事件。
手动重置事件在触发后会一直保持触发状态,直到被明确重置。这意味着当这个事件被触发时,所有正在等待它的线程都会被唤醒,而且之后开始等待的线程也会立即继续执行。
c
HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
if (hEvent == NULL) {
// 处理创建失败
}
// 在一个线程中等待事件
DWORD WaitResult = WaitForSingleObject(hEvent, INFINITE);
if (WaitResult == WAIT_OBJECT_0) {
// 事件被触发
}
// 在另一个线程中触发事件
SetEvent(hEvent);
自动重置事件的行为不同。当它被触发时,只唤醒一个正在等待的线程,然后自动恢复到未触发状态。这适合那种一次只能有一个线程继续执行的场景。
c
HANDLE hAutoEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
// 多个线程可以这样等待
DWORD result = WaitForSingleObject(hAutoEvent, 5000); // 等待5秒
if (result == WAIT_TIMEOUT) {
// 超时处理
}
计时器内核对象
计时器对象让线程可以在特定时间点被唤醒,或者按固定间隔重复执行。这在需要定期执行任务或延迟执行的场景中很有用。
c
// 创建一个在5秒后触发的计时器
HANDLE hTimer = CreateWaitableTimer(NULL, TRUE, TEXT("MyTimer"));
if (hTimer != NULL) {
LARGE_INTEGER liDueTime;
liDueTime.QuadPart = -50000000; // 5秒后(100纳秒单位)
SetWaitableTimer(hTimer, &liDueTime, 0, NULL, NULL, FALSE);
// 等待计时器触发
WaitForSingleObject(hTimer, INFINITE);
CloseHandle(hTimer);
}
计时器有同步和异步两种使用方式。同步使用时,线程等待计时器触发;异步使用时,可以结合I/O完成端口或可等待计时器与工作线程配合。
信号量内核对象
信号量用于控制对有限资源的访问。它维护一个计数器,表示当前可用的资源数量。当线程需要资源时,它尝试减少计数器;如果计数器为0,线程需要等待。
c
// 创建一个最多允许3个线程同时访问的信号量
HANDLE hSemaphore = CreateSemaphore(NULL, 3, 3, NULL);
// 线程访问资源的代码
DWORD WaitResult = WaitForSingleObject(hSemaphore, 1000); // 等待1秒
if (WaitResult == WAIT_OBJECT_0) {
// 获得访问权限
// 使用资源...
// 释放访问权限
ReleaseSemaphore(hSemaphore, 1, NULL);
} else if (WaitResult == WAIT_TIMEOUT) {
// 超时,未能获得访问权限
}
信号量的初始计数和最大计数可以分别设置。如果创建一个初始计数为0的信号量,它可以用来表示某个任务尚未完成,多个线程可以等待这个任务,完成后通过增加信号量计数来通知等待的线程。
互斥量内核对象
互斥量确保一次只有一个线程访问受保护的资源。与关键段类似,但互斥量是内核对象,可以跨进程使用,并且有超时机制。
c
// 创建一个命名的互斥量,可以跨进程使用
HANDLE hMutex = CreateMutex(NULL, FALSE, TEXT("Global\\MySharedMutex"));
if (hMutex == NULL) {
// 处理错误
}
// 尝试获取互斥量
DWORD result = WaitForSingleObject(hMutex, 100);
if (result == WAIT_OBJECT_0) {
// 成功获取互斥量
// 访问共享资源...
// 释放互斥量
ReleaseMutex(hMutex);
} else if (result == WAIT_TIMEOUT) {
// 超时,未能获取互斥量
} else if (result == WAIT_ABANDONED) {
// 互斥量被前一个所有者遗弃
// 需要检查数据一致性
}
CloseHandle(hMutex);
互斥量有一个有用的特性:如果持有互斥量的线程意外终止,系统会将互斥量标记为"遗弃",下一个等待的线程会以特殊状态获得它,这样程序可以检测到异常情况。
二值信号量与互斥量的差异
虽然二值信号量和互斥量在功能上有些相似,但它们的设计目的不同。互斥量用于保护共享资源,它有所有权的概念------通常哪个线程获取了互斥量,就应该由哪个线程释放。信号量则不同,它更像是通行证,任何线程都可以释放信号量。
这种差异在实际使用中很重要。用二值信号量模拟互斥量时,如果获取信号量的线程崩溃了,其他线程可能永远等待。而互斥量在所有者线程崩溃时会被系统自动释放。另外,互斥量通常支持递归获取,同一个线程可以多次获取同一个互斥量而不会死锁,这在实现递归函数时很有用。
其他同步机制
除了上面提到的对象,Windows还提供了其他同步工具。WaitForMultipleObjects函数允许线程同时等待多个对象,这在需要响应多种事件的场景中很有用。
c
HANDLE handles[2];
handles[0] = hEvent; // 一个事件
handles[1] = hMutex; // 一个互斥量
// 等待任意一个对象
DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
switch (result) {
case WAIT_OBJECT_0: // 事件触发
// 处理事件
break;
case WAIT_OBJECT_0 + 1: // 互斥量可用
// 获取互斥量并处理
ReleaseMutex(handles[1]);
break;
}
MsgWaitForMultipleObjects在处理Windows消息循环的线程中特别有用。它允许线程在等待内核对象的同时仍然处理消息,这在GUI程序中很常见。
c
// 在消息循环中等待对象
while (TRUE) {
DWORD result = MsgWaitForMultipleObjects(
1, &hEvent, // 等待的对象
FALSE, // 等待任意一个
INFINITE, // 无限等待
QS_ALLINPUT // 也处理所有消息
);
if (result == WAIT_OBJECT_0) {
// 事件触发
break;
} else if (result == WAIT_OBJECT_0 + 1) {
// 有消息到达
MSG msg;
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
}
这些函数提供了更大的灵活性,但使用起来也更复杂,需要仔细考虑各种返回值和处理逻辑。
选择合适的同步机制需要考虑具体场景。如果需要快速、进程内的同步,用户模式对象通常更好。如果需要跨进程、超时、或更精细的控制,内核对象是合适的选择。理解每种工具的特性和适用场景,才能在实际开发中做出恰当的选择。