Skip to content
Published at:

第8章:线程同步(进程间)

调度程序对象(Dispatcher Objects)

内核对象(Kernel Object)驻留在系统空间,可以通过三种方式跨进程共享:句柄继承(Handle Inheritance)、命名(Naming)和句柄复制(Handle Duplication)。

调度程序对象(Dispatcher Object),又称可等待对象(Waitable Object),存在两种状态:已通知(Signaled)和未通知(Non-Signaled)。不同对象类型的“已通知”含义各不相同:

对象类型已通知(Signaled)未通知(Non-Signaled)
进程(Process)已退出/已终止正在运行
线程(Thread)已退出/已终止正在运行
作业(Job)作业时间已到限制未达到/未设置
互斥锁(Mutex)空闲(无拥有者)已被拥有
信号量(Semaphore)计数 > 0计数 = 0
事件(Event)事件已设置事件未设置
文件(File)I/O 完成I/O 进行中
可等待计时器(Waitable Timer)计时器已到期计时器未到期
I/O 完成端口(I/O Completion Port)异步 I/O 完成I/O 未完成

有两个主要函数用于等待对象变为已通知状态:

cpp
DWORD WaitForSingleObject(
    _In_ HANDLE hHandle,
    _In_ DWORD dwMilliseconds
);

DWORD WaitForMultipleObjects(
    _In_ DWORD nCount,
    _In_ CONST HANDLE* lpHandles,
    _In_ BOOL bWaitAll,
    _In_ DWORD dwMilliseconds
);

WaitForSingleObject 接受一个具有 SYNCHRONIZE 访问权限的句柄。超时参数 dwMilliseconds 可以为零(不等待,立即返回)、INFINITE(无限等待)或任意毫秒值。该函数有四种可能的返回值:

  • WAIT_OBJECT_0:对象在超时前变为已通知状态。
  • WAIT_TIMEOUT:对象在超时时间内未变为已通知状态(使用 INFINITE 时永远不会返回此值)。
  • WAIT_FAILED:函数调用失败,调用 GetLastError 获取详细信息。
  • WAIT_ABANDONED:等待一个被废弃的互斥锁(Mutex)。

WaitForMultipleObjects 接受一个句柄数组,最多 MAXIMUM_WAIT_OBJECTS(64)个句柄。第三个参数 bWaitAll 决定线程是等待所有对象都变为已通知(TRUE),还是等待任意一个对象变为已通知(FALSE)。

bWaitAll = FALSE 时,返回值指示满足等待条件的数组索引(相对于 WAIT_OBJECT_0WAIT_ABANDONED_0 的偏移量)。如果多个对象同时变为已通知,返回最小索引。

等待成功(Waiting Successfully)

当等待函数因对象变为已通知而成功返回时,线程被唤醒并继续执行。对象是否保持已通知状态取决于其类型。进程和线程对象一旦终止就永久保持已通知状态(只要还有打开的句柄存在)。

某些对象类型在成功等待后会改变状态。互斥锁在被成功等待后会自动恢复为未通知状态。自动重置事件(Auto-Reset Event)只会释放一个等待线程,然后自动翻转为未通知状态。

当多个线程等待同一个互斥锁时,只有其中一个线程在互斥锁变为已通知时能获取它。在内核层面,等待线程被存储在 FIFO 队列中,因此最先进入队列的线程最先被唤醒(无论优先级如何)。然而,不应该依赖这一行为,因为内部机制(如调试器挂起)可能会将线程从等待状态中移除,恢复时将其放回队尾。此外,该算法在未来的 Windows 版本中也可能发生变化。

互斥锁(Mutex)

互斥锁(Mutex,Mutual Exclusion 的缩写)提供与临界区(Critical Section)类似的功能:保护共享数据免受并发访问。同一时刻只有一个线程能成功获取互斥锁,其他等待线程必须等到拥有者线程释放它。

创建函数:

cpp
HANDLE CreateMutex(
    _In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,
    _In_     BOOL bInitialOwner,
    _In_opt_ LPCTSTR lpName
);

HANDLE CreateMutexEx(
    _In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,
    _In_opt_ LPCTSTR lpName,
    _In_ DWORD dwFlags,
    _In_ DWORD dwDesiredAccess
);

如果 bInitialOwnerTRUECreateMutex 会在内部调用 WaitForSingleObject,只有获取到互斥锁后才会返回。使用 CreateMutexEx 时,在 dwFlags 中传入 CREATE_MUTEX_INITIAL_OWNER 可以达到相同效果。

lpName 参数为互斥锁赋予一个名称。如果同名的互斥锁已经存在(且安全权限允许),这些函数会打开一个指向已存在互斥锁的句柄。如果该名称存在但属于不同类型的对象,则函数调用失败。

CreateMutexEx 允许指定访问掩码(Access Mask),这在以低于 MUTEX_ALL_ACCESS 的权限打开已存在的互斥锁时很有用。

按名称打开已存在的互斥锁:

cpp
HANDLE OpenMutexW(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ LPCWSTR lpName
);

如果命名互斥锁不存在,返回 NULL。使用 CreateMutex / CreateMutexEx 更简单且能避免竞态条件(Race Condition):第一个调用者创建对象,后续调用者获得指向已存在对象的新句柄。所有打开的句柄最终都应该通过 CloseHandle 关闭。

等待互斥锁会导致线程阻塞,直到互斥锁变为已通知(即空闲)。一旦获取到互斥锁,它会自动转换为未通知状态,阻止其他线程获取。完成共享数据的操作后,拥有者调用 ReleaseMutex

cpp
BOOL ReleaseMutex(_In_ HANDLE hMutex);

互斥锁可以被同一线程递归获取,内部计数器会递增。对 ReleaseMutex 的调用次数必须与获取次数匹配。从非拥有者线程调用 ReleaseMutex 会失败。

使用互斥锁的简单递增示例:

cpp
void CMainDlg::DoMutexCount() {
    auto handles = std::make_unique<HANDLE[]>(m_Threads);
    m_hMutex = ::CreateMutex(nullptr, FALSE, nullptr);
    for (int i = 0; i < m_Threads; i++) {
        handles[i] = ::CreateThread(nullptr, 0, [](auto param) {
            return ((CMainDlg*)param)->IncMutexThread();
        }, this, 0, nullptr);
    }

    ::WaitForMultipleObjects(m_Threads, handles.get(), TRUE, INFINITE);

    for (int i = 0; i < m_Threads; i++)
        ::CloseHandle(handles[i]);

    ::CloseHandle(m_hMutex);
}

DWORD CMainDlg::IncMutexThread() {
    for (int i = 0; i < m_Loops; i++) {
        ::WaitForSingleObject(m_hMutex, INFINITE);
        m_Count++;
        ::ReleaseMutex(m_hMutex);
    }

    return 0;
}

使用互斥锁(内核对象)需要从用户模式切换到内核模式,与临界区相比会产生额外的开销。

互斥锁演示应用程序(Mutex Demo Application)

MutexDemo 应用程序演示了不同进程中的线程使用命名互斥锁同步访问共享文件。由于涉及多个进程,临界区无法使用。

测试步骤: 从两个独立的命令行窗口运行 MutexDemo.exe 的两个实例,指向同一个文件路径。每个实例会输出其进程 ID 和互斥锁句柄值。

可以使用 Process Explorer 验证:找到两个进程实例,定位名为 "ExampleMutex" 的互斥锁,确认句柄计数为 2,状态为未通知("Held: FALSE")。

在两个控制台窗口中按下任意键后,每个线程会向文件中追加包含其进程 ID 的行。输出显示来自两个进程的交错行(每个进程 100 行,总共 200 行)。

主函数:

cpp
int wmain(int argc, const wchar_t* argv[]) {
    if (argc < 2) {
        printf("Usage: MutexDemo <file>\n");
        return 0;
    }

    HANDLE hMutex = ::CreateMutex(nullptr, FALSE, L"ExampleMutex");
    if (!hMutex)
        return Error("Cannot create/open mutex");

    printf("Process %d. Mutex handle: 0x%X\n", ::GetCurrentProcessId(), HandleToULong(hMutex));
    printf("Press any key to start...\n");
    _getch();

错误处理辅助函数:

cpp
int Error(const char* text) {
    printf("%s (%d)\n", text, ::GetLastError());
    return 1;
}

循环获取互斥锁并写入文件:

cpp
    printf("Working...\n");
    for (int i = 0; i < 100; i++) {
        // 插入一些随机性
        ::Sleep(::GetTickCount() & 0xff);

        // 获取互斥锁
        ::WaitForSingleObject(hMutex, INFINITE);

        // 写入文件
        if (!WriteToFile(argv[1]))
            return Error("Cannot write to file");

        ::ReleaseMutex(hMutex);
    }

    ::CloseHandle(hMutex);
    printf("Done.\n");

    return 0;
}

WriteToFile 函数:

cpp
bool WriteToFile(PCWSTR path) {
    HANDLE hFile = ::CreateFile(path, GENERIC_WRITE, FILE_SHARE_READ,
        nullptr, OPEN_ALWAYS, 0, nullptr);
    if (hFile == INVALID_HANDLE_VALUE)
        return false;

    ::SetFilePointer(hFile, 0, nullptr, FILE_END);
    char text[128];
    sprintf_s(text, "This is text from process %d\n", ::GetCurrentProcessId());
    DWORD bytes;
    BOOL ok = ::WriteFile(hFile, text, (DWORD)strlen(text), &bytes, nullptr);
    ::CloseHandle(hFile);
    return ok;
}

可以将互斥锁名称改为 NULL 并重复实验,观察不使用跨进程同步时的输出差异。

废弃的互斥锁(Abandoned Mutex)

如果拥有互斥锁的线程退出或终止(无论什么原因),由于只有拥有者才能释放它,可能会导致死锁(Deadlock)。这种情况下的互斥锁被称为废弃的互斥锁(Abandoned Mutex)。

内核会跟踪互斥锁的拥有关系。如果内核检测到线程在持有互斥锁时终止,它会显式释放该废弃的互斥锁。下一个成功获取该互斥锁的线程会从 WaitForSingleObject 收到 WAIT_ABANDONED 而不是 WAIT_OBJECT_0。这是一个提示,表明前一个拥有者在未释放互斥锁的情况下终止了,通常意味着存在需要调查的 bug。

一个常见的改进实践是为互斥锁编写 RAII(Resource Acquisition Is Initialization)包装器,确保互斥锁在任何情况下都能被正确释放。

信号量(Semaphore)

信号量(Semaphore)以线程安全的方式限制对某些资源的访问。在第 7 章介绍的进程内同步原语中没有与之直接等价的概念。

初始化时,信号量有一个当前计数(Current Count)和一个最大计数(Maximum Count)。当当前计数大于 0 时,信号量处于已通知状态。当线程对已通知的信号量调用 WaitForSingleObject 时,计数减 1 且线程继续执行。当计数达到零时,信号量变为未通知状态,阻塞任何试图等待它的线程。

线程通过 ReleaseSemaphore 释放一个或多个信号量计数,增加计数值并使其返回已通知状态。

创建函数:

cpp
HANDLE CreateSemaphore(
    _In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
    _In_ LONG lInitialCount,
    _In_ LONG lMaximumCount,
    _In_opt_ LPCTSTR lpName);

HANDLE CreateSemaphoreEx(
    _In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
    _In_ LONG lInitialCount,
    _In_ LONG lMaximumCount,
    _In_opt_ LPCTSTR lpName,
    _Reserved_ DWORD dwFlags,
    _In_ DWORD dwDesiredAccess);

信号量可以被命名以便跨进程共享。初始计数和最大计数通常设置为相同的值。CreateSemaphoreExdwFlags 参数暂未使用(必须为零)。CreateSemaphore 的默认访问权限是 SEMAPHORE_ALL_ACCESS

释放信号量:

cpp
BOOL ReleaseSemaphore(
    _In_ HANDLE hSemaphore,
    _In_ LONG lReleaseCount,
    _Out_opt_ LPLONG lpPreviousCount);

lReleaseCount 指定要增加到当前计数的值(通常为 1,但可以更大)。最后一个参数可选地接收增加前的计数值。指定释放计数为零仅检索当前计数,但这存在竞态条件,因为计数可能在采取行动之前发生变化。

信号量与互斥锁的一个关键区别:最大计数为 1 的信号量不等同于互斥锁。信号量没有拥有权概念——任何线程都可以获取或释放计数。这种灵活性如果使用不当可能导致死锁,但总体上是有益的。

按名称打开:

cpp
HANDLE OpenSemaphore(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ LPCTSTR lpName);

队列演示应用程序(Queue Demo Application)

这是第 7 章队列演示的增强版。引入一个信号量来限制生产者-消费者队列的大小,防止队列无限制增长耗尽内存。

信号量根据对话框中指定的最大队列大小创建:

cpp
int queueSize = GetDlgItemInt(IDC_MAX_QUEUE_SIZE);
if (queueSize < 10 || queueSize > 100000) {
    DisplayError(L"Maximum queue size must be between 10 and 100000");
    return;
}

// 创建信号量
m_hQueueSem.reset(::CreateSemaphore(nullptr, queueSize, queueSize, nullptr));

m_hQueueSem 是一个智能句柄(wil::unique_handle),确保在清理时自动调用 CloseHandle

修改后的生产者代码: 在推送项目之前增加了对信号量的等待。

cpp
DWORD CMainDlg::ProducerThread() {
    for (;;) {
        if (::WaitForSingleObject(m_hAbortEvent.get(), 0) == WAIT_OBJECT_0)
            break;
        // 等待以确保队列未满
        ::WaitForSingleObject(m_hQueueSem.get(), INFINITE);

        WorkItem item;
        item.IsPrime = false;
        LARGE_INTEGER li;
        ::QueryPerformanceCounter(&li);
        item.Data = li.LowPart;
        {
            AutoCriticalSection locker(m_QueueLock);
            m_Queue.push(item);
        }

        ::WakeConditionVariable(&m_QueueCondVar);

        // 偶尔休眠
        if ((item.Data & 0x7f) == 0)
            ::Sleep(1);
    }
    return 0;
}

修改后的消费者代码: 在弹出项目后释放一个信号量计数。

cpp
DWORD CMainDlg::ConsumerThread(int index) {
    auto& data = m_ConsumerThreads[index];
    auto tick = ::GetTickCount64();
    for (;;) {
        WorkItem value;
        {
            bool abort = false;
            AutoCriticalSection locker(m_QueueLock);
            while (m_Queue.empty()) {
                if (::WaitForSingleObject(m_hAbortEvent.get(), 0) == WAIT_OBJECT_0) {
                    abort = true;
                    break;
                }
                ::SleepConditionVariableCS(&m_QueueCondVar, &m_QueueLock, INFINITE);
            }

            if (abort)
                break;

            ATLASSERT(!m_Queue.empty());
            value = m_Queue.front();
            m_Queue.pop();

            ::ReleaseSemaphore(m_hQueueSem.get(), 1, nullptr);
        }

        // 其余代码省略...
    }
    return 0;
}

使用 Process Explorer 的句柄视图可以查看信号量的属性。信号量的灵活性在此体现得很明显:生产者等待信号量,而消费者释放信号量——它们不是同一个线程。这也说明了信号量没有拥有权的特点。

事件(Event)

事件(Event)是最简单的同步原语——一个可以被设置(Signaled)或重置(Non-Signaled)的标志。作为命名内核对象,它们可以在单个进程内或跨进程工作。在之前的队列演示中已经使用了事件。

存在两种类型的事件:

事件类型内核名称SetEvent 的效果
手动重置(Manual-Reset)通知事件(Notification Event)设置为已通知,释放所有等待线程,保持已通知状态
自动重置(Auto-Reset)同步事件(Synchronization Event)释放一个等待线程,然后自动恢复到未通知状态

内核类型名称会出现在 Process Explorer 等工具中。

创建函数:

cpp
HANDLE CreateEvent(
    _In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
    _In_ BOOL bManualReset,
    _In_ BOOL bInitialState,
    _In_opt_ LPCTSTR lpName);

HANDLE CreateEventEx(
    _In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
    _In_opt_ LPCTSTR lpName,
    _In_ DWORD dwFlags,
    _In_ DWORD dwDesiredAccess);

对于 CreateEventbManualReset = TRUE 表示手动重置事件;FALSE 表示自动重置事件。对于 CreateEventEx,在 dwFlags 中设置 CREATE_EVENT_MANUAL_RESET 来创建手动重置事件。

初始状态:bInitialState = TRUECREATE_EVENT_INITIAL_SET 标志表示初始为已通知状态。

CreateEventEx 允许指定访问掩码(CreateEvent 的默认值为 EVENT_ALL_ACCESS)。如果命名事件已经存在,则打开一个新句柄,并忽略类型和初始状态参数。

可以通过调用 GetLastError 并检查 ERROR_ALREADY_EXISTS 来区分新创建的对象和已存在的对象。

按名称打开:

cpp
HANDLE OpenEvent(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ LPCTSTR lpName);

状态操作:

cpp
BOOL SetEvent(_In_ HANDLE hEvent);   // 设置为已通知
BOOL ResetEvent(_In_ HANDLE hEvent); // 设置为未通知

使用事件(Using Events)

关闭场景示例: 系统中多个进程需要在控制器进程发出信号时优雅关闭。手动重置事件非常适合此场景。所有进程创建一个命名事件:

cpp
HANDLE hShutdown = ::CreateEvent(nullptr, TRUE, FALSE, L"ShutdownEvent");

只有第一个进程实际创建该对象,后续进程获取指向已存在对象的句柄。每个非控制器进程等待:

cpp
::WaitForSingleObject(hShutdown, INFINITE);
// 对象已通知,开始关闭...

控制器在需要关闭时设置事件:

cpp
::SetEvent(hShutdown);
// 开始自己的关闭流程...

这里必须使用手动重置事件,因为设置事件时需要唤醒所有等待线程。

自动重置事件的行为: SetEvent 将事件变为已通知状态。如果没有线程在等待,事件将保持已通知状态,直到至少有一个线程等待它。然后恰好一个线程被释放,事件自动恢复为未通知状态。要唤醒另一个线程,需要再次调用 SetEvent

队列演示中的中止事件:OnInitDialog 中创建为手动重置事件:

cpp
m_hAbortEvent.reset(::CreateEvent(nullptr, TRUE, FALSE, nullptr));

生产者代码以零超时检查(不等待):

cpp
DWORD CMainDlg::ProducerThread() {
    for (;;) {
        if (::WaitForSingleObject(m_hAbortEvent.get(), 0) == WAIT_OBJECT_0)
            break;
        //...
    }
}

使用普通的布尔变量代替事件是有问题的,因为编译器(以及 CPU)可能会将其优化掉。volatile 可以有所帮助,但并非万无一失。更重要的是,简单变量无法模拟自动重置行为,也无法跨进程协调。

PulseEvent: 该函数临时设置一个事件,如果没有线程在等待则立即重置。

cpp
BOOL PulseEvent(_In_ HANDLE hEvent);

文档明确指出此函数不可靠,不应使用;它的存在主要是为了向后兼容。应避免使用 PulseEvent

可等待计时器(Waitable Timer)

Windows 提供了几种计时器模型:

  • SetTimer API: 向调用线程的消息队列发送 WM_TIMER 消息(用于 GUI 应用程序)。
  • 多媒体计时器(Multimedia Timer): 使用 timeSetEvent 创建,在优先级为 15 的独立线程上回调,支持高精度。
  • 可等待计时器(Waitable Timer): 一种内核对象,到期时变为已通知状态。

多媒体计时器示例:

cpp
#include <mmsystem.h>
#pragma comment(lib, "winmm")

void main() {
    auto id = ::timeSetEvent(
        1000,           // 间隔(毫秒)
        10,             // 分辨率(毫秒)
        OnTimer,        // 回调函数
        0,              // 用户数据
        TIME_PERIODIC); // 周期性或一次性

    ::Sleep(10000);
    ::timeKillEvent(id);
}

void CALLBACK OnTimer(UINT id, UINT, DWORD_PTR userData, DWORD_PTR, DWORD_PTR) {
    printf("Timer struck at %u\n", ::GetTickCount());
}

可等待计时器创建:

cpp
HANDLE CreateWaitableTimer(
    _In_opt_ LPSECURITY_ATTRIBUTES lpTimerAttributes,
    _In_ BOOL bManualReset,
    _In_opt_ LPCTSTR lpTimerName);

HANDLE CreateWaitableTimerEx(
    _In_opt_ LPSECURITY_ATTRIBUTES lpTimerAttributes,
    _In_opt_ LPCTSTR lpTimerName,
    _In_ DWORD dwFlags,
    _In_ DWORD dwDesiredAccess);

可等待计时器可以被命名。与事件类似,存在两种变体:手动重置(bManualReset = TRUECREATE_WAITABLE_TIMER_MANUAL_RESET)或自动重置(同步计时器,bManualReset = FALSEdwFlags = 0)。CreateWaitableTimer 的默认访问权限是 TIMER_ALL_ACCESS

按名称打开:

cpp
HANDLE OpenWaitableTimer(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ LPCTSTR lpTimerName);

设置计时器:

cpp
typedef VOID (CALLBACK *PTIMERAPCROUTINE)(
    _In_opt_ LPVOID lpArgToCompletionRoutine,
    _In_     DWORD dwTimerLowValue,
    _In_     DWORD dwTimerHighValue);

BOOL SetWaitableTimer(
    _In_ HANDLE hTimer,
    _In_ const LARGE_INTEGER* lpDueTime,
    _In_ LONG lPeriod,
    _In_opt_ PTIMERAPCROUTINE pfnCompletionRoutine,
    _In_opt_ LPVOID lpArgToCompletionRoutine,
    _In_ BOOL fResume);

BOOL SetWaitableTimerEx(
    _In_ HANDLE hTimer,
    _In_ const LARGE_INTEGER* lpDueTime,
    _In_ LONG lPeriod,
    _In_opt_ PTIMERAPCROUTINE pfnCompletionRoutine,
    _In_opt_ LPVOID lpArgToCompletionRoutine,
    _In_opt_ PREASON_CONTEXT WakeContext,
    _In_ ULONG TolerableDelay);

lpDueTime 含义(LARGE_INTEGER,有符号 64 位):

  • 正值: 绝对时间,以 100 纳秒为间隔,从 1601 年 1 月 1 日午夜 UTC 起计算。
  • 负值: 相对时间,以 100 纳秒为间隔。

实际分辨率取决于硬件,而非 100 纳秒的精度。

相对间隔示例(10 毫秒):

cpp
LARGE_INTEGER interval;
interval.QuadPart = -10000 * 10;

绝对时间示例(2020 年 3 月 10 日 17:30:00 UTC):

cpp
SYSTEMTIME st = { 0 };
st.wYear = 2020;
st.wMonth = 3;
st.wDay = 10;
st.wHour = 17;
st.wMinute = 30;

FILETIME ft;
::SystemTimeToFileTime(&st, &ft);
LARGE_INTEGER dueTime;
dueTime.QuadPart = *(LONGLONG*)&ft;

如果使用本地时间,需要先转换为 UTC:

cpp
FILETIME ft;
::TzSpecificLocalTimeToSystemTime(nullptr, &st, &st);
::SystemTimeToFileTime(&st, &ft);

第三个参数 lPeriod:为零表示一次性计时器;非零值表示以毫秒为单位的周期(对绝对和相对到期时间均有效)。

第四个参数是一个可选的回调完成例程(PTIMERAPCROUTINE)。如果为 NULL,使用常规等待函数检测计时器到期。如果非 NULL,该例程将作为 APC(Asynchronous Procedure Call,异步过程调用) 排队到调用 SetWaitableTimer(Ex) 的线程。

APC 是发送到特定线程的回调,只能由该线程执行。它们被推入线程的 APC 队列,但不会立即执行——线程必须进入可提醒状态(Alertable State)。在此状态下,线程检查其 APC 队列并在恢复正常执行之前运行所有累积的 APC。

进入可提醒状态:SleepEx 是最简单的方法。

cpp
DWORD SleepEx(
    _In_ DWORD dwMilliseconds,
    _In_ BOOL  bAlertable);

bAlertable = TRUE 时,线程在可提醒状态下休眠。如果休眠期间有 APC 到达,它们会立即执行,休眠也随之结束。如果调用 SleepEx 时 APC 队列中已有 APC,则根本不会休眠——APC 会先执行。

SimpleTimer 示例——每秒周期性回调:

cpp
void  CALLBACK OnTimer(void* param, DWORD low, DWORD high) {
    printf("TID: %u Ticks: %u\n", ::GetCurrentThreadId(), ::GetTickCount());
}

int  main() {
    auto  hTimer = ::CreateWaitableTimer(nullptr, TRUE, nullptr);
    LARGE_INTEGER interval;
    interval.QuadPart = -10000 * 1000LL;
    ::SetWaitableTimer(hTimer, &interval, 1000, OnTimer, nullptr, FALSE);
    printf("Main thread ID: %u\n", ::GetCurrentThreadId());
    while  (true)
        ::SleepEx(INFINITE, TRUE);

    // 永远不会到达这里
    return  0;
}

调用线程和回调线程是同一个线程。无限循环使线程保持活动状态。SleepEx(0, TRUE) 可以作为轻量级的“垃圾回收”使用,在不实际等待的情况下运行任何累积的 APC。

Windows 支持三种 APC 类型:用户模式 APC、内核模式 APC 和特殊内核模式 APC。本书仅涉及用户模式 APC。

SetWaitableTimer(Ex) 的第五个参数是传递给回调的用户定义数据。回调接收该数据作为第一个参数,外加两个构成 64 位绝对时间的 32 位值,表示计时器触发的时间。

第六个参数 fResume 指定计时器到期是否应将系统从节能状态(如 Connected Standby)唤醒。

SetWaitableTimerEx 的附加参数:

第六个参数是一个可选的 REASON_CONTEXT 结构体指针:

cpp
typedef  struct  _REASON_CONTEXT {
    ULONG Version;
    DWORD Flags;
    union  {
        struct  {
            HMODULE LocalizedReasonModule;
            ULONG LocalizedReasonId;
            ULONG ReasonStringCount;
            LPWSTR *ReasonStrings;
        } Detailed;
        LPWSTR SimpleReasonString;
    } Reason;
} REASON_CONTEXT, *PREASON_CONTEXT;

它为与电源相关的请求提供附加上下文。传递 NULL 表示没有特定上下文。

最后一个参数是可容忍延迟(Tolerable Delay),以毫秒为单位,与计时器合并(Timer Coalescing,Windows 7 引入)相关。如果多个计时器在接近的时间间隔内到期,系统可以将它们的信号合并到更少的 CPU 唤醒中。零(SetWaitableTimer 内部使用的值)表示无容忍度——最佳精度但可能带来更高的功耗。

取消计时器:

cpp
BOOL CancelWaitableTimer(_In_ HANDLE hTimer);

线程池计时器(将在下一章介绍)提供了一种更方便的替代方案。

其他等待函数(Other Wait Functions)

除了 WaitForSingleObjectWaitForMultipleObjects 之外,Windows 还提供了几种变体。

在可提醒状态下等待(Waiting in Alertable State)

经典等待函数的扩展版本添加了一个可提醒标志:

cpp
DWORD WaitForSingleObjectEx(
    _In_ HANDLE hHandle,
    _In_ DWORD dwMilliseconds,
    _In_ BOOL bAlertable);

DWORD WaitForMultipleObjectsEx(
    _In_ DWORD nCount,
    _In_reads_(nCount) CONST HANDLE* lpHandles,
    _In_ BOOL bWaitAll,
    _In_ DWORD dwMilliseconds,
    _In_ BOOL bAlertable);

bAlertable = FALSE 等同于原始函数。当 bAlertable = TRUE 时,排队到调用线程的任何 APC 都会依次执行,然后等待结束。这种情况下的返回值是 WAIT_IO_COMPLETION。如果发生这种情况,线程如果仍想等待,可以再次调用等待函数。

在 GUI 线程上等待(Waiting on GUI Threads)

GUI 线程通常应避免使用 INFINITE 超时的 WaitForSingleObjectWaitForMultipleObjects。如果对象需要很长时间才能变为已通知,UI 会冻结——窗口变得无响应,标题栏中出现"未响应"。

解决方案:MsgWaitForMultipleObjects(Ex)

cpp
DWORD WINAPI
MsgWaitForMultipleObjects(
    _In_ DWORD nCount,
    _In_ CONST HANDLE *pHandles,
    _In_ BOOL fWaitAll,
    _In_ DWORD dwMilliseconds,
    _In_ DWORD dwWakeMask);

WINUSERAPI
DWORD
WINAPI
MsgWaitForMultipleObjectsEx(
    _In_ DWORD nCount,
    _In_ CONST HANDLE *pHandles,
    _In_ DWORD dwMilliseconds,
    _In_ DWORD dwWakeMask,
    _In_ DWORD dwFlags);

这些函数在等待对象的同时也等待发送到调用线程的 UI 消息。dwWakeMask 参数指定消息类型。QS_ALLEVENTS 使函数在任何消息出现时返回 WAIT_OBJECT_0 + nCount。然后线程应该泵送消息并继续等待。

示例:在等待事件的同时泵送消息:

cpp
void WaitWithMessages(HANDLE hEvent) {
    while (::MsgWaitForMultipleObjects(1, &hEvent, FALSE, INFINITE, QS_ALLEVENTS)
        == WAIT_OBJECT_0 + 1) {
        MSG msg;
        while (::PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) {
            ::TranslateMessage(&msg);
            ::DispatchMessage(&msg);
        }
    }
}

对于扩展函数,移除的 fWaitAll 参数被 dwFlags 替代,它可以是零或以下值的组合:

  • MWMO_ALERTABLE —— 函数在可提醒状态下等待。
  • MWMO_INPUTAVAILABLE —— 如果消息队列中有输入则返回,即使已被 PeekMessageGetMessage 检查过。
  • MWMO_WAITALL —— 仅当所有对象都已通知且有输入时才返回。

其他 QS_* 掩码值可在文档中查找。

等待空闲的 GUI 线程(Waiting for an Idle GUI Thread)

WaitForInputIdle 等待指定进程中的 GUI 线程准备好进行消息处理:

cpp
DWORD WINAPI WaitForInputIdle(
    _In_ HANDLE hProcess,
    _In_ DWORD dwMilliseconds);

这对于创建子进程并希望与其 GUI 线程交互的父进程最为有用。进程创建是异步的,因此父进程无法知道子进程的线程何时准备好接收消息。过早发送消息(在消息队列就绪之前)会导致消息丢失。

典型用法:

cpp
PROCESS_INFORMATION pi;
//...
::CreateProcess(..., &pi);
// 省略错误处理
::WaitForInputIdle(pi.hProcess, INFINITE);
// GUI 线程就绪,向主线程发送一些消息
::PostThreadMessage(pi.dwThreadId, WM_USER, 0, 0);
//...

原子性地发出信号和等待(Atomically Signaling and Waiting)

SignalObjectAndWait 将发出一个对象的信号和等待另一个对象组合为单个原子操作:

cpp
DWORD SignalObjectAndWait(
    _In_ HANDLE hObjectToSignal,
    _In_ HANDLE hObjectToWaitOn,
    _In_ DWORD dwMilliseconds,
    _In_ BOOL bAlertable);

hObjectToSignal 只能是事件(通过 SetEvent 发出信号)、信号量(以计数 1 调用 ReleaseSemaphore)或互斥锁(ReleaseMutex)。hObjectToWaitOn 可以是任何可等待对象。

两个优点:

  1. 效率: 将两个操作合并为一次内核模式转换:
cpp
// 代替:
::SetEvent(hEvent1);
::WaitForSingleObject(hEvent2, INFINITE);

// 使用:
::SignalObjectAndWait(hEvent1, hEvent2, INFINITE, FALSE);
  1. 原子性: 没有其他线程可以在发出信号和等待之间观察到已通知对象的状态。这在某些边缘情况下提供了 PulseEvent 的可靠替代方案。

前面关于避免使用 PulseEvent 的警告仍然适用。

练习(Exercises)

  1. 创建一个系统,同时运行多个工作项,其中某些项依赖于其他项。例如:在 Visual Studio 中编译项目,某些项目依赖于其他项目,必须按顺序处理。示例依赖关系:项目 4 依赖于项目 1,项目 5 依赖于项目 2 和 3,等等。使用事件对象进行流程同步。

总结(Summary)

本章介绍了常用于线程同步的调度程序对象(Dispatcher Objects),包括互斥锁(Mutex)、信号量(Semaphore)、事件(Event)和可等待计时器(Waitable Timer)。这些内核对象通过命名机制可以跨进程共享,实现进程间的线程同步。

  • 互斥锁提供了与临界区类似的互斥访问能力,但可以跨进程使用,并具有拥有权概念和废弃检测机制。
  • 信号量限制对资源的并发访问数量,没有拥有权概念,生产者和消费者可以是不同的线程。
  • 事件是最简单的同步原语,分为手动重置和自动重置两种类型,广泛用于线程间的信号通知。
  • 可等待计时器将时间概念引入同步,支持相对时间和绝对时间,并可以通过 APC 机制执行回调。

本章还介绍了各种等待函数的变体,包括可提醒等待、GUI 线程等待和原子信号等待。下一章将探讨线程池(Thread Pool),它是显式创建线程的常见替代方案。