CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • Windows 10系统编程 引言
  • 第1章:基础
  • 第2章:对象和句柄
  • 第3章:进程
  • 第4章:作业(Jobs)
  • 第5章:线程基础
  • 第6章:线程调度
  • 第7章:进程内线程同步
  • 第8章:进程间线程同步
    • 调度器对象
      • 等待成功
    • 互斥体
      • 互斥锁演示应用程序
      • 废弃的互斥锁
    • 信号量(Semaphore)
      • 队列演示应用程序
    • 事件
      • 使用事件
    • 可等待定时器
    • 其他等待函数
      • 在可提醒状态下等待
      • 等待图形用户界面线程(GUI线程)
      • 等待空闲的GUI线程
      • 原子性地发出信号和等待
    • 练习
    • 总结
  • 第9章:线程池
  • 第10章:高级线程
  • 第11章:文件和设备输入输出
  • 第12章:内存管理基础
  • 第13章:内存操作
  • 第14章:内存映射文件
  • 第15章:动态链接库
  • 第16章:安全性
  • 第17章:注册表
目录

第8章:进程间线程同步

# 第8章:进程间线程同步

上一章介绍了各种同步机制,它们有一个共同点:可用于在同一进程中运行的线程之间进行同步。本章将介绍一些基于内核对象的同步机制,作为对上一章的补充。内核对象本质上属于系统空间,因此可以自然地在进程之间共享,进而被(可能)运行在不同进程中的线程使用。这并不意味着这些机制在同一进程场景中毫无用处,远非如此。但它们确实具备第7章中介绍的机制所没有的特殊能力。

本章内容包括:

  • 调度器对象(Dispatcher Objects)
  • 互斥体(Mutex)
  • 信号量(Semaphore)
  • 事件(Event)
  • 可等待定时器(Waitable Timer)
  • 其他等待函数

# 调度器对象

第2章对内核对象和句柄进行了详细介绍。以下列出了与内核对象相关的最重要要点。如需更多详细信息,请参考第2章。

  • 内核对象驻留在系统(内核)空间中,理论上,任何进程都可以访问,前提是该进程能够获取到请求对象的句柄。
  • 句柄是与进程相关的。
  • 跨进程共享对象有三种方式:句柄继承、名称和句柄复制。

一些内核对象更为特殊,被称为调度器对象或可等待对象。这类对象可以处于两种状态之一:已发出信号(signaled)或未发出信号(non-signaled)。已发出信号和未发出信号的含义取决于对象的类型。表8-1总结了常见调度器对象的这些状态的含义。

表8-1:常见调度器对象及其已发出信号/未发出信号的含义

对象类型 已发出信号 未发出信号
进程 已退出/已终止 正在运行
线程 已退出/已终止 正在运行
作业(Job) 作业时间结束 未达到或未设置限制
互斥体 空闲(未被占用) 已被占用
信号量 计数值大于零 计数值为零
事件 事件已设置 事件未设置
文件 I/O操作完成 I/O操作正在进行或未开始
可等待定时器 定时器计数已过期 定时器计数未过期
I/O完成端口 异步I/O操作完成 I/O操作未完成

文件和I/O完成端口将在第11章讨论。可等待定时器、互斥体、信号量和事件对象将在本章后面进行讨论。

等待一个对象变为已发出信号状态,通常可以通过以下两个函数之一来完成(I/O完成端口有其自己的等待函数,将在第11章讨论,此处除外):

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

DWORD WaitForMultipleObjects(
    _In_ DWORD nCount,
    _In_ CONST HANDLE* lpHandles,
    _In_ BOOL bWaitAll,
    _In_ DWORD dwMilliseconds
);
1
2
3
4
5
6
7
8
9
10
11

WaitForSingleObject接受一个调度器对象的句柄,该句柄必须具有SYNCHRONIZE访问权限。超时参数指定最多等待该对象变为已发出信号状态的时长。该值可以为零,这意味着无论如何都不进行等待。相反,该值也可以设置为INFINITE,这意味着线程愿意一直等待,直到对象变为已发出信号状态。WaitForSingleObject可能有四种返回值:

  • WAIT_OBJECT_0:等待结束,因为在超时时间到期之前对象变为了已发出信号状态。
  • WAIT_TIMEOUT:在该线程等待期间,对象未变为已发出信号状态。如果超时时间为INFINITE,则永远不会返回该值。
  • WAIT_FAILED:函数因某种原因失败。调用常用的GetLastError函数可查看原因。
  • WAIT_ABANDONED:等待的是一个互斥体对象,并且该互斥体已被放弃。已放弃的互斥体的含义将在本章后面的“互斥体”部分进行讨论。

扩展函数WaitForMultipleObjects允许等待一个或多个句柄。该函数期望第二个参数为句柄数组,第一个参数表示句柄的数量。句柄数量限制为MAXIMUM_WAIT_OBJECTS(64个)。第三个参数指定线程是应该等待所有对象同时变为已发出信号状态(TRUE),还是只要有一个(任意一个)对象变为已发出信号状态即可(FALSE)。

该函数的返回值包括WAIT_TIMEOUT和WAIT_FAILED,与WaitForSingleObject中的含义相同。如果bWaitAll为TRUE(等待所有对象),返回值介于WAIT_OBJECT_0和WAIT_OBJECT_0 + count - 1之间,其中count是句柄的数量。如果返回值介于WAIT_ABANDONED_0和WAIT_ABANDONED_0 + count - 1之间,则意味着所有对象都已发出信号,并且至少有一个互斥体已被放弃。

如果bWaitAll为FALSE,返回值(如果不是超时或错误)表示哪个对象(数组中的索引)处于已发出信号状态,偏移量为WAIT_OBJECT_0或WAIT_ABANDONED_0。例如,返回WAIT_OBJECT_0 + 3意味着第四个对象已发出信号,并且等待因此结束。如果有多个对象已发出信号,则返回最小的索引值。

# 等待成功

如果一个等待函数因为对象变为已发出信号状态而成功,那么该线程将被唤醒并可以继续执行。刚刚发出信号的对象会一直保持已发出信号的状态吗?这取决于对象的类型。有些对象会保持已发出信号的状态,比如进程和线程。一旦一个进程退出或终止,它就会变为已发出信号状态,并在其剩余生命周期内(只要还有指向该进程的打开句柄)一直保持这种状态。

有些类型的对象在等待成功后可能会改变其已发出信号的状态。例如,对一个互斥体的等待成功后,它会变回未发出信号状态(原因将在下一节讨论互斥体时变得明显)。另一个在发出信号时表现出特殊行为的对象是自动重置事件(auto-reset event)。当它发出信号时,会释放一个线程(且仅一个),并且在发生这种情况时,它的状态会自动翻转到未发出信号状态。

如果多个线程等待同一个互斥体,并且该互斥体变为已发出信号状态,会发生什么呢?在互斥体变回未发出信号状态之前,只有一个线程可以获取该互斥体。在底层,等待某个对象的线程存储在一个先进先出(FIFO,First-In-First-Out)队列中,所以队列中的第一个线程会被唤醒(无论其优先级如何)。然而,这种行为不应该被依赖。一些内部机制可能会将一个线程从等待状态中移除(例如,如果它被调试器挂起),然后当该线程恢复时,它会被推到队列的末尾。所以这里的简单规则是,无法确定哪个线程会首先被唤醒。而且在任何情况下,这种算法在未来版本的Windows中随时可能会改变。

# 互斥体

我们要研究的第一种内核对象类型是互斥体。互斥体(“mutual exclusion”的缩写)提供的功能与第7章讨论的临界区(Critical Section)类似。其目的是相同的:保护共享数据免受并发访问。一次只有一个线程能够成功获取互斥体,并继续访问共享数据。所有其他等待互斥体的线程必须继续等待,直到获取互斥体的线程释放它。

创建一个互斥体对象需要调用CreateMutex或CreateMutexEx函数:

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
);
1
2
3
4
5
6
7
8
9
10
11
12

这两个函数的第一个参数都是常见的SECURITY_ATTRIBUTES指针,通常设置为NULL。如果bInitialOwner设置为TRUE,CreateMutex将通过调用WaitForSingleObject尝试获取互斥体,直到获取成功后才返回。使用CreateMutexEx时,通过在dwFlags参数中指定CREATE_MUTEX_INITIAL_OWNER标志也能达到相同的效果。如果创建了一个新对象,那么这种获取操作会立即成功。

lpName参数允许为互斥体设置一个名称。如果具有相同名称的互斥体对象已经存在(并且没有安全限制),这些函数将打开一个指向现有互斥体的句柄。如果名称存在,但对象不是互斥体,则这些函数会失败。

最后,扩展函数CreateMutexEx允许为互斥体指定所需的访问掩码。这在打开现有互斥体时非常有用,可能会请求比MUTEX_ALL_ACCESS更弱的访问掩码,而MUTEX_ALL_ACCESS是CreateMutex默认请求的访问掩码。

可以使用OpenMutex通过名称打开现有互斥体:

HANDLE OpenMutexW(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ LPCWSTR lpName
);
1
2
3
4
5

如果指定名称的互斥体不存在,该函数将失败并返回NULL。如果两个(或更多)线程想要使用同名互斥体进行同步,调用CreateMutex或CreateMutexEx会更简单(并且可以避免竞态条件):第一个线程(无论它是谁)创建对象,后续调用者将获取指向现有对象的新句柄。

与内核对象一样,任何打开的句柄最终都应该使用CloseHandle关闭。

对一个互斥体调用WaitForSingleObject会导致线程等待,直到该互斥体变为已发出信号状态,这意味着它是空闲的,或者没有被其他任何线程占用。一旦获取到互斥体,它会自动转换到未发出信号状态,防止其他任何线程获取它。一旦互斥体的所有者完成对共享数据的操作,它会调用ReleaseMutex来释放对互斥体的所有权,使其再次变为已发出信号状态:

BOOL ReleaseMutex(_In_ HANDLE hMutex);
1

第7章中的“简单增量”应用程序可以配置为使用互斥体作为同步原语(图8-1)。请注意,正确完成计数所需的时间要长得多。这是因为使用互斥体(与所有内核对象一样)进行同步需要从用户模式转换到内核模式,这并非没有开销。当然,这个示例是人为设计的,所以与临界区相比,差异看起来很大。在实际应用中,情况并没有那么糟糕。

img 图8-1:使用互斥体的简单增量

在这个循环增量中使用互斥体的代码如下:

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;
}
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

互斥体可以被同一个线程递归获取,这会导致一个内部计数器递增。这意味着需要调用相同次数的ReleaseMutex才能真正释放互斥体。由一个不拥有互斥体的线程调用ReleaseMutex会失败。

# 互斥锁演示应用程序

MutexDemo应用程序展示了在不同进程中运行的线程如何同步对共享文件的访问,从而确保在同一时间只有一个线程可以访问该文件。由于涉及多个进程,因此不能使用临界区(critical section)。

为了进行测试,打开两个命令窗口并导航到MutexDemo.exe所在的目录。或者,你也可以从Visual Studio运行,但需要在项目属性中设置命令行参数(图8-2)。该参数应该是某个不存在的文件路径。

img 图8-2:设置命令行参数

在两个命令窗口中都运行该应用程序,并指向同一个文件。你应该在每个命令窗口中看到如下输出:

进程25092。互斥锁句柄:0x9C
按任意键开始...
1
2

进程ID会有所不同,句柄值也很可能不同。这些是同一个互斥对象的句柄。为了验证这一点,打开进程资源管理器(Process Explorer),找到这两个进程实例。在每个实例中找到互斥锁(其名称为“ExampleMutex”)。注意,句柄值与打印的值是对应的(图8-3)。

img 图8-3:进程资源管理器中的一个互斥锁句柄

现在双击该句柄,验证互斥锁的句柄计数为2(图8-4)。同时,注意它处于未发信号状态(Held: FALSE)。

img 图8-4:进程资源管理器中的互斥锁属性

现在在两个控制台窗口中快速按下任意键。这些进程中的线程现在将使用互斥锁来同步对文件的访问。每个线程都会向文件追加一行包含进程ID的字符串。

一旦进程执行完毕,你可以在文本编辑器中打开该文件。你应该会看到类似如下内容:

这是来自进程25092的文本
这是来自进程25092的文本
这是来自进程25092的文本
这是来自进程25092的文本
这是来自进程25092的文本
这是来自进程36460的文本
这是来自进程25092的文本
这是来自进程36460的文本
这是来自进程25092的文本
这是来自进程36460的文本
这是来自进程36460的文本
这是来自进程25092的文本
这是来自进程36460的文本
这是来自进程25092的文本
这是来自进程36460的文本
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

总行数应该是200行。每个进程都应该准确地写入了100行包含自身进程ID的文本。

主函数创建/打开命名互斥锁,打印进程ID和互斥锁句柄,然后等待用户按下一个键:

int wmain(int argc, const wchar_t* argv[]) {
    if (argc < 2) {
        printf("用法: MutexDemo <文件>\n");
        return 0;
    }

    HANDLE hMutex = ::CreateMutex(nullptr, FALSE, L"ExampleMutex");
    if (!hMutex)
        return Error("无法创建/打开互斥锁");

    printf("进程 %d。互斥锁句柄: 0x%X\n", ::GetCurrentProcessId(), HandleToULong(hMutex));
    printf("按任意键开始...\n");
    _getch();
1
2
3
4
5
6
7
8
9
10
11
12
13

Error函数提供了一个简单的错误显示,我们之前多次遇到过:

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

一旦按下一个键,就会执行一个循环100次,在每次迭代中获取互斥锁、访问文件并释放互斥锁:

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

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

    // 写入文件
    if (!WriteToFile(argv[1]))
        return Error("无法写入文件");

    ::ReleaseMutex(hMutex);
}

::CloseHandle(hMutex);
printf("完成。\n");

return 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

WriteToFile函数打开文件,将文件指针设置到文件末尾,将文本写入文件并关闭文件:

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, "这是来自进程 %d的文本\n", ::GetCurrentProcessId());
    DWORD bytes;
    BOOL ok = ::WriteFile(hFile, text, (DWORD)strlen(text), &bytes, nullptr);
    ::CloseHandle(hFile);
    return ok;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

文件API将在第11章详细讨论。

你可以同时运行任意数量的QueueDemo进程,共享文件不会被损坏。这个演示使用相同的可执行文件这一事实并不重要,即使从不同的可执行文件运行,效果也是一样的。重要的是共享的互斥锁。

将互斥锁名称更改为NULL并重复该实验。结果如何?

# 废弃的互斥锁

如果拥有互斥锁的线程退出或终止(无论出于何种原因)会发生什么情况?由于互斥锁的所有者是唯一可以释放它的线程,这可能会导致死锁,即其他等待该互斥锁的线程永远无法获取它。这种互斥锁被称为废弃的互斥锁(abandoned mutex),字面意思是被其所有者线程抛弃了。

幸运的是,内核知道互斥锁的所有权,因此如果它发现一个线程在持有互斥锁(或者如果有多个互斥锁,则是持有多个互斥锁)的情况下终止,它会显式地释放这个废弃的互斥锁。这会导致下一个成功获取互斥锁的线程从其WaitForSingleObject调用中返回WAIT_ABANDONED,而不是WAIT_OBJECT_0。这意味着线程正常获取了互斥锁,但这个特殊的返回值被用作一个提示,表明前一个所有者在终止之前没有释放互斥锁。这通常表明存在一个应该进一步调查的错误。

为互斥锁编写RAII包装器。

# 信号量(Semaphore)

信号量是我们研究的第一个同步内核对象,在第7章所探讨的进程内原语中,没有与之直接对应的内容。信号量的作用是,以一种线程安全的方式对某些事物进行限制。

信号量在初始化时会设置当前计数和最大计数。只要其当前计数大于零,它就处于已通知状态(signaled state)。每当一个线程在信号量处于已通知状态时调用WaitForSingleObject函数,信号量的计数就会减1,并且该线程被允许继续执行。一旦信号量的计数达到零,它就会变为未通知状态(non-signaled),任何试图等待该信号量的线程都会被阻塞。

相反,想要 “释放” 一个(或多个)信号量计数的线程,会调用ReleaseSemaphore函数,这会使信号量的计数增加,并将其再次设置为已通知状态。

让我们稍微回顾一下,看看如何使用以下函数之一来创建信号量:

HANDLE CreateSemaphore(
    _In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
    _In_ LONG lInitialCount,
    _In_ LONG lMaximumCount,
    _In_opt_ LPCTSTR lpName);
1
2
3
4
5
HANDLE CreateSemaphoreEx(
    _In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
    _In_ LONG lInitialCount,
    _In_ LONG lMaximumCount,
    _In_opt_ LPCTSTR lpName,
    _Reserved_ DWORD dwFlags,
    _In_ DWORD dwDesiredAccess);
1
2
3
4
5
6
7

和互斥锁(mutex)一样,信号量也可以有一个名称。这使得在不同进程中运行的线程之间可以轻松共享信号量。上述创建函数中独特的参数是信号量的初始计数和最大计数。通常将它们设置为相同的值,这表明由信号量限制的任何事物(例如一个队列)目前没有元素。扩展函数允许指定所需的访问掩码(调用CreateSemaphore时,默认值是SEMAPHORE_ALL_ACCESS)。dwFlags参数目前未使用,必须设置为零。

获取信号量的计数是通过常用的等待函数来完成的。释放信号量计数则是通过ReleaseSemaphore函数实现的:

BOOL ReleaseSemaphore(
    _In_ HANDLE hSemaphore,
    _In_ LONG lReleaseCount,
    _Out_opt_ LPLONG lpPreviousCount);
1
2
3
4

该函数允许指定要释放的计数(即要添加到当前信号量计数中的数量)。这个值通常是1,但也可以更大。最后一个参数允许获取新的信号量计数。也可以将释放计数指定为零,仅获取当前信号量的计数。当然,任何这样的获取操作都可能存在竞态条件(race condition),因为在获取计数和根据结果进行操作之间,信号量的计数可能已被另一个线程更改。

最大计数为1的信号量是否等同于互斥锁呢?思考一下这个问题。答案显然是否定的。原因是信号量没有任何所有权的概念。任何线程都可以获取它的一个计数,任何线程也都可以调用ReleaseSemaphore函数。信号量的用途与互斥锁有很大不同。这种 “自由形式” 的行为如果不加以注意,可能会导致死锁,但在大多数情况下,正如我们将在下一个代码示例中看到的,这是一件好事。

与其他命名对象一样,可以通过OpenSemaphore函数根据名称获取现有信号量的句柄:

HANDLE OpenSemaphore(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ LPCTSTR lpName);
1
2
3
4

# 队列演示应用程序

图8-5所示的队列演示应用程序是基于第7章中同名的应用程序开发的。这一次,添加了一个信号量,以便工作项队列的数量受到指定数量的限制。在第7章的版本中,如果生产者线程生成数据的速度比消费者线程处理数据的速度快得多,队列可能会无限增长。这是有问题的,因为队列的内存消耗可能会过高,在极端情况下甚至可能导致内存不足的情况(特别是在32位进程中)。

img 图8-5:增强版队列演示应用程序

这就是信号量发挥作用的地方。在原始应用程序中,有新数据的生产者线程会直接将数据推入队列,并在需要时使用条件变量唤醒一个消费者线程。这一次,生产者线程首先对信号量调用WaitForSingleObject函数。如果信号量的计数大于零,即处于已通知状态,这意味着队列未满,生产者线程可以继续推送一个项目。在消费者端,消费者线程从队列中弹出一个项目,然后调用ReleaseSemaphore函数,表明队列现在少了一个项目。

首先,在CMainDlg::Run函数中,根据对话框中指定的值创建信号量:

// ...
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));
1
2
3
4
5
6
7
8
9

m_hQueueSem是一个新的数据成员,它持有一个指向信号量的智能句柄(wil::unique_handle),确保在信号量超出作用域或再次重置时调用CloseHandle函数。

以下是修改后的生产者代码:

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;
}
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

唯一的新增内容是对信号量调用WaitForSingleObject函数。消费者线程的代码也有所改变,一旦从队列中移除一个项目,就会释放信号量的一个计数:

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;
    }
}
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

你可以使用进程资源管理器(Process Explorer)的句柄视图查看信号量的基本属性。图8-6展示了队列演示应用程序运行时的信号量(不过,进程资源管理器不会自动更新对象的状态)。

img 图8-6:进程资源管理器中信号量的属性

信号量的灵活性显而易见:生产者线程等待获取信号量的一个计数,而消费者线程释放计数 —— 它们不是同一批线程。

为信号量编写资源获取即初始化(RAII,Resource Acquisition Is Initialization)包装器。

# 事件

从某种意义上讲,事件(Event)是最简单的同步原语——它只是一个可以设置(已发出信号状态)或重置(未发出信号状态)的标志。作为一个(可能有名称的)内核对象,这让它能够灵活地在单个进程内或跨进程工作。我们已经在队列演示(Queue Demo)应用程序中使用过事件。现在,我们将详细讨论它。

事件存在一个复杂之处,即有两种类型的事件:手动重置事件和自动重置事件。表8-2总结了它们的属性,接下来将详细阐述。

表8-2:事件类型差异

事件类型 内核名称 SetEvent的效果
手动重置 通知(Notification) 将事件置于已发出信号状态,并释放所有等待该事件的线程(如果有的话)。事件会保持在已发出信号状态
自动重置 同步(Synchronization) 释放一个等待的线程,然后事件会自动返回未发出信号状态

表8-2中的内核类型名称在诸如进程资源管理器(Process Explorer)之类的工具中很有用,这些工具使用内核术语来提供事件的类型名称。

创建事件对象与创建其他对象类型没有区别,可以通过以下函数之一来完成:

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);
1
2
3
4
5
6
7
8
9
10
11

这些函数用于指定需要创建的事件类型。一旦做出决定,就无法更改。CreateEvent函数使用bManualReset参数来指示是手动重置事件(TRUE)还是自动重置事件(FALSE)。对于CreateEventEx函数,事件类型通过dwFlags参数指定,将其设置为CREATE_EVENT_MANUAL_RESET表示手动重置事件。

接下来要做的第二个决定是事件的初始状态(已发出信号或未发出信号)。CreateEvent函数允许在bInitialState参数中指定初始状态。对于CreateEventEx函数,使用另一个标志CREATE_EVENT_INITIAL_SET来指示初始状态为已发出信号。

与互斥锁(Mutex)和信号量(Semaphore)的扩展创建函数一样,CreateEventEx函数允许为新的事件句柄指定访问掩码(CreateEvent函数默认使用EVENT_ALL_ACCESS)。与前面提到的对象类似,如果指定了名称且具有该名称的事件已经存在(并且排除安全约束),则会打开该事件的另一个句柄,并且事件类型和初始状态将被忽略。

请记住,区分新对象和现有对象的方法是调用GetLastError函数并检查是否返回ERROR_ALREADY_EXISTS错误。

与其他对象类似,事件可以通过名称使用OpenEvent函数打开:

HANDLE OpenEvent(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ LPCTSTR lpName);
1
2
3
4

# 使用事件

可以使用SetEvent和ResetEvent函数显式更改事件的状态:

BOOL SetEvent(_In_ HANDLE hEvent);   // 已发出信号
BOOL ResetEvent(_In_ HANDLE hEvent); // 未发出信号
1
2

让我们来看一个示例场景。假设有几个正在运行的进程属于同一个系统。进一步假设其中一个进程(我们称之为控制器进程)需要向该应用程序的所有其他进程发出信号,以便它们正常关闭。如何实现呢?

事件是完美的选择。进程之间是隔离的,因此无法进行直接通信。在大多数情况下,这种隔离是有益的。但在某些情况下,进程之间必须传递一些信息。在这个例子中,要传递的信息很简单:只需一位就足以表示需要进行关闭操作。

这种流程同步可以通过手动重置事件来实现。所有进程都使用预定义的名称(如“ShudownEvent”)创建一个命名事件:

// 注意这是一个手动重置事件
HANDLE hShutdown = ::CreateEvent(nullptr, TRUE, FALSE, L"ShutdownEvent");
1
2

由于该对象是命名的,只有第一个进程会实际创建它,其余进程会获取现有对象的句柄。调用CreateEvent函数很方便,并且不需要对“谁先创建事件”进行任何同步——这根本无关紧要。

接下来,除了控制器进程之外,每个进程都需要在某个地方等待该事件。当事件变为已发出信号状态时,每个进程都会启动自己的关闭过程:

::WaitForSingleObject(hShutdown, INFINITE);
// 对象已发出信号,启动关闭操作...
1
2

控制器进程只需要在需要关闭时设置该事件:

::SetEvent(hShutdown);
// 启动自身的关闭操作...
1
2

这里需要使用手动重置事件,因为设置事件应该唤醒所有等待它的线程,这正是此场景所需要的。

可能会出现一个问题:参与的进程究竟在哪里等待事件呢?最简单的解决方案是创建一个线程,其唯一目的就是进行等待。但这并不理想,因为不应该仅仅为了等待而创建线程。有一个更好的替代方法是使用线程池(Thread Pool),我们将在下一章探讨。

自动重置事件的行为有所不同。调用SetEvent函数会将其更改为已发出信号状态。如果没有线程在等待它,它会保持在已发出信号状态,直到至少有一个线程等待它。然后,会释放一个线程,并且事件会自动返回未发出信号状态。要唤醒另一个等待的线程,则需要再次调用SetEvent函数。

队列演示应用程序展示了一个常见的示例,即使用事件向生产者和消费者线程指示是时候中止了。该事件首先在CMainDlg::OnInitDialog函数中创建为手动重置事件,因为需要通过一次调用通知多个线程是时候退出了:

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

生产者代码会在不等待的情况下检查事件是否已发出信号:

DWORD CMainDlg::ProducerThread() {
    for (;;) {
        if (::WaitForSingleObject(m_hAbortEvent.get(), 0) == WAIT_OBJECT_0)
            break;
        //...
    }
}
1
2
3
4
5
6
7

如果事件已发出信号,它会立即通过跳出无限循环来中止。每次迭代都会进行此调用,以便生产者能够尽快退出。消费者线程的工作方式类似。

你可能想知道为什么不直接使用布尔变量,检查它是否为真,如果为真则退出。主要问题是编译器(可能还有CPU)会将该变量优化掉,认为它的值不会改变,因为编译器不知道它可能会被另一个线程更改。一种可能的解决方案是将变量标记为volatile以防止任何优化,并强制CPU访问实际值。即便如此,也并非万无一失。通常,最好避免这些选项,因为这种情况属于数据竞争——多个线程访问同一内存,且至少有一个线程在写入。无论如何,都无法使用简单变量模拟自动重置事件的行为,更不用说在不同进程的线程之间进行协调了。

最后一个用于操作事件的函数是PulseEvent:

BOOL PulseEvent(_In_ HANDLE hEvent);
1

PulseEvent函数的目的是暂时设置事件,如果当前没有线程在等待,则重置该事件。文档中指出:“此函数不可靠,不应使用。它主要是为了向后兼容而存在。”并且还举例说明了为什么使用此函数不是一个好主意。

避免使用PulseEvent函数。

# 可等待定时器

Windows API提供了几种具有不同语义和编程模型的定时器。主要有以下几种:

  • 对于窗口化场景,SetTimer API提供了一个定时器,它通过向调用线程的消息队列发送WM_TIMER消息来工作。这个定时器适用于GUI应用程序,因为定时器消息可以在UI线程上处理。
  • Windows多媒体API提供了一个多媒体定时器,可以使用timeSetEvent函数创建。它会在优先级为15的单独线程上调用回调函数。该定时器可以是一次性的或周期性的,并且可以非常精确(其分辨率可以通过函数设置)。将分辨率设置为零可请求系统提供的最高分辨率。下面是一个使用多媒体定时器的简单示例:
#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());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

本节我们将重点介绍的是可等待定时器(Waitable Timer),它是一个内核对象,因此适合在本章中讨论。当可等待定时器的到期时间到达时,它会变为已发出信号状态。

创建可等待定时器可以通过两个函数之一来完成,这两个函数与之前遇到的函数有些类似:

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);
1
2
3
4
5
6
7
8
9
10

可等待定时器可以像互斥锁、信号量和事件一样有名称。可等待定时器有两种变体,与事件类似:手动重置定时器(bManualReset为TRUE或dwFlags为CREATE_WAITABLE_TIMER_MANUAL_RESET)或自动重置定时器(也称为同步定时器,bManualReset为FALSE或dwFlags为零)。最后,CreateWaitableTimerEx函数可以为返回的句柄指定显式的访问掩码(CreateWaitableTimer函数默认使用TIMER_ALL_ACCESS)。

由于可等待定时器可以命名,因此可以使用OpenWaitableTimer函数通过名称打开现有定时器:

HANDLE OpenWaitableTimer(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ LPCTSTR lpTimerName);
1
2
3
4

创建定时器是使用它的第一步,还需要使用非常重要的SetWaitableTimer(Ex)函数:

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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

这两个函数的前五个参数是相同的,所以我们先来看这些参数。lpDueTime参数表示定时器应该何时到期。它是一个LARGE_INTEGER结构,实际上就是一个64位整数,如果需要,可以方便地访问其两个32位部分。存储的数字是有符号的,正数和负数有不同的含义:

  • 正数表示从1601年1月1日午夜(协调世界时,也称为格林威治标准时间)开始以100纳秒为单位的绝对时间。
  • 负数表示以100纳秒为单位的相对时间。

定时器的实际分辨率取决于硬件,并非100纳秒这个精度范围。

我们从最常见的情况——相对时间间隔开始讲起。以毫秒为单位的时间间隔相当常见,将100纳秒(10^(-7)秒)转换为毫秒(10^(-3) 秒),意味着要乘以10000。例如,10毫秒的时间间隔可以通过如下方式初始化一个 LARGE_INTEGER 结构体来设置:

LARGE_INTEGER interval;
interval.QuadPart = -10000 * 10;
1
2

绝对时间的处理要更为复杂,因为零时间点似乎处于遥远过去的一个奇怪时刻。最好的办法是使用Windows API提供的辅助函数来获取所需的值。例如,假设定时器应在2020年3月10日17:30:00(协调世界时,UTC)到期(我写这些内容的日期),下面的代码片段有助于计算出正确的值:

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;
1
2
3
4
5
6
7
8
9
10
11

FILETIME 结构体与 LARGE_INTEGER 结构体相同,但奇怪的是它没有单个64位数据成员,只有两个32位值;这就是为什么最后一行代码要强制将其作为64位值读取。此外,文件系统时间中常用的 “文件时间(file time)” 采用相同的时间度量方式。

如果需要以本地时间而非协调世界时为基准,则可以在调用 SystemTimeToFileTime 之前调用 TzSpecificLocalTimeToSystemTime,如下所示:

FILETIME ft;
::TzSpecificLocalTimeToSystemTime(nullptr, &st, &st);
::SystemTimeToFileTime(&st, &ft);
1
2
3

TzSpecificLocalTimeToSystemTime 默认使用当前时区(第一个 NULL 值),并会考虑夏令时(Daylight Saving Time,DST)(如果启用)。

如果到期时间是绝对时间且指定的值在过去,定时器会立即发出信号。

SetWaitableTimer(Ex) 的第三个参数用于指示定时器是一次性的还是周期性的。指定为零表示一次性定时器;否则,该值就是以毫秒为单位的周期。请注意,这在绝对到期时间和相对到期时间的情况下都适用。

第四个参数是一个可选的函数指针,当定时器发出信号(到期)时会调用该函数。这个参数可以为 NULL,在这种情况下,可以使用常规的等待函数来判断定时器何时到期。如果该值不为 NULL,定时器到期时不会立即调用该函数;相反,该函数会被封装在一个异步过程调用(Asynchronous Procedure Call,APC)中,并附加到调用 SetWaitableTimer(Ex) 的线程上。

异步过程调用(APC)是发送给特定线程的回调,因此必须仅由该线程执行。对于可等待定时器(waitable timer),APC会被添加到调用 SetWaitableTimer(Ex) 的线程的APC队列中。棘手的是,这个APC不会立即执行。这太危险了,因为在定时器到期时,线程可能正在执行某些操作,强行将其转向APC的回调可能会产生意想不到的后果。如果在调用时线程已经获取了一个临界区(critical section)怎么办?一般来说,APC并不知道线程在执行过程中的位置。

这意味着APC被推送到特定线程队列的末尾,但为了运行它们,线程必须进入可提醒状态(alertable state)。在这种状态下,线程首先会检查其APC队列中是否积累了任何APC,如果有,则在恢复进入可提醒状态之后的代码执行之前,按顺序运行所有这些APC。

线程如何进入可提醒状态呢?有几个函数可以实现这一点,其中最简单的是 SleepEx:

DWORD SleepEx(
    _In_ DWORD dwMilliseconds,
    _In_ BOOL  bAlertable);
1
2
3

SleepEx 是我们熟悉的 Sleep 函数的超集。实际上,Sleep 函数是通过将 bAlertable 参数设置为 FALSE 来调用 SleepEx 实现的。将 bAlertable 设置为 TRUE 调用 SleepEx 会使线程在可提醒状态下睡眠指定的时长。如果在睡眠期间,APC出现在线程的队列中,它们会立即被执行,睡眠也会结束。如果在调用 SleepEx 时队列中已经存在任何APC,则根本不会进入睡眠状态。

下面的示例展示了如何设置一个可等待定时器,通过将线程置于无限期的可提醒睡眠状态,使其每秒调用一次回调函数(本章代码示例中的 SimpleTimer 项目):

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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

运行这段代码会得到类似如下的输出:

Main thread ID: 32024
TID: 32024 Ticks: 19648406
TID: 32024 Ticks: 19649406
TID: 32024 Ticks: 19650421
TID: 32024 Ticks: 19651421
TID: 32024 Ticks: 19652437
TID: 32024 Ticks: 19653437
TID: 32024 Ticks: 19654453
1
2
3
4
5
6
7
8

注意,调用 SetWaitableTimer 的线程与执行回调的线程是同一个。代码使用了带有无限超时时间的 SleepEx,因为该线程除了运行定时器回调之外无事可做。无限循环是必要的,否则在第一次回调运行之后,睡眠就会结束,程序也会退出。这个循环使线程保持存活,只是等待APC出现。

SleepEx 的另一个有用选项是使用零超时时间。这可以被视为一种简单的 “垃圾回收”,即线程不时调用 SleepEx(0, TRUE) 来运行可能积累的任何APC,但线程根本不想等待。

SleepEx 足够简单,但在其他场景中需要更高的灵活性。其他允许线程在可提醒状态下等待的函数包括经典函数的扩展版本,将在下一节中讨论。

其他使用APC的方式将在第11章中介绍。

Windows支持三种类型的APC:用户模式APC、内核模式APC和特殊内核模式APC。本书讨论的是前者。内核模式的变体(显然)仅适用于内核模式调用者(实际上在Windows驱动程序工具包(WDK)中没有文档说明),并且无论如何都不在本书的讨论范围内。有关内核模式APC的详细描述,请参阅我的《Windows内核编程》一书以及各种在线资源。

SetWaitableTimer(Ex) 的第五个参数是一个用户定义的值,如果提供了该值,它会原样传递给回调函数。回调函数本身将这个值作为其第一个参数接收,同时还会接收两个32位值,这两个值构成前面描述的绝对格式的64位值,表示定时器被触发的时间。

SetWaitableTimer 的第六个(也是最后一个)参数指定如果系统处于节能状态(如连接待机),定时器到期是否应触发唤醒系统。

关于 SetWaitableTimer 的介绍就到这里。扩展函数(从Windows 7和Windows Server 2008 R2开始可用)还有两个参数。第一个(第六个)是一个指向 REASON_CONTEXT 结构体的可选指针,其定义如下:

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;
1
2
3
4
5
6
7
8
9
10
11
12
13

该结构体可以为定时器请求提供额外的上下文信息。它也用于电源请求。如果定时器导致系统从低功耗状态唤醒,这有助于进行日志记录。传递 NULL 表示没有特定的上下文。有关 REASON_CONTEXT 的详细信息,请参阅相关文档。

SetWaitableTimerEx 的最后一个参数是定时器到期的容差值(以毫秒为单位)。这与Windows 7中引入的一项名为合并定时器(coalescing timers)的功能相关。假设你有两个定时器,一个在100毫秒后到期,另一个在105毫秒后到期。通常情况下,CPU必须在100毫秒后唤醒并发出第一个定时器的信号,然后进入睡眠状态,接着在5毫秒后再次唤醒以发出第二个定时器的信号。然而,如果第二个(或第一个)定时器请求了(比如说)10毫秒的容差,系统会只唤醒CPU一次,并一次性发出两个定时器的信号,因为应用程序表明在偏离精确时间的一定容差间隔内发出定时器信号是可以接受的。指定为零(这是 SetWaitableTimer 内部的做法)意味着没有容差,应用程序希望以可能更高的功耗为代价获得最佳精度。否则,系统会在考虑其他定时器的情况下考虑这个容差值。

我们对定时器的讨论还没有结束——通过使用线程池(thread pool),可以更方便地处理定时器,我们将在下一章中介绍。

最后,在成功调用 SetWaitableTimer(Ex) 之后(但在定时器到期之前),可以使用 CancelWaitableTimer 取消定时器:

BOOL CancelWaitableTimer(_In_ HANDLE hTimer);
1

# 其他等待函数

经典的等待函数 WaitForSingleObject 和 WaitForMultipleObjects 是最常用的。不过,还有其他变体,我们将在本节中介绍。

# 在可提醒状态下等待

常见函数的扩展版本存在,它们接受一个额外的参数来指示是否在可提醒状态下等待:

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);
1
2
3
4
5
6
7
8
9
10
11

将 bAlertable 设置为 FALSE 与调用原始函数相同。将 bAlertable 设置为 TRUE 时,附加到调用线程的任何APC都会按顺序执行,然后等待结束。在这种情况下,返回值是 WAIT_IO_COMPLETION。如果返回这个值,并且线程仍然想等待对象,可以再次调用等待函数。

还有其他函数遵循相同的模式,提供在可提醒状态下等待的选项,将在接下来的部分介绍。

# 等待图形用户界面线程(GUI线程)

如果等待时间可能较长,图形用户界面线程(GUI线程)通常不应使用WaitForSingleObject或WaitForMultipleObjects(或它们的扩展变体)并设置无限超时(INFINITE timeout)。问题在于,如果相关对象长时间未发出信号,由该线程管理的所有用户界面(UI)活动都会冻结,在任务管理器中显示可怕的“未响应”状态,该线程创建的窗口会变灰且无响应,并在其标题栏中添加“未响应”字样。这会给用户带来非常糟糕的体验,应不惜一切代价避免。

在许多情况下,GUI线程不需要等待内核对象,但在某些情况下这是不可避免的。幸运的是,有一个解决方案:MsgWaitForMultipleObject(Ex)函数:

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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这些函数正常等待一个或多个对象,同时也等待发往调用线程的用户界面消息,消息类型由dwWakeMask参数指定。最简单的是QS_ALLEVENTS,它会使函数在线程的消息队列中出现任何消息时,以值WAIT_OBJECT_0 + nCount返回。在这种情况下,线程应泵送消息(pump messages)并继续等待。下面是一个等待事件对象并在其间泵送消息的示例:

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);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
查看文档中其他状态掩码(QS_)的值。

扩展函数从原始函数中去掉了fWaitAll参数,取而代之的是提供了一个额外的dwFlags参数,该参数可以为零,也可以包含以下一个或多个标志:

  • MWMO_ALERTABLE - 函数在可提醒状态下等待,如前一节所述。
  • MWMO_INPUTAVAILABLE - 如果消息队列中有输入,函数就会返回,即使该输入已通过PeekMessage或GetMessage查看过。如果没有这个标志,只有新的输入才会使函数返回。
  • MWMO_WAITALL - 当所有对象都发出信号且有输入时,函数才会返回。

# 等待空闲的GUI线程

WaitForInputIdle函数可用于等待指定进程中的GUI线程准备好处理消息:

DWORD WINAPI WaitForInputIdle(
    _In_ HANDLE hProcess,
    _In_ DWORD dwMilliseconds);
1
2
3

该函数对于创建子进程并希望与其GUI线程(通常是第一个线程)进行交互的父进程最为有用。由于子进程的创建是异步的,父进程无法知道子进程的线程何时准备好接收消息。过早地向线程发送消息(在线程的消息队列准备好之前)会导致消息丢失。

下面是实现此功能的典型代码:

PROCESS_INFORMATION pi;
//...
::CreateProcess(..., &pi);
// 错误处理省略
::WaitForInputIdle(pi.hProcess, INFINITE);
// GUI线程已准备好,向主线程发送一些消息
::PostThreadMessage(pi.dwThreadId, WM_USER, 0, 0);
//...
1
2
3
4
5
6
7
8

# 原子性地发出信号和等待

本章我们要介绍的最后一个等待函数是SignalObjectAndWait:

DWORD SignalObjectAndWait(
    _In_ HANDLE hObjectToSignal,
    _In_ HANDLE hObjectToWaitOn,
    _In_ DWORD dwMilliseconds,
    _In_ BOOL bAlertable);
1
2
3
4
5

hObjectToSignal只能是事件(event)、信号量(semaphore)或互斥体(mutex),分别使用各自的函数发出信号:SetEvent、ReleaseSemaphore(计数为1)和ReleaseMutex。hObjectToWaitOn可以指向任何可等待对象。该函数将对一个对象发出信号和对另一个对象进行原子性等待这两个操作结合起来。

这个函数的第一个优点是效率高。与下面这样的代码相比:

::SetEvent(hEvenet1);
::WaitForSingleObject(hEvent2, INFINITE);
1
2

SignalObjectAndWait将这两个函数合并,这样只需要一次进入内核模式的转换:

::SignalObjectAndWait(hEvent1, hEvent2, INFINITE, FALSE);
1

第二个优点是这两个操作是原子性的,这意味着在该线程进入对另一个对象的等待状态之前,没有其他线程可以观察到被发出信号的对象的信号状态。在一些涉及PulseEvent函数的极端情况下,SignalObjectAndWait提供了一个可靠的解决方案。

前面的警告仍然适用 - 不要使用PulseEvent。

# 练习

  1. 创建一个系统,该系统可以并发运行多个工作项,但其中一些工作项可能依赖于其他工作项。举个具体的例子,比如在Visual Studio中编译项目。有些项目依赖于其他项目,所以必须按顺序处理。下面是一个项目依赖关系的示例(解读:项目4依赖于项目1,项目5依赖于项目2和项目3,依此类推)。目标是在遵守依赖关系的前提下,尽快编译所有项目。使用事件对象进行流程同步。

img 示例项目依赖关系

# 总结

在本章中,我们介绍了常用于线程同步的调度程序对象(dispatcher objects)。在下一章中,我们将探讨线程池(thread pools),它是显式创建线程的常见替代方案。

第7章:进程内线程同步
第9章:线程池

← 第7章:进程内线程同步 第9章:线程池→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式