深入浅出Windows API程序设计:核心编程篇

978-7-115-57159-5
作者: 王端明
译者:
编辑: 陈聪聪杨海玲

图书目录:

详情

本套书基于Win10和VS2019编写,提供了大量的示例程序和许多有商业价值的代码,分为基础篇和核心编程篇。本书是核心编程篇部分,包括内存管理、多线程及线程间同步、进程间通信、文件操作、动态链接库、异常处理、WinSock网络编程、系统服务和UAC用户账户控制等,其中对dll注入和API Hook进行了深入讲解,对WinSock网络编程的讲解也颇为深入,介绍了各种异步I/O模型,通过线程池和完成端口技术可以实现一个高性能的服务程序。另外,核心编程篇还对32/64位程序的PE/PE32+文件格式进行了深入剖析,这是加壳脱壳必备的基础知识。

图书摘要

版权信息

书名:深入浅出Windows API程序设计:核心编程篇

ISBN:978-7-115-57159-5

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    王端明

责任编辑 陈聪聪

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e57159”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


本书是Windows API程序设计的进阶图书,内容包括多线程编程,内存管理,文件、驱动器和目录操作,进程,剪贴板,动态链接库,INI配置文件和注册表操作,Windows异常处理,WinSock网络编程,其他常用Windows API编程知识,PE文件格式深入剖析。通过阅读本书,读者可以对Windows程序设计有更加深入的认识,并将其应用到实际场景中。

本书适合有一定经验的Windows API程序开发人员阅读,也可以作为培训学校的教材使用。


2015年7月,Windows 10操作系统正式发行,新版本的操作系统在UI界面、安全性和易用性等方面都有了大幅提升。64位操作系统已经普及,但传统的32位Windows系统API也应该称为Windows API,因为不管编译为32位还是64位的应用程序,使用的都是相同的API,只不过是扩展了一些64位数据类型。目前微软公司Windows在操作系统市场中占据相当大的份额,读者学习Windows程序设计的需求非常迫切。但是遗憾的是,近年来国内可选的关于Windows API的图书较少。

使用Windows API是编写程序的一种经典方式,这一方式为Windows程序提供了优秀的性能、强大的功能和较好的灵活性,生成的执行代码量相对比较小,不需要外部程序库就可以运行,更重要的是,无论将来读者用什么编程语言来编写Windows程序,只要熟悉Windows API,就能对Windows的内部机理有更深刻、更独到的理解。

热爱逆向研究的读者都应该先学好Windows API程序设计,而初学Windows程序设计的读者可能会非常困惑。于是,在2018年年初,我产生了一个想法:总结我这10年的程序设计经验,为Windows开发人员写一本深入浅出的符合国内市场需求的书。本来我计划用一年的时间撰写本书,可是没想到一写就是3年!

为了确保本书内容的准确性,MSDN是最主要的参考对象。我的初心就是把10年的程序设计经验毫无保留地分享给读者,并帮助读者学会调试技术。为了精简篇幅,大部分程序的完整源代码并没有写入书中。读者通过本书可以全面掌握Windows程序设计,对于没有涉及的问题也可以通过使用MSDN自行解决。

本书基于Windows 10和Visual Studio 2019(VS 2019)编写,提供了大量的示例程序。本书内容包括内存管理、多线程及线程间同步、进程间通信、文件操作、剪贴板、动态链接库、注册表、异常处理、WinSock网络编程、系统服务和用户账户控制等,其中对动态链接库(DLL)注入和API Hook进行了深入讲解,并解析了WinSock网络编程以及各种异步I/O模型,通过线程池和完成端口技术实现了一个高性能的服务程序。另外,本书还对32位/64位程序的PE/PE32+文件格式进行了深入剖析,这是加壳、脱壳必备的基础知识。

(1)对Windows程序设计已经有一定了解的读者,通过本书可以高效而全面地掌握Windows程序设计。

(2)学习Windows程序设计多年但仍有困感的读者,通过本书可以系统地学习Windows程序设计的方方面面。

(3)其他任何爱好或需要学习Windows API程序设计的读者,通过本书可以进一步了解Windows API程序设计的基本技巧。

在阅读本书前,读者必须熟悉C或C++语法。除此之外,不需要具备任何其他专业知识。

(1)可以加入我提供的QQ群进行学习交流。

(2)可以到我提供的Windows中文网的相应版块中进行提问,我通常会集中时间进行统一解答。

如果读者是Windows API程序设计的初学者,可以先阅读《深入浅出Windows API程序设计:编程基础篇》来进行基础知识的学习。

本书并没有涉及内核方面的相关知识,如果读者需要学习Windows操作系统的内核安全编程技术,那么推荐阅读谭文和陈铭霖所著的《Windows内核编程》和《Windows内核安全与驱动开发》。

本书可以成功出版,得益于多位专业人士的共同努力。感谢家人无条件的支持,感谢微软和CSDN的朋友、15PB信息安全教育创始人任晓珲、《Windiws内核编程》的作者陈铭霖、《Windows环境下32位汇编语言程序设计》的作者罗云彬、微软总部高级软件工程师Tiger Sun以及各软件安全论坛的朋友对本书提出的宝贵建议以及予以的认可和肯定。

由于我的能力和水平的限制,书中难免会存在疏漏,欢迎读者批评指正。读者可以通过Windows中文网与我沟通。


王端明,从2008年开始参与Windows API程序设计,精通汇编语言、C/C++语言和Windows API程序设计,精通Windows环境下的桌面软件开发和加密/解密。曾为客户定制开发32位/64位Windows桌面软件,对加密/解密情有独钟,对VMProtect、Safengine等高强加密保护软件的脱壳或内存补丁有深入的研究和独到的见解,喜欢分析软件安全漏洞,曾在金山和360等网站发表过多篇杀毒软件漏洞相关的分析文章。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书免费提供配套源代码。要获得相关配套资源,请在异步社区本书页面中单击“配套资源”,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

您还可以扫码右侧二维码, 关注【异步社区】微信公众号,回复“e57159”直接获取,同时可以获得异步社区15天VIP会员卡,近千本电子书免费畅读。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

扫描下方二维码,您将会在异步社区微信服务号中看到本书信息及相关的服务提示。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书技术审校等工作,可以发邮件给本书的责任编辑(chencongcong@ptpress.com.cn)。

如果您来自学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接通过邮件发给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


磁盘中存储的可执行文件是由指令和数据等组成的二进制文件,是一个静态的概念。进程(process)是系统中正在运行的一个可执行文件,可执行文件一旦运行就成为进程,是一个动态的概念,是一个活动的实体。进程是一个正在运行的可执行文件所使用的资源的总和,包括虚拟地址空间、代码、数据、对象句柄、环境变量等。一个可执行文件被同时多次执行,产生多个进程,虽然它们是同一个可执行文件,但是它们的虚拟地址空间是相互隔离的,就像不同的可执行文件在同时执行。

进程是不“活泼”的。要使进程中的代码被真正运行,必须拥有在这个进程环境中运行代码的“执行单元”,也就是线程。线程是操作系统分配CPU处理器时间的基本单位,一个线程可以看作一个执行单元,它负责执行进程地址空间中的代码。当一个进程被创建时,系统会自动为它创建一个线程。这个线程从程序指定的入口地址处开始执行,通常把这个线程称为主线程。当主线程执行完最后一行代码(例如return msg.wParam;)时,进程结束,这时系统会撤销进程所拥有的地址空间和资源,程序终止。

在主线程中,程序可以继续创建多个线程来“同时”执行进程地址空间中的代码,这些线程被称为子线程。操作系统为每个线程保存各自的寄存器和栈环境,但是它们共享进程的地址空间、对象句柄、代码和数据等其他资源,它们可以执行相同的代码,可以对相同的数据进行操作,也可以使用相同的句柄。进程和线程的关系可以看作“容器”和“内容物”的关系,进程是线程的容器,线程总是在某个进程的环境中被创建,它不可以脱离进程而单独存在,而且线程的整个生命周期都存在于进程中,如果进程被终止,则其中的线程也会同时结束。

系统中可以同时存在多个进程,每个进程中又可以有多个线程同时执行。为了使所有进程中的线程都能够“同时”执行,操作系统为每个线程轮流分配CPU时间片。当轮到一个线程执行的时候,系统将保存的线程的寄存器值恢复并开始执行。当时间片结束时,系统将线程当前的寄存器环境保存下来并切换到另一个线程中执行,如此循环。

对单CPU处理器的计算机来说,不同线程实际上是在轮流使用同一个处理器。一个程序的运行速度并不会因为创建了多个线程而加快,因为线程多了以后,每个线程等待时间片的时间也就越长。但是对于多核CPU的计算机,操作系统可以将不同的线程安排到不同的处理器内核中执行,系统可以同时执行与计算机上的CPU处理器内核一样多的线程,这样一个进程中的多个线程会因为同时获得多个时间片而加快整个进程的运行速度。

不过,多线程编程的出发点并不仅仅是为了充分利用多核CPU,编程过程中会遇到仅依靠一个主线程无法解决问题的情况,下面我们将通过一个典型的“问题程序”来引出多线程编程。

本节的示例程序界面如图1.1所示。

图1.1

初始状态下,停止、暂停和继续按钮是禁用的,用户单击“开始”按钮,调用自定义函数Counter进入一个while循环。在循环中,不停地把一个数进行自加,并实时显示到编辑控件中。在计数循环过程中,用户可以随时按下“停止”“暂停”或“继续”按钮。

Counter.cpp源文件的内容如下:

#include <windows.h>
#include "resource.h"

#pragma comment(linker,"\"/manifestdependency:type='win32' \
     name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \
     processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

// 常量定义
#define F_START     1       // 开始计数
#define F_STOP      2       // 停止计数

// 全局变量
HWND g_hwndDlg;
int g_nOption;              // 标志

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
VOID Counter();

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     // 创建模态对话框
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;

}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     static HWND hwndBtnStart, hwndBtnStop, hwndBtnPause, hwndBtnContinue;

     switch (uMsg)
     {
     case WM_INITDIALOG:
          g_hwndDlg = hwndDlg;
          hwndBtnStart = GetDlgItem(hwndDlg, IDC_BTN_START);
          hwndBtnStop = GetDlgItem(hwndDlg, IDC_BTN_STOP);
          hwndBtnPause = GetDlgItem(hwndDlg, IDC_BTN_PAUSE);
          hwndBtnContinue = GetDlgItem(hwndDlg, IDC_BTN_CONTINUE);

          // 禁用停止、暂停、继续按钮
          EnableWindow(hwndBtnStop, FALSE);
          EnableWindow(hwndBtnPause, FALSE);
          EnableWindow(hwndBtnContinue, FALSE);
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_START:
               g_nOption = 0;                  // 如果按下开始按钮,然后停止,然后再开始,则g_nOption的值为3
               g_nOption |= F_START;
               Counter();                  // 开始计数

               EnableWindow(hwndBtnStart, FALSE);
               EnableWindow(hwndBtnStop, TRUE);
               EnableWindow(hwndBtnPause, TRUE);
               break;

          case IDC_BTN_STOP:
               g_nOption |= F_STOP;
               EnableWindow(hwndBtnStart, TRUE);
               EnableWindow(hwndBtnStop, FALSE);
               EnableWindow(hwndBtnPause, FALSE);
               EnableWindow(hwndBtnContinue, FALSE);
               break;

          case IDC_BTN_PAUSE:
               g_nOption &= ~F_START; 
               EnableWindow(hwndBtnStart, FALSE);
               EnableWindow(hwndBtnStop, TRUE);
               EnableWindow(hwndBtnPause, FALSE);
               EnableWindow(hwndBtnContinue, TRUE);
               break;

          case IDC_BTN_CONTINUE:
               g_nOption |= F_START; 
               EnableWindow(hwndBtnStart, FALSE);
               EnableWindow(hwndBtnStop, TRUE);
               EnableWindow(hwndBtnPause, TRUE);
               EnableWindow(hwndBtnContinue, FALSE);
               break;

          case IDCANCEL:
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

VOID Counter()
{
     int n = 0;

     while (!(g_nOption & F_STOP))
     {
          if (g_nOption & F_START)
               SetDlgItemInt(g_hwndDlg, IDC_EDIT_COUNT, n++, FALSE);
     }
}

代码很简单。按下“开始”按钮,设置开始标志F_START,调用Counter函数开始计数并显示,禁用“开始”“继续”按钮,启用“停止”“暂停”按钮;按下“暂停”按钮,为标志变量g_nOption清除开始标志F_START,然后禁用“开始”“暂停”按钮,启用“停止”“继续”按钮;按下“继续”按钮,为标志变量g_nOption设置开始标志F_START,然后禁用“开始”“继续”按钮,启用“停止”“暂停”按钮;按下“停止”按钮,为标志变量g_nOption设置停止标志F_STOP,然后禁用“停止”“暂停”“继续”按钮,启用“开始”按钮。

按Ctrl + F5组合键编译运行程序,单击“开始”按钮,可以看到编辑控件并没有实时显示计数值,“开始”按钮没有被禁用,“停止”“暂停”按钮也没有被启用,鼠标指针悬停在客户区时变成一个忙碌形状的光标,程序已经失去响应。

当一个进程被创建时,系统会自动为它创建一个“主线程”。按下“开始”按钮,“主线程”执行WM_COMMAND消息的IDC_BTN_START分支,调用Counter函数后,“主线程”一直处于while循环中,Counter函数后面的语句不会被执行,因此永远不会返回对 WM_COMMAND消息 IDC_BTN_START的处理结果,导致“主线程”没有机会去处理后续的任何消息。2.2.4节将讲解消息循环的原理,本例是对话框程序,即按下“开始”按钮后,程序停留在对话框内建消息循环的DispatchMessage函数调用中不能返回,消息队列中的后续消息得不到获取、分发,更得不到处理,因此出现程序窗口中的按钮不能单击、程序失去响应、程序界面得不到刷新等情况。

在程序设计中有一个“1/10秒规则”,即窗口过程处理任何一条消息的时间都不应超过1/10秒,否则会造成程序无法及时响应用户操作的情况。如果程序的消息处理过程中存在一项非常复杂或耗时的任务,就需要使用其他合理的解决方法,例如在处理WM_COMMAND消息的IDC_BTN_START分支时,可以创建一个新的“子线程”负责执行Counter函数,由“子线程”去处理耗时的操作,“主线程”可以继续往下执行,处理其他消息。

CreateThread函数用于在当前进程中创建一个新线程:

HANDLE WINAPI CreateThread(
     _In_opt_  LPSECURITY_ATTRIBUTES  lpThreadAttributes,
                                                // 指向线程安全属性结构的指针
     _In_      SIZE_T                dwStackSize,          // 线程的栈空间大小,以字节为单位
     _In_      LPTHREAD_START_ROUTINE lpStartAddress,    // 线程函数指针
     _In_opt_  LPVOID                 lpParameter,          // 传递给线程函数的参数
     _In_      DWORD                  dwCreationFlags,      // 线程创建标志
     _Out_opt_ LPDWORD                lpThreadId);        // 返回线程ID,可以设置为NULL

当程序调用CreateThread函数时,系统会为线程创建一个用来管理线程的数据结构,其中包含线程的一些信息,例如安全描述符、引用计数和退出码等,这个数据结构称为线程对象。线程对象属于内核对象,内核对象由操作系统管理,内核对象的数据结构只能由操作系统内核访问,应用程序不能在内存中定位这些数据结构并修改其内容。在调用一个创建内核对象的函数后,函数会返回一个句柄。该句柄标识了所创建的内核对象,可以由同一个进程中的任何线程使用,例如,对CreateThread函数来说,就是创建一个线程内核对象,返回一个线程句柄,线程句柄标识了所创建的线程对象。以后我们还会学习很多内核对象,例如进程对象、文件对象等都是内核对象。

接下来,系统会从进程的地址空间中为线程的栈分配内存空间并开始执行线程函数。栈空间用于存放线程执行时所需的函数参数和局部变量等,新线程在创建线程的进程环境中执行,因此它可以访问进程的所有句柄和其中的所有内存等,同一个进程中的多个线程也可以很容易地相互通信。

当线程结束时,线程的栈空间被释放,但是线程对象却不一定如此。在调用CreateThread函数后,线程对象的引用计数被设置为1,但是由于返回了一个线程句柄,引用计数又被加1,所以线程对象的引用计数为2。线程句柄可以由同一个进程中的任何线程使用,如果其他地方用不到该线程句柄,那么在调用CreateThread函数创建线程后,可以接着调用CloseHandle函数关闭线程句柄。关闭线程句柄会使线程对象的引用计数减1。这样一来当线程结束时,线程对象的引用计数再次减1,系统发现线程对象的引用计数为0,会立即销毁该线程对象。在调用CloseHandle函数关闭线程句柄后,对当前进程来说这个句柄就无效了,不可以再试图引用它,因此还应同时将这个线程句柄变量设为NULL,防止在其他函数调用中使用这个无效的线程句柄。例如:

HANDLE hThread;
hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
if (hThread != NULL)
{
     CloseHandle(hThread);
     hThread = NULL;
}

当然,当进程结束时,该进程所属的一切对象、资源都会被系统释放,不会因为没有调用相关对象、资源关闭或释放函数而造成内存泄漏。对线程对象来说,适时地调用CloseHandle函数关闭线程对象句柄,是为了在线程结束时立即销毁线程对象,但是即使没有调用CloseHandle函数,如果进程结束了,系统也会自动关闭线程句柄。

(1)lpThreadAttributes参数。lpThreadAttributes参数是一个指向SECURITY_ATTRIBUTES结构的指针,该结构在minwinbase.h头文件中定义如下:

typedef struct _SECURITY_ATTRIBUTES {
     DWORD  nLength;                     // 该结构的大小
     LPVOID lpSecurityDescriptor;    // 指向安全描述符SECURITY_DESCRIPTOR结构的指针
     BOOL   bInheritHandle;          // 在创建新进程时是否继承返回的句柄,TRUE或FALSE
} SECURITY_ATTRIBUTES, *PSECURITY_ATTRIBUTES, *LPSECURITY_ATTRIBUTES;

lpSecurityDescriptor字段用于指定线程的安全属性,通常设置为NULL,表示使用默认的安全属性。

新线程创建以后,可以在新线程中创建子进程,bInheritHandle字段用于指定CreateThread函数返回的线程句柄是否可以被新线程的子进程继承使用。

lpThreadAttributes参数通常设置为NULL,表示使用默认的安全属性,返回的线程句柄不能被新线程的子进程继承。如果希望线程句柄被新线程的子进程继承,那么可以按如下方式设置:

SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
sa.lpSecurityDescriptor = NULL;
sa.bInheritHandle = TRUE;
hThread = CreateThread(&sa, 0, ThreadProc, NULL, 0, NULL);

(2)dwStackSize参数。dwStackSize参数指定为新线程保留的栈空间大小,以字节为单位。系统会从进程的地址空间中为每个新线程分配私有的栈空间,在线程结束时栈空间会自动被系统释放,通常可以指定为0,表示新线程的栈空间大小和主线程使用的栈空间大小相同(默认是1MB)。

(3)lpStartAddress和lpParameter参数。lpStartAddress参数指定新线程执行的线程函数的地址,线程函数的定义格式如下:

DWORD WINAPI ThreadProc(LPVOID lpParameter);

线程函数的名称可以随意设置。线程函数的lpParameter参数是从CreateThread函数的lpParameter参数传递过来的值,该参数可以用来传递一些自定义数据,例如可以是一个数值,也可以是一个指向某数据结构的指针。

多个子线程可以使用同一个线程函数,例如对于Web服务器,每当有客户端请求时可以创建一个线程来执行本次请求,所有的客户端请求可以执行相同的线程函数,但是为每个客户端调用CreateThread函数创建线程时可以指定不同的lpParameter参数,在线程函数中通过传递的参数来区别是哪一个客户端。

线程函数(实际包括所有函数)应该尽可能地使用函数参数和局部变量。在使用静态变量和全局变量时,多个线程都可以访问这些变量,这可能会破坏变量中保存的数据。而函数参数和局部变量存放在线程的栈空间中,因此不太可能被其他线程破坏。

线程函数返回值为DWORD类型,因此线程函数必须返回一个值,该值将作为线程对象的退出码。

(4)dwCreationFlags参数。dwCreationFlags参数用来指定线程创建标志,可以指定为0表示线程创建后立即开始运行;也可以指定为CREATE_SUSPENDED表示线程创建后处于挂起状态,直到调用ResumeThread函数显式地启动线程为止。第二种情况下,用户可以在线程执行代码前修改线程的一些属性,不过通常不需要。

(5)lpThreadId参数。lpThreadId参数是一个指向DWORD类型变量的指针,函数在该变量中返回线程ID。如果不需要线程ID,则该参数可以设置为NULL。

如果线程创建成功,则函数返回一个线程句柄,该句柄可以用在一些控制线程的函数中,例如SuspendThread(暂停线程)、ResumeThread(恢复线程)和TerminateThread(终止线程)等函数,如果线程创建失败则返回值为NULL。

接下来将Counter程序改进为多线程程序,只需要把WM_COMMAND消息的IDC_BTN_START中对Counter函数的调用修改如下:

hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL); // 创建一个子线程
if (hThread != NULL)
{
     CloseHandle(hThread);
     hThread = NULL;
}

完整代码参见Chapter1\CounterThread项目。Counter函数需要修改为线程函数ThreadProc,线程函数返回值为DWORD类型,因此在线程函数末尾需要返回一个值。当用户按下“停止”按钮后,为标志变量g_nOption设置停止标志F_STOP,线程函数的while循环条件不满足,退出循环,然后线程函数返回,线程结束。

在调用CreateThread函数创建线程后,我们调用CloseHandle(hThread),及时关闭不需要的线程句柄,因此线程对象的引用计数减1。在线程结束后,系统也会递减线程对象的引用计数。因此在本例中的线程结束后,线程对象会马上被系统释放。及时关闭用不到的内核对象句柄的好处是,程序运行过程中不会造成内存泄漏。在进程结束时,该进程所属的一切对象、资源都会被系统释放,不会因为没有调用相关对象、资源关闭或释放函数而造成内存泄漏。

在按下“开始”按钮后,计数值实时显示在编辑控件中,但是“停止”和“暂停”按钮未显示为启用状态,实际上这两个按钮已经启用。在Windows 7系统中测试该程序,不存在这个问题。SetDlgItemInt函数实际上是通过发送WM_SETTEXT消息来实现的,线程函数向主窗口发送WM_SETTEXT消息的速度非常快,因为这个while循环已经导致系统的CPU占用率飙升(可以按Ctrl + Alt + Delete组合键打开任务管理器中的进程选项卡,查看本程序占用CPU的情况),而WM_PAINT消息又是一个低优先级的消息,所以程序窗口中的按钮得不到立即刷新。

消息队列与线程和窗口互相关联。如果在某个线程中创建了一个窗口,则Windows会为该线程分配一个消息队列。为了使该窗口正常工作,线程中必须存在一个消息循环来分发消息,即如果一个窗口是在子线程中创建的,则主线程中的消息循环无法获得该窗口的消息,子线程必须单独设置一个消息循环。当调用SendMessage或PostMessage函数向一个窗口发送消息时,系统会先确认该窗口是由哪个线程创建的,然后将消息发送到正确线程的消息队列中。

如果在一个线程中创建了窗口,就必须设置消息循环。窗口过程应该遵循1/10秒规则,即该线程不应该用来处理耗时的工作。在一个程序中为不同的线程设置多个消息循环,不但会使代码复杂化,而且会产生其他许多问题,所以在多线程程序设计中,规划好程序结构很重要。规划多线程程序的原则是:首先,处理用户界面(指拥有窗口和需要处理窗口消息)的线程不应该处理1/10秒以上的工作;其次,处理长时间工作的线程不应该拥有用户界面。根据这个规则,我们大致可以把线程分成两大类。

处理用户界面的线程:这类线程通常会创建一个窗口并设置消息循环来负责分发消息,一个进程中并不需要太多这种线程,一般由主线程负责该项工作。

工作线程:这类线程通常不会创建窗口,因此也不用处理消息,工作线程一般在后台运行,执行一些耗时的复杂的计算任务。

一般来说,处理用户界面的工作由主线程处理,如果主线程接到一个用户指令,完成该指令可能需要比较长的时间,那么主线程可以创建一个工作线程来完成该项工作,并负责指挥该工作线程。

线程从线程函数的第一句代码开始执行,直到线程被终止。如果线程是正常终止的,系统会执行以下工作。

线程函数中创建的所有C++对象都能通过其析构函数被正确销毁。

线程使用的栈空间被释放。

系统将线程对象中的退出码设置为线程函数的返回值。线程终止后的退出码可以被其他线程通过调用GetExitCodeThread函数检测到。

系统将递减线程对象的引用计数。

线程可以通过以下4种方式来终止线程。

(1)线程函数的return语句返回(强烈推荐)。在这种情况下,上面列出的所有项目都会得以执行。

(2)线程通过调用ExitThread函数结束线程(要避免使用这种方法)。为了强迫线程终止运行,线程可以调用ExitThread函数:VOID ExitThread(__in DWORD dwExitCode)。

ExitThread函数的dwExitCode参数用于指定线程的退出码。ExitThread函数本身没有返回值,因为线程已终止,不能继续执行代码。

ExitThread函数将终止线程的运行,系统会清理该线程使用的所有资源,但是C/C++资源(例如C++类对象)不会被销毁。

ExitThread函数只能用于终止当前线程,而不能用于在一个线程中终止另外一个线程,因为没有线程句柄或线程ID参数。

(3)同一个进程或另一个进程中的线程调用TerminateThread函数(要避免使用这种方法)。

BOOL WINAPI TerminateThread(
     _Inout_ HANDLE hThread,     // 要终止的线程的句柄
     _In_    DWORD  dwExitCode); // 线程的退出码,调用GetExitCodeThread函数可以获取线程的退出码

不同于ExitThread函数只能终止当前线程,TerminateThread函数可以终止任何线程,hThread参数指定要终止的线程的句柄;线程终止运行时,其退出码就是dwExitCode参数传递的值。

TerminateThread函数是异步的,在函数返回时,并不保证线程已经终止。如果需要确定线程是否已经终止运行,则可以通过调用WaitForSingleObject或GetExitCodeThread函数检测。

一个设计良好的应用程序不会使用这个函数,因为被终止运行的线程收不到它被终止的通知,线程无法正确清理,而且线程不能阻止自己被终止运行。如果使用的是TerminateThread,除非拥有此线程的进程终止运行,否则系统不会销毁该线程的栈。微软公司以这种方式来实现TerminateThread函数,因为假设还有其他正在运行的线程需要引用被终止线程的数据,就会引发访问违规,使被终止线程的栈保留在内存中,其他线程则可以继续正常运行。此外,动态链接库通常会在线程终止运行时收到通知,如果线程是调用TerminateThread函数强行终止的,则动态链接库不会收到这个通知,其结果是不能执行正常的清理工作。

(4)线程所属的进程终止运行(要避免使用这种方法)。

可以随时显式调用ExitProcess函数结束一个进程的执行,该函数的调用会导致系统自动结束进程中所有线程的运行。在多线程程序中,用这种方法结束线程相当于对每个线程调用 TerminateThread函数,所以也应当避免这种做法。

正常情况下,在我们启动一个进程时,系统都会创建一个主线程。对于用微软公司 C/C++编译器生成的应用程序,主线程首先会执行C/C++运行库的启动代码,然后C/C++运行库会调用程序的入口点函数WinMain并继续执行,直到入口点函数返回,最后C/C++运行库会调用ExitProcess函数结束进程。因此,如果进程中并发运行有多个线程,则需要在主线程返回前,明确处理好每个线程的终止过程,否则其他所有正在运行中的线程都会在毫无预警的前提下突然终止。

线程终止运行时,还会发生以下事情。

当一个线程终止运行时,系统会自动销毁由线程创建的任何窗口,并卸载由线程创建或安装的任何钩子(后面会详细介绍钩子)。窗口和钩子都是与线程相关联的。

线程对象的退出码从STILLL_ACTIVE变成线程函数的返回值(线程创建时线程对象的退出码被设置为STILL_ACTIVE)。

线程对象的状态变成有信号状态(后面会学习这个问题)。

如果该线程是进程中的最后一个活动线程,则表示进程中正在运行的线程数量为0,则进程失去继续存在的意义,进程会随线程结束而终止。

其他线程可以通过调用GetExitCodeThread函数来检查hThread参数指定的线程是否已终止运行,如果已终止,可以返回其退出码:

BOOL WINAPI GetExitCodeThread(
     _In_  HANDLE  hThread,        // 线程句柄
     _Out_ LPDWORD lpExitCode);  // 返回线程的退出码

如果在调用GetExitCodeThread函数时线程尚未终止,则lpExitCode参数指向的DWORD值为STILL_ACTIVE常量;如果线程已经终止,则lpExitCode参数指向的DWORD值为线程的退出码。通过检查lpExitCode参数指向的DWORD值是否为STILL_ACTIVE即可确定一个线程是否已经结束。

其他相关函数

线程对象数据结构中有一个字段表示线程的挂起(暂停)计数。调用CreateThread函数时,系统首先会创建一个线程对象,并把挂起计数设置为1,因此刚开始的时候系统不会为该线程调度CPU,因为线程初始化需要时间,在完成线程初始化前,不会执行线程函数。当线程初始化完成后,CreateThread函数会检查dwCreationFlags参数,如果指定为CREATE_SUSPENDED标志,则CreateThread函数会返回并使新的线程处于挂起状态;如果指定为0,函数会将线程的挂起计数递减为0,当线程的挂起计数为0时才可以被调度。

当调用CreateThread函数创建线程时,如果dwCreationFlags参数指定了CREATE_SUSPENDED标志,那么线程创建后并不马上开始执行,而是处于被挂起状态,直到调用ResumeThread函数启动它为止。调用ResumeThread函数可以减少线程的挂起计数,当线程的挂起计数为0时,线程成为可调度状态:

DWORD WINAPI ResumeThread(_In_ HANDLE hThread);

如果函数执行成功,则返回值是线程的先前挂起计数;如果函数执行失败,则返回值为−1。

一个线程可以被挂起(暂停),也可以在挂起后恢复执行。除在创建线程时使线程处于挂起状态外,也可以调用SuspendThread函数将正在运行中的线程挂起。SuspendThread函数用于挂起指定的线程,并增加线程的挂起计数:

DWORD WINAPI SuspendThread(_In_ HANDLE hThread);

如果函数执行成功,则返回值是线程的先前挂起计数;如果函数执行失败,则返回值为−1。

任何线程都可以调用SuspendThread函数挂起另一个线程(只要有线程的句柄),线程也可以将自己挂起,但是它无法将自己恢复,一个线程最多可以被挂起MAXIMUM_SUSPEND_COUNT(127)次。一个线程可以被多次挂起,也可以被多次恢复。如果一个线程被挂起3次,那么必须恢复3次以后该线程才可以调度,即如果多次调用SuspendThread函数导致挂起计数远远大于1,就必须多次调用ResumeThread函数;当线程的挂起计数为0时,线程可被调度,即线程恢复运行。

调用Sleep函数可以暂停执行当前线程,直到指定的超时时间结束:

VOID WINAPI Sleep(_In_ DWORD dwMilliseconds);   // 以毫秒为单位

即告知系统,在一段时间内自己不需要被调度。如果dwMilliseconds参数设置为0,表示告知系统放弃本线程在当前CPU时间片的剩余时间,系统可以转去调度其他线程,待该线程轮流到下一个时间片时再继续执行。

主线程创建工作线程时可以通过线程函数参数向工作线程传递自定义数据。当工作线程开始运行后,主线程可能还需要控制工作线程,工作线程有时也需要将一些工作情况主动通知给主线程。常用的线程间通信方式有全局变量、自定义消息和事件对象(Event)。

最简单常用的方式是使用全局变量,例如CounterThread程序就是通过在主线程中设置g_nOption变量的值来控制工作线程的工作。使用全局变量传递数据的缺点是当多个工作线程使用同一个全局变量时,由于每个线程都可以修改全局变量,因此可能会引起同步问题,后面会探讨这个问题。

例如,当工作线程完成自己的工作后,可以向主线程发送自定义的WM_XXX消息来通知主线程,因此主线程不需要随时检查工作线程是否已经完成某项操作或工作线程是否结束,只需要在窗口过程中处理WM_XXX消息。当然,主线程也可以向工作线程发送自定义消息,但是工作线程需要维护一个消息循环。如果工作线程创建了窗口,则还需要有一个窗口过程,这违背了多线程程序设计的原则,所以发送自定义消息的方法通常用于工作线程向主线程发送。

工作线程向主线程发送自定义消息比较简单,调用SendMessage/PostMessage函数即可。下面通过一个示例来创建两个工作线程。

CustomMSG程序有“开始”和“停止”两个按钮。用户按下“开始”按钮,创建显示线程和计数线程,计数线程模拟执行一项任务,每50ms计数加1。创建计数线程时需要将显示线程的ID作为线程函数参数,以便计数线程定时通过 PostThreadMessage 函数向显示线程发送自定义消息WM_WORKPROGRESS报告工作进度,显示线程获取到WM_WORKPROGRESS消息后将工作进度显示在程序的编辑控件中。如果计数线程的计数已经达到100,则说明工作已经完成,向显示线程发送WM_QUIT 消息通知其终止线程,向主线程发送自定义消息 WM_CALCOVER 告知工作已完成,主线程获取到WM_CALCOVER消息后会关闭两个线程句柄,启用/禁用相关按钮,然后显示一个消息框。

在计数线程工作过程中,用户随时可以按下“停止”按钮,主线程将全局变量g_bRuning设置为FALSE告知计数线程终止线程,调用PostThreadMessage函数向显示线程发送WM_QUIT消息告知其终止线程,然后关闭两个线程句柄,启用/禁用相关按钮。

CustomMSG.cpp源文件的内容如下:

#include <windows.h>
#include "resource.h"

#pragma comment(linker,"\"/manifestdependency:type='win32' \
     name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \
     processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

// 自定义消息,用于计数线程向显示线程发送消息报告工作进度(这两个都是工作线程)
#define WM_WORKPROGRESS (WM_APP + 1)
// 自定义消息,计数线程发送消息给主线程告知工作已完成
#define WM_CALCOVER     (WM_APP + 2)

// 全局变量
HWND g_hwndDlg;
BOOL g_bRuning;  // 计数线程没有消息循环,主线程通过将该标志设置为FALSE通知其终止线程

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 线程函数声明
DWORD WINAPI ThreadProcShow(LPVOID lpParameter);    // 将数值显示到编辑控件中
DWORD WINAPI ThreadProcCalc(LPVOID lpParameter);    // 模拟执行一项任务,定时把一个数加1

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     static HANDLE hThreadShow, hThreadCalc;
     static DWORD dwThreadIdShow;

     switch (uMsg)
     {
     case WM_INITDIALOG:
          g_hwndDlg = hwndDlg;
          // 禁用停止按钮
          EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_STOP), FALSE);
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_START:
               g_bRuning = TRUE;
               // 创建显示线程和计数线程
               hThreadShow = CreateThread(NULL, 0, ThreadProcShow, NULL, 0, &dwThreadIdShow);
               hThreadCalc = CreateThread(NULL, 0, ThreadProcCalc, (LPVOID)dwThreadIdShow, 0, NULL);

               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), FALSE);
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_STOP), TRUE);
               break;

          case IDC_BTN_STOP:
               // 通知计数线程退出
               g_bRuning = FALSE;
               // 通知显示线程退出
               PostThreadMessage(dwThreadIdShow, WM_QUIT, 0, 0);

               CloseHandle(hThreadShow);
               CloseHandle(hThreadCalc);
               hThreadShow = hThreadCalc = NULL;
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), TRUE);
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_STOP), FALSE);
               break;

          case IDCANCEL:
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;

     case WM_CALCOVER:
          CloseHandle(hThreadShow);
          CloseHandle(hThreadCalc);
          hThreadShow = hThreadCalc = NULL;
          EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), TRUE);
          EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_STOP), FALSE);

          MessageBox(hwndDlg, TEXT("计数线程工作已完成"), TEXT("提示"), MB_OK);
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProcShow(LPVOID lpParameter)
{
     MSG msg;

     while (GetMessage(&msg, NULL, 0, 0) != 0)
     {
          switch (msg.message)
          {
          case WM_WORKPROGRESS:
               SetDlgItemInt(g_hwndDlg, IDC_EDIT_COUNT, (UINT)msg.wParam, FALSE);
               break;
          }
     }

     return msg.wParam;
}

DWORD WINAPI ThreadProcCalc(LPVOID lpParameter)
{
     // lpParameter参数是传递过来的显示线程ID
     DWORD dwThreadIdShow = (DWORD)lpParameter;
     int nCount = 0;

     while (g_bRuning)
     {
          PostThreadMessage(dwThreadIdShow, WM_WORKPROGRESS, nCount++, NULL);
          Sleep(50);

          // nCount到达100,说明工作完成
          if (nCount > 100)
          {
               // 通知显示线程退出
               PostThreadMessage(dwThreadIdShow, WM_QUIT, 0, 0);

               // 发送消息给主线程告知工作已完成
               PostMessage(g_hwndDlg, WM_CALCOVER, 0, 0);

               // 本计数线程也退出
               g_bRuning = FALSE;
               break;
          }
     }

     return 0;
}

#define WM_MYTHREADMSG (WM_APP + 1)语句用于定义一个自定义消息,常量WM_USER和WM_APP在WinUser.h头文件中定义如下:

#define WM_USER  0x0400
#define WM_APP   0x8000

Windows所用的消息分为表1.1所示的几类。

表1.1

范围

含义

0~WM_USER-1

系统定义的消息,这些消息的含义由操作系统定义,不能更改

WM_USER~0x7FFF

用于向自己注册的窗口类窗口中发送自定义消息

WM_APP~0xBFFF

用于向系统预定义的窗口类控件(例如按钮、编辑框)发送自定义消息,因为WM_USER + x都已被系统使用,例如WM_USER + 1在不同的系统预定义控件中表示不同的消息:
#define TB_ENABLEBUTTON (WM_USER + 1)    // 工具栏消息
#define TTM_ACTIVATE (WM_USER + 1)      // 工具提示消息
#define DM_SETDEFID (WM_USER + 1)      // 对话框消息

0xC000~0xFFFF

已注册的消息,这些消息的含义由RegisterWindowMessage函数的调用者确定

0xFFFF~

系统保留的消息

WM_USER~0x7FFF和WM_APP~0xBFFF都可以用于自定义消息,自定义消息的wParam和lParam参数的含义由用户指定。向自己注册的窗口类窗口中发送自定义消息时可以使用WM_USER + x或WM_APP + x,向系统预定义的窗口类控件(例如按钮、编辑框)发送自定义消息建议使用WM_APP + x(WM_USER + x已被系统使用),因此如果需要在程序中发送自定义消息,那么建议直接使用WM_APP + x

PostThreadMessage函数用于把一个消息发送到指定线程的消息队列,并立即返回,不需要等待线程处理完消息:

BOOL WINAPI PostThreadMessage(
     _In_ DWORD  idThread,       // 要将消息发送到的线程的线程ID
     _In_ UINT   Msg,            // 消息类型
     _In_ WPARAM wParam,         // 消息参数
     _In_ LPARAM lParam);        // 消息参数

下面来看CounterThread程序的工作线程的线程函数:

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     int n = 0;

     while (!(g_nOption & F_STOP))
     {
          if (g_nOption & F_START)
               SetDlgItemInt(g_hwndDlg, IDC_EDIT_COUNT, n++, FALSE);
     }

     return 0;
}

当用户按下“暂停”按钮时,虽然while循环内部的if语句不成立,不会执行下面的SetDlgItemInt函数,但是整个while循环实际上还在高速运转,一直在循环判断是否设置了停止标志。因此,即使用户按下“暂停”按钮,程序的CPU占用率还是居高不下。

程序为了实时检测标志耗费大量的CPU开销。对于这种问题,最彻底的解决方法是由操作系统来决定是否继续执行代码。如果操作系统了解线程需要等待和执行的具体时间,系统就可以仅在线程执行时为其分配CPU时间片,在线程等待时取消分配时间片,这样就不会因为需要实时检测一个标志浪费CPU资源。

一个可行的方法是使用SuspendThread和ResumeThread函数来挂起和恢复线程,主线程不必通过设置标志位来通知工作线程进入等待状态,而是直接使用SuspendThread函数将工作线程挂起。使用这种方法的好处是可以解决CPU占用率高的问题,因为操作系统不会为挂起的线程分配时间片;缺点是无法精确地控制线程,因为主线程并不了解工作线程会在哪里被暂停。指令是CPU执行的最小单位,线程不可能在一条指令执行到一半时被打断,如下面的线程函数反汇编代码所示,暂停点可能在下面的任何一条指令中,甚至在执行SetDlgItemInt函数的系统内核中,在内核中中断可能会导致该程序出现一些问题。另外,当挂起一个线程时,我们不了解线程在做什么,例如,如果线程正在分配堆中的内存,线程将锁定堆,其他线程要访问堆时需要等待,直到第一个线程完成,而现在第一个线程被暂停,这就会导致其他线程一直等待,形成死锁。在程序设计中,调用SuspendThread函数需要谨慎或避免:

00A111F0 > .  A1 78B8A400      MOV     EAX, DWORD PTR [g_nOption]
00A111F5   .  56                 PUSH    ESI
00A111F6   .  33F6              XOR     ESI, ESI
00A111F8   .  A8 02             TEST    AL, 2
00A111FA   .  75 26             JNZ     SHORT Counter.00A11222
00A111FC   .  57                PUSH    EDI
00A111FD   .  8B3D 28B1A300 MOV     EDI, DWORD PTR [<&USER32.SetDlgItemInt>]  ;  
                                         USER32.SetDlgItemInt
00A11203   >  A8 01             TEST       AL, 1
00A11205   .  74 16             JE         SHORT Counter.00A1121D
00A11207   .  6A 00             PUSH        0
00A11209   .  56                PUSH        ESI
00A1120A   .  68 E9030000      PUSH        3E9
00A1120F   .  FF35 74B8A400 PUSH        DWORD PTR [g_hwndDlg]
00A11215   .  FFD7              CALL        EDI                      ;  调用SetDlgItemInt
00A11217   .  A1 78B8A400      MOV         EAX, DWORD PTR [g_nOption]
00A1121C   .  46                INC         ESI                      ;  n++
00A1121D   >  A8 02             TEST        AL, 2
00A1121F   .^ 74 E2             JE          SHORT Counter.00A11203
00A11221   .  5F                POP         EDI
00A11222   >  33C0              XOR         EAX, EAX
00A11224   .  5E                POP         ESI
00A11225   .  C2 0400          RET         4

下面介绍另一个内核对象:事件对象,利用事件对象可以解决上述问题。

内核对象由操作系统管理,其数据结构只能由操作系统内核访问,应用程序无法在内存中定位这些数据结构并修改其内容。调用一个创建内核对象的函数后,函数会返回一个句柄。该句柄标识了程序创建的内核对象,可以由同一个进程中的任何线程使用,但是这些句柄值与进程相关联,如果将内核对象句柄值传递给另一个进程中的线程(通过某种进程间的通信方式),则另一个进程对该句柄进行操作会出错。

我们学过的线程对象数据结构中包含线程的一些信息,例如安全描述符、引用计数和退出码等,很快我们还会学习其他内核对象例如互斥量(Mutex)对象、信号量(Semaphore)对象、可等待计时器(Waitable Timer)对象、进程对象、文件对象、文件映射对象、I/O完成端口对象、邮件槽对象、管道(Pipe)对象等。所有内核对象的数据结构中通常包含安全描述符和引用计数字段,其他字段则根据内核对象的不同而有所不同,例如进程对象有进程ID、基本优先级和退出码等属性,而文件对象有文件偏移、共享模式和打开模式等属性。

所有内核对象的数据结构中通常包含安全描述符和引用计数字段,这说明创建这些内核对象的函数都有一个指定对象安全属性和线程的子进程能否继承返回的句柄的SECURITY_ATTRIBUTES结构,例如CreateThread函数的lpThreadAttributes参数,有引用计数则说明在不需要内核对象时需要调用CloseHandle函数关闭内核对象句柄。

要使用事件对象,首先需要调用CreateEvent函数创建一个事件对象:

HANDLE WINAPI CreateEvent(
     _In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,// 指向事件对象安全属性结构的指针
     _In_     BOOL                    bManualReset,     // 手动重置还是自动重置,TRUE或FALSE
     _In_     BOOL                    bInitialState,    // 事件对象的初始状态,TRUE或FALSE
     _In_opt_ LPCTSTR               lpName);           // 事件对象的名称字符串,区分大小写

lpEventAttributes参数是一个指向SECURITY_ATTRIBUTES结构的指针,创建内核对象的函数通常都有一个SECURITY_ATTRIBUTES结构的参数,一般设置为NULL,表示使用默认的安全属性,返回的对象句柄不可以被线程的子进程继承。

可以把事件对象看作一个由Windows管理的标志,事件对象有两种状态:有信号和无信号状态,也称为触发和未触发状态。bManualReset参数指定创建的事件对象是手动重置还是自动重置类型,这里的重置可以理解为使之恢复为无信号(未触发)状态。如果设置为TRUE,则表示创建手动重置事件对象,可以调用SetEvent函数将事件对象状态设置为有信号,或调用ResetEvent函数将事件对象状态设置为无信号。如果事件对象为有信号状态,会一直保持到调用ResetEvent函数以后才转变为无信号状态。如果设置为FALSE,则表示创建自动重置事件对象。如果需要设置事件对象为有信号状态可以调用SetEvent函数,当等待事件对象状态的函数(例如WaitForSingleObject)获取到事件对象有信号的信息后,系统会自动设置事件对象为无信号状态,不需要程序调用ResetEvent函数。

bInitialState参数指定事件对象创建时的初始状态。TRUE表示初始状态是有信号状态,FALSE表示初始状态是无信号状态。

lpName参数用于指定事件对象的名称,区分大小写,最多MAX_PATH(260)个字符。

如前所述,“调用一个创建内核对象的函数后,函数会返回一个句柄,该句柄标识了所创建的内核对象,可供同一个进程中的任何线程使用,这些句柄值是与进程相关联的,如果将句柄值传递给另一个进程中的线程(通过某种进程间的通信方式),则另一个进程对这个句柄进行操作时会出错。”内核对象是由系统管理的,如何允许其他进程使用这个内核对象呢?一种方法是为内核对象指定一个名称。以事件对象为例,在调用CreateEvent函数时,如果通过lpName参数为事件对象指定一个名称,假设为“MyEventObject”,这表示创建一个命名事件对象,则在其他进程中可以通过调用CreateEvent或OpenEvent函数并指定lpName参数为“MyEventObject”来获取到这个事件对象。如果不需要共享这个事件对象,则lpName参数可以设置为NULL表示创建一个匿名事件对象。

(1)如果系统中已经存在一个名称为“MyEventObject”的事件对象,那么调用

hEvent = CreateEvent(NULL, TRUE, FALSE, TEXT("MyEventObject"));

不会创建一个新的事件对象,而是会获取到名称为“MyEventObject”的事件对象,函数成功被调用并返回一个事件对象句柄,返回的句柄值不一定与其他进程中该事件对象的句柄值相同,但是指的是同一个事件对象。这种情况下调用GetLastError函数将返回ERROR_ALREADY_EXISTS。

(2)如果系统中已经存在一个名称为“MyEventObject”的其他内核对象,例如互斥量(Mutex)对象、信号量(Semaphore)对象,则调用

hEvent = CreateEvent(NULL, TRUE, FALSE, TEXT("MyEventObject"));

会失败,返回值为NULL,调用GetLastError函数将返回ERROR_INVALID_HANDLE。

因此,如果要创建一个命名事件对象,应保证事件对象名称在系统中是唯一的。

可以在调用CreateEvent以后,立即调用GetLastError函数判断是创建了一个新的事件对象,还是仅仅打开了一个已经存在的事件对象:

hEvent = CreateEvent(NULL, TRUE, FALSE, TEXT("MyEventObject"));
if (hEvent != NULL)
{
     if (GetLastError() == ERROR_ALREADY_EXISTS)
     {
          // 打开了一个已经存在的事件对象
     }
     else
     {
          // 创建了一个新的事件对象
     }
} 
else
{
     // CreateEvent函数执行失败
}

如果 CreateEvent 函数执行成功,则返回值是事件对象的句柄,否则返回值为NULL。如果调用CreateEvent 函数时指定了事件对象名称,并且系统中已经存在指定名称的事件对象,则函数调用会成功并获取到该事件对象,然后返回一个事件对象句柄,调用 GetLastError 函数将返回 ERROR_ALREADY_EXISTS;如果系统中已经存在指定名称的其他内核对象,则函数调用会失败,返回值为NULL,调用GetLastError函数将返回ERROR_INVALID_HANDLE。

在创建事件对象后,可以调用SetEvent函数将事件对象的状态设置为有信号,也可以调用ResetEvent函数将事件对象的状态重置为无信号(主要用于手动重置事件对象):

BOOL WINAPI SetEvent(_In_ HANDLE hEvent);   // CreateEvent或OpenEvent函数返回的事件对象句柄
BOOL WINAPI ResetEvent(_In_ HANDLE hEvent); // CreateEvent或OpenEvent函数返回的事件对象句柄

可以通过调用OpenEvent函数打开一个已经存在的命名事件对象:

HANDLE WINAPI OpenEvent(
     _In_ DWORD   dwDesiredAccess,    // 事件对象访问权限,一般设置为NULL
     _In_ BOOL    bInheritHandle,     // 在创建新进程时是否继承返回的句柄,TRUE或FALSE
     _In_ LPCTSTR lpName);            // 要打开的事件对象的名称,区分大小写

如果没有找到该名称对应的事件对象,函数将返回NULL,GetLastError返回ERROR_FILE_NOT_FOUND;如果找到了该名称对应的一个内核对象,但是类型不同,函数也将返回NULL,GetLastError返回ERROR_INVALID_HANDLE;如果名称相同,类型也相同,函数将返回事件对象的句柄。调用CreateEvent和OpenEvent函数的主要区别在于,如果事件对象不存在,则CreateEvent函数会创建它;OpenEvent函数则不同,如果对象不存在,则函数返回NULL。调用CreateEvent或OpenEvent函数打开一个已经存在的命名事件对象,都会导致事件对象的引用计数加1。

在创建或打开事件对象后,当不再需要事件对象句柄时,需要调用CloseHandle函数关闭句柄。

我们可以把事件对象看作一个由Windows管理的标志,如何检测该标志是有信号状态还是无信号状态呢?WaitForSingleObject函数用于等待指定的对象变成有信号状态:

DWORD WINAPI WaitForSingleObject(
     _In_ HANDLE hHandle,            // 要等待的对象句柄,可以是事件对象,也可以是其他内核对象
     _In_ DWORD  dwMilliseconds);    // 超时时间,以毫秒为单位

dwMilliseconds参数用于指定超时时间,也就是要等待多久,以毫秒为单位。如果指定为0,则函数在测试指定对象的状态后立即返回;如果指定为一个非零值,则函数会一直等待直到指定的对象变成有信号状态或超时时间已过才返回;如果指定为INFINITE(0xFFFFFFFF),则函数会一直等待直到指定的对象变成有信号状态才返回。

WaitForSingleObject函数检查指定对象的当前状态,如果对象是无信号状态,则调用线程进入等待状态,直到对象有信号或超时时间已过;如果线程在调用该函数时,相应的对象已经处于有信号状态,则线程不会进入等待状态。在等待过程中,调用线程处于不可调度状态,即系统不会为调用线程分配CPU时间片,因此不应该在主线程中指定较长时间或INFINITE来调用该函数。

函数返回值表明函数返回的原因,可以是表1.2中的任意值。

表1.2

返回值

含义

WAIT_OBJECT_0

等待的对象变成有信号状态

WAIT_TIMEOUT

超时时间已过

WAIT_FAILED

函数执行失败

改写CounterThread程序,使用事件对象作为开始和停止计数的标志。Chapter1\CounterThread2\ Counter\Counter.cpp源文件的内容如下:

#include <windows.h>
#include "resource.h"

#pragma comment(linker,"\"/manifestdependency:type='win32' \
     name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \
     processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

// 全局变量
HWND g_hwndDlg;
HANDLE g_hEventStart;       // 事件对象句柄,作为开始标志
HANDLE g_hEventStop;        // 事件对象句柄,作为停止标志

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
DWORD WINAPI ThreadProc(LPVOID lpParameter);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     static HWND hwndBtnStart, hwndBtnStop, hwndBtnPause, hwndBtnContinue;
     HANDLE hThread;

     switch (uMsg)
     {
     case WM_INITDIALOG:
          g_hwndDlg = hwndDlg;
          hwndBtnStart = GetDlgItem(hwndDlg, IDC_BTN_START);
          hwndBtnStop = GetDlgItem(hwndDlg, IDC_BTN_STOP);
          hwndBtnPause = GetDlgItem(hwndDlg, IDC_BTN_PAUSE);
          hwndBtnContinue = GetDlgItem(hwndDlg, IDC_BTN_CONTINUE);

          // 禁用停止、暂停、继续按钮
          EnableWindow(hwndBtnStop, FALSE);
          EnableWindow(hwndBtnPause, FALSE);
          EnableWindow(hwndBtnContinue, FALSE);

          // 创建事件对象
          g_hEventStart = CreateEvent(NULL, TRUE, FALSE, NULL);
          g_hEventStop = CreateEvent(NULL, TRUE, FALSE, NULL);
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_START:
               hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
               CloseHandle(hThread);
               hThread = NULL;

               SetEvent(g_hEventStart);    // 设置开始标志
               ResetEvent(g_hEventStop);   // 清除停止标志

               EnableWindow(hwndBtnStart, FALSE);
               EnableWindow(hwndBtnStop, TRUE);
               EnableWindow(hwndBtnPause, TRUE);
               break;

          case IDC_BTN_STOP:
               SetEvent(g_hEventStop);     // 设置停止标志
               EnableWindow(hwndBtnStart, TRUE);
               EnableWindow(hwndBtnStop, FALSE);
               EnableWindow(hwndBtnPause, FALSE);
               EnableWindow(hwndBtnContinue, FALSE);
               break;

          case IDC_BTN_PAUSE:
               ResetEvent(g_hEventStart);  // 清除开始标志
               EnableWindow(hwndBtnStart, FALSE);
               EnableWindow(hwndBtnStop, TRUE);
               EnableWindow(hwndBtnPause, FALSE);
               EnableWindow(hwndBtnContinue, TRUE);
               break;

          case IDC_BTN_CONTINUE:
               SetEvent(g_hEventStart);    // 设置开始标志
               EnableWindow(hwndBtnStart, FALSE);
               EnableWindow(hwndBtnStop, TRUE);
               EnableWindow(hwndBtnPause, TRUE);
               EnableWindow(hwndBtnContinue, FALSE);
               break;

          case IDCANCEL:
               // 关闭事件对象句柄
               CloseHandle(g_hEventStart);
               CloseHandle(g_hEventStop);
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     int n = 0;

     // 是否设置了停止标志
     while (WaitForSingleObject(g_hEventStop, 0) != WAIT_OBJECT_0) 
     {
          // 是否设置了开始标志
          if (WaitForSingleObject(g_hEventStart, 100) == WAIT_OBJECT_0)
               SetDlgItemInt(g_hwndDlg, IDC_EDIT_COUNT, n++, FALSE);
     }

     return 0;
}

编译运行程序,按下“开始”按钮,可以看到系统CPU占用率飙升;按下“暂停”按钮,CPU占用率立即下降,如图1.2所示。

图1.2

具体代码参见Chapter1\CounterThread2项目。

等待函数WaitForSingleObject可以测试的对象有多种,例如互斥量(Mutex)对象、信号量(Semaphore)对象、可等待计时器(Waitable Timer)对象、进程对象、线程对象等。不同对象对状态的定义是不同的,对事件对象来说,调用SetEvent函数后状态为有信号,调用ResetEvent函数后状态重置为无信号。对线程对象来说,创建时总是处于无信号状态,当线程终止时,系统会自动将线程对象的状态更改为有信号。

WaitForSingleObject函数每次只能测试一个对象,在实际应用中,有时候可能需要同时测试多个对象的状态,这时可以使用另外一个函数WaitForMultipleObjects。WaitForMultipleObjects函数用于等待指定的多个对象变为有信号状态:

DWORD WINAPI WaitForMultipleObjects(
     _In_       DWORD  nCount,           // 要等待的对象句柄个数,最大为MAXIMUM_WAIT_OBJECTS(64)
     _In_ const HANDLE *lpHandles,    // 要等待的对象句柄数组
     _In_       BOOL   bWaitAll,        // 是否等待lpHandles数组中的所有对象的状态都变为有信号
_In_       DWORD  dwMilliseconds);    // 超时时间

bWaitAll参数指定是否等待lpHandles数组中的所有对象的状态都转变为有信号。如果设置为TRUE,则当lpHandles数组中的所有对象的状态都转变为有信号时函数才返回;如果设置为FALSE,则当任何一个对象的状态转变为有信号时函数就返回。

dwMilliseconds参数的含义同WaitForSingleObject函数。

函数返回值表明函数返回的原因,函数返回值可以是表1.3中的任意值。

表1.3

返回值

含义

WAIT_TIMEOUT

超时时间已过

WAIT_FAILED

函数执行失败

WAIT_OBJECT_0

如果给bWaitAll参数传递的是TRUE并且所有对象都是有信号状态,则返回值是WAIT_OBJECT_0

WAIT_OBJECT_0~
(WAIT_OBJECT_0 + nCount − 1)

如果给bWaitAll参数传递的是FALSE,则只要有任何一个对象变成有信号状态,函数就会立即返回,这时的返回值是WAIT_OBJECT_0到(WAIT_OBJECT_0 + nCount − 1)之间的一个值,即如果返回值既不是WAIT_FAILED,也不是WAIT_TIMEOUT,则应该把返回值减去WAIT_OBJECT_0,得到的数值是lpHandles参数指定的对象句柄数组的一个索引,该索引表示转变为有信号状态的是哪个对象

当线程成功等待到自动重置事件对象有信号时,事件对象会自动重置为无信号状态,因此自动重置事件对象通常不需要调用ResetEvent函数。手动和自动重置事件对象有一个很重要的区别:当一个手动重置事件对象转变为有信号状态时,正在等待该事件对象的所有线程都将变成可调度状态;而当一个自动重置事件对象转变为有信号状态时,只有一个正在等待该事件对象的线程可以变成可调度状态。

具体请看图1.3的示例。

图1.3

ManualAuto程序在WM_INITDIALOG消息中创建了一个手动重置匿名事件对象,用户按下“创建三个线程”按钮,程序创建3个线程,这3个线程可以各自完成一些工作,本例中是每个线程函数弹出一个消息框。单击“SetEvent”按钮,可以理解为一个线程完成了相应的工作以后通知其他线程,程序调用SetEvent函数设置事件对象为有信号状态,此时3个正在等待事件对象的线程都会收到事件对象变为有信号状态的通知,依次弹出3个消息框。代码如下:

#include <windows.h>
#include "resource.h"

// 全局变量
HWND g_hwndDlg;
HANDLE g_hEvent;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
DWORD WINAPI ThreadProc1(LPVOID lpParameter);
DWORD WINAPI ThreadProc2(LPVOID lpParameter);
DWORD WINAPI ThreadProc3(LPVOID lpParameter);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     HANDLE hThread[3];

     switch (uMsg)
     {
     case WM_INITDIALOG:
          g_hwndDlg = hwndDlg;

          // 创建事件对象,手动重置
          g_hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

          EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_SETEVENT), FALSE);
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_CREATETHREAD:
               // 重置事件对象
               ResetEvent(g_hEvent);

               hThread[0] = CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
               hThread[1] = CreateThread(NULL, 0, ThreadProc2, NULL, 0, NULL);
               hThread[2] = CreateThread(NULL, 0, ThreadProc3, NULL, 0, NULL);
               for (int i = 0; i < 3; i++)
                    CloseHandle(hThread[i]);

               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_SETEVENT), TRUE);
               break;

          case IDC_BTN_SETEVENT:
               // 设置事件对象
               SetEvent(g_hEvent);
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_SETEVENT), FALSE);
               break;

          case IDCANCEL:
               // 关闭事件对象句柄
               CloseHandle(g_hEvent);
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProc1(LPVOID lpParameter)
{
     WaitForSingleObject(g_hEvent, INFINITE);
     MessageBox(g_hwndDlg, TEXT("线程1成功等待到事件对象"), TEXT("提示"), MB_OK);
     // 做一些工作

     return 0;
}

DWORD WINAPI ThreadProc2(LPVOID lpParameter)
{
     WaitForSingleObject(g_hEvent, INFINITE);
     MessageBox(g_hwndDlg, TEXT("线程2成功等待到事件对象"), TEXT("提示"), MB_OK);
     // 做一些工作

     return 0;
}

DWORD WINAPI ThreadProc3(LPVOID lpParameter)
{
     WaitForSingleObject(g_hEvent, INFINITE);
     MessageBox(g_hwndDlg, TEXT("线程3成功等待到事件对象"), TEXT("提示"), MB_OK);
     // 做一些工作

     return 0;
}

WM_COMMAND消息中IDC_BTN_CREATETHREAD的ResetEvent函数调用是为了防止下次创建3个线程的时候,使用的还是前面的有信号状态的事件对象。具体代码参见Chapter1\ManualAuto项目。

但是,如果把创建事件对象的代码改为g_hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);来创建一个自动重置匿名事件对象,重新编译运行程序,先单击“创建三个线程”按钮,再单击“SetEvent”按钮,可以发现只有一个消息框弹出,这是因为在调用SetEvent函数后,系统只允许3个线程中的一个变成可调度状态,但是不确定会调度其中的哪个线程,剩下的2个线程则一直等待。

对于本程序中设置事件对象为自动重置的情况,可以在每个线程函数返回前加上一句SetEvent(g_hEvent);设置事件对象为有信号状态,这样3个线程都可以等待到事件对象的有信号状态。

对多线程的程序来说,线程间的同步是一个非常重要的话题,例如当多个线程同时读写同一个内存变量或文件时很容易出现混乱,如果一个线程正在修改文件的数据,而这时另一个线程也在修改或读取文件的数据,则文件的数据内容就会出现混乱。

产生同步问题的根源在于线程之间的切换是无法预测的,在一个线程执行完任何一条指令后,系统可能会打断当前线程的执行,而去执行另一个线程。而另一个线程可能会修改前一个线程正在读写的数据,这就可能会引发错误的结果,一个线程不了解自己的CPU时间片何时结束,也无法获知下一个CPU时间片会分配给哪个线程。

如果系统中线程的运行机制是,当一个线程修改共享资源时,其他线程只能等待前一个线程修改完成后才可以对该资源进行操作,因此程序员不需要关心线程间的同步问题。但是,Windows是一个抢占式多任务多线程操作系统,系统可以在任何时刻停止一个线程而去调度另一个线程。

我们先看一个会产生线程同步问题的程序ThreadSync,如图1.4所示。

图1.4

单击“开始”按钮,程序把全局变量g_n的值赋值为10,然后创建两个线程同时对全局变量g_n的值做以下运算1亿次:

g_n++; g_n−−;

等这两个线程结束后,把g_n的值显示到编辑控件中。ThreadSync.cpp源文件内容如下:

#include <windows.h>
#include "resource.h"

// 常量定义
#define NUM 2

// 全局变量
int g_n;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
DWORD WINAPI ThreadProc(LPVOID lpParameter);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     HANDLE hThread[NUM];

     switch (uMsg)
     {
     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_START:
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), FALSE);
               g_n = 10;       // 创建线程执行线程函数以前把全局变量g_n赋值为10
               for (int i = 0; i < NUM; i++)
                    hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

               // 实际编程中避免在主线程中这样无限制地等待内核对象
               WaitForMultipleObjects(NUM, hThread, TRUE, INFINITE);
               for (int i = 0; i < NUM; i++)
                    CloseHandle(hThread[i]);

               // 所有线程结束以后,把g_n的最终值显示在编辑控件中
               SetDlgItemInt(hwndDlg, IDC_EDIT_NUM, g_n, TRUE);
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), TRUE);
               break;

          case IDCANCEL:
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     for (int i = 1; i <= 100000000; i++)
     {
          g_n++;
          g_n--;
     }

     return 0;
}

具体代码参见Chapter1\ThreadSync项目。

正常情况下,线程函数中g_n++;g_n−−;会保持全局变量g_n的值不变,但是编译运行程序后,每次按下“开始”按钮,编辑控件中显示的g_n的值都不同。注意不要编译为Release版本,否则智能的编译器发现g_n++;g_n−−;是在做无用功,会进行优化,结果总是为10。

在g_n++;一行按F9键设置断点,按F5键开始调试,然后单击“开始”按钮,程序中断,选择VS菜单栏的调试→窗口→反汇编命令,可以看到g_n++; g_n−−;这两条语句被汇编为以下语句:

         g_n++;
001C4839  mov eax,   [g_n]
001C483E  add eax,   1
001C4841  mov [g_n], eax
         g_n--;
001C4846  mov eax,   [g_n]
001C484B  sub eax,   1
001C484E  mov [g_n], eax

前面说过:在一个线程执行完任何一条指令后,系统可能会打断线程的执行,而去执行另一个线程,而另一个线程可能会修改前一个线程正在读写的对象,这就可能会引发错误的结果,一个线程并不了解自己的CPU时间片何时会结束,也无法确定下一个CPU时间片会分配给哪个线程。一个线程有6条敏感指令,线程函数循环1亿次,如果线程1执行了1条指令,然后切换到线程2执行了2条指令,以此类推,有无数种可能的组合。请看下面的指令执行顺序组合,有缩进的代码行代表线程2:

001C4839  mov eax, [g_n]            // 10
     001C4839  mov eax, [g_n]
001C483E  add eax, 1
     001C483E  add eax, 1
001C4841  mov [g_n], eax            // 12
     001C4841  mov [g_n], eax
001C4846  mov eax, [g_n]
001C484B  sub eax, 1
     001C4846  mov eax, [g_n]      // 12
     001C484B  sub eax, 1
001C484E  mov [g_n], eax            // 11
     001C484E  mov [g_n], eax

以上代码将全局变量g_n的值增加了1变为11。

线程同步要解决的问题就是当多个线程同时访问一个共享资源时避免破坏资源的完整性。当有一个线程正在对共享资源进行操作时,其他线程只能等待,直到该线程完成操作后才可以对该共享资源进行操作,即保证线程对共享资源操作的独占性、原子性。

Windows提供了许多线程间同步机制,包括用户模式下的关键段(Critical Section)对象,内核模式下的事件对象、可等待计时器对象、信号量对象以及互斥量对象等。关键段对象是由进程维护的,使用关键段进行线程间同步称为用户模式下的线程同步。事件对象、可等待计时器对象、信号量对象以及互斥量对象属于内核对象,内核对象由操作系统维护,使用这些内核对象进行线程间同步称为内核模式下的线程同步。

用户模式下的线程同步最常用的是关键段,在进行线程同步时线程保持处于用户模式,在用户模式下进行线程同步的最大好处是速度非常快。与用户模式下的同步机制相比,使用内核对象进行线程间同步,调用线程必须从用户模式切换到内核模式,这种切换非常耗时,可能需要上千个CPU周期。

1.Interlocked原子访问系列函数

就上面的ThreadSync示例而言,最简单的线程同步方式是使用Interlocked原子访问系列函数。InterlockedIncrement、InterlockedDecrement这两个函数可以保证以原子方式对多个线程的共享变量进行递增、递减操作:

LONG InterlockedIncrement(_Inout_ LONG volatile* Addend);
LONG InterlockedDecrement(_Inout_ LONG volatile* Addend);

当读取一个变量时,为了提高读取速度,编译器优化时可能会把变量读取到一个寄存器中,下次读取变量值时直接从寄存器中取值。volatile关键字表示告知编译器不要对该变量进行任何形式的优化,而是始终从变量所在的内存地址中读取变量的值。当多个线程同时读写一个共享变量时,为了安全起见,可以为共享变量设置volatile关键字。

把线程函数ThreadProc中对全局变量g_n的递增、递减更改为以下代码:

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     for (int i = 1; i <= 100000000; i++)
     {
          InterlockedIncrement((PLONG)&g_n);
          InterlockedDecrement((PLONG)&g_n);
     }

     return 0;
}

即可保证两个线程函数执行结束后全局变量g_n的值始终为10。

也可以使用InterlockedExchangeAdd函数:

LONG InterlockedExchangeAdd(
     _Inout_ LONG volatile* Addend, // 共享变量
     _In_     LONG             Value); // 要加到Addend参数指向的变量的值,指定为负数就是减

该函数将Addend + Value的结果放入Addend参数指向的变量中,函数返回值为原Addend参数指向的变量的值。

InterlockedExchange 函数用于将一个共享变量的值设置为指定的值,InterlockedExchangePointer函数用于将一个共享指针变量的值设置为指定的指针值:

LONG InterlockedExchange(
     _Inout_ LONG volatile* Target,            // 共享变量
     _In_    LONG           Value);              // *Target = Value
PVOID InterlockedExchangePointer(
     _Inout_ PVOID volatile* Target,          // 共享变量
     _In_    PVOID           Value);             // *Target = Value

以上两个函数的返回值是原Target参数指向的变量的值。

InterlockedCompareExchange函数用于将一个共享变量的值与指定值进行比较,如果相等则将共享变量赋值为另一个指定值,InterlockedCompareExchangePointer函数用于将一个共享指针变量的值与指定的指针值进行比较,如果相等则将共享指针变量赋值为另一个指定的指针值:

LONG InterlockedCompareExchange(
     _Inout_ LONG volatile* Destination,     // 共享变量
     _In_    LONG              ExChange,         
     _In_    LONG              Comperand);        // if (*Destination == Comperand) *Destination   
                                             // = ExChange;
PVOID InterlockedCompareExchangePointer(
     _Inout_ PVOID volatile* Destination,    // 共享变量
     _In_    PVOID              Exchange,        
     _In_    PVOID              Comperand);        // if (*Destination == Comperand) *Destination   
                                        // = ExChange;

以上两个函数的返回值是原Destination参数指向的变量的值。

将一个共享变量的值和指定值进行按位与、按位或、按位异或的函数分别是InterlockedAnd、InterlockedOr、InterlockedXor:

LONG InterlockedAnd(
     _Inout_ LONG volatile* Destination,        // 共享变量
     _In_    LONG              Value);              // *Destination = *Destination & Value
LONG InterlockedOr(
     _Inout_ LONG volatile* Destination,        // 共享变量
     _In_    LONG              Value);              // *Destination = *Destination | Value
LONG InterlockedXor(
     _Inout_ LONG volatile* Destination,        // 共享变量
     _In_    LONG              Value);              // *Destination = *Destination ^ Value

以上3个函数的返回值是原Destination参数指向的变量的值。

如果程序编译为 64 位,LPVOID则为 64 位指针,因此 InterlockedExchangePointer 和 Interlocked CompareExchangePointer这两个函数可以对32位和64位的指针值进行操作。除了这两个函数,上述其他函数都是对32位值进行操作,Windows也提供了对64位值进行操作的相关函数:

InterlockedIncrement64;
InterlockedDecrement64;
InterlockedExchangeAdd64;
InterlockedExchange64;
InterlockedCompareExchange64;

2.关键段

关键段(Critical Section)对象也称为临界区对象,即把操作共享资源的一段代码保护起来,当一个线程正在执行操作共享资源的这段代码时,其他试图访问共享资源的线程都将被挂起,一直等待到前一个线程执行完,其他线程才可以执行操作共享资源的代码。当然,系统也可以暂停当前线程去调度其他线程,但是在当前线程离开关键段前,系统是不会去调度任何想要访问同一资源的其他线程的。

使用关键段对象进行线程间同步,涉及以下4个函数:

// 初始化关键段对象
VOID WINAPI InitializeCriticalSection(_Out_ LPCRITICAL_SECTION lpCriticalSection);
// 试图进入关键段
VOID WINAPI EnterCriticalSection(_Inout_ LPCRITICAL_SECTION lpCriticalSection);
// 离开关键段
VOID WINAPI LeaveCriticalSection(_Inout_ LPCRITICAL_SECTION lpCriticalSection);
// 释放关键段对象
VOID WINAPI DeleteCriticalSection(_Inout_ LPCRITICAL_SECTION lpCriticalSection);

lpCriticalSection参数是一个指向CRITICAL_SECTION结构的指针,我们不需要关注结构的具体字段,因为其维护和测试工作都由Windows完成。CRITICAL_SECTION结构通常需要定义为全局变量,以便进程中的所有线程都能够访问到该结构。

在使用关键段对象前必须先调用InitializeCriticalSection函数初始化CRITICAL_SECTION结构,该函数会设置CRITICAL_SECTION结构的一些字段。

对共享资源进行操作的代码必须包含在EnterCriticalSection和LeaveCriticalSection函数调用之间,为了实现对共享资源的互斥访问,每个线程在执行操作共享资源的任何代码前必须先调用EnterCriticalSection函数,该函数试图拥有关键段对象的所有权。同一时刻只能有一个线程拥有关键段对象,EnterCriticalSection函数会一直等待,直到获取了关键段对象的所有权后函数才返回,等待超时时间由注册表HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SessionManager\CriticalSectionTimeout指定,默认值为2 592 000秒,大约相当于30天。

执行完操作共享资源的代码后,需要调用LeaveCriticalSection函数释放对关键段对象的所有权,以便其他正在等待的线程获得关键段对象的所有权并执行操作共享资源的代码。

不再需要关键段对象时需要调用DeleteCriticalSection函数释放关键段对象,该函数会释放关键段对象使用的所有系统资源。

下面使用关键段对象改写ThreadSync程序,Chapter1\ThreadSync_CriticalSection\ThreadSync\ ThreadSync.cpp源文件的内容如下:

#include <windows.h>
#include "resource.h"

// 常量定义
#define NUM 2

// 全局变量
int g_n;
CRITICAL_SECTION g_cs;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
DWORD WINAPI ThreadProc(LPVOID lpParameter);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     HANDLE hThread[NUM];

     switch (uMsg)
     {
     case WM_INITDIALOG:
          // 初始化关键段对象CRITICAL_SECTION结构
          InitializeCriticalSection(&g_cs);
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_START:
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), FALSE);
               g_n = 10;       // 创建线程执行线程函数以前将全局变量g_n赋值为10
               for (int i = 0; i < NUM; i++)
                    hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

               WaitForMultipleObjects(NUM, hThread, TRUE, INFINITE);
               for (int i = 0; i < NUM; i++)
                    CloseHandle(hThread[i]);

               // 所有线程结束后,将g_n的最终值显示在编辑控件中
               SetDlgItemInt(hwndDlg, IDC_EDIT_NUM, g_n, TRUE);
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), TRUE);
               break;

          case IDCANCEL:
               // 释放关键段对象
               DeleteCriticalSection(&g_cs);
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     for (int i = 1; i <= 100000000; i++)
     {
          // 进入关键段
          EnterCriticalSection(&g_cs);
          g_n++;
          g_n--;
          // 离开关键段
          LeaveCriticalSection(&g_cs);
     }

     return 0;
}

具体代码参见Chapter1\ThreadSync_CriticalSection项目。因为每个线程独占对共享资源的访问,因此使用关键段对象进行线程同步后,执行速度肯定会慢一些,但是能够保证操作结果的正确性。

有以下两个需要注意的问题。

(1)同时访问多个共享资源。有时候程序可能需要同时访问两个(或多个)共享资源,例如程序可能需要锁定一个资源来从中读取数据,同时锁定另一个资源将刚刚读取的数据写入其中,如果每个资源都有专属的关键段对象:

DWORD WINAPI ThreadProc1(LPVOID lpParameter)    
{
      EnterCriticalSection(&g_cs1);
      EnterCriticalSection(&g_cs2);
      // 从资源1读取数据
      // 向资源2写入数据
      LeaveCriticalSection(&g_cs2);
      LeaveCriticalSection(&g_cs1);
      return 0;
}

假设程序中有另一个线程2也需要访问这两个共享资源:

DWORD WINAPI ThreadProc2(LPVOID lpParameter)
{
      EnterCriticalSection(&g_cs2);
      EnterCriticalSection(&g_cs1);
      // 从资源1读取数据
      // 向资源2写入数据
      LeaveCriticalSection(&g_cs1);
      LeaveCriticalSection(&g_cs2);
      return 0;
}

线程2函数所做的改动是调换EnterCriticalSection和LeaveCriticalSection函数使用两个关键段对象的顺序,假设线程1开始运行并得到g_cs1关键段的所有权,然后执行线程2并得到g_cs2关键段的所有权,程序将发生死锁,当线程1和线程2中的任何一个试图继续执行时,都无法得到它需要的另一个关键段的所有权。

为了解决这个问题,我们必须在代码中以完全相同的顺序来获得关键段的所有权。调用LeaveCriticalSection函数时顺序则无关紧要,这是因为调用该函数从来不会使线程进入等待状态。

(2)一个线程不要长时间独占共享资源。如果一个关键段被长时间独占,那么其他需要获得关键段所有权的线程只能进入等待状态,这会影响到应用程序的性能。下面的代码将在WM_SOMEMSG消息被发送到另一个窗口并得到处理前阻止其他线程修改g_struct结构的值:

SOMESTRUCT g_struct;
CRITICAL_SECTION g_cs;

DWORD WINAPI SomeThreadProc(LPVOID lpParameter)
{
      EnterCriticalSection(&g_cs);
      SendMessage(hwndSomeWnd, WM_SOMEMSG, (WPARAM) &g_struct, 0);
      LeaveCriticalSection(&g_cs);
      return 0;
}

我们不确定hwndSomeWnd所属的窗口过程需要多长时间来处理WM_SOMEMSG消息,可能只需要几秒,也可能需要几小时。在这段时间内,其他线程都无法得到对g_struct结构的访问权。将前述代码写成以下形式会更好:

SOMESTRUCT g_struct;
CRITICAL_SECTION g_cs;

DWORD WINAPI SomeThreadProc(LPVOID lpParameter)
{
     EnterCriticalSection(&g_cs);
     SOMESTRUCT structTemp = g_struct;   // 复制一份g_struct结构作为临时变量
     LeaveCriticalSection(&g_cs);

     SendMessage(hwndSomeWnd, WM_SOMEMSG, (WPARAM)&structTemp, 0);
     return 0;
}

复制一份g_struct结构作为临时变量后,即可调用LeaveCriticalSection函数释放关键段的所有权。采用这样的处理方式,如果其他线程需要等待使用g_struct结构,那么它们最多只需要等待几个CPU周期,而不是一段长度不确定的时间。当然,前提是假设hwndSomeWnd窗口过程只需要读取g_struct结构的内容,并且窗口过程不会修改结构中的字段。

SendMessage函数为指定的窗口调用窗口过程,直到窗口过程处理完消息后函数才返回,返回值为指定消息处理的结果,即当窗口过程处理完该消息后,Windows才把控制权交还给SendMessage调用的下一条语句。与SendMessage函数不同的是PostMessage函数是将一个消息投递到一个线程的消息队列然后立即返回,PostMessage是把消息发送到指定窗口句柄所在线程的消息队列再由线程来分发。这里不可以使用PostMessage函数代替SendMessage函数调用,因为程序无法保证在WM_SOMEMSG消息得到处理前g_struct结构的字段不发生变化。

3.SRW锁

SRW锁(Slim Reader/Writer Locks)和关键段对象类似,也可以用于把操作共享资源的一段代码保护起来,系统对SRW锁进行了速度优化,占用的内存较少。SRW锁的性能与关键段不相上下,在某些场合性能可能会超过关键段,因此SRW锁可以替代关键段来使用。

SRW锁提供了以下两种对于共享资源的访问模式。

共享模式。多个读取线程(用于读取共享资源的线程)可以同时获取到SRW锁对象,所以可以同时读取共享资源的内容。如果一个进程中线程读取操作的频率超过写入操作,则与关键段相比,这种并发性可以提高程序的性能和吞吐量。

独占模式。同一时刻只能有一个写入线程(用于写入共享资源的线程)可以获取到SRW锁对象,如果一个写入线程以独占模式获取到SRW锁对象,则在该线程释放锁前,其他任何线程都无法获取到SRW锁对象因而不能访问共享资源。

在使用SRW锁前必须对其进行初始化,InitializeSRWLock函数用于动态初始化一个SRW锁对象:

VOID WINAPI InitializeSRWLock(_Out_ PSRWLOCK pSRWLock);

pSRWLock参数指向的SRWLOCK结构只有一个LPVOID类型的指针字段(结构的具体字段不需要也不应该关心),优点是更新锁状态的速度很快,缺点是只能存储很少的状态信息,因此SRW锁无法检测共享模式下不正确的递归使用。

InitializeSRWLock函数用于动态初始化SRWLOCK结构,也可以将常量SRWLOCK_INIT赋值给SRWLOCK结构的变量以静态初始化。

一个读取线程可以通过调用AcquireSRWLockShared函数以共享模式获取SRW锁;当不再需要SRW锁时,同一线程应该调用ReleaseSRWLockShared函数释放以共享模式获取到的SRW锁。需要注意的是,SRW锁必须由获取它的同一个线程释放。这两个函数的原型如下:

VOID WINAPI AcquireSRWLockShared(_Inout_ PSRWLOCK pSRWLock);
VOID WINAPI ReleaseSRWLockShared(_Inout_ PSRWLOCK pSRWLock);

一个写入线程可以通过调用AcquireSRWLockExclusive函数以独占模式获取SRW锁;当不再需要SRW锁时,同一线程应该调用ReleaseSRWLockExclusive函数释放以独占模式获取到的SRW锁。同样需要注意,SRW锁必须由获取它的同一个线程释放。这两个函数的原型如下:

VOID WINAPI AcquireSRWLockExclusive(_Inout_ PSRWLOCK pSRWLock);
VOID WINAPI ReleaseSRWLockExclusive(_Inout_ PSRWLOCK pSRWLock);

不应该递归获取共享模式SRW锁,因为当与独占获取结合时会形成死锁;不能递归获取独占模式SRW锁,如果一个线程试图获取它已经持有的锁,会失败或形成死锁。

4.条件变量

条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程因为“等待条件变量触发”而进入睡眠状态,另一个线程可以“触发条件变量”从而唤醒睡眠线程。条件变量可以和关键段或SRW锁一起使用。

假设这样一种情况,读取线程和写入线程共用一个缓冲区,且共用一个关键段或SRW锁对象以同步对共享缓冲区的读/写,缓冲区有大小限制,比如说只能写入10项数据,写入线程作为生产者负责向缓冲区写入数据,读取线程作为消费者从缓冲区读取数据。写入线程不断向缓冲区写入数据,当缓冲期已满(队列已满),写入线程可以释放关键段或SRW锁对象并让自己进入睡眠状态,这样一来,读取线程就可以获取到关键段或SRW锁对象从而进行读取操作;读取线程每读取一项就让缓冲区减少一项(清空一项,减小队列),当队列为空的时候,读取线程可以释放关键段或SRW锁对象并让自己进入睡眠状态,这样一来,写入线程就可以获取到关键段或SRW锁对象从而进行写入操作。当然,为了提高工作效率,当读取线程清空一项时,可以唤醒写入线程继续写入数据(进行生产工作),当写入线程写入一项时,可以唤醒读取线程继续读取数据(进行消费工作),就是说只要队列未满那么写入线程就不停生产,只要队列不为空那么读取线程就不停消费。

在使用条件变量前必须对其进行初始化,InitializeConditionVariable函数用于动态初始化条件变量:

VOID WINAPI InitializeConditionVariable(_Out_ PCONDITION_VARIABLE pConditionVariable);

pConditionVariable参数指向的CONDITION_VARIABLE结构只有一个LPVOID类型的指针字段(结构的具体字段不需要也不应该关心)。InitializeConditionVariable函数用于动态初始化CONDITION_VARIABLE结构,也可以将常量CONDITION_VARIABLE_INIT赋值给CONDITION_VARIABLE结构的变量以静态初始化。

一个线程可以通过调用SleepConditionVariableCS函数原子性地释放关键段并进入睡眠状态;另一个线程可以通过调用SleepConditionVariableSRW函数原子性地释放SRW锁并进入睡眠状态。这两个函数的原型如下:

BOOL WINAPI SleepConditionVariableCS(
    _Inout_ PCONDITION_VARIABLE pConditionVariable,     // 指向条件变量的指针
    _Inout_ PCRITICAL_SECTION   pCriticalSection,       // 指向关键段对象的指针
    _In_      DWORD                 dwMilliseconds);        // 超时时间,以毫秒为单位
BOOL WINAPI SleepConditionVariableSRW(
    _Inout_ PCONDITION_VARIABLE pConditionVariable,    // 指向条件变量的指针
    _Inout_ PSRWLOCK              pSRWLock,               // 指向SRW锁对象的指针
    _In_      DWORD                 dwMilliseconds,        // 超时时间,以毫秒为单位
    _In_      ULONG                 ulFlags);               // SRW锁的访问模式

pConditionVariable参数是一个指向条件变量的指针,调用线程正在该条件变量上睡眠。

pCriticalSection和pSRWLock参数是分别指向关键段对象和SRW锁对象的指针,关键段对象和SRW锁对象用于同步对共享资源的访问。

SleepConditionVariableCS和SleepConditionVariableSRW函数可以分别原子性地释放关键段对象和SRW锁对象的所有权并使调用线程进入睡眠状态,因此在调用这两个函数前必须已经分别调用EnterCriticalSection和AcquireSRWLockShared(或AcquireSRWLockExclusive)函数获取到关键段对象和SRW锁对象的所有权。

dwMilliseconds参数指定超时时间,以毫秒为单位。如果超时时间已过,SleepConditionVariableCS和SleepConditionVariableSRW函数将会分别重新获取到关键段对象和SRW锁对象的所有权并返回FALSE;如果该参数设置为0,这两个函数在测试指定条件变量的状态后会立即返回;如果该参数设置为INFINITE,表示超时时间永不过期。

SleepConditionVariableSRW函数的ulFlags参数用于指定SRW锁的访问模式。如果该参数设置为CONDITION_VARIABLE_LOCKMODE_SHARED(1),表示SRW锁处于共享模式;如果该参数设置为0,表示SRW锁处于独占模式。

如果函数执行成功,则返回值为TRUE;如果函数执行失败或超时时间已过,则返回值为FALSE。

WakeConditionVariable函数用于唤醒正在条件变量上睡眠的一个线程;WakeConditionVariable函数用于唤醒正在条件变量上睡眠的所有线程。前者仅唤醒正在条件变量上睡眠的单个线程,后者可以唤醒正在条件变量上睡眠的所有线程,唤醒一个线程类似于自动重置事件,而唤醒所有线程类似于自动重置事件。线程被唤醒后,会重新获取到线程进入睡眠状态时释放的关键段/SRW锁对象。这两个函数的原型如下:

VOID WINAPI WakeConditionVariable(_Inout_ PCONDITION_VARIABLE pConditionVariable);
VOID WINAPI WakeAllConditionVariable(_Inout_ PCONDITION_VARIABLE pConditionVariable);

条件变量会受到虚假唤醒(与显式唤醒无关的唤醒)和被盗唤醒(另一个线程设法在被唤醒线程之前运行)的影响,因此应该在SleepConditionVariableCS/SleepConditionVariableSRW函数调用返回后重新检查“所需的条件”是否成立。例如下面的伪代码:

CRITICAL_SECTION   g_csCritSection;
CONDITION_VARIABLE g_cvConditionVar;

VOID PerformOperationOnSharedData()
{
   // 获取关键段对象的所有权
   EnterCriticalSection(&g_csCritSection);

   // 除非"所需的条件"成立,否则一直睡眠
   while ( "所需的条件"不成立 )
       SleepConditionVariableCS(&g_cvConditionVar, &g_csCritSection, INFINITE);

   // 现在"所需的条件"已经成立,可以安全地读/写共享资源
   // ...

   // 释放关键段对象的所有权
   LeaveCriticalSection(&g_csCritSection);

   // 这里,可以通过调用WakeConditionVariable / WakeAllConditionVariable函数来唤醒其他线程
}

条件变量的例子参见ConditionVariableDemo项目。

需要反复说明的是,用户模式下的线程同步最常用的是关键段。在进行线程同步时使线程保持在用户模式下,在用户模式下进行线程同步的最大好处是速度非常快。与用户模式下的同步机制相比,使用内核对象进行线程间同步,调用线程必须从用户模式切换到内核模式,这种切换是非常耗时的,可能需要上千个CPU周期。

但是关键段对象的缺点是,关键段只能用来对同一个进程中的线程进行同步,一般用于对速度要求比较高并且不需要跨进程进行同步的情况。调用EnterCriticalSection函数进入关键段的时候没有指定最长等待时间的参数,如果一个线程在调用 EnterCriticalSection 函数以后被迫中断,则其他线程对EnterCriticalSection函数的调用就永远不会返回,即其他线程一直没有机会获得关键段对象的所有权。

1.事件对象

根据自动重置事件对象(Event)的特点:当一个自动重置事件对象变成有信号状态时,只有一个正在等待该事件对象的线程可以变成可调度状态,可以使用自动重置事件对象进行线程间同步。

下面使用事件对象改写ThreadSync程序,Chapter1\ThreadSync_Event\ThreadSync\ThreadSync.cpp源文件的内容如下:

#include <windows.h>
#include "resource.h"

// 常量定义
#define NUM 2

// 全局变量
int g_n;
HANDLE g_hEvent;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
DWORD WINAPI ThreadProc(LPVOID lpParameter);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     HANDLE hThread[NUM];

     switch (uMsg)
     {
     case WM_INITDIALOG:
          // 创建一个自动重置匿名事件对象
          g_hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_START:
               // 设置事件对象为有信号状态
               SetEvent(g_hEvent);

               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), FALSE);
               g_n = 10;       // 创建线程执行线程函数以前将全局变量g_n赋值为10
               for (int i = 0; i < NUM; i++)
                    hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

               WaitForMultipleObjects(NUM, hThread, TRUE, INFINITE);
               for (int i = 0; i < NUM; i++)
                    CloseHandle(hThread[i]);

               // 所有线程结束以后,将g_n的最终值显示在编辑控件中
               SetDlgItemInt(hwndDlg, IDC_EDIT_NUM, g_n, TRUE);
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), TRUE);
               break;

          case IDCANCEL:
               // 关闭事件对象句柄
               CloseHandle(g_hEvent);
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

    return FALSE;
}

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     for (int i = 1; i <= 100000000; i++)
     {
          // 等待事件对象
          WaitForSingleObject(g_hEvent, INFINITE);
          g_n++;
          g_n--;
          // 设置事件对象为有信号
          SetEvent(g_hEvent);
     }

     return 0;
}

具体代码参见Chapter1\ThreadSync_Event项目。编译运行程序可以发现,使用事件对象后,执行速度相比使用关键段对象慢了很多,读者可以设置较少的循环次数(例如100万次)。

事件对象不仅可以用于同一个进程中的线程同步,还可以用于不同进程中的线程同步。在调用CreateEvent函数创建事件对象时,可以将最后一个参数lpName设置为事件名称字符串,表示创建一个命名事件对象,在其他进程中可以使用CreateEvent或OpenEvent函数指定相同的事件名称打开该事件对象进行使用。

2.互斥量对象

互斥量对象(Mutex)与关键段对象类似,用于提供对共享资源的互斥访问,同一时刻只能有一个线程拥有互斥量对象的所有权。互斥量有两种状态:有信号和无信号状态,当没有任何线程拥有互斥量的所有权时为有信号状态,如果有一个线程拥有了互斥量的所有权则为无信号状态。

使用互斥量对象进行线程间同步,涉及以下函数。

// 创建一个互斥量对象
HANDLE WINAPI CreateMutex(
     _In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,     
     _In_     BOOL                       bInitialOwner,           // 初始情况下调用线程是否拥有互斥量  
                                                    // 对象的所有权
     _In_opt_ LPCTSTR                  lpName);             // 互斥量对象名称字符串,区分大小写
// 等待互斥量对象
WaitForSingleObject
// 释放互斥量对象所有权
BOOL WINAPI ReleaseMutex(_In_ HANDLE hMutex);
// 关闭互斥量对象句柄
CloseHandle

lpMutexAttributes参数是一个指向SECURITY_ATTRIBUTES结构的指针,与创建线程、事件对象的第一个参数的含义相同。

bInitialOwner参数指定初始情况下调用线程是否拥有互斥量对象的所有权。如果设置为TRUE则调用线程创建互斥量对象以后自动获得其所有权,如果设置为FALSE则初始情况下调用线程不会获得互斥量对象的所有权。

lpName参数的用法与创建事件对象的同名参数用法相同,用于指定互斥量对象的名称,区分大小写,最多MAX_PATH(260)个字符。如果需要共享该互斥量对象,可以设置一个名称,表示创建一个命名互斥量对象,在其他地方可以通过调用CreateMutex或OpenMutex函数并指定名称来获取到该互斥量对象;如果不需要共享互斥量对象,lpName参数可以设置为NULL表示创建一个匿名互斥量对象。

如果 CreateMutex 函数执行成功,则返回值为互斥量对象的句柄,否则返回值为NULL。如果调用CreateMutex函数时指定了互斥量对象名称,则有下面两种情况。

(1)如果系统中已经存在指定名称的互斥量对象,则函数会获取到该互斥量对象。函数调用成功并返回一个互斥量对象句柄,调用GetLastError函数将返回ERROR_ALREADY_EXISTS。

(2)如果系统中已经存在一个名称相同的其他内核对象,例如事件对象、信号量对象,则函数调用会失败,返回值为NULL,调用GetLastError函数将返回ERROR_INVALID_HANDLE。

如前所述,在执行操作共享资源的代码前,应该调用WaitForSingleObject函数等待互斥量对象变成有信号状态。如果有其他线程正在拥有互斥量对象的所有权,则函数会一直等待。当没有任何线程拥有互斥量对象的所有权时,函数返回,并拥有互斥量对象的所有权,接下来即可进行对共享资源的独占操作。

执行完操作共享资源的代码后,应该调用ReleaseMutex释放对互斥量对象的所有权。

可以通过调用OpenMutex函数打开一个已经存在的命名互斥量对象:

HANDLE WINAPI OpenMutex(
     _In_ DWORD   dwDesiredAccess,    // 互斥量对象访问权限,一般设置为NULL
     _In_ BOOL    bInheritHandle,     // 在创建新进程时是否继承返回的句柄,TRUE或FALSE
     _In_ LPCTSTR lpName);            // 要打开的互斥量对象的名称,区分大小写

如果没有找到这个名称的互斥量对象,函数将返回NULL,GetLastError返回ERROR_FILE_NOT_FOUND;如果找到了这个名称的一个内核对象,但是类型不同,函数将返回NULL,GetLastError返回ERROR_INVALID_HANDLE;如果名称相同,类型也相同,函数将返回互斥量对象的句柄。调用CreateMutex和OpenMutex函数的主要区别在于,如果互斥量对象不存在,CreateMutex函数会创建它;OpenMutex函数则不同,如果对象不存在,函数将返回NULL。调用CreateMutex或OpenMutex函数打开一个已经存在的命名互斥量对象,都会导致互斥量对象的引用计数加1。

创建或打开互斥量对象后,不再需要时应该调用CloseHandle函数关闭互斥量对象句柄。

下面使用互斥量对象改写 ThreadSync 程序,Chapter1\ThreadSync_Mutex\ThreadSync\ThreadSync. cpp源文件的内容如下:

#include <windows.h>
#include "resource.h"

// 常量定义
#define NUM 2

// 全局变量
int g_n;
HANDLE g_hMutex;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
DWORD WINAPI ThreadProc(LPVOID lpParameter);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     HANDLE hThread[NUM];

     switch (uMsg)
     {
     case WM_INITDIALOG:
          // 创建互斥量对象
          g_hMutex = CreateMutex(NULL, FALSE, NULL);
          break;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_START:
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), FALSE);
               g_n = 10;       // 创建线程执行线程函数以前将全局变量g_n赋值为10
               for (int i = 0; i < NUM; i++)
                    hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

               WaitForMultipleObjects(NUM, hThread, TRUE, INFINITE);
               for (int i = 0; i < NUM; i++)
                    CloseHandle(hThread[i]);

               // 所有线程结束后,将g_n的最终值显示在编辑控件中
               SetDlgItemInt(hwndDlg, IDC_EDIT_NUM, g_n, TRUE);
               EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), TRUE);
               break;

          case IDCANCEL:
               // 关闭互斥量对象句柄
               CloseHandle(g_hMutex);
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     for (int i = 1; i <= 1000000; i++)
     {
          // 等待互斥量
          WaitForSingleObject(g_hMutex, INFINITE);
          g_n++;
          g_n--;
          // 释放互斥量
          ReleaseMutex(g_hMutex);
     }

    return 0;
}

具体代码参见Chapter1\ThreadSync_Mutex项目,使用互斥量对象后,执行速度会很慢,读者可以减少循环次数,例如100万次。

互斥量是内核对象,不同进程中的线程可以访问同一个互斥量,而关键段是用户模式下的线程同步对象,互斥量比关键段在速度上要慢得多。但是作为内核对象,互斥量对象有更多的用途,例如有的程序会利用命名内核对象来防止运行一个应用程序的多个实例,有些游戏程序不允许同时运行两个程序,用户无法在两个程序中登录不同的账号刷积分。这只需要在程序的开头调用Create*函数来创建一个命名内核对象(具体创建什么类型的内核对象无关紧要),Create*函数返回后,立即调用GetLastError函数。如果GetLastError返回ERROR_ALREADY_EXISTS,表明应用程序的另一个实例已经在运行,新的实例即可退出。例如:

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     HANDLE g_hMutex = CreateMutex(NULL, FALSE, TEXT("{FA531CC1-0497-11d3-A180- 00105A276C3E}"));
     if (GetLastError() == ERROR_ALREADY_EXISTS)
     {
          // 已经有一个程序实例正在运行
          MessageBox(NULL, TEXT("已经有一个程序实例正在运行"), TEXT("提示"), MB_OK);
          CloseHandle(g_hMutex);
          return 0;
     }

     // 程序的第一个实例
     // 程序正常执行
     // ...
}

具体代码参见Chapter1\HelloWindows项目。

3.信号量对象

信号量对象(Semaphore)是一个允许指定数量的线程同时拥有的内核对象,信号量对象通常用于线程排队。内核对象的数据结构中通常包含安全描述符和引用计数字段,其他字段则根据内核对象的不同而有所不同。信号量对象还有两个计数值:最大可用资源计数和当前可用资源计数,最大可用资源计数表示允许同时有多少个线程拥有信号量对象,当前可用资源计数表示当前还可以有多少个线程拥有信号量对象。信号量对象同样有两种状态:有信号状态和无信号状态,如果信号量的当前可用资源计数值大于0为有信号状态,如果信号量的当前可用资源计数值等于0则为无信号状态。当前可用资源计数值不会小于0,也不会大于最大可用资源计数。

例如,一个服务器程序创建了3个工作线程,可以同时处理3个客户端的请求。这种情况下可以创建一个最大可用资源计数为3的信号量对象,初始情况下当前可用资源计数为3,当有一个客户端请求时,需要等待信号量对象,等待成功以后执行工作线程,同时当前可用资源计数值减1,在当前可用资源计数值为0时,其他所有客户端请求只能处于等待状态,当工作线程处理完一个客户端请求后,应该释放信号量对象使当前可用资源计数值加1。

与使用互斥量对象类似,使用信号量对象进行线程间同步,涉及以下4个函数:

// 创建信号量对象
HANDLE WINAPI CreateSemaphore(
     _In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,    // 同其他创建内核对象函数的相关参数
     _In_     LONG                      lInitialCount,         // 信号量对象的当前可用资源计数
     _In_     LONG                      lMaximumCount,         // 信号量对象的最大可用资源计数
     _In_opt_ LPCTSTR                  lpName);              // 同其他创建内核对象函数的lpName参数
// 等待信号量
WaitForSingleObject
// 释放信号量
BOOL WINAPI ReleaseSemaphore(
     _In_      HANDLE   hSemaphore,               // 信号量对象句柄
     _In_      LONG     lReleaseCount,            // 当前可用资源计数增加的量,通常设置为1
     _Out_opt_ LPLONG lpPreviousCount);        // 返回先前的当前可用资源计数值
// 关闭信号量对象句柄
CloseHandle

为了获得对共享资源的访问权,线程需要调用等待函数并传入信号量对象的句柄,等待函数会检查信号量对象的当前可用资源计数,如果值大于0(信号量对象处于有信号状态),则函数会把当前可用资源计数值减1并使调用线程继续运行。如果等待函数发现信号量对象的当前可用资源计数为0(信号量对象处于无信号状态),则系统会使调用线程进入等待状态,当另一个线程将信号量对象的当前可用资源计数递增时,系统会使等待的线程变成可调度状态(并相应地递减当前可用资源计数)。

下面使用信号量对象改写ThreadSync程序。为了使一个线程独占对共享资源的访问,在调用CreateSemaphore函数创建信号量对象时,将当前可用资源计数和最大可用资源计数参数都设置为1,在执行操作共享资源的代码前调用WaitForSingleObject函数等待信号量对象变为有信号,执行完操作共享资源的代码以后调用ReleaseSemaphore函数使当前可用资源计数值递增1。Chapter1\ThreadSync_Semaphore\ThreadSync\ThreadSync.cpp源文件的内容如下:

#include <windows.h>
#include "resource.h"

// 常量定义
#define NUM 2

// 全局变量
int g_n;
HANDLE g_hSemaphore;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
DWORD WINAPI ThreadProc(LPVOID lpParameter);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     HANDLE hThread[NUM];

     switch (uMsg)
     {
     case WM_INITDIALOG:
           // 创建信号量对象
           g_hSemaphore = CreateSemaphore(NULL, 1, 1, NULL);
           return TRUE;

      case WM_COMMAND:
           switch (LOWORD(wParam))
           {
           case IDC_BTN_START:
                EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), FALSE);
                g_n = 10;       // 创建线程执行线程函数之前将全局变量g_n赋值为10
                for (int i = 0; i < NUM; i++)
                     hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

                WaitForMultipleObjects(NUM, hThread, TRUE, INFINITE);
                for (int i = 0; i < NUM; i++)
                     CloseHandle(hThread[i]);

                // 所有线程结束以后,将g_n的最终值显示在编辑控件中
                SetDlgItemInt(hwndDlg, IDC_EDIT_NUM, g_n, TRUE);
                EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_START), TRUE);
                break;

           case IDCANCEL:
                // 关闭信号量对象句柄
                CloseHandle(g_hSemaphore);
                EndDialog(hwndDlg, 0);
                break;
           }
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     for (int i = 1; i <= 1000000; i++)
     {
          // 等待信号量
          WaitForSingleObject(g_hSemaphore, INFINITE);
          g_n++;
          g_n--;
          // 释放信号量,当前可用资源计数值递增1
          ReleaseSemaphore(g_hSemaphore, 1, NULL);
     }

     return 0;
}

信号量对象是内核对象,一些用法和事件对象、互斥量对象等是相似的,例如:在调用CreateSemaphore函数时可以指定一个名称以共享该信号量对象,在其他位置可以通过调用CreateSemaphore或OpenSemaphore函数打开该信号量对象,不再需要时应该调用CloseHandle函数关闭信号量对象句柄。

4.可等待计时器对象

可等待计时器(Waitable Timer)是一种内核对象,可以在指定的时间触发(有信号状态),也可以选择每隔一段时间触发(有信号状态)一次,通常可以用于在某个时间执行一些任务。因为是内核对象,因此其用法与前面介绍的其他内核对象类似,可等待计时器对象同样有两种状态:有信号状态和无信号状态(也称为触发状态和未触发状态)。

调用CreateWaitableTimer函数可以创建一个可等待计时器对象:

HANDLE WINAPI CreateWaitableTimer(
     _In_opt_ LPSECURITY_ATTRIBUTES lpTimerAttributes,    // 同其他内核对象的相关参数
     _In_      BOOL                     bManualReset,     // 手动重置还是自动重置,TRUE或FALSE
     _In_opt_ LPCTSTR                 lpTimerName);     // 同其他内核对象的相关参数

bManualReset参数表示要创建的是一个手动重置计时器还是自动重置计时器。当手动重置计时器被触发时,正在等待该计时器的所有线程都会变成可调度状态;当自动重置计时器被触发时,只有一个正在等待该计时器的线程会变成可调度状态。

创建计时器对象后,初始情况下计时器处于未触发状态,可以通过调用SetWaitableTimer函数触发计时器:

BOOL WINAPI SetWaitableTimer(
     _In_      HANDLE               hTimer,       // CreateWaitableTimer或OpenWaitableTimer函数返回的句柄

     _In_      LARGE_INTEGER        *pDueTime,// 指定计时器触发的时间,UTC时间
     _In_      LONG                   lPeriod,  // 指定计时器多久触发一次,以毫秒为单位
     _In_opt_ PTIMERAPCROUTINE pfnCompletionRoutine,    // 指向完成例程的指针
     _In_opt_ LPVOID               lpArgToCompletionRoutine,// 传递给完成例程的自定义数据的指针
     _In_      BOOL                 fResume);  // 系统挂起的时候是否继续触发计时器

pDueTime参数指定计时器触发的时间。可以指定一个基于协调世界时UTC的绝对时间,例如2019年8月5日17:45:00。还可以指定一个相对时间,这时需要在pDueTime参数中传入一个负值,单位是100纳秒。1秒 = 1000毫秒 = 1000 000微秒 = 1000 000 000纳秒,即1秒为10 000 000个100纳秒。

lPeriod参数表示计时器在第一次触发后每隔多久触发一次,即计时器应该以怎样的频度触发,以毫秒为单位。如果将lPeriod参数设置为一个正数,则表示计时器是周期性的,每经过指定的时间后计时器被触发一次,直到调用CancelWaitableTimer函数取消计时器或调用SetWaitableTimer函数重新设置计时器;如果将lPeriod参数设置为0,则表示计时器是一次性的,只会被触发一次。

例如,下面的代码将计时器的第一次触发时间设置为2019年8月5日17:45:00,之后每隔10秒触发一次:

SYSTEMTIME st = { 0 };
FILETIME ftLocal, ftUTC;
LARGE_INTEGER li;

st.wYear = 2019;
st.wMonth = 8;
st.wDay = 5;
st.wHour = 17;
st.wMinute = 45;
st.wSecond = 0;
st.wMilliseconds = 0;
// 系统时间转换成FILETIME时间
SystemTimeToFileTime(&st, &ftLocal);
// 本地FILETIME时间转换成UTC的FILETIME时间
LocalFileTimeToFileTime(&ftLocal, &ftUTC);
// 不要将指向FILETIME结构的指针强制转换为LARGE_INTEGER *或__int64 *类型,
li.LowPart = ftUTC.dwLowDateTime;
li.HighPart = ftUTC.dwHighDateTime;
// 设置可等待计时器
SetWaitableTimer(g_hTimer, &li, 10 * 1000, NULL, NULL, FALSE);

SetWaitableTimer函数的pDueTime参数是一个指向LARGE_INTEGER结构的指针,该结构在winnt.h头文件中定义如下:

typedef union _LARGE_INTEGER {
     struct {
          DWORD LowPart;
          LONG HighPart;
    }   DUMMYSTRUCTNAME;
     struct {
          DWORD LowPart;
          LONG HighPart;
     } u;
     LONGLONG QuadPart;
} LARGE_INTEGER;

FILETIME结构在minwindef.h头文件中定义如下:

typedef struct _FILETIME {
     DWORD dwLowDateTime;
     DWORD dwHighDateTime;
} FILETIME, *PFILETIME, *LPFILETIME;

系统时间SYSTEMTIME无法直接赋值给LARGE_INTEGER结构,因此需要先调用SystemTimeToFileTime函数将系统时间转换为FILETIME时间。SetWaitableTimer函数的pDueTime参数需要的是一个基于UTC的时间,因此还需要调用LocalFileTimeToFileTime函数将本地FILETIME时间转换成UTC的FILETIME时间,然后可以把UTC的FILETIME时间赋值给LARGE_INTEGER结构的相应字段。

虽然FILETIME结构与LARGE_INTEGER结构类似,但是FILETIME结构的地址必须对齐到32位(4字节)边界,而LARGE_INTEGER结构的地址必须对齐到64位(8字节)边界,因此不可以直接把指向FILETIME结构的指针强制转换为指向LARGE_INTEGER结构的指针。

用户还可以指定一个相对时间,这时需要在pDueTime参数中传入一个负值,单位是100纳秒,例如下面的代码将计时器的第一次触发时间设置为SetWaitableTimer函数调用结束的60秒后,之后每隔10秒触发一次:

const int nSecond = 10000000;

li.QuadPart = -(60 * nSecond);
// 设置可等待计时器
SetWaitableTimer(g_hTimer, &li, 10 * 1000, NULL, NULL, FALSE);

如果需要重新设置计时器的触发时间或频率,只需要再次调用SetWaitableTimer函数。如果需要取消计时器,可以调用CancelWaitableTimer函数,之后计时器不会再被触发:

BOOL WINAPI CancelWaitableTimer(_In_ HANDLE hTimer);

可等待计时器对象的简单示例程序参见Chapter1\WaitableTimer项目。

微信扫码关注【异步社区】微信公众号,回复“e57159”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


相关图书

云原生测试实战
云原生测试实战
Kubernetes快速入门(第2版)
Kubernetes快速入门(第2版)
Kubernetes零基础实战
Kubernetes零基础实战
深入浅出Windows API程序设计:编程基础篇
深入浅出Windows API程序设计:编程基础篇
云原生技术中台:从分布式到云平台设计
云原生技术中台:从分布式到云平台设计
持续集成与持续交付实战:用Jenkins、Travis CI和CircleCI构建和发布大规模高质量软件
持续集成与持续交付实战:用Jenkins、Travis CI和CircleCI构建和发布大规模高质量软件

相关文章

相关课程