[C++]《Windows核心编程》读书笔记
这篇笔记是我在读《Windows核心编程》第5版时做的记录和总结(部分章节是第4版的书),没有摘抄原句,包含了很多我个人的思考和对实现的推断,因此不少条款和Windows实际机制可能有出入,但应该是合理的。开头几章由于我追求简洁,往往是很多单独的字句,后面的内容更为连贯。
海量细节。
第1章 错误处理
1. GetLastError返回的是最后的错误码,即更早的错误码可能被覆盖。
2. GetLastError可能用于描述成功的原因(CreatEvent)。
3. VS监视窗口err,hr。
4. FormatMessage。
5. SetLastError。
第2章 字符和字符串处理
1. ANSI版本的API全部是包装Unicode版本来的,在传参和返回是多了一次编码转换。
2. MS的C库的ANSI和Unicode版本之间也是没有互相调用关系的,没有编码转换开销。
3. 宽字符函数:_tcscpy,_tcscat,_tcslen。
4. UNICODE宏是Windows API使用的,而MS的C库中,对于非标准的东西用_前缀区分,所以_UNICODE宏是MS的C API使用的。
5. MS提供的避免缓冲区溢出攻击的函数在<StrSafe.h>文件中,包括StringCbCat和StringCchCat等函数(其中Cb表示Count of Byte,Cch表示Count of Character,都用于表示衡量目标缓冲大小的单位);另外<TChar.h>中有_tcscpy_s等_s后缀的函数。在源串过短时,<StrSafe.h>的函数截断,<TChar.h>的函数断言。
6. 要想接管CRT的错误处理(比如assert),使用_set_invalid_parameter_handler设置自己的处理函数,然后使用_CrtSetReportMode(_CRT_ASSERT, 0);来禁止CRT弹出对话框。
7. Windows也提供了字符串处理函数,但lstrcat、lstrcpy(针对T字符的)已经过时了,因为没考虑缓冲区溢出攻击。考虑使用StrFormatKBSize、StrFormatByteSize、CompareString(有很多比较选项)、CompareStringOrdinal(相当于_tcscmp)。
8. GetThreadLocale返回线程的语言信息:LCID(Locale ID),供很多函数使用(包括使用CompareString针对语言来比较的时候)。
9. 宽字节转多字节WideCharToMultiByte,反之MultiByteToWideChar。其中,在宽字节转多字节的时候,如果有Unicode字符在多字节编码中没有对应项,那宽字节会被替换成参数lpDefaultChar,并且lpUsedDefaultChar会被标记为TRUE。当用这两个函数计算结果串的大小时,返回的是字符数。
10. IsTextUnicode。
第3章 内核对象
1. 简单区分内核对象和其他对象的方法:创建需要安全信息的多半是内核对象。
2. 每个进程有一个内核对象表,表的每一项是一个简单结构,包括真实内核对象地址和访问权限等。用户代码持有的内核对象句柄其实是对象表中对应项的索引。因此如果CloseHandle关闭一个对象后没有清空变量,且在对象表的同样位置恰好又创建了一个新的内核对象,对之前没清空的无效变量的访问会造成bug。(比如对同一个句柄多调用了一次CloseHandle导致另一个内核对象被关闭。)
3. 进程退出时,会释放各种内存、内核对象、GDI对象等。
4. 跨进程使用内核对象的理由:跨进程传输:用文件映像对象实现共享内存、邮件槽和命名管道实现数据通信、信号量和互斥量进行同步等。
5. 跨进程使用内核对象的三种方式:对象句柄继承、命名内核对象、复制对象句柄。
6. 对象句柄继承:创建内核对象的时候可以指定SECURITY_ATTRIBUTES. bInheritHandle表示可继承(任何时候可以使用SetHandleInformation修改可继承性等属性),创建子进程时指定CreateProcess的参数bInheritHandles为TRUE,则子进程从父进程的对象表中拷贝所有可继承的对象到自己的对象表的相同表位置中(并增加引用计数),因为表项结构被完全拷贝且内核对象实际地址在地址空间后2G的内核地址段中,所以拷贝过来的表项完全有效,进而父子进程的可继承内核对象的句柄值完全相同,于是只要以任何方式将要继承的对象的句柄值跨进程交给子进程(创建子进程时的命令行参数、环境变量、共享内存、消息等手段),则后者可以使用。
7. 命名内核对象:要访问已经存在的命名内核对象,可以使用CreateXXX或者OpenXXX,后者在对象不存在的时候返回NULL。如果打开了一个已经存在的命名对象,在打开时为API指定的对象名以外的参数被忽略。注意,一个进程打开同一对象两次,除了增加引用两次外,返回的句柄值是不同的,需要分别关闭一次,即打开和关闭完全对称(很合理的行为)。在Vista及以上的系统,对象名可以包括在命名空间下,避免被低授权用户访问。
8. 复制对象句柄:DuplicateHandle。
第4章 进程
1. 进程是执行文件的运行时形态。包括两部分:内核数据(对应内核对象)、地址空间(包括执行文件代码和栈堆等动态内存)。
2. 把VC的“系统-子系统”值删除掉,即不指定控制台或GUI,则编译器会根据代码中存在main或者WinMain来自动选择子系统(这里不谈Unicode了),很方便。
3. 启动程序:根据子系统执行mainCRTStartup/WinMainCRTStartup,在该函数中干几件事(1)准备命令行和环境变量(用于char *argv[]和char *env[])(2)初始化CRT的全局变量(包括_osver、_winmajor、_winver、__argc、_environ等)(3)初始化CRT运行库的内存分配(malloc、free)、IO函数等(4)初始化全局对象调用C++构造函数。
4. 退出程序:main返回后mainCRTStartup会调用exit,exit干以下几件事:(1)执行通过_onexit注册的函数(2)执行全局对象的C++析构函数(通过atexit注册的)(3)判断_CrtDumpMemoryLeaks设置的内存泄漏检测标志,尝试检测内存泄漏(4)调用ExitProcess。
5. HINSTANCE和HMOUDLE完全相同,都是表示映像文件加载到内存后的基址(链接器中可以配置)。GetModuleHandle传入文件名可以获得模块基址;传入NULL可以得到执行文件的HINSTANCE(即使调用者位于某个模块中同样返回应用程序基址);GetModuleHandleEx可以根据函数地址得到模块基址
6. 访问环境变量:char *env[]参数、GetEnvironmentStrings、GetEnvironmentVariable、ExpandEnvironmentStrings(将一个使用了类似”%USERPROFILE%”环境变量的字符串中的变量替换成值)。
7. 系统环境变量:HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Enviroment。用户环境变量:HKEY_CURRENT_USER\Enviroment。
8. 修改环境变量后可以通知相关的系统窗口(如控制面板等):SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, (LPARAM) “Enviroment”)。
9. 可以设置特定线程在一个CPU核心集合上执行。
10. SetErrorMode。设置该进程如何响应各种错误。
11. 关于相对路径:在通过GetEnvironmentStrings返回的环境变量中,有一部分不是真正的环境变量,比如“=C:=C:\Windows”“ =F:=F:\Projects\Test05”,他们表示一种进程相关配置“本进程在特定驱动器下对应的当前文件夹”。一个进程除了有以上配置外,还有一个当前驱动器,最终GetCurrentDirectory返回的当前路径就是当前驱动器+当前驱动器对应的当前文件夹。使用SetCurrentDirectory会改变该驱动器的当前文件夹,还会改变进程的当前驱动器(但这个API的改变并不会在GetEnvironmentStrings上体现出来,使用C函数_chdir可以同时改变两者,故C函数更优)。进程刚启动时,如果不考虑从父进程继承的环境,则只有进程当前驱动有当前文件夹,其他驱动都无配置。使用相对路径访问文件的时候,其绝对路径可以用GetFullPathName得到。”文件名”这样的相对路径的绝对路径是GetCurrentDirectory() + “文件名”;”驱动器盘符:文件名”(注意不是”驱动符:/文件名”)这样的相对路径的绝对路径就是”该驱动器的当前文件夹”(如果无配置,则是根目录) + “文件名”。
看如下代码:
_chdir("D:/Downloads"); // 修改D:的当前路径为Downloads,且进程当前驱动器为D:
_chdir("F:/Projects"); // 修改F:的当前路径为Projects,且进程当前驱动器为F:
std::ofstream("1.txt"); // 当前驱动器是F:,所以绝对路径是F:/Projects/1.txt
std::ofstream("d:1.txt"); // D:的当前路径是Downloads,所以绝对路径是D:/Downloads/1.txt
这种行为从cmd的cd命令也可以看得出点端倪。
归纳:相对路径访问文件的时候,首先将相对路径展开成绝对路径,使用GetFullPathName,后者分两步:首先判断是否包含驱动器(以X:开头),如果没有,则在开头添加进程当前驱动器;然后检查是否以”X:/”开头,如果没有,则将”X:”展开成”X:/” + “对应驱动的当前文件夹”。两步过后得到绝对路径。
12. GetVersionEx获取系统版本信息。VerifyVersionInfo检测当前系统是否满足版本需要。
13. CreateProcess的参数:关于lpApplicationName和lpCommandLine,有两种用法:(1)前者指定应用程序路径,后者指定参数(第一个参数前面要有一个空格,似乎底层会直接连接两个串)(2)前者为NULL,后者指定路径和参数,空格隔开。常用第二种方法。注意,lpCommandLine中由于是用空格分隔参数的,所以对其中含有空格的路径一定要用内层引号括起来。另外CreateProcessW有一个奇怪的行为,它会修改参数lpCommandLine(似乎只在lpApplicationName为空的时候会修改),所以使用Unicode版本的时候传入的该参数不能是常字符串(如L”Nodepad 1.txt”),而应该另外准备缓冲传给该API供其修改,因为ANSI版本是调用Unicode版本的且在编码转换的时候内置了缓冲,所以CreateProcessA的lpCommandLine参数可以是常串(最终API会修改转换编码的临时缓冲)。默认情况下,CUI的CUI型子进程会和父进程共享控制台,在参数dwCreationFlags中添加DETACHED_PROCESS或CREATE_NEW_CONSOLE标志可以阻止这种行为。在dwCreationFlags中添加CREATE_NEW_PROCESS_GROUP标志,可以控制进程组的组织,用户按下Ctrl+C的时候同一进程组的所有进程得到通知。lpEnvironment指定为NULL的时候,底层为用GetEnvironmentStrings来填充。lpCurrentDirectory为NULL的时候,子进程继承父进程的当前目录。lpStartupInfo不能为空,至少要初始化结构为0并将cb赋为sizeof。使用STARTUPINFOEX结构作为lpStartupInfo参数,还可以具体指定子进程要继承哪些父进程的可继承内核对象(即使bInheritHandles参数为FALSE)。
14. cmd进程输入命令行前显示的路径,就是其当前路径(GetCurrentDirectory)。在CreateProcess时,cmd没有设置子进程当前路径,而资源管理器将路径设置成子进程镜像目录。因为cmd的子进程会继承cmd的当前路径(lpCurrentDirectory为空的结果),因此最好在用cmd启动程序的时候先将cmd的当前路径设置为新进程的镜像路径。
15. 进程和线程结束后,句柄对象被标记为激活, WaitForSingleObject会返回。
16. CreateProcess后,可以使用WaitForInputIdle或类似函数来等待新进程初始化环境完毕开始运行。
17. WoW64:Windows 32 On Windows 64。所有64位windows运行着这个虚拟机,用来执行32位程序。判断一个32位程序是否是运行在64位系统的32位虚拟机中:IsWow64Process。
18. 父进程创建子进程时使用的lpStartupInfo,在子进程中可以使用GetStartupInfo来查询。
19. 创建一个子进程时,进程和主线程本身的存在就有了引用1,而调用CreateProcess的父进程又会有他们的引用所以计数到了2。要完全销毁进程和线程,需要计数为0,所以除了需要进程本身结束外,引用的该进程的其他线程也要释放引用。当然,CreateProcess过后父进程马上CloseHandle并不会结束子进程,只是释放自己的引用,使其计数为1,这是正常的行为。要确保某个进程或线程不被销毁,不调用CloseHandle即可。如果进程本身已经退出了,但还有其他进程引用它,则它的地址空间被回收,只有内核对象还存在(比如这时再对句柄使用API查看内存,则内存信息为空),这也是为什么可以查看已经退出的进程的退出码的原因(退出码保存在内核对象中)。
20. 进程和线程的ID位于同一个系统顶层名空间。即任意进程的任意线程ID绝不可能和任意进程ID相同。这个ID会被系统循环利用。
21. GetProcessIDOfThread。
22. 进程只有在它所有线程都结束后才会结束。ExitProcess会杀死所有线程,所以可以直接结束进程,在主线程中调用ExitThread只会结束主线程(即,主线程创建一个死循环线程后自己_exitthreadex,这个进程不会退出。)。main返回后CRT调用exit后者再调用ExitProcess,所以在main中return可以直接结束进程。
23. 通过ExitProcess或ExitThread(单线程时)结束进程,由于这些API比CRT更底层,他们只能保证正确的释放Windows资源(内存、内核对象引用),并不保证释放C++资源(CRT底层资源、全局对象的析构函数),故一定要从main中返回自然的结束进程(其他原因在后面章节说明)。TerminateProcess也出于相同的原因应该避免使用。
24. CreateProcess创建的子进程会继承父进程的Security Token权限,而ShellExecuteEx可以提高子进程的权限(令lpVerb参数为”runas”)。资源管理器使用前者创建子进程,所以通过它开打的程序都具有和资源管理器相同的权限。
25. 关于Vista及更高系统的UAC(User Account Control):Vista以前的系统如果以管理员账号登陆,资源管理器(Explorer)会获得一个管理员权限的Security Token,然后从资源管理器打开的子进程都会继承这个最高权限,这种行为非常危险。Vista以后,即使以管理员账号登陆,资源管理器仍然只持有一个一般权限的Token(Filtered Token),子进程如果想提升权限,有两种途径:(1)用户“以管理员身份运行”启动该进程(2)子进程自己提出请求要求用户提升权限(子进程是安装程序、或者子进程配置有.manifest文件说明权限需求)。另外,在很多软件中出现有小盾牌图标的按钮,也是要求提高权限,点击过后会结束当前进程,重启一个高权限进程(如资源管理器中“显示所有用户的进程”按钮)。其实这三种提高权限都是父进程调用了ShellExecuteEx。
26. IsUserAnAdmin判断当前用户是否是管理员。在Vista及以上的系统中,即使是管理员,进程也有可能因为筛选Token而不具备最高权限。
27. 枚举所有进程:Process32First、Process32Next、EnumProcesses。
28. 可以从HMOUDLE中读取IMAGE_DOS_HEADER和IMAGE_NT_HEADERS,进而从这些PE头中取得模块的推荐加载地址等信息。
29. PEB(Process Enviroment Block)包含了进程的启动命令行、当前路径等数据。该字段可以通过NtQueryInformationProcess的PROCESS_BASIC_INFORMATION参数取得。
30. 可以通过WinDbg的dt命令,查看一些结构的具体成员布局,如PEB等。
31. Windows完整性机制(Windows Integrity Mechanism):这是UAC之外的另一套安全机制,Windows通过在系统访问控制表(SACL, System Access Control List)中增加访问控制项(ACE, Access Control Entry)实现,每一种受保护的资源都有对应的完整性级别(Integrity Level),每个进程都有一个基于Token计算的完整性级别,如果进程的级别小于资源的级别,则不能访问资源。提升Token权限之前的进程级别为中,提升后为高,而像IE这样可以能执行网络代码的进程为低。可以通过GetTokenInfomation查看一些和完整性级别相关的策略。窗口系统也根据完整性级别,拒绝低级别者向高级别使用PostMessage、SendMessage等API。
32. Vista以上有一些进程是特殊的受保护进程,ToolHelp API对他们无效,因此无法查看进程信息。
33. GetProcessTime查看进程时间,GetProcessIoCounters查看IO次数。
34. GetProcessImageFileName返回内核格式的文件名。
第5章 作业
1. Job(作业),也就是进程组的概念,添加进同一个作业的进程能够通过作业内核对象来集中控制,设置一些额外的属性等。添加进一个作业就不能再移出。
2. IsProcessInJob、CreateJobObject、OpenJobObject。
3. 作业内核对象在它内部的所有进程都结束后才会被销毁。
4. 细节:当客户的作业句柄变量都被关闭后,即使作业对象还存在(因为进程没有全部结束),也不能再通过作业名打开作业再操作了。
5. Vista以上,通过任务管理器创建的进程,都被添加进了一个独立的作业;从命令行(cmd)创建的进程则不然。
6. 能够对作业添加的限制:基本限制(限制进程时间、优先级、物理内存占用等)、扩展限制(基础限制之上,还能限制内存使用总量,以及查看峰值内存使用)、UI限制(限制关机/重启、访问剪切板、切换桌面、改变显示器设置、访问作业外进程的句柄等)、安全限制(安全限制一旦设置,则不能修改)。SetInformationJobObject、QueryInformationJobObject用于设置和查询限制。
7. AssignProcessToJobObject添加进程到作业。
8. 父进程位于某一作业中,子进程创建后也自动加入同一作业。除非作业的基本限制中包含JOB_OBJECT_LIMIT_BREAKAWAY_OK(允许进程时脱离作业),并且CreateProcess时指定CREATE_BREAKAWAY_FROM_JOB标记。
9. TerminateJobObject强制结束作业,同时结束作业内所有进程(等价于对作业内每个进程调TerminateProcess)。
10. QueryInformationJobObject除了查看作业限制外,也可以查看作业信息,包括总进程数、活跃进程数、总时间、总IO次数、进程ID列表等。
11. 作业结束后(所有内部进程结束),内核对象处于激活态,WaitForSingleObject返回。
12. 作业通知机制:将作业对象和IO完成端口绑定,作业中的事件(进程结束、时间到期、内存达到限制等)将通过完成端口事件来通知。
第6章 线程基础
1. 像进程一样,线程在数据上也分为两个部分:线程内核对象(包括统计信息)、栈。(进程的两个部分是,内核对象和地址空间)。---
2. 比起ExitThread和TerminateThread,应该让线程的主函数返回来结束线程,否则一些栈对象不能正常析构(这里不再考虑CRT函数)。
3. 在C/C++编程中不要使用CreateThread、ExitThread,应该使用编译器厂商提供的包装函数,如MS的_beginthreadex、_endthreadex。因为使用前者,C/C++的CRT不能正常初始化和释放线程相关资源(C/C++中有一些全局变量如errno和一些有内部状态的函数strtok、asctime都需要通过TLS来正确实现,毕竟C库函数的诞生早于多线程)。事实上,如果在C/C++中使用了CreateThread和EndThread,部分有内部状态的函数还是可以正常使用的,因为这些函数内部会尝试取得TLS,发现还未分配的话会自动分配,CRT的Dll版本库也会在得到线程退出通知时尝试释放TLS,只是因为这份TLS是中途分配的信息不够全面,部分状态函数还是会有问题,因此在C/C++中还是要尽量使用后者。
4. 线程栈最大为CreateThread的dwStackSize参数和/STACK链接选项(VC中默认为1MB)两者中的较大值。
5. TerminateThread的一些细节:该函数是异步的,函数返回时,线程还没有结束,需要WaitForSingleObject;DllMain不会收到被Terminate线程的结束通知。
6. 只有当线程函数结束(正常返回或Exit掉)后,该线程的栈空间才会被回收(也就是说TerminateThread函数刚返回时被杀死线程栈空间还在,直到线程对象处于激活态)。
7. 对进程中的各个线程来说,ExitProcess和TerminateProcess都将导致对线程的TerminateThread调用,因此进程的main函数结束前,尽量确保工作线程都正常退出。
8. 大部分的资源都是进程相关的,窗口句柄和hook句柄是线程相关的,线程退出时会释放他们(在C/C++中还有CRT的TLS变量)。
9. GetCurrentProcess、GetCurrentThread返回的都是伪句柄,如果想要把这个句柄保存下来在其他线程、进程中使用的话,是有歧义的,可以用保存ID来代替,如果一定要保存句柄的话,两种方法:(1)DuplicateHandle(2)先GetCurrentThreadID,再OpenThread。
第7章 线程调度、优先级和关联性
1. Windows线程调度的时间间隔(发生上下文切换的时间片)大概是15毫秒(GetSystemTimeAdjustment的lpTimeIncrement参数)。
2. 每个线程都有一个挂起计数,当计数非0的时候,该线程不参与线程调度。CreateThread、CreateProcess传入特定的参数可以使计数初始化为1。SuspendThread可以增加计数,ResumeThread可以减少计数,两者都返回新的挂起计数。显然线程无法对自身调用ResumeThread。
3. 调试进程的WaitForDebugEvent返回后,被调试进程的所有线程被挂起,直到调试进程调用ContinueDebugEvent。
4. Sleep的休眠时间可能不精确,取决于线程调度时间片大小(一般是15毫秒左右)以及其他线程的运行情况。
5. Sleep(0)和SwitchToThread的区别在于:如果存在另一个更低优先级的线程,前者不会将CPU让出,而后者会。即如果存在多个线程,SwitchThread总是让出CPU。
6. YieldProcessor用于支持超线程技术的CPU切换超线程。
7. GetThreadTimes、GetProcessTimes返回指定线程或进程的内核代码时间和用户代码时间(两者都是绝对的CPU执行代码时间,不包括调度过程中的中断时间以及主动的Sleep或者Wait时间)。因此在对代码段计时的时候,使用GetThreadTimes明显优于GetTickCount等,因为后者得出的时间包括了其他线程的时间片。
8. 用于计时,最基本的有clock、GetTickCount、timeGetTime等;为了地提高精度,可以使用QueryPerformanceCounter;为了去掉因线程调度中断的时间和Sleep、Wait的时间,可以使用GetThreadTimes、GetProcessTimes等。在Vista以上的系统中,有新的机制,可以使用ReadTimeStampCounter(对应GetTickCount)、QueryThreadCycleTime(不考虑中断休眠,对应GetThreadTimes)、QueryProcessCycleTime等。对于没有考虑线程调度影响的函数,可以先用SetThreadPriority提高优先级尽量独占时间片。应该确保每次调用QueryPerformanceCounter的时候在同一CPU核心上,使用SetThreadAffinityMask。
9. 线程上下文(CONTEXT)保存在线程的内核对象数据中,主要包括线程相关的CPU寄存器状态等。上下文有两份,分别记录内核和用户模式,GetThreadContext只能返回用户模式上下文,在调用该函数前应该确保用户上下文不再改变了,即线程正处于内核态或者虽然在用户态但已经调用过SuspendThread。
10. 先SuspendThread、再SetThreadContext改变线程上下文,可以改变执行流等,一般用于调试器 “跳到指定位置执行” 的功能等。
11. 高优先级线程可以被调度时(没有Sleep、Wait等),低优先级线程得不到时间片;即使低优先级线程正在执行,一旦有高优先级线程可以调度,前者会被中断并让出CPU资源。
12. SetPriorityClass设置进程的优先级类,SetThreadPriority设置线程的相对优先级(相对于进程优先级类),二者共同决定线程的实际优先级(这个映射根据Windows版本不同而异,是一个0~31的整数,用户不可访问)。将线程的实际优先级设置为最高(31)是危险的,因为它将抢占系统资源,导致IO不能响应等。
13. 当线程有IO事件或消息到来时,操作系统会暂时提高线程的优先级;或者线程可调度但长时间(数秒)都得不到时间片的时候,系统也会暂时提高线程优先级。可以设置是否允许系统自动提升优先级:SetProcessPriorityBoost、SetThreadPriorityBoost。
14. 特定类型计算机的几个相关CPU核心之间可以共享内存缓存等,因此Windows支持设置线程关联CPU核心SetProcessAffinityMask、SetThreadAffinityMask。当然这组API也可以用于为特定线程提供专用CPU资源以提高性能。子进程默认继承父进程的核心关联设置。
15. SetThreadIdealProcessor设置线程最多可以使用的闲置CPU数量。该设置会覆盖AffinityMask。
16. 进程的默认AffinityMask可以在镜像文件头中设置(因为没有链接选项只有手工写文件):ImageLoad->GetImageConfigInformation->ilcd.ProcessAffinityMask->SetImageConfigInformation->ImageUnload。
第8章 用户模式下的线程同步
1. Interlocked系列函数:InterlockedIncrement(对应++)、InterlockedExchangeAdd(对应+=)、InterlockedExchange(对应=)、InterlockedCompareExchange(cas)。
2. _aligned_malloc可以指定分配内存的对齐边界。
3. spinlock(自旋锁)是CAS的应用。使用自旋锁的时候因为有while(true) { …; Sleep(0); }这样的循环,因此线程优先级不能太高,使用SetThreadPriorityBoost来禁用优先级提升,避免被自动提升后不会让出CPU(或者使用SwitchToThread)。自旋锁适用于单个线程不会占用资源太久的情况(因为一个线程占有资源期间,其他线程在循环检测浪费CPU)。
4. CAS(InterlockedCompareExchange)必须是原语!必须!用C++编写的CAS是不行的。
5. InitializeSListHead、InterlockedPushEntrySList、QueryDepthSList等API可以以Interlocked的方式操作一个单链表。
6. CacheLine:是Cache和内存通信的基本单位,可能是32/64字节等,CPU读写内存的时候会先将对应的CacheLine加载进Cache,修改完成后Flush到内存上。因此数据组织为CacheLine Size对齐、以及将只读和读写数据分别组织到不同的CacheLine都能提高效率。多个CPU(或者具有独立Cache的多个CPU核心)访问同一地址时,该地址附近的数据会被多个Cache映射成各自的CacheLine,如果其中某个CPU修改了其CacheLine的数据,该CPU会通知其他CPU更新各自的CacheLine,这种行为会影响性能,故尽量避免跨线程共享数据以及利用AffinityMask尽量使用同一个CPU。
7. GetLogicalProcessorInformation提供CPU描述信息(比如能够查询到包括4个CPU核心,3级Cache,1、2级Cache为各个核心独有,3级Cache为共享Cache,其Cache Line Size为64字节等)。
8. 所有线程都处于等待状态数分钟后,电源管理器介入。
9. volatile的作用:编译器不会将变量优化成寄存器变量,即每次读写都会访问内存。对struct应用该关键字会影响每个字段。
10. CRITICAL_SECTION内部记录了拥有访问权的线程以及引用次数。TryEnterCriticalSection如果返回TRUE,则已经增加了计数需要对称调用LeaveCriticalSection。
11. CRITICAL_SECTION在实现上结合了spinlock(自旋锁),调用EnterCriticalSection时发现资源正被占用需要切换到内核态休眠之前(切换到内核态开销很大,高达数千CPU周期),可以尝试进行一定次数的循环判断。使用InitializeCriticalSectionAndSpinCount可以启用结合自旋锁功能(作为参考,用于保护进程堆的CS的SpinCount为4000),使用SetCriticalSectionSpinCount可以修改旋转次数。当SpinCount为1的时候,关键段内部用于休眠和唤醒的事件对象会第一时间创建,而不是等到EnterCriticalSection的时候才创建。建议总是启用自旋锁。
12. Slim Reader/Writer Lock是性能比关键段更好的选择,相比后者,它的缺陷是不能递归加锁、且没有TryLock。InitializeSRWLock、AcquireSRWLockShared(申请读锁)、AcquireSRWLockExclusive(申请写锁)。
13. 在都能完成任务的情况下,性能从高到底依次是:无锁、volatile、Interlocked、SRW、CRITICAL_SECTION、内核对象(因为切换到内核态开销很大)。
14. SleepConditionVariableCS、SleepConditionVariableSRW用法:已经获得锁(CS、SRW)的线程开始在一个ConditionVariable对象上睡眠,同时释放锁;如果其他线程Wakeup这个ConditionVariable对象,则函数返回TRUE,且再度获得锁;如果超时,返回FALSE,不会获得锁。应用:消费者获得锁后发现没有产品于是开始休眠等待生产者产出产品后唤醒。
15. 技巧:按资源的逻辑个数而不是对象个数来组织锁;需要加多层锁的时候,总是按固定顺序,比如按锁的地址大小来依次加锁,避免死锁;通过拷贝资源等方式来减小锁粒度。
第9章 用内核对象进行线程同步
1. 内核对象用于线程同步更灵活比如可以设置等待时间以及跨进程等,但开销更大(需要切换到内核模式)。
2. 内核对象中都有一个表示触发状态的BOOLEAN值。
3. 进程和线程对象在结束前是非触发,结束后是触发状态,其他时候不会再改变。
4. 文件对象有正在处理的异步IO请求时处于非触发,其他时候触发。
5. 控制台输入句柄在没有输入的时候非触发。
6. 内核对象触发后,Wait在上面的线程被唤醒,决定哪一个线程首先被唤醒的规则基本上就是等待顺序的先入先出,和线程的优先级等无关。
7. PulseEvent会在Event对象上产生一个触发脉冲。近似于SetEvent(h);ResetEvent(h);两句。
8. WaitableTimer在平时处于非触发,第一次时间到或者之后周期性时间到都会处于触发状态。另外在SetWaitableTimer的时候可以传入回调指定在触发的时候往APC(Asynchronous Procedure Call)队列中加入回调,但必须定时器触发时线程正处于Alertable(使用SleepEx等带Ex的API)状态下才会入队列(避免因为回调处理太慢及其他因素导致过量入队)。一般定时器的APC和WaitFor两种模式不混用。SetWaitableTimer指定第一次的时间时,正数表示绝对时间(SystemTimeToFileTime得到),负数表示相对时间。每次调用SetWaitableTimer会自动取消上次调用的设置,故两次调用间不必CancelWaitableTimer。该定时器和基于消息的SetTimer定时器建议适时选用。
9. Semaphore的当前计数非0时处于触发。ReleaseSemaphore增加计数发现达到最大时会返回FALSE,WaitFor减少计数到0的时候会休眠。
10. Mutex和CriticalSection在使用上完全相同,都记录了Owner线程和递归次数。由于CriticalSection和Mutex记录了Owner线程,因此需要该线程来释放计数,如果在计数减少到0前线程退出了,则同步对象处于Abandoned(遗弃)状态。对于Abandoned的情况,系统能检测到发生在Mutex上的问题,并在底层自动释放计数,只是WaitFor会返回WAIT_ABANDONED表示Mutex对象的计数是由系统自动回收的,该Mutex保护的资源可能处在未定义状态。而CS的计数不会被自动释放,一旦Abandoned则CS永远的失效了。
11. WaitForInputIdle:进程中创建第一个窗口的线程的消息队列中没有需要处理的输入消息后返回。
12. MsgWaitForMultipleObjects:等待的内核对象触发后或者线程的消息队列中有相应消息后返回。
13. SignalObjectAndWait增加一个对象计数的同时原子地等待另一个对象。能够增加计数的对象只限于Event(SetEvent)、Mutex(ReleaseMutex)、Semaphore(ReleaseSemaphore),而等待的对象类型不限。使用:客户端填充好请求于是通知服务端准备处理并等待服务端处理完毕。
14. 在Vista以上可以通过WCT(等待链遍历,Wait Chain Traversal)相关API来追踪死锁。OpenThreadWaitChainSession、GetThreadWaitChain。
第10章 同步设备I/O与异步设备I/O
1. 打开设备的方式:文件-CreateFile,参数时路径名或UNC路径名。目录-CreateFile,参数为路径名或UNC路径名,另外指定FILE_FLAG_BACKUP_SEMANTICS允许改变目录属性。逻辑磁盘驱动器-CreateFile,参数为””” \\.\x:”,打开后可以格式化和检测大小等。物理磁盘驱动器-CreateFile,参数为””” \\.\PHYSICALDRIVEx”,(其中x为012等)。串口-CreateFile,参数为”” COMx”。并口-CreateFile,参数为”” LPTx”。邮件槽服务器-CreateMailSlot,参数为”\\.\mailslot\abcd”。邮件槽客户端-CreateFile,参数为””\\serverName\mailslot\abcd””。命名管道服务器-CreateNamedPipe,参数为”\\.\pipe\abcd “。命名管道客户端-CreateFile,参数为””\\serverName\pipe\abcd “。匿名管道-CreatePipe。套接字-Socket、accept、AcceptEx。控制台-CreateConsoleScreenBuffer、GetStdHandle。前面的设备路径规则:””””\\服务器\设备”,其中如果在本机的话,服务器就是”” .”。
2. SetCommConfig可以设置串口波特率等属性。
3. SetMailSlotInfo可以设置超时。
4. 一般用CloseHandle关闭设备。closesocket关闭套接字。
5. GetFileType可以返回设备的类型:FILE_TYPE_DISK-磁盘文件;FILE_TYPE_CHAR-字符文件,包括控制台和打印机等;FILE_TYPE_PIPE-命名管道或匿名管道。
6. 多次CreateFile打开同一个文件得到的是不同的内核对象,各自维护自己的文件指针等数据; DuplicateHandle得到的多个句柄仍然标志的是同一个对象。
7. CreateFile的dwShareMode参数:0表示独占,如果文件已经被打开,则本次打开失败;如果本次打开成功,在关闭前不能在其他地方打开同一个文件。FILE_SHARE_READ,如果本次打开前已经有写句柄,本次打开失败;如果本次打开成功,在关闭前在其他地方不能打开写句柄。FILE_SHARE_WRITE也类似。FILE_SHARE_DELETE表示,如果本次打开成功,其他地方又删除了文件,则删除时只是打上删除标记,待这里的句柄关闭后才真正删除。
8. CreateFile的dwFlagsAndAttributes参数:(1)关于内置缓冲。内置缓冲至少有两个作用,首先,加速,频繁的小字节块访问会被缓冲为少数大字节块的设备读写;其次,最底层设备访问需要按一定的字节块对齐(文件无缓冲读写需要按磁盘扇区大小对齐),缓冲屏蔽了这个限制,方便上层使用。FILE_FLAG_NO_BUFFERING,底层不提供缓冲,需要上层自己提供缓冲,缓冲区首地址、文件读写偏移/指针、读写字节数三者都必须按磁盘扇区大小对齐(扇区大小可以通过GetDiskFreeSpace获得,比如512字节)。文件太大有可能打开失败,也需要指定这个标记。当有缓冲时,FILE_FLAG_SEQUENTIAL_SCAN承诺会连续访问(不会用SetFilePointer),因此底层可以尝试缓冲更多连续内容;FILE_FLAG_RANDOM_ACESS表示会随机访问,因此底层会尽量不要缓冲太多(缓冲的作用还剩下避免要求扇区对齐)。FILE_FLAG_WRITE_THROUGH,表示写文件不使用缓冲,这样避免在数据Flush到文件前对象就被非法关闭导致数据丢失。(2)其他标志。(1)FILE_FLAG_DELETE_ON_CLOSE,关闭文件的时候删除,适合临时文件。FILE_FLAG_OVERLAPPED异步IO。
9. CreateFile的dwFlagsAndAttributes参数:只在创建文件的时候有效,用于指定ARCHIVE、ENCRYPTED(加密)、HIDDEN、READONLY、SYSTEM、TEMPORARY等属性
10. CreateFile的hFileTemplate参数:只在创建新文件时有效,传入另一个文件句柄的话,系统会忽略dwFlagsAndAttributes参数和直接使用该句柄对应的dwFlagsAndAttributes。
11. FILE_ATTRIBUTE_TEMPORARY和FILE_FLAG_DELETE_ON_CLOSE标记结合适用于临时文件,前者会让系统尽量将文件维护在内存而不是磁盘中,后者会在关闭句柄时删除文件。
12. 获取文件大小:GetFileSizeEx、GetCompressedFileSize(尤其针对压缩属性的文件)分别返回逻辑大小和磁盘上的实际大小。
13. SetFilePointerEx可以超出文件实际大小,超出后,除非写文件或者SetEndOfFile否则文件不会变大。
14. SetEndOfFile是减小文件的唯一手段。
15. FlushFileBuffers。
16. 在Vista以上,可以用CancelSynchronousIo来中止一个线程的同步IO。
17. 异步IO的实际访问设备顺序不一定和请求顺序(API调用顺序)相同(比如驱动会根据磁盘磁头位置选择先处理距离最近的IO请求)。
18. 对异步IO的文件发出IO请求有可能是同步操作,因为可能数据正好在底层缓冲中可以立即完成。
19. 关于取消异步IO请求:(1)CancelIo取消调用线程在指定设备上的异步IO请求。(2)线程结束会取消该线程的所有异步请求。(3)关闭设备会取消所有该设备的请求。(4)CancelIoEx能取消调用线程以外线程在指定设备上的特定请求。(5)CancelIoEx能取消特定设备的所有请求。
20. OVERLAPPED结构的Internal表示错误码,InternalHigh表示传输的字节。由于异步IO跟文件指针无关(文件指针来不及修改),所以偏移存储在该结构中。
21. GetOverlappedResult函数实现为,访问结构的Internal、InternalHigh字段,另外如果结构的hEvent为空尝试Wait设备否则Wait事件(函数参数bWait为TRUE的时候)。
22. QueueUserAPC向线程的APC队列抛出一个用户自定义函数。
23. QueueUserWorkItem向线程池抛出任务。
24. 异步IO有四种方式得到完毕通知:(1)设备内核对象触发。(2)OVERLAPPED的hEvent内核对象触发。(3)APC回调(ReadFileEx)。(4)IO完成端口。
25. 异步IO-设备内对象触发:对FILE_FLAG_OVERLAPPED的文件使用ReadFile,将OVERLAPPED的hEvent设置为空,IO完成时设备句柄将触发,因此只能同时进行一次IO(瓶颈)。可以一个线程请求,另一线程响应完成。
26. 异步IO-事件内核对象的触发:将OVERLAPPED的hEvent设置为事件以获得通知。可以用SetFileCompletionNotificationModes来避免IO完成时去触发设备对象。可以一个线程请求,另一线程响应完成。
27. 异步IO-APC队列:ReadFileEx后使用SleepEx等让线程进入Alertable状态。同一个线程发出请求和响应完成(瓶颈)。
28. 异步IO-IO完成端口:步骤(1)CreateIoComplitionPort创建完成端口,指定活跃线程数(建议为CPU核心数)。(2)用CreateIoComplitionPort向完成端口添加异步设备。(3)创建完成端口服务线程(建议为CPU核心*2个,或者动态估计),初始化后使用GetQueuedCompletionStatus使线程和完成端口绑定并休眠。(4)执行异步IO,IO完成后底层会用PostQueuedCompletionStatus令正在GetQueuedCompletionStatus上休眠的服务线程苏醒响应。细节:可以在OVERLAPPED的hEvent指定一个值为hEvent | 1的数,令IO完成后不发出完成通知(即不Post)。可以使用GetQueuedCompletionStatusEx来一次响应多个请求。完成端口服务线程中,使用GetQueuedCompletionStatus休眠的线程叫等待线程,从GetQueued…返回的线程叫释放线程(活跃线程),活跃线程如果因其他原因(如Sleep、Wait)再挂起叫暂停线程,完成端口能够检测到各个线程的数量,会控制GetQueuedCompletionStatus的返回以使活跃线程尽量逼近创建完成端口时指定的数目。默认情况下异步IO即使同步完成,也会Post…,可以使用SetFileCompletionNotificationModes来禁用Post…。对于完成事件的响应是先入先出的,但服务线程的激活却是后入先出的(尽量激活相同线程,其他线程长期休眠其栈内存可以换出到页面文件提高性能)。
第11章 线程池的使用(第4版)
1. MessageBox弹出的对话框是可用修改的,FindWindow找到后,0x0000ffff是静态文本框的控件ID等,因此很容易实现倒计时自动关闭的消息框。
2. 从win2000开始提供的线程池主要有4种用法:(1)异步调用函数(QueueUserWorkItem)。(2)定时器回调(CreateTimerQueueTimer)。(3)内核对象触发后回调(RegisterWaitForSingleObject)。(4)内置IOCP实现(BindIoCompletionCallback)。
3. 线程池模块下有几种底层线程:(1)可变数量的长任务线程,用于执行标记为WT_EXECUTELONGFUNCTION的长时间回调。(2)1个Timer线程。所有CreateTimerQueueTimer调用都被转发为在Timer线程上创建以APC方式通知的WaitableTimer,这个线程除了删除和创建WaitableTimer外,就是在Alertable态下休眠等待定时器的APC。由于这个线程一旦创建就贯穿进程生命期不会销毁,因此WT_EXECUTEINPERSISTENTTHREAD标志的线程池回调也由本线程执行。(3)多个Wait线程。服务于RegisterWaitForSingleObject,每个线程用WaitForMultipleObjects等待最多63(MAXIMUM_WAIT_OBJECTS减去一个用于维护对象数组的工作对象)个内核对象,对象触发后执行回调。(4)可变数量的IO线程。由于发出异步IO请求(ReadFileEx)后,一旦请求线程结束,请求将被撤销,因此请求被驱动执行完毕之前IO请求线程一定要存在,而线程池内的线程大都会根据CPU繁忙情况动态创建和删除,因此线程池中有一部分线程被赋予了特殊行为,他们会检测自己执行回调时发出的异步IO请求是否完成,如果没有,就不会结束运行,这些追踪自身发起的异步IO请求执行情况的特殊线程叫做IO线程。因此只能在线程池的IO线程上执行异步IO调用。(5)可变数量的非IO线程。线程池内部实现了一个IO完成端口,服务于BindIoCompletionCallback,其中IOCP的服务线程(在GetQueuedCompletionStatus上休眠)由于数量会根据CPU情况动态调整,不应用于执行异步IO,故叫非IO线程。
4. 四种用法中,如果Flags参数指定的回调执行线程与默认线程不符,底层可以使用QueueUserWorkItem来切换线程。比如CreateTimerQueueTimer用法的默认线程肯定是Timer线程,发现WT_EXECUTELONGFUNCTION标记后,使用Queue…来切换到专门执行长任务的线程避免阻塞Timer线程影响定时器功能。
5. 用法1-异步函数调用:QueueUserWorkItem 。Flags参数为0(WT_EXECUTEDEFAULT)的时候回调交给非IO线程执行(通过PostQueuedCompletionStatus通知非IO线程)。还可以指定WT_EXECUTEINIOTHREAD交给IO线程、指定WT_EXECUTEINPERSISTENTTHREAD交给Timer线程、指定WT_EXECUTELONGFUNCTION交给长任务线程等。
6. 用法2-定时器回调:CreateTimerQueue-创建专用TimerQueue。DeleteTimerQueueEx-删除专用TimerQueue,参数CompletionEvent是用于接受删除Queue完毕通知的事件对象,如果设置为NULL表示不接受通知,设置为INVALID_HANDLE_VALUE表示阻塞等待删除完成。注意不能在Timer线程上的回调中以INVALID_HANDLE_VALUE为参数调用DeleteTimerQueueEx,因为后者实现为向Timer线程抛出一个要求维护Timer列表的APC,在线程的APC回调中抛出新的APC并且还阻塞等待,结果就是死锁。CreateTimerQueueTimer-创建具体的Timer对象,TimerQueue参数指定为NULL表示在默认的Queue上创建对象,适用于Timer对象不多的用法。使用WT_EXECUTEINTIMERTHREAD标记即要求在Timer线程上执行回调,因不必切换线程效率较高,注意回调不能过长影响Timer线程的功能。ChangeTimerQueueTimer-改变Timer对象的一些参数。DeleteTimerQueueTimer-删除Timer对象,注意使用INVALID_HANDLE_VALUE参数造成死锁的可能。
7. 用法3-等待内核对象触发回调:RegisterWaitForSingleObject-在内核对象触发或超时后执行回调。标记WT_EXECUTEINWAITTHREAD表示在Wait线程上执行,效率较高。WT_EXECUTEONLYONCE只执行一次回调,适用于进程/线程句柄这种触发后不再重置的对象。PulseEvent的脉冲可能不会被Wait线程检测到(线程刚好在干其他事)。UnregisterWaitEx-取消回调,注意INVALID_HANDLE_VALUE参数可能的死锁。
8. 用法4-内置IOCP实现:BindIoCompletionCallback。将异步IO设备和内置的IO完成端口管理起来,异步完成后执行回调。标志只能为0,默认在非IO线程(IOCP的服务线程)上执行,如果需要切换线程,手工QueueUserWorkItem。