datasheet
超过460,000+ 应用技术资源下载
pdf

Windows核心编程(第5版)中文版

  • 1星
  • 日期: 2016-12-15
  • 大小: 36.42MB
  • 所需积分:1分
  • 下载次数:7
  • favicon收藏
  • rep举报
  • 分享
  • free评论
标签: Windows核心编程

Windows核心编程(第5版)中文版

下载 第一部分 程序员必读 第1章 对程序错误的处理 在开始介绍 Microsoft Windows 的特性之前,必须首先了解 Windows的各个函数是如何进 行错误处理的。 当调用一个Windows函数时,它首先要检验传递给它的的各个参数的有效性,然后再设法 执行任务。如果传递了一个无效参数,或者由于某种原因无法执行这项操作,那么操作系统就 会返回一个值,指明该函数在某种程度上运行失败了。表 1-1列出了大多数 Windows函数使用 的返回值的数据类型。 表1-1 Windows函数常用的返回值类型 数据类型 VOID BOOL HANDLE PVOID LONG/DWORD 表示失败的值 该函数的运行不可能失败。 Windows函数的返回值类型很少是 VOID 如果函数运行失败,那么返回值是 0,否则返回的是非0值。最好对返回值进行测试, 以确定它是 0还是非 0。不要测试返回值是否为 TRUE 如果函数运行失败,则返回值通常是NULL,否则返回值为HANDLE,用于标识你可 以操作的一个对象。注意,有些函数会返回一个句柄值INVALID_ HANDLE_VALUE, 它被定义为 -1。函数的Platform SDK文档将会清楚地说明该函数运行失败时返回的是 NULL还是INVALID_HANDLE_VALID 如果函数运行失败,则返回值是NULL,否则返回PVOID,以标识数据块的内存地址 这是个难以处理的值。返回数量的函数通常返回 LONG或DWORD。如果由于某种 原因,函数无法对想要进行计数的对象进行计数,那么该函数通常返回 0或-1(根据 函数而定)。如果调用的函数返回了 LONG/DWORD,那么请认真阅读 Platform SDK 文档,以确保能正确检查潜在的错误 一个Windows函数返回的错误代码对了解该函数为什么会运行失败常常很有用。 Microsoft 公司编译了一个所有可能的错误代码的列表,并且为每个错误代码分配了一个 32位的号码。 从系统内部来讲,当一个 Windows函数检测到一个错误时,它会使用一个称为线程本地存 储器(thread-local storage)的机制,将相应的错误代码号码与调用的线程关联起来(线程本地存储 器将在第 21章中介绍)。这将使线程能够互相独立地运行,而不会影响各自的错误代码。当函 数返回时,它的返回值就能指明一个错误已经发生。若要确定这是个什么错误,请调用 G e t L a s t E r r o r函数: 该函数只返回线程的 32位错误代码。 当你拥有32位错误代码的号码时,必须将该号码转换成更有用的某种对象。 WinError.h头 文件包含了Microsoft公司定义的错误代码的列表。下面显示了该列表的某些内容,使你能够看 到它的大概样子: 2计计第一部分 程序员必读 下载 如你所见,每个错误都有 3种表示法:一个消息 ID(这是你可以在源代码中使用的一个宏, 以便与 GetLastError的返回值进行比较),消息文本(对错误的英文描述)和一个号码(应该避 下载 3 第 1章 对程序错误的处理 计计 免使用这个号码,可使用消息 ID)。请记住,这里只显示了 WinError.h头文件中的很少一部分内 容,整个文件的长度超过 21000行。 当Windows函数运行失败时,应该立即调用 GetLastError函数。如果调用另一个 Windows函 数,它的值很可能被改写。 注意 GetLastError能返回线程产生的最后一个错误。如果该线程调用的 Windows函数 运行成功,那么最后一个错误代码就不被改写,并且不指明运行成功。有少数 Windows函数并不遵循这一规则,它会更改最后的错误代码;但是 Platform SDK文档 通常指明,当函数运行成功时,该函数会更改最后的错误代码。 Windows 98 许多Windows 98的函数实际上是用Microsoft公司的16位Windows 3.1产 品产生的 16位代码来实现的。这种比较老的代码并不通过 GetLastError之类的函数来 报告错误,而且Microsoft公司并没有在Windows 98中修改16位代码,以支持这种错误 处理方式。对于我们来说,这意味着 Windows 98中的许多Win32函数在运行失败时不 能设置最后的错误代码。该函数将返回一个值,指明运行失败,这样你就能够发现该 函数确实已经运行失败,但是你无法确定运行失败的原因。 有些Windows函数之所以能够成功运行,其中有许多原因。例如,创建指明的事件内核对 象之所以能够取得成功,是因为你实际上创建了该对象,或者因为已经存在带有相同名字的事 件内核对象。你应搞清楚成功的原因。为了将该信息返回, Microsoft公司选择使用最后错误代 码机制。这样,当某些函数运行成功时,就能够通过调用 GetLadtError函数来确定其他的一些 信息。对于具有这种行为特性的函数来说, Platform SDK文档清楚地说明了 GetLastError函数 可以这样使用。请参见该文档,找出 CreateEvent函数的例子。 进行调试的时候,监控线程的最后错误代码是非常有用的。在Microsoft Visual studio 6.0中, Microsoft的调试程序支持一个非常有用的特性,即可以配置 Watch窗口,以便始终都能显示线 程的最后错误代码的号码和该错误的英文描述。通过选定 Watch窗口中的一行,并键入 “@err,hr”,就能够做到这一点。观察图 1-1,你会看到已经调用了 CreateFile函数。该函数返回 INVALID_HANDLE_VALUE(-1)的HANDLE,表示它未能打开指定的文件。但是 Watch窗口 向我们显示最后错误代码(即如果调用 GetLastError函数,该函数返回的错误代码)是 0x00000002。该Watch窗口又进一步指明错误代码 2是指“系统不能找到指定的文件。”你会发 现它与WinError.h头文件中的错误代码 2所指的字符串是相同的。 图1-1 在Visual Studio 6.0的Watch窗口中键入 “@err,hr”,就可以查看当前线程的最后错误代码 4计计第一部分 程序员必读 Visual studio还配有一个小的实用程序, 称为 Error Lookup 。可以使用 Error Lookup 将错误代码的号码转换成相应文本描述(见 图1-2)。 如果在编写的应用程序中发现一个错误, 可能想要向用户显示该错误的文本描述。 Windows提供了一个函数,可以将错误代码 转换成它的文本描述。该函数称为 FormatMessage,显示如下: 下载 图1-2 Error Lookup窗口 FormatMessage函数的功能实际上是非常丰富的,在创建向用户显示的字符串信息时,它 是首选函数。该函数之所以有这样大的作用,原因之一是它很容易用多种语言进行操作。该函 数能够检测出用户首选的语言(在 Regional Settings Control Panel小应用程序中设定),并返回 相应的文本。当然,首先必须自己转换字符串,然后将已转换的消息表资源嵌入你的 .exe文件 或DLL模块中,然后该函数会选定正确的嵌入对象。 ErrorShow示例应用程序(本章后面将加 以介绍)展示了如何调用该函数,以便将 Microsoft公司定义的错误代码转换成它的文本描述。 有些人常常问我, Microsoft公司是否建立了一个主控列表,以显示每个 Windows函数可能 返回的所有错误代码。可惜,回答是没有这样的列表,而且 Microsoft公司将永远不会建立这样 的一个列表。因为在创建系统的新版本时,建立和维护该列表实在太困难了。 建立这样一个列表存在的问题是,你可以调用一个 Windows函数,但是该函数能够在内部 调用另一个函数,而这另一个函数又可以调用另一个函数,如此类推。由于各种不同的原因, 这些函数中的任何一个函数都可能运行失败。有时,当一个函数运行失败时,较高级的函数对 它进行恢复,并且仍然可以执行你想执行的操作。为了创建该主控列表, Microsoft公司必须跟 踪每个函数的运行路径,并建立所有可能的错误代码的列表。这项工作很困难。而且,当创建 系统的新版本时,这些函数的运行路径还会改变。 1.1 定义自己的错误代码 前面已经说明 Windows函数是如何向函数的调用者指明发生的错误,你也能够将该机制用 于自己的函数。比如说,你编写了一个希望其他人调用的函数,你的函数可能因为这样或那样 的原因而运行失败,你必须向函数的调用者说明它已经运行失败。 若要指明函数运行失败,只需要设定线程的最后的错误代码,然后让你的函数返回 FALSE、 INVALID_HANDLE_VALUE 、NULL或者返回任何合适的信息。若要设定线程的最后错误代 码,只需调用下面的代码: 请将你认为合适的任何 32位号码传递给该函数。尝试使用 WinError.h中已经存在的代码, 下载 5 第 1章 对程序错误的处理 计计 只要该代码能够正确地指明想要报告的错误即可。如果你认为 WinError.h中的任何代码都不能 正确地反映该错误的性质,那么可以创建你自己的代码。错误代码是个 32位的数字,划分成表 1-2 所示的各个域。 表1-2 错误代码的域 位 内容 含义 31~30 严重性 0 =成功 1 =供参考 2 =警告 3=错误 29 Microsoft/客户 0=Microsoft公司定义的代码 1=客户定义的代码 28 保留 必须是0 27~16 设备代码 由Microsoft 公司定义 15~0 异常代码 由M i c r o s o f t / 客户定义 这些域将在第 24章中详细讲述。现在,需要知道的重要域是第 29位。Microsoft公司规定, 他们建立的所有错误代码的这个信息位均使用 0。如果创建自己的错误代码,必须使 29位为1。 这样,就可以确保你的错误代码与 Microsoft公司目前或者将来定义的错误代码不会发生冲突。 1.2 ErrorShow示例应用程序 ErrorShow应用程序“01 ErrorShow.exe”(在清单1-1中列出)展示了如何获取错误代码的文 本描述的方法。该应用程序的源代码和资源文件位于本书所附光盘上的 01-ErrorShow目录下。 一般来说,该应用程序用于显示调试程序的 Watch窗口和 Error Lookup程序是如何运行的。当启动该程序时,就会出现 如图1-3所示的窗口。 可以将任何错误代码键入该编辑控件。当单击 Look up按 钮时,在底部的滚动窗口中就会显示该错误的文本描述。该 图1-3 Error Show窗口 应用程序唯一令人感兴趣的特性是如何调用 FormatMessage函数。下面是使用该函数的方法: 第一个代码行用于从编辑控件中检索错误代码的号码。然后,建立一个内存块的句柄并将 它初始化为 NULL。FormatMessage函数在内部对内存块进行分配,并将它的句柄返回给我们。 6计计第一部分 程序员必读 下载 当调用FormatMessage函数时,传递了 FORMAT_MESSAGE_FROM_SYSTEM标志。该标 志告诉 FormatMessage函数,我们想要系统定义的错误代码的字符串。还传递了 FORMAT_ MESSAGE_ALLOCATE_BUFFER标志,告诉该函数为错误代码的文本描述分配足够大的内存 块。该内存块的句柄将在 hlocal变量中返回。第三个参数指明想要查找的错误代码的号码,第 四个参数指明想要文本描述使用什么语言。 如果FormatMessage函数运行成功,那么错误代码的文本描述就位于内存块中,将它拷贝 到对话框底部的滚动窗口中。如果 FormatMessage函数运行失败,设法查看 NetMsg.dll模块中的 消息代码,以了解该错误是否与网络有关。使用 NetMsg.dll模块的句柄,再次调用 FormatMessage函数。你会看到,每个 DLL(或.exe)都有它自己的一组错误代码,可以使用 Message Compiler(MC.exe)将这组错误代码添加给该模块,并将一个资源添加给该模块。这 就是Visual Studio 的Error Lookup 工具允许你用 Modules对话框进行的操作。以下是清单 1-1 ErrorShow示例应用程序。 清单1-1 ErrorShow示例应用程序 下载 7 第 1章 对程序错误的处理 计计 8计计第一部分 程序员必读 下载 下载 9 第 1章 对程序错误的处理 计计 10计计第一部分 程序员必读 下载 下载 第2章 Unicode 随着Microsoft公司的Windows操作系统在全世界日益广泛的流行,对于软件开发人员来说, 将目标瞄准国际上的各个不同市场,已经成为一个越来越重要的问题。美国的软件版本比国际 版本提前 6个月推向市场,这曾经是个司空见惯的现象。但是,由于各国对 Windows操作系统 提供了越来越多的支持,因此就更加容易为国际市场生产各种应用软件,从而缩短了软件的美 国版本与国际版本推出的时间间隔。 Windows操作系统始终不逾地提供各种支持,以帮助软件开发人员进行应用程序的本地化 工作。应用软件可以从各种不同的函数中获得特定国家的信息,并可观察控制面板的设置,以 确定用户的首选项。 Windows甚至支持不同的字体,以适应应用的需要。 之所以将这一章放在本书的开头,是因为考虑到 Unicode是开发任何应用程序时要采用的 基本步骤。本书的每一章中几乎都要讲到关于 Unicode的问题,而且书中给出的所有示例应用 程序都是“用Unicode实现的”。如果你为Microsoft Windows 2000或Microsoft Windows CE开发 应用程序,你应该使用 Unicode进行开发。如果你为Microsoft Windows 98开发应用程序,你必 须对某些问题作出决定。本章也要讲述 Windows 98的有关问题。 2.1 字符集 软件的本地化要解决的真正问题,实际上就是如何来处理不同的字符集。多年来,许多人 一直将文本串作为一系列单字节字符来进行编码,并在结尾处放上一个零。对于我们来说,这 已经成了习惯。当调用strlen函数时,它在以0结尾的单字节字符数组中返回字符的数目。 问题是,有些文字和书写规则(比如日文中的汉字就是个典型的例子)的字符集中的符号 太多了,因此单字节(它提供的符号最多不能超过 256个)是根本不敷使用的。为此出现了双 字节字符集(DBCS),以支持这些文字和书写规则。 2.1.1 单字节与双字节字符集 在双字节字符集中,字符串中的每个字符可以包含一个字节或包含两个字节。例如,日文 中的汉字,如果第一个字符在 0x81与0x9F之间,或者在0xE0与0xFC之间,那么就必须观察下 一个字节,才能确定字符串中的这个完整的字符。使用双字节字符集,对于程序员来说简直是 个很大的难题,因为有些字符只有一个字节宽,而有些字符则是两个字节宽。 如果只是调用 strlen函数,那么你无法真正了解字符串中究竟有多少字符,它只能告诉你 到达结尾的0之前有多少个字节。 ANSI的C运行期库中没有配备相应的函数,使你能够对双字 节字符集进行操作。但是, Microsoft Visual C++的运行期库却包含许多函数,如 _mbslen,它可 以用来操作多字节(既包括单字节也包括双字节)字符串。 为了帮助你对DBCS字符串进行操作, Windows提供了下面的一组帮助函数(见表 2-1)。 前两个函数 CharNext 和Char Prev 允许前向或逆向遍历 DBCS 字符串,方法是每次一个字 符。第三个函数 IsDBCSLeadByte, 在字节返回到一个两字字节符的第一个字节时将返回 TRUE。 12计计第一部分 程序员必读 下载 表2-1 对DBCS字符串进行操作的帮助函数 函数 描述 PTSTR CharNext(PCTSTR pszCurrentChar); PTSTR CharPrev(PCTSTR pszStart,PCTSTR p s z C u r r e n t C h a r ); BOOL IsDBCSLeadByteTRUE(BYTE bTestChar); 返回字符串中的下一个字符的地址 返回字符串中的上一个字符的地址 如果该字节是DBCS字符的第一个字节,则返回 尽管这些函数使得我们对 DBCS的操作更容易,但还需要,一个更好的方法让我们来看 看Unicode。 2.1.2 Unicode:宽字节字符集 Unicode是Apple和Xerox公司于1988年建立的一个技术标准。 1991年,成立了一个集团机 构负责Unicode的开发和推广应用。该集团由 Apple、Compaq、HP、IBM、Microsoft、Oracle、 Silicon Graphics, Inc.、Sybase、Unisys和Xerox等公司组成(若要了解该集团的全部成员,请 通过网址www.Unicode.org查找)。该集团负责维护 Unicode标准。Unicode的完整描述可以参阅 AddisonWesley出版的《Unicode Standard》一书(该书可以通过网址www.Unicode.org订购)。 Unicode提供了一种简单而又一致的表示字符串的方法。 Unicode字符串中的所有字符都是 16位的(两个字节)。它没有专门的字节来指明下一个字节是属于同一个字符的组成部分,还 是一个新字符。这意味着你只需要对指针进行递增或递减,就可以遍历字符串中的各个字符, 不再需要调用CharNext、CharPrev和IsDBCSLeadByte之类的函数。 由于Unicode用一个 16位的值来表示每个字符,因此总共可以得到 65 000 个字符,这样, 它就能够对世界各国的书面文字中的所有字符进行编码,远远超过了单字节字符集的 256个字 符的数目。 目前,已经为阿拉伯文、中文拼音、西里尔字母(俄文)、希腊文、西伯莱文、日文、韩 文和拉丁文(英文)字母定义了 Unicode代码点 。这些字符集中还包含了大量的标点符号、 数学符号、技术符号、箭头、装饰标志、区分标志和其他许多字符。如果将所有这些字母和符 号加在一起,总计约达 35000个不同的代码点,这样,总计 65 000多个代码点中,大约还有一 半可供将来扩充时使用。 这65 536个字符可以分成不同的区域。表 2-2 显示了这样的区域的一部分以及分配给这些 区域的字符。 表2-2 区域字符 1 6位代码 0000-007F 0080-00FF 0100-017F 0180-01FF 0250-02AF 02B0-02FF 字符 ASCII 拉丁文1字符 欧洲拉丁文 扩充拉丁文 标准拼音 修改型字母 16 位 代 码 0300-036F 0400-04FF 0530-058F 0590-05FF 0600-06FF 0900-097F 字符 通用区分标志 西里尔字母 亚美尼亚文 西伯莱文 阿拉伯文 梵文 目前尚未分配的代码点大约还有 29 000个,不过它们是保留供将来使用的。另外,大约有 6000个代码点是保留供个人使用的。 代码点是字符集中符号的位置。 下载 13 第 2章 Unicode计计 2.2 为什么使用Unicode 当开发应用程序时,当然应该考虑利用 Unicode的优点。即使现在你不打算对应用程序进 行本地化,开发时将 Unicode放在心上,肯定可以简化将来的代码转换工作。此外, Unicode还 具备下列功能: • 可以很容易地在不同语言之间进行数据交换。 • 使你能够分配支持所有语言的单个二进制 .exe文件或DLL文件。 • 提高应用程序的运行效率(本章后面还要详细介绍)。 2.3 Windows 2000与Unicode Windows 2000是使用Unicode从头进行开发的,用于创建窗口、显示文本、进行字符串操 作等的所有核心函数都需要 Unicode字符串。如果调用任何一个 Windows函数并给它传递一个 ANSI字符串,那么系统首先要将字符串转换成 Unicode,然后将 Unicode字符串传递给操作系 统。如果希望函数返回 ANSI字符串,系统就会首先将 Unicode字符串转换成 ANSI字符串,然 后将结果返回给你的应用程序。所有这些转换操作都是在你看不见的情况下发生的。当然,进 行这些字符串的转换需要占用系统的时间和内存。 例如,如果调用CreateWindowEx函数,并传递类名字和窗口标题文本的非 Unicode字符串, 那么CreateWindowEx必须分配内存块(在你的进程的默认堆中),将非Unicode字符串转换成 Unicode字符串,并将结果存储在分配到的内存块中,然后调用 Unicode版本的CreateWindowEx 函数。 对于用字符串填入缓存的函数来说,系统必须首先将 Unicode字符串转换成非Unicode字符 串,然后你的应用程序才能处理该字符串。由于系统必须执行所有这些转换操作,因此你的应 用程序需要更多的内存,并且运行的速度比较慢。通过从头开始用 Unicode来开发应用程序, 就能够使你的应用程序更加有效地运行。 2.4 Windows 98与Unicode Windows 98不是一种全新的操作系统。它继承了 16位Windows操作系统的特性,它不是用 来处理Unicode的。如果要增加对 Unicode的支持,其工作量非常大,因此在该产品的特性列表 中没有包括这个支持项目。由于这个原因, Windows 98像它的前任产品一样,几乎都是使用 ANSI字符串来进行所有的内部操作的。 仍然可以编写用于处理Unicode字符和字符串的 Windows应用程序,不过,使用 Windows函 数要难得多。例如,如果想要调用 CreateWindowEx函数并将ANSI字符串传递给它,这个调用 的速度非常快,不需要从你进程的默认堆栈中分配缓存,也不需要进行字符串转换。但是,如 果想要调用 CreateWindowEx函数并将Unicode字符串传递给它,就必须明确分配缓存,并调用 函数,以便执行从 Unicode到ANSI字符串的转换操作。然后可以调用 CreateWindowEx,传递 ANSI字符串。当CreateWindowEx函数返回时,就能释放临时缓存。这比使用 Windows 2000上 的Unicode要麻烦得多。本章的后面要介绍如何在 Windows 98下进行这些转换。 虽然大多数Unicode函数在Windows 98中不起任何作用,但是仍有少数 Unicode函数确实非 常有用。这些函数是: ■ EnumResourceLanguagesW ■ GetTextExtentPoint32W ■ EnumResourceNamesW ■ GetTextExtentPointW 14计计第一部分 程序员必读 下载 ■ EnumResourceTypesW ■ LstrlenW ■ ExtTextOutW ■ MessageBoxExW ■ FindResourceW ■ MessageBoxW ■ FindResourceExW ■ TextOutW ■ GetCharWidthW ■ WideCharToMultiByte ■ GetCommandLineW ■ MultiByteToWideChar 可惜的是,这些函数中有许多函数在 Windows 98中会出现各种各样的错误。有些函数无法 使用某些字体,有些函数会破坏内存堆栈,有些函数会使打印机驱动程序崩溃,等等。如果要 使用这些函数,必须对它们进行大量的测试。即使这样,可能仍然无法解决问题。因此必须向 用户说明这些情况。 2.5 Windows CE与Unicode Windows CE操作系统是为小型设备开发的,这些设备的内存很小,并且不带磁盘存储器。 你可能认为,由于 Microsoft公司的主要目标是建立一种尽可能小的操作系统,因此它会使用 ANSI作为自己的字符集。但是 Microsoft公司并非鼠目寸光,他们懂得, Windows CE的设备要 在世界各地销售,他们希望降低软件开发成本,这样就能更加容易地开发应用程序。为此, Windows CE 本身就是使用Unicode的一种操作系统。 但是,为了使Windows CE尽量做得小一些,Microsoft公司决定完全不支持ANSI Windows 函数。因此,如果要为 Windows CE开发应用程序,必须懂得 Unicode,并且在整个应用程序中 使用Unicode。 2.6 需要注意的问题 下面让我们进一步明确一下“ Microsoft公司对Unicode支持的情况”: • Windows 2000既支持Unicode,也支持ANSI,因此可以为任意一种开发应用程序。 • Windows 98只支持ANSI,只能为ANSI开发应用程序。 • Windows CE只支持Unicode,只能为Unicode开发应用程序。 虽然Microsoft公司试图让软件开发人员能够非常容易地开发在这 3种平台上运行的软件, 但是Unicode与ANSI之间的差异使得事情变得困难起来,并且这种差异通常是我遇到的最大的 问题之一。请不要误解, Microsoft公司坚定地支持 Unicode,并且我也坚决鼓励你使用它。不 过你应该懂得,你可能遇到一些问题,需要一定的时间来解决这些问题。建议你尽可能使用 Unicode。如果运行Windows 98 ,那么只有在必要时才需转换到 ANSI。 不过,还有另一个小问题你应该了解,那就是 COM。 2.7 对COM的简单说明 当Microsoft公司将 COM从16位Windows转换成 Win32时,公司作出了一个决定,即需要字 符串的所有COM接口方法都只能接受 Unicode字符串。这是个了不起的决定,因为 COM通常用 于使不同的组件能够互相进行通信,而 Unicode则是传递字符串的最佳手段。 如果你为Windows 2000或Windows CE开发应用程序,并且也使用 COM,那么你将会如虎 添翼。在你的整个源代码中使用 Unicode,将使与操作系统进行通信和与 COM对象进行通信的 操作变成一件轻而易举的事情。 如果你为Windows 98开发应用程序,并且也使用 COM,那么将会遇到一些问题。 COM要 下载 15 第 2章 Unicode计计 求使用Unicode字符串,而操作系统的大多数函数要求使用 ANSI字符串。那是多么难办的事情 啊!我曾经从事过若干个项目的开发,在这些项目中,我编写了许多代码,仅仅是为了来回进 行字符串的转换。 2.8 如何编写Unicode源代码 Microsoft公司为Unicode设计了Windows API,这样,可以尽量减少对你的代码的影响。实 际上,你可以编写单个源代码文件,以便使用或者不使用 Unicode来对它进行编译。只需要定 义两个宏( UNICODE和_UNICODE),就可以修改然后重新编译该源文件。 2.8.1 C运行期库对Unicode的支持 为了利用 Unicode字符串,定义了一些数据类型。标准的 C头文件 String.h已经作了修改, 以便定义一个名字为 wchar_t的数据类型,它是一个 Unicode字符的数据类型: 例如,如果想要创建一个缓存,用于存放最多为 99个字符的 Unicode字符串和一个结尾为 零的字符,可以使用下面这个语句: 该语句创建了一个由 100个16位值组成的数组。当然,标准的 C运行期字符串函数,如 strcpy、strchr和strcat等,只能对ANSI字符串进行操作,不能正确地处理 Unicode字符串。因此, ANSI C也拥有一组补充函数。清单2-1显示了一些标准的ANSI C字符串函数,后面是它们的等 价Unicode函数。 清单2-1 标准的ANSI C 字符串函数和它们的等价Unicode函数 请注意,所有的 Unicode函数均以wcs开头,wcs是宽字符串的英文缩写。若要调用 Unicode 函数,只需用前缀 wcs来取代 ANSI字符串函数的前缀 str即可。 注意 大多数软件开发人员可能已经不记得这样一个非常重要的问题了,那就是 Microsoft公司提供的 C运行期库与 ANSI的标准C运行期库是一致的。 ANSI C规定,C 运行期库支持Unicode字符和字符串。这意味着始终都可以调用 C运行期函数,以便对 Unicode字符和字符串进行操作,即使是在 Windows 98上运行,也可以调用这些函数。 换句话说, wcscat、wcslen和wcstok等函数都能够在 Windows 98上很好地运行,这些 都是必须关心的操作系统函数。 16计计第一部分 程序员必读 下载 对于包含了对 str函数或wcs函数进行显式调用的代码来说,无法非常容易地同时为 ANSI和 Unicode对这些代码进行编译。本章前面说过,可以创建同时为 ANSI和Unicode进行编译的单 个源代码文件。若要建立双重功能,必须包含 TChar.h文件,而不是包含String.h文件。 TChar.h文件的唯一作用是帮助创建 ANSI/Unicode通用源代码文件。它包含你应该用在源 代码中的一组宏,而不应该直接调用 str函数或者 wcs函数。如果在编译源代码文件时定义了 _UNICODE,这些宏就会引用 wcs这组函数。如果没有定义 _UNICODE,那么这些宏将引用 str 这组宏。 例如,在TChar.h中有一个宏称为 _tcscpy。如果在包含该头文件时没有定义 _UNICODE,那 么_tcscpy就会扩展为ANSI的strcpy函数。但是如果定义了_UNICODE, _tcscpy将扩展为Unicode 的wcscpy函数。拥有字符串参数的所有 C运行期函数都在 TChar.h文件中定义了一个通用宏。如 果使用通用宏,而不是 ANSI/Unicode的特定函数名,就能够顺利地创建可以为 ANSI或Unicode 进行编译的源代码。 但是,除了使用这些宏之外,还有一些操作是必须进行的。 TChar.h文件包含了另外一些 宏。 若要定义一个ANSI/Unicode通用的字符串数组,请使用下面的 TCHAR数据类型。如果定 义了_UNICODE,TCHAR将声明为下面的形式: 如果没有定义_UNICODE,则TCHAR将声明为下面的形式: 使用该数据类型,可以像下面这样分配一个字符串: 也可以创建对字符串的指针: 不过上面这行代码存在一个问题。按照默认设置, Microsoft公司的C++编译器能够编译所 有的字符串,就像它们是 ANSI字符串,而不是 Unicode字符串。因此,如果没有定义 _UNICODE,该编译器将能正确地编译这一行代码。但是,如果定义了 _UNICODE,就会产生 一个错误。若要生成一个 Unicode字符串而不是ANSI字符串,必须将该代码行改写为下面的样 子: 字符串(literal string)前面的大写字母L,用于告诉编译器该字符串应该作为 Unicode字符 串来编译。当编译器将字符串置于程序的数据部分中时,它在每个字符之间分散插入零字节。 这种变更带来的问题是,现在只有当定义了 _UNICODE时,程序才能成功地进行编译。我们需 要另一个宏,以便有选择地在字符串的前面加上大写字母 L。这项工作由 _TEXT宏来完成, _TEXT宏也在TChar.h文件中做了定义。如果定义了 _UNICODE,那么_TEXT定义为下面的形 式: 如果没有定义_UNICODE,_TEXT将定义为 使用该宏,可以改写上面这行代码,这样,无论是否定义了 _UNICODE宏,它都能够正确 地进行编译。如下所示: 下载 17 第 2章 Unicode计计 _TEXT宏也可以用于字符串。例如,若要检查一个字符串的第一个字符是否是大写字母 J, 只需编写下面的代码即可: 2.8.2 Windows定义的Unicode数据类型 Windows头文件定义了表2-3列出的数据类型。 表2-3 Uincode 数据类型 数据类型 说明 WCHAR PWSTR PCWSTR Unicode字符 指向Unicode字符串的指针 指向一个恒定的 Unicode字符串的指针 这些数据类型是指 Unicode字符和字符串。 Windows头文件也定义了 ANSI/Unicode通用数 据类型PTSTR和PCTSTR。这些数据类型既可以指 ANSI字符串,也可以指 Unicode字符串,这 取决于当编译程序模块时是否定义了 UNICODE宏。 请注意,这里的 UNICODE宏没有前置的下划线。 _UNICODE宏用于 C运行期头文件,而 UNICODE宏则用于 Windows头文件。当编译源代码模块时,通常必须同时定义这两个宏。 2.8.3 Windows中的Unicode函数和ANSI函数 前面已经讲过,有两个函数称为 CreateWindowEx,一个CreateWindowEx接受Unicode字符 串,另一个CreateWindowEx接受ANSI字符串。情况确实如此,不过,这两个函数的原型实际 上是下面的样子: 18计计第一部分 程序员必读 下载 CreateWindowExW是接受Unicode字符串的函数版本。函数名结尾处的大写字母W是英文wide (宽)的缩写。每个Unicode字符的长度是16位,因此,它们常常称为宽字符。 CreateWindowExA 的结尾处的大写字母 A表示该函数可以接受 ANSI字符串。 但是,在我们的代码中,通常只包含了对 CreateWindowEx的调用,而不是直接调用 CreateWindowExW或者 CreateWindowExA 。在WinUser.h文件中, CreateWindowEx实际上是定 义为下面这种形式的一个宏: 当编译源代码模块时, UNICODE 是否已经作了定义,将决定你调用的是哪个 CreateWindowEx版本。当转用一个 16位的Windows应用程序时,你在编译期间可能没有定义 UNICODE。对CreateWindowEx函数的任何调用都会将该宏扩展为对 CreateWindowExA的调用, 即对CreateWindowEx的ANSI版本的调用。由于 16位Windows只提供了 CreateWindowsEx的 ANSI版本,因此可以比较容易地转用它的应用程序。 在Windows 2000下,Microsoft的CreateWindowExA源代码只不过是一个形实替换程序层或 翻译层,用于分配内存,以便将 ANSI字符串转换成 Unicode字符串。该代码然后调用 Create WindowExW,并传递转换后的字符串。当 CreateWindowExW返回时,CreateWindowExA便释 放它的内存缓存,并将窗口句柄返回给你。 如果要创建其他软件开发人员将要使用的动态链接库( DLL),请考虑使用下面的方法。 在DLL中提供两个输出函数。一个是 ANSI版本,另一个是Unicode版本。在ANSI版本中,只需 要分配内存,执行必要的字符串转换,并调用该函数的 Unicode版本(本章后面部分介绍这个 进程)。 在Windows 98下,Microsoft的CreateWindowExA源代码是执行操作的函数。 Windows 98 提供了接受 Unicode参数的所有 Windows函数的进入点,但是这些函数并不将 Unicode字符串转 换成 A N S I字符串,它们只返回运行失败的消息。调用 G e t L a s t E r r o r将返回 E R R O R _ CALL_NOT_IMPLEMENTED。这些函数中只有 ANSI版本的函数才能正确地运行。如果编译 的代码调用了任何宽字符函数,应用程序将无法在 Windows 98下运行。 Windows API中的某些函数,比如WinExec和OpenFile等,只是为了实现与16位Windows程 序的向后兼容而存在,因此,应该避免使用。应该使用对 CreateProcess和CreateFile函数的调用 来取代对 WinExec和OpenFile函数的调用。从系统内部来讲,老的函数完全可以调用新的函数。 老的函数存在的一个大问题是,它们不接受 Unicode字符串。当调用这些函数时,必须传递 ANSI字符串。另一方面,所有新的和未过时的函数在 Windows 2000 中都同时拥有 ANSI和 下载 19 第 2章 Unicode计计 Unicode两个版本。 2.8.4 Windows字符串函数 Windows还提供了一组范围很广的字符串操作函数。这些函数与 C运行期字符串函数(如 strcpy和wcscpy)很相似。但是该操作系统函数是操作系统的一个组成部分,操作系统的许多 组件都使用这些函数,而不使用 C运行期库。建议最好使用操作系统函数,而不要使用 C运行 期字符串函数。这将有助于稍稍提高你的应用程序的运行性能,因为操作系统字符串函数常常 被大型应用程序比如操作系统的外壳进程 Explorer.exe所使用。由于这些函数使用得很多,因 此,在你的应用程序运行时,它们可能已经被装入 RAM。 若要使用这些函数,系统必须运行 Windows 2000 或Windows 98 。如果安装了 Internet Explorer 4.0或更新的版本,也可以在较早的 Windows版本中获得这些函数。 在经典的操作系统函数样式中,操作系统字符串函数名既包含大写字母,也包含小写字母, 它的形式类似这个样子: StrCat、StrChr、StrCmp和StrCpy等。若要使用这些函数,必须加上 ShlWApi.h头文件。另外,如前所述,这些字符串函数既有 ANSI版本,也有 Unicode版本,例 如StrCatA和StrCatW。由于这些函数属于操作系统函数,因此,当创建应用程序时,如果定义 了UNICODE(不带前置下划线),那么它们的符号将扩展为宽字符版本。 2.9 成为符合ANSI和Unicode的应用程序 即使你不打算立即使用 Unicode,最好也应该着手将你的应用程序转换成符合 Unicode的应 用程序。下面是应该遵循的一些基本原则: • 将文本串视为字符数组,而不是 chars数组或字节数组。 • 将通用数据类型(如TCHAR和PTSTR)用于文本字符和字符串。 • 将显式数据类型(如BYTE和PBYTE)用于字节、字节指针和数据缓存。 • 将TEXT宏用于原义字符和字符串。 • 执行全局性替换(例如用PTSTR替换PSTR)。 • 修改字符串运算问题。例如函数通常希望你在字符中传递一个缓存的大小,而不是字节。 这意味着你不应该传递 sizeof(szBuffer),而应该传递( sizeof(szBuffer)/sizeof(TCHAR)。另外, 如果需要为字符串分配一个内存块,并且拥有该字符串中的字符数目,那么请记住要按字节来 分配内存。这就是说,应该调用 malloc(nCharacters *sizeof(TCHAR)), 而不是调用 malloc (nCharacters)。在上面所说的所有原则中,这是最难记住的一条原则,如果操作错误,编译器 将不发出任何警告。 当我为本书的第一版编写示例程序时,我编写的原始程序只能编译为 ANSI程序。后来, 当我开始撰写本章的内容时,我想我应该鼓励使用 Unicode,并且打算创建一些示例程序,以 便展示你可以非常容易地编写既可以用 Unicode也可以用ANSI来编译的程序。这时我发现最好 的办法是将本书的所有示例程序进行转换,使它们都能够用 Unicode和ANSI进行编译。 我用了大约 4个小时将所有程序进行了转换。考虑到我以前从来没有这方面的转换经验, 这个速度是相当不错了。 2.9.1 Windows字符串函数 Windows也提供了一组用于对 Unicode字符串进行操作的函数,表 2-4对它们进行了描述。 20计计第一部分 程序员必读 下载 表2-4 对Unicode字符串进行操作的函数 函数 lstrcat lstrcmp lstrcmpi lstrcpy lstrlen 描述 将一个字符串置于另一个字符串的结尾处 对两个字符串进行区分大小写的比较 对两个字符串进行不区分大小写的比较 将一个字符串拷贝到内存中的另一个位置 返回字符串的长度(按字符数来计量) 这些函数是作为宏来实现的,这些宏既可以调用函数的 Unicode版本,也可以调用函数的 ANSI版本,这要根据编译源代码模块时是否已经定义了 UNICODE而定。例如,如果没有定义 UNICODE,lstrcat函数将扩展为lstrcatA。如果定义了UNICODE,lstrcat将扩展为lstrcatW。 有两个字符串函数,即lstrcmp和lstrcmpi,它们的行为特性与等价的C运行期函数是不同的。 C运行期函数 strcmp、strcmpi、wcscmp和wcscmpi只是对字符串中的代码点的值进行比较,这 就是说,这些函数将忽略实际字符的含义,只是将第一个字符串中的每个字符的数值与第二个 字符串中的字符的数值进行比较。而 Windows函数 lstrcmp和lstrcmpi是作为对 Windows函数 CompareString的调用来实现的。 该函数对两个 Unicode字符串进行比较。 CompareString的第一个参数用于设定语言 ID (LCID),这是个32位值,用于标识一种特定的语言。 CompareString使用这个LCID来比较这两 个字符串,方法是对照一种特定的语言来查看它们的字符的含义。这种操作方法比 C运行期函 数简单地进行数值比较更有意义。 当lstrcmp函数系列中的任何一个函数调用 CompareString时,该函数便将调用 Windows的 GetThreadString函数的结果作为第一个参数来传递: 每次创建一个线程时,它就被赋予一种语言。函数将返回该线程的当前语言设置。 C o m p a r e S t r i n g的第二个参数用于标识一些标志,这些标志用来修改该函数比较两个字符 串时所用的方法。表 2-5显示了可以使用的标志。 表2-5 Compare String 的标志及含义 标志 含义 NORM_IGNORECASE N O R M _ I G N O R E K A N AT Y P E N O R M _ I G N O R E N O N S PA C E NORM_IGNORESYMBOLS NORM_IGNOREWIDTH S O RT _ S T R I N G S O RT 忽略字母的大小写 不区分平假名与片假名字符 忽略无间隔字符 忽略符号 不区分单字节字符与作为双字节字符的同一个字符 将标点符号作为普通符号来处理 当lstrcmp调用 CompareString时,它传递 0作为 fdwStyle的参数。但是,当 lstrcmpi调用 CompareString时,它就传递 NORM_IGNORECASE。CompareString的其余4个参数用于设定两 下载 21 第 2章 Unicode计计 个字符串和它们各自的长度。如果为 cch1参数传递-1,那么该函数将认为 pString1字符串是以0 结尾,并计算该字符串的长度。对于 pString2字符串来说,参数cch2的作用也是一样。 其他C运行期函数没有为 Unicode字符串的操作提供很好的支持。例如, tolower和toupper 函数无法正确地转换带有重音符号的字符。为了弥补 C运行期库中的这些不足,必须调用下面 这些Windows函数,以便转换 Unicode字符串的大小写字母。这些函数也可以正确地用于 ANSI 字符串。 头两个函数: 和 既可以转换单个字符,也可以转换以 0结尾的整个字符串。若要转换整个字符串,只需要 传递字符串的地址即可。若要转换单个字符,必须像下面这样传递各个字符: 将单个字符转换成一个 PTSTR,便可调用该函数,将一个值传递给它,在这个值中,较低 的16位包含了该字符,较高的 16位包含0。当该函数看到较高位是 0时,该函数就知道你想要转 换单个字符,而不是整个字符串。返回的值是个 32位值,较低的16位中是已经转换的字符。 下面两个函数与前面两个函数很相似,差别在于它们用于转换缓存中包含的字符(该缓存 不必以0结尾): 其他的C运行期函数,如 isalpha、islower和isupper,返回一个值,指明某个字符是字母字 符、小写字母还是大写字母。 Windows API 提供了一些函数,也能返回这些信息,但是 Wi n d o w s函数也要考虑用户在控制面板中指定的语言: printf函数家族是要介绍的最后一组 C运行期函数。如果在定义了 _UNICODE的情况下编译 你的源代码模块,那么printf函数家族便希望所有字符和字符串参数代表 Unicode字符和字符串。 但是,如果在没有定义 _UNICODE的情况下编译你的源代码模块, printf函数家族便希望传递 给它的所有字符和字符串都是 ANSI字符和字符串。 Microsoft公司已经给C运行期的printf函数家族增加了一些特殊的域类型。其中有些域类型 尚未被ANSI C 采用。新类型使你能够很容易地对 ANSI和Unicode字符和字符串进行混合和匹配。 操作系统的wsprintf函数也得到了增强。下面是一些例子(请注意大写 S和小写s的使用): 22计计第一部分 程序员必读 下载 2.9.2 资源 当资源编译器对你的所有资源进行编译时,输出文件是资源的二进制文件。资源(字符串 表、对话框模板和菜单等)中的字符串值总是写作 Unicode字符串。在Windows 98和Windows 2000下,如果应用程序没有定义 UNICODE宏,那么系统就会进行内部转换。 例如,如果在编译源代码模块时没有定义 UNICODE,调用 LoadString实际上就是调用 LoadStringA函数。这时LoadStringA就从你的资源中读取字符串,并将该字符串转换成 ANSI字 符串。ANSI形式的字符串将从该函数返回给你的应用程序。 2.9.3 确定文本是ANSI文本还是Unicode文本 到现在为止,Unicode文本文件仍然非常少。实际上, Microsoft公司自己的大多数产品并 没有配备任何 Unicode文本文件。但是预计将来这种情况是会改变的(尽管这需要一个很长的 过程)。当然, Windows 2000的Notepad(记事本)应用程序允许你既能打开 Unicode文件,也能 打开ANSI文件,并且可以创建这些文件。图 2-1显示了Notepad的Save As(文件另存为)对话 框。请注意可以用不同的方法来保存文本文件。 图2-1 Windows 2000 Notepad的File Save As对话框 对于许多用来打开文本文件和处理这些文件的应用程序(如编译器)来说,打开一个文件 后,应用程序就能方便地确定该文本文件是包含 ANSI字符还是Unicode字符。IsTextUnicode函 数能够帮助进行这种区分: 文本文件存在的问题是,它们的内容没有严格和明确的规则,因此很难确定该文件是包含 下载 23 第 2章 Unicode计计 ANSI字符还是Unicode字符。IsTextUnicode使用一系列统计方法和定性方法,以便猜测缓存的 内容。由于这不是一种确切的科学方法,因此 IsTextUnicode有可能返回不正确的结果。 第一个参数 pvBuffer用于标识要测试的缓存的地址。该数据是个无效指针,因为你不知道 你拥有的是ANSI字符数组还是Unicode字符数组。 第二个参数 cb用于设定 pvBuffer指向的字节数。同样,由于你不知道缓存中放的是什么, 因此cb是个字节数,而不是字符数。请注意,不必设定缓存的整个长度。当然, IsTextUnicode 能够测试的字节越多,得到的结果越准确。 第三个参数pResult是个整数的地址,必须在调用 IsTextUnicode之前对它进行初始化。对该 整数进行初始化后,就可以指明你要 IsTextUnicode执行哪些测试。也可以为该参数传递 NULL, 在这种情况下,IsTextUnicode将执行它能够进行的所有测试(详细说明请参见 Platform SDK文 档)。 如果IsTextUnicode认为缓存包含Unicode文本,便返回TRUE,否则返回FALSE。确实是这 样,尽管Microsoft将该函数的原型规定为返回 DWORD,但是它实际上返回一个布尔值。如果 在pResult参数指向的整数中必须进行特定的测试,该函数就会在返回之前设定整数中的信息位, 以反映每个测试的结果。 Windows98 在Windows 98 下,IsTextUnicode函数没有有用的实现代码,它只是返回 FALSE。调用 GetLastError函数将返回 ERROR_CALL_NOT_IMPLEMENTD。 第17章中的Flie Rev示例应用程序演示了 IsTextUnicode 函数的使用。 2.9.4 在Unicode与ANSI之间转换字符串 Windows函数 MultiByteToWideChar用于将多字节字符串转换成宽字符串。下面显示了 M u l t i B y t e To Wi d e C h a r函数。 uCodePage参数用于标识一个与多字节字符串相关的代码页号。 dwFlags参数用于设定另一 个控件,它可以用重音符号之类的区分标记来影响字符。这些标志通常并不使用,在 dwFlags 参数中传递0。pMultiByteStr参数用于设定要转换的字符串, cchMultiByte参数用于指明该字符 串的长度(按字节计算)。如果为 cchMultiByte参数传递 -1,那么该函数用于确定源字符串的长 度。 转换后产生的 Unicode版本字符串将被写入内存中的缓存,其地址由 pWideCharStr参数指 定。必须在 c c h Wi d e C h a r 参 数 中 设 定 该 缓 存 的 最 大 值 ( 以 字 符 为 计 量 单 位 )。如果调用 MultiByteToWideChar,给cchWideChar参数传递 0,那么该参数将不执行字符串的转换,而是 返回为使转换取得成功所需要的缓存的值。一般来说,可以通过下列步骤将多字节字符串转换 成Unicode等价字符串: 1) 调用MultiByteToWideChar函数,为pWideCharStr参数传递NULL,为cchWideChar参数 传递0。 2) 分配足够的内存块,用于存放转换后的 Unicode字符串。该内存块的大小由前面对 24计计第一部分 程序员必读 下载 MultByteToWideChar的调用返回。 3) 再次调用 MultiByteToWideChar,这次将缓存的地址作为 pWideCharStr参数来传递,并 传递第一次调用 MultiByteToWideChar时返回的缓存大小,作为cchWidechar参数。 4. 使用转换后的字符串。 5) 释放Unicode字符串占用的内存块。 函数WideCharToMultiByte将宽字符串转换成等价的多字节字符串,如下所示: 该函数与MultiBiteToWideChar函数相似。同样, uCodePage参数用于标识与新转换的字符 串相关的代码页。 dwFlags则设定用于转换的其他控件。这些标志能够作用于带有区分符号的 字符和系统不能转换的字符。通常不需要为字符串的转换而拥有这种程度的控制手段,你将为 dwFlags参数传递 0。 pWideCharStr参数用于设定要转换的字符串的内存地址,cchWideChar参数用于指明该字符串 的长度(用字符数来计量)。如果你为cchWideChar参数传递-1,那么该函数用于确定源字符串的 长度。 转换产生的多字节版本的字符串被写入由pMultiByteStr参数指明的缓存。必须在cchMultiByte 参数中设定该缓存的最大值(用字节来计量)。如果传递 0作为WideCharToMultiByte函数的 cchMultiByte参数,那么该函数将返回目标缓存需要的大小值。通常可以使用将多字节字符串 转换成宽字节字符串时介绍的一系列类似的事件,将宽字节字符串转换成多字节字符串。 你会发现, WideCharToMultiByte函数接受的参数比 MultiByteToWideChar函数要多 2个, 即pDefaultChar和pfUsedDefaultChar。只有当WideCharToMultiByte函数遇到一个宽字节字符, 而该字符在uCodePage参数标识的代码页中并没有它的表示法时, WideCharToMultiByte函数才 使用这两个参数。如果宽字节字符不能被转换,该函数便使用 pDefaultChar参数指向的字符。 如果该参数是 NULL(这是大多数情况下的参数值),那么该函数使用系统的默认字符。该默 认字符通常是个问号。这对于文件名来说是危险的,因为问号是个通配符。 pfUsedDefaultChar参数指向一个布尔变量,如果宽字符串中至少有一个字符不能转换成等 价多字节字符,那么函数就将该变量置为 TRUE。如果所有字符均被成功地转换,那么该函数 就将该变量置为 FALSE。当函数返回以便检查宽字节字符串是否被成功地转换后,可以测试该 变量。同样,通常为该测试传递 NULL。 关于如何使用这些函数的详细说明,请参见 Platform SDK文档。 如果使用这两个函数,就可以很容易创建这些函数的 Unicode版本和ANSI版本。例如,你 可能有一个动态链接库,它包含一个函数,能够转换字符串中的所有字符。可以像下面这样编 写该函数的 Unicode版本: 下载 25 第 2章 Unicode计计 你可以编写该函数的 ANSI版本以便该函数根本不执行转换字符串的实际操作。你也可以 编写该函数的 ANSI版本,以便该函数它将 ANSI字符串转换成Unicode字符串,将 Unicode字符 串传递给StringReverseW函数,然后将转换后的字符串重新转换成 ANSI字符串。该函数类似下 面的样子: 26计计第一部分 程序员必读 下载 最后,在用动态链接库分配的头文件中,可以像下面这样建立这两个函数的原型: 下载 第3章 内 核 对 象 在介绍Windows API的时候,首先要讲述内核对象以及它们的句柄。本章将要介绍一些比 较抽象的概念,在此并不讨论某个特定内核对象的特性,相反只是介绍适用于所有内核对象的 特性。 首先介绍一个比较具体的问题,准确地理解内核对象对于想要成为一名 Windows软件开发 能手的人来说是至关重要的。内核对象可以供系统和应用程序使用来管理各种各样的资源,比 如进程、线程和文件等。本章讲述的概念也会出现在本书的其他各章之中。但是,在你开始使 用实际的函数来操作内核对象之前,是无法深刻理解本章讲述的部分内容的。因此当阅读本书 的其他章节时,可能需要经常回过来参考本章的内容。 3.1 什么是内核对象 作为一个 Windows软件开发人员,你经常需要创建、打开和操作各种内核对象。系统要创 建和操作若干类型的内核对象,比如存取符号对象、事件对象、文件对象、文件映射对象、 I/O完成端口对象、作业对象、信箱对象、互斥对象、管道对象、进程对象、信标对象、线程 对象和等待计时器对象等。这些对象都是通过调用函数来创建的。例如, CreateFileMapping函 数可使系统能够创建一个文件映射对象。每个内核对象只是内核分配的一个内存块,并且只能 由该内核访问。该内存块是一种数据结构,它的成员负责维护该对象的各种信息。有些数据成 员(如安全性描述符、使用计数等)在所有对象类型中是相同的,但大多数数据成员属于特定 的对象类型。例如,进程对象有一个进程 ID、一个基本优先级和一个退出代码,而文件对象则 拥有一个字节位移、一个共享模式和一个打开模式。 由于内核对象的数据结构只能被内核访问,因此应用程序无法在内存中找到这些数据结构 并直接改变它们的内容。 Microsoft规定了这个限制条件,目的是为了确保内核对象结构保持状 态的一致。这个限制也使 Microsoft能够在不破坏任何应用程序的情况下在这些结构中添加、删 除和修改数据成员。 如果我们不能直接改变这些数据结构,那么我们的应用程序如何才能操作这些内核对象 呢?解决办法是,Windows提供了一组函数,以便用定义得很好的方法来对这些结构进行操作。 这些内核对象始终都可以通过这些函数进行访问。当调用一个用于创建内核对象的函数时,该 函数就返回一个用于标识该对象的句柄。该句柄可以被视为一个不透明值,你的进程中的任何 线程都可以使用这个值。将这个句柄传递给 Windows的各个函数,这样,系统就能知道你想操 作哪个内核对象。本章后面还要详细讲述这些句柄的特性。 为了使操作系统变得更加健壮,这些句柄值是与进程密切相关的。因此,如果将该句柄值 传递给另一个进程中的一个线程(使用某种形式的进程间的通信)那么这另一个进程使用你的 进程的句柄值所作的调用就会失败。在 3.3节“跨越进程边界共享内核对象”中,将要介绍 3种 机制,使多个进程能够成功地共享单个内核对象。 3.1.1 内核对象的使用计数 内核对象由内核所拥有,而不是由进程所拥有。换句话说,如果你的进程调用了一个创建 28计计第一部分 程序员必读 下载 内核对象的函数,然后你的进程终止运行,那么内核对象不一定被撤消。在大多数情况下,对 象将被撤消,但是如果另一个进程正在使用你的进程创建的内核对象,那么该内核知道,在另 一个进程停止使用该对象前不要撤消该对象,必须记住的是,内核对象的存在时间可以比创建 该对象的进程长。 内核知道有多少进程正在使用某个内核对象,因为每个对象包含一个使用计数。使用计数 是所有内核对象类型常用的数据成员之一。当一个对象刚刚创建时,它的使用计数被置为 1。 然后,当另一个进程访问一个现有的内核对象时,使用计数就递增 1。当进程终止运行时,内 核就自动确定该进程仍然打开的所有内核对象的使用计数。如果内核对象的使用计数降为 0, 内核就撤消该对象。这样可以确保在没有进程引用该对象时系统中不保留任何内核对象。 3.1.2 安全性 内核对象能够得到安全描述符的保护。安全描述符用于描述谁创建了该对象,谁能够访问 或使用该对象,谁无权访问该对象。安全描述符通常在编写服务器应用程序时使用,如果你编 写客户机端的应用程序,那么可以忽略内核对象的这个特性。 Windows 98 根据原来的设计, Windows 98并不用作服务器端的操作系统。为此, Microsoft公司没有在Windows 98中配备安全特性。不过,如果你现在为 Windows 98 设计软件,在实现你的应用程序时仍然应该了解有关的安全问题,并且使用相应的访 问信息,以确保它能在Windows 2000上正确地运行 用于创建内核对象的函数几乎都有一个指向 SECURITY_ATTRIBUTES结构的指针作为其 参数,下面显示了 CreateFileMapping函数的指针: 大多数应用程序只是为该参数传递 NULL,这样就可以创建带有默认安全性的内核对象。 默认安全性意味着对象的管理小组的任何成员和对象的创建者都拥有对该对象的全部访问权, 而其他所有人均无权访问该对象。但是,可以指定一个 SECURITY_ATTRIBUTES结构,对它 进行初始化,并为该参数传递该结构的地址。 SECURITY_ATTRIBUTES结构类似下面的样子: 尽管该结构称为 SECURITY_ATTRIBUTES,但是它包含的与安全性有关的成员实际上只 有一个,即 lpSecurityDescriptor。如果你想要限制人们对你创建的内核对象的访问,必须创建 一个安全性描述符,然后像下面这样对 SECURITY_ATTRIBUTES结构进行初始化: 下载 29 第 3章 内 核 对 象计计 由于bInheritHandle这个成员与安全性毫无关系,因此准备推迟到本章后面部分继承性一节 中再介绍 bInheritHandle这个成员。 当你想要获得对相应的一个内核对象的访问权(而不是创建一个新对象)时,必须设定要 对该对象执行什么操作。例如,如果想要访问一个现有的文件映射内核对象,以便读取它的数 据,那么应该调用下面这个 OpenfileMapping函数: 通过将FILE_MAP_READ作为第一个参数传递给 OpenFileMapping,指明打算在获得对该 文件映象的访问权后读取该文件, OpenFileMapping函数在返回一个有效的句柄值之前,首先 执行一次安全检查。如果(已登录用户)被允许访问现有的文件映射内核对象, OpenFile Mapping就返回一个有效的句柄。但是,如果被拒绝访问该对象, OpenFileMapping将返回 NULL,而调用 GetLastError函数则返回 5(ERROR_ACCESS_DENIED),同样,大多数应用程 序并不使用该安全性,因此将不进一步讨论这个问题。 Windows 98 虽然许多应用程序不需要考虑安全性问题,但是 Windows的许多函数要 求传递必要的安全访问信息。为 Windows 98 设计的若干应用程序在Windows 2000上无 法正确地运行,因为在实现这些应用程序时没有对安全问题给于足够的考虑。 例如,假设一个应用程序在开始运行时要从注册表的子关键字中读取一些数据。 为了正确地进行这项操作,你的代码应该调用 RegOpenKeyEx,传递KEY_QUERY_ VALUE,以便获得必要的访问权。 但是,许多应用程序原先是为 Windows 98开发的,当时没有考虑到运行 Windows 2000的需要。由于Windows 98没有解决注册表的安全问题,因此软件开发人员常常要 调用RegOpenKeyEx函数,传递 KEY_All_ACCESS,作为必要的访问权。开发人员这 样做的原因是,它是一种比较简单的解决方案,意味着开发人员不必考虑究竟需要什 么访问权。问题是注册表的子关键字可以被用户读取,但是不能写入。 因此,当该应用程序现在放在 Windows 2000上运行时,用KEY_ALL_ACCESS调 用R e g O p e n K e y E x就会失败,而且,没有相应的错误检查方法,应用程序的运行就会 产生不可预料的结果。 如果开发人员想到安全问题,把 KEY_ALL_ACCESS改为KEY_QUERY_VALUE, 则该产品可适用于两种操作系统平台。 开发人员的最大错误之一就是忽略安全访问标志。使用正确的标志会使最初为 Windows 98 设计的应用程序更易于向Windows 2000 转换。 除了内核对象外,你的应用程序也可以使用其他类型的对象,如菜单、窗口、鼠标光标、 刷子和字体等。这些对象属于用户对象或图形设备接口( GDI)对象,而不是内核对象。当初 次着手为 Windows编程时,如果想要将用户对象或 GDI对象与内核对象区分开来,你一定会感 到不知所措。比如,图标究竟是用户对象还是内核对象呢?若要确定一个对象是否属于内核对 象,最容易的方法是观察创建该对象所用的函数。创建内核对象的所有函数几乎都有一个参数, 你可以用来设定安全属性的信息,这与前面讲到的 CreateFileMapping函数是相同的。 用于创建用户对象或GDI对象的函数都没有 PSECURITY_ATTRIBUTES参数。例如,让我们 来看一看下面这个 CreateIcon函数: 30计计第一部分 程序员必读 下载 3.2 进程的内核对象句柄表 当一个进程被初始化时,系统要为它分配一个句柄表。该句柄表只用于内核对象,不用于用户对 象或GDI对象。句柄表的详细结构和管理方法并没有具体的资料说明。通常我并不介绍操作系统中 没有文档资料的那些部分。不过,在这种情况下,我会进行例外处理,因为,作为一个称职的 Windows程序员,必须懂得如何管理进程的句柄表。由于这些信息没有文档资料,因此不能保证所 有的详细信息都正确无误,同时,在Windows 2000、Windows 98和Windows CE中,它们的实现方 法是不同的。为此,请认真阅读下面介绍的内容以加深理解,在此不学习系统是如何进行操作的。 表3-1显示了进程的句柄表的样子。可以看到,它只是个数据结构的数组。每个结构都包 含一个指向内核对象的指针、一个访问屏蔽和一些标志。 表3-1 进程的句柄结构 索引 1 2 … 内核对象内存块 的指针 0x???????? 0x???????? … 访问屏蔽 (标志位的 DWORD) 0x???????? 0x???????? … 标志 (标志位的 DWORD) 0x???????? 0x???????? … 3.2.1 创建内核对象 当进程初次被初始化时,它的句柄表是空的。然后,当进程中的线程调用创建内核对象的 函数时,比如 CreateFileMapping,内核就为该对象分配一个内存块,并对它初始化。这时,内 核对进程的句柄表进行扫描,找出一个空项。由于表 3-1中的句柄表是空的,内核便找到索引 1 位置上的结构并对它进行初始化。该指针成员将被设置为内核对象的数据结构的内存地址,访 问屏蔽设置为全部访问权,同时,各个标志也作了设置(关于标志,将在本章后面部分的继承 性一节中介绍)。 下面列出了用于创建内核对象的一些函数(但这决不是个完整的列表): 下载 31 第 3章 内 核 对 象计计 用于创建内核对象的所有函数均返回与进程相关的句柄,这些句柄可以被在相同进程中运 行的任何或所有线程成功地加以使用。该句柄值实际上是放入进程的句柄表中的索引,它用于 标识内核对象的信息存放的位置。因此当调试一个应用程序并且观察内核对象句柄的实际值时, 会看到一些较小的值,如 1,2等。请记住,句柄的含义并没有记入文档资料,并且可能随时变 更。实际上在Windows 2000中,返回的值用于标识放入进程的句柄表的该对象的字节数,而不 是索引号本身。 每当调用一个将内核对象句柄接受为参数的函数时,就要传递由一个 Create*&函数返回的 值。从内部来说,该函数要查看进程的句柄表,以获取要生成的内核对象的地址,然后按定义 得很好的方式来生成该对象的数据结构。 如 果 传 递 了 一 个 无 效 索 引 ( 句 柄 ),该函数便返回失败,而 G e t L a s t E r r o r 则返回 6 (ERROR_INVALID_HANDLE)。由于句柄值实际上是放入进程句柄表的索引,因此这些句柄 是与进程相关的,并且不能由其他进程成功地使用。 如果调用一个函数以便创建内核对象,但是调用失败了,那么返回的句柄值通常是 0 (NULL)。发生这种情况是因为系统的内存非常短缺,或者遇到了安全方面的问题。不过有少 数函数在运行失败时返回的句柄值是- 1(INVALID_HANDLE_VALUE)。例如,如果CreateFile 未能打开指定的文件,那么它将返回 INVALID_HANDLE_VALUE,而不是返回NULL。当查看 创建内核对象的函数返回值时,必须格外小心。特别要注意的是,只有当调用CreateFile函数时, 才能将该值与INVALID_HANDLE_VALUE进行比较。下面的代码是不正确的: 同样,下面的代码也不正确: 32计计第一部分 程序员必读 下载 3.2.2 关闭内核对象 无论怎样创建内核对象,都要向系统指明将通过调用 CloseHandle来结束对该对象的操作: 该函数首先检查调用进程的句柄表,以确保传递给它的索引(句柄)用于标识一个进程实际 上无权访问的对象。如果该索引是有效的,那么系统就可以获得内核对象的数据结构的地址,并 可确定该结构中的使用计数的数据成员。如果使用计数是0,该内核便从内存中撤消该内核对象。 如果将一个无效句柄传递给 CloseHandle,将会出现两种情况之一。如果进程运行正常, CloseHandle返回FALSE,而GetLastError则返回ERROR_INVALID_HANDLE。如果进程正在 排除错误,系统将通知调试程序,以便能排除它的错误。 在C l o s e H a n d l e返回之前,它会清除进程的句柄表中的项目,该句柄现在对你的进程已经 无效,不应该试图使用它。无论内核对象是否已经撤消,都会发生清除操作。当调用 CloseHandle函数之后,将不再拥有对内核对象的访问权,不过,如果该对象的使用计数没有 递减为0,那么该对象尚未被撤消。这没有问题,它只是意味着一个或多个其他进程正在使用 该对象。当其他进程停止使用该对象时(通过调用 CloseHandle),该对象将被撤消。 假如忘记调用 CloseHandle函数,那么会不会出现内存泄漏呢?答案是可能的,但是也不 一定。在进程运行时,进程有可能泄漏资源(如内核对象)。但是,当进程终止运行时,操作 系统能够确保该进程使用的任何资源或全部资源均被释放,这是有保证的。对于内核对象来说, 系统将执行下列操作:当进程终止运行时,系统会自动扫描进程的句柄表。如果该表拥有任何 无效项目(即在终止进程运行前没有关闭的对象),系统将关闭这些对象句柄。如果这些对象 中的任何对象的使用计数降为 0,那么内核便撤消该对象。 因此,应用程序在运行时有可能泄漏内核对象,但是当进程终止运行时,系统将能确保所 有内容均被正确地清除。另外,这个情况适用于所有对象、资源和内存块,也就是说,当进程 终止运行时,系统将保证进程不会留下任何对象。 3.3 跨越进程边界共享内核对象 许多情况下,在不同进程中运行的线程需要共享内核对象。下面是为何需要共享的原因: • 文件映射对象使你能够在同一台机器上运行的两个进程之间共享数据块。 • 邮箱和指定的管道使得应用程序能够在连网的不同机器上运行的进程之间发送数据块。 • 互斥对象、信标和事件使得不同进程中的线程能够同步它们的连续运行,这与一个应用 程序在完成某项任务时需要将情况通知另一个应用程序的情况相同。 由于内核对象句柄与进程相关,因此这些任务的执行情况是不同的。不过, Microsoft公司 有若干很好的理由将句柄设计成与进程相关的句柄。最重要的理由是要实现它的健壮性。如果 内核对象句柄是系统范围的值,那么一个进程就能很容易获得另一个进程使用的对象的句柄, 从而对该进程造成很大的破坏。另一个理由是安全性。内核对象是受安全性保护的,进程在试 图操作一个对象之前,首先必须申请获得操作该对象的许可权。对象的创建人只需要拒绝向用 户赋予许可权,就能防止未经授权的用户接触该对象。 在下面的各节中,将要介绍允许进程共享内核对象的 3个不同的机制。 3.3.1 对象句柄的继承性 只有当进程具有父子关系时,才能使用对象句柄的继承性。在这种情况下,父进程可以使 下载 33 第 3章 内 核 对 象计计 用一个或多个内核对象句柄,并且该父进程可以决定生成一个子进程,为子进程赋予对父进程 的内核对象的访问权。若要使这种类型的继承性能够实现,父进程必须执行若干个操作步骤。 首先,当父进程创建内核对象时,必须向系统指明,它希望对象的句柄是个可继承的句柄。 请记住,虽然内核对象句柄具有继承性,但是内核对象本身不具备继承性。 若要创建能继承的句柄,父进程必须指定一个 SECURITY_ATTRIBUTES结构并对它进行 初始化,然后将该结构的地址传递给特定的 Create函数。下面的代码用于创建一个互斥对象, 并将一个可继承的句柄返回给它: 该代码对一个 SECURITY_ATTRIBUTES结构进行初始化,指明该对象应该使用默认安全 性(在Windows 98中该安全性被忽略)来创建,并且返回的句柄应该是可继承的。 Windows 98 尽管Windows 98不拥有完整的对安全性的支持,但是它却支持继承 性,因此,Windows 98能够正确地使用bInheritHandle成员的值。 现在介绍存放在进程句柄表项目中的标志。每个句柄表项目都有一个标志位,用来指明该句 柄是否具有继承性。当创建一个内核对象时,如果传递 NULL作为PSECURITY_ATTRIBUTES的 参数,那么返回的句柄是不能继承的,并且该标志位是 0。如果将 bInheritHandle成员置为 TRUE,那么该标志位将被置为 1。 表3-2显示了一个进程的句柄表。 表3-2 包含两个有效项目的进程句柄表 索引 内核对象内存块 的指针 访问屏蔽 (标志位的 DWORD) 标志 (标志位的 DWORD) 1 0xF0000000 2 0x00000000 3 0xF0000010 0x???????? (无) 0x???????? 0x00000000 (无) 0x00000001 表3-2表示该进程拥有对两个内核对象(句柄 1和3)的访问权。句柄1是不可继承的,而句 柄3是可继承的。 使用对象句柄继承性时要执行的下一个步骤是让父进程生成子进程。这要使用 Create Process函数来完成: 34计计第一部分 程序员必读 下载 下一章将详细介绍这个函数的用法,不过现在我想要让你注意 bInheritHandle这个参数。一 般来说,当生成一个进程时,将为该参数传递 FALSE。该值告诉系统,不希望子进程继承父进 程的句柄表中的可继承句柄。 但是,如果为该参数传递 TRUE,那么子进程就可以继承父进程的可继承句柄值。当传递 TRUE时,操作系统就创建该新子进程,但是不允许子进程立即开始执行它的代码。当然,系 统为子进程创建一个新的和空的句柄表,就像它为任何新进程创建句柄表那样。不过,由于将 TRUE 传递给了 CreateProcess的bInheritHandles参数,因此系统要进行另一项操作,即它要遍历 父进程的句柄表,对于它找到的包含有效的可继承句柄的每个项目,系统会将该项目准确地拷 贝到子进程的句柄表中。该项目拷贝到子进程的句柄表中的位置将与父进程的句柄表中的位置 完全相同。这个情况非常重要,因为它意味着在父进程与子进程中,标识内核对象所用的句柄 值是相同的。 除了拷贝句柄表项目外,系统还要递增内核对象的使用计数,因为现在两个进程都使用该 对象。如果要撤消内核对象,那么父进程和子进程必须调用该对象上的 CloseHandle函数,也 可以终止进程的运行。子进程不必首先终止运行,但是父进程也不必首先终止运行。实际上, CreateProcess函数返回后,父进程可以立即关闭对象的句柄,而不影响子进程对该对象进行操 作的能力。 表3-3显示了子进程被允许运行前该进程的句柄表。可以看到,项目 1和项目2尚未初始化, 因此是个无效句柄,子进程是无法使用的。但是,项目 3确实标识了一个内核对象。实际上, 它标识的内核对象的地址是 0xF0000010,这与父进程的句柄表中的对象地址相同。访问屏蔽与 父进程中的屏蔽相同,两者的标志也相同。这意味着如果该子进程要生成它自己的子进程(即 父进程的孙进程),该孙进程也将继承与该内核对象句柄相同的句柄值、同样的访问权和相同 的标志,同时,对象的使用计数再次被递增。 表3-3 继承父进程的可继承句柄后的子进程句柄表 索引 1 2 3 内核对象内存块 的指针 0x00000000 0x00000000 0xF0000010 访问屏蔽 (标志位的 DWORD) (无) (无) 0x???????? 标志 (标志位的 DWORD) (无) (无) 0x00000001 应该知道,对象句柄的继承性只有在生成子进程的时候才能使用。如果父进程准备创建带 有可继承句柄的新内核对象,那么已经在运行的子进程将无法继承这些新句柄。 对象句柄的继承性有一个非常奇怪的特征,那就是当使用它时,子进程不知道它已经继承 了任何句柄。只有在另一个进程生成子进程时记录了这样一个情况,即它希望被赋予对内核对 象的访问权时,才能使用内核对象句柄的继承权。通常,父应用程序和子应用程序都是由同一 个公司编写的,但是,如果另一个公司记录了子应用程序期望的对象,那么该公司也能够编写 子应用程序。 子进程为了确定它期望的内核对象的句柄值,最常用的方法是将句柄值作为一个命令行参 数传递给子进程,该子进程的初始化代码对命令行进行分析(通常通过调用 sscanf函数来进行 分析),并取出句柄值。一旦子进程拥有该句柄值,它就具有对该对象的无限访问权。请注意, 句柄继承权起作用的唯一原因是,父进程和子进程中的共享内核对象的句柄值是相同的,这就 是为什么父进程能够将句柄值作为命令行参数来传递的原因。 下载 35 第 3章 内 核 对 象计计 当然,可以使用其他形式的进程间通信,将已继承的内核对象句柄值从父进程传送给子进 程。方法之一是让父进程等待子进程完成初始化(使用第 9章介绍的WaitForInpuIdle函数),然 后,父进程可以将一条消息发送或展示在子进程中的一个线程创建的窗口中。 另一个方法是让父进程将一个环境变量添加给它的环境程序块。该变量的名字是子进程知 道要查找的某种信息,而变量的值则是内核对象要继承的值。这样,当父进程生成子进程时, 子进程就继承父进程的环境变量,并且能够非常容易地调用 GetEnvironmentVariable函数,以 获取被继承对象的句柄值。如果子进程要生成另一个子进程,那么使用这种方法是极好的,因 为环境变量可以被再次继承。 3.3.2 改变句柄的标志 有时会遇到这样一种情况,父进程创建一个内核对象,以便检索可继承的句柄,然后生成 两个子进程。父进程只想要一个子进程来继承内核对象的句柄。换句话说,有时可能想要控制 哪个子进程来继承内核对象的句柄。若要改变内核对象句柄的继承标志,可以调用 SetHandleInformation函数: 可以看到,该函数拥有 3个参数。第一个参数 hObject用于标识一个有效的句柄。第二个参 数dwMask告诉该函数想要改变哪个或那几个标志。目前有两个标志与每个句柄相关联: 如果想同时改变该对象的两个标志,可以逐位用 OR将这些标志连接起来。 SetHandle Information函数的第三个参数是 dwFlags,用于指明想将该标志设置成什么值。例如,若要打 开一个内核对象句柄的继承标志,请创建下面的代码: 若要关闭该标志,请创建下面的代码: HANDLE_FLAG_PROTECT_FROM_CLOSE标志用于告诉系统,该句柄不应该被关闭: 如果一个线程试图关闭一个受保护的句柄, CloseHandle就会产生一个异常条件。很少想 要将句柄保护起来,使他人无法将它关闭。但是如果一个进程生成了子进程,而子进程又生成 了孙进程,那么该标志可能有用。父进程可能希望孙进程继承赋予子进程的对象句柄。不过, 子进程有可能在生成孙进程之前关闭该句柄。如果出现这种情况,父进程就无法与孙进程进行 通信,因为孙进程没有继承该内核对象。通过将句柄标明为“受保护不能关闭”,那么孙进程 就能继承该对象。 但是这种处理方法有一个问题。子进程可以调用下面的代码来关闭 HANDLE_FLAG_ PROTECT_FROM_CLOSE标志,然后关闭句柄。 36计计第一部分 程序员必读 下载 父进程可能打赌说,子进程将不执行该代码。当然,父进程也可能打赌说,子进程将生成 孙进程。因此这种打赌没有太大的风险。 为了完整地说明问题,也要讲一下 GetHandleInformation函数的情况: 该函数返回 pdwFlags指向的DWORD中特定句柄的当前标志的设置值。若要了解句柄是否 是可继承的,请使用下面的代码: 3.3.3 命名对象 共享跨越进程边界的内核对象的第二种方法是给对象命名。许多(虽然不是全部)内核对 象都是可以命名的。例如,下面的所有函数都可以创建命名的内核对象: 所有这些函数都有一个共同的最后参数 pszName。当为该参数传递 NULL时,就向系统指 明了想创建一个未命名的(匿名)内核对象。当创建一个未命名的对象时,可以通过使用继承 下载 37 第 3章 内 核 对 象计计 性(如上一节介绍的那样)或 DuplicateHandle(下一节将要介绍 )共享跨越进程的对象。若要按 名字共享对象,必须为对象赋予一个名字。 如果没有为 pszName参数传递 MULL,应该传递一个以 0结尾的字符串名字的地址。该名字 的长度最多可以达到 MAX_PATH(定义为260)个字符。但是, Microsoft没有提供为内核对象 赋予名字的指导原则。例如,如果试图创建一个称为“ JeffObj”的对象,那么不能保证系统中 不存在一个名字为“ JeffObj”的对象。更为糟糕的是,所有这些对象都共享单个名空间。由于 这个原因,对下面这个 CreateSemaphore函数的调用将总是返回 NULL : 如果在执行上面的代码后观察 dwErrorcode的值,会看到返回的代码是 6(ERROR_ INVALID_HANDLE)。这个错误代码没有很强的表意性,但是你又能够做什么呢? 既然已知道如何给对象命名,那么让我们来看一看如何用这种方法来共享对象。比如说, Process A启动运行,并调用下面的函数: 调用该函数能创建一个新的互斥内核对象,为它赋予名字“ JeffMutex”。请注意,Process A的句柄 hMutexProcessA不是一个可继承的句柄,并且当你只是命名对象时,它不必是个可继 承的句柄。 过些时候,某个进程会生成 Process B。Process B不一定是 Process A的子进程。它可能是 Explorer或其他任何应用程序生成的进程。 Process B不必是Process A的子进程这一事实正是使 用命名对象而不是继承性的优越性。当 Process B启动运行时,它执行下面的代码: 当Process B调用CreateMutex时,系统首先要查看是否已经存在一个名字为“ JeffMutex” 的内核对象。由于确实存在一个带有该名字的对象,因此内核要检查对象的类型。由于试图创 建一个互斥对象,而名字为“ JeffMutex”的对象也是个互斥对象,因此系统会执行一次安全 检查,以确定调用者是否拥有对该对象的完整的访问权。如果拥有这种访问权,系统就在 Process B的句柄表中找出一个空项目,并对该项目进行初始化,使该项目指向现有的内核对象。 如果该对象类型不匹配,或者调用者被拒绝访问,那么 CreateMutex将运行失败(返回 NULL)。 当Process B对CreateMutex的调用取得成功时,它并不实际创建一个互斥对象。相反, Process B只是被赋予一个与进程相关的句柄值,用于标识内核中现有的互斥对象。当然,由于 Process B 的句柄表中的一个新项目要引用该对象,互斥对象的使用计数就会递增。在 Process A 和Process B同时关闭它们的对象句柄之前,该对象是不会被撤消的。请注意,这两个进程中的 句柄值很可能是不同的值。这是可以的。 Process A将使用它的句柄值,而Process B 则使用它自 己的句柄值来操作一个互斥内核对象。 注意 当你的多个内核对象拥有相同的名字时,有一个非常重要的细节必须知道。 当Process B调用CreateMutex时,它将安全属性信息和第二个参数传递给该函数。如 果已经存在带有指定名字的对象,那么这些参数将被忽略。应用程序能够确定它是否 确实创建了一个新内核对象,而不是打开了一个现有的对象。方法是在调用 Create*函 数后立即调用 GetLastError: 38计计第一部分 程序员必读 下载 按名字共享对象的另一种方法是,进程不调用 Create*函数,而是调用下面显示的 Open*函 数中的某一个: 注意,所有这些函数都拥有相同的原型。最后一个参数 pszName用于指明内核对象的名字。 不能为该参数传递 NULL,必须传递以0结尾的地址。这些函数要搜索内核对象的单个名空间, 以便找出匹配的空间。如果不存在带有指定名字的内核对象,该函数返回 NULL,GetLastError 返回 2(ERROR_FILE_NOT_FOUND)。但是,如果存在带有指定名字的内核对象,并且它是 相同类型的对象,那么系统就要查看是否允许执行所需的访问(通过 dwDesiredAccess参数进 行访问)。如果拥有该访问权,调用进程的句柄表就被更新,对象的使用计数被递增。如果为 bInheritHandle参数传递TRUE,那么返回的句柄将是可继承的。 调用Create*函数与调用Open*函数之间的主要差别是,如果对象并不存在,那么 Create*函 数将创建该对象,而 Open*函数则运行失败。 如前所述,Microsoft没有提供创建唯一对象名的指导原则。换句话说,如果用户试图运行 来自不同公司的两个程序,而每个程序都试图创建一个称为“ MyObject”的对象,那么这就是 个问题。为了保证对象的唯一性,建议创建一个GUID,并将GUID的字符串表达式用作对象名。 命名对象常常用来防止运行一个应用程序的多个实例。若要做到这一点,只需要调用 main或 下载 39 第 3章 内 核 对 象计计 WinMain函数中 Create*函数,以便创建一个命名对象(创建的是什么对象则是无所谓的)。当 C r e a t e *函 数 返 回 时 , 调 用 G e t L a s t E r r o r 函 数 。 如 果 G e t L a s t E r r o r 函 数 返 回 ERROR_ALREADY_EXISTS,那么你的应用程序的另一个实例正在运行,新实例可以退出。 下面是说明这种情况的部分代码: 3.3.4 终端服务器的名字空间 注意,终端服务器能够稍稍改变上面所说的情况。终端服务器拥有内核对象的多个名字空 间。如果存在一个可供内核对象使用的全局名字空间,就意味着它可以供所有的客户程序会 话访问。该名字空间主要供服务程序使用。此外,每个客户程序会话都有它自己的名字空间。 它能防止运行相同应用程序的两个或多个会话之间出现互相干扰的情况,也就是说一个会话 无法访问另一个会话的对象,尽管该对象拥有相同的名字。在没有终端服务器的机器上,服 务程序和应用程序拥有上面所说的相同的内核对象名字空间,而在拥有终端服务器的机器上, 却不是这样。 服务程序的名字空间对象总是放在全局名字空间中。按照默认设置,在终端服务器中,应 用程序的命名内核对象将放入会话的名字空间中。但是,如果像下面这样将“ Global\”置于对 象名的前面,就可以使命名对象进入全局名字空间: 也可以显式说明想让内核对象进入会话的名字空间,方法是将“ Local\”置于对象名的前 面: Microsoft将Global和Local视为保留关键字,除非要强制使用特定的名字空间,否则不应该 使用这两个关键字。Microsoft还将Session视为保留关键字,虽然目前它没有任何意义。请注意, 所有这些保留关键字是区分大小写字母的。如果主机不运行终端服务器,这些关键字将被忽 略。 3.3.5 复制对象句柄 共享跨越进程边界的内核对象的最后一个方法是使用 DuplicateHandle函数: 40计计第一部分 程序员必读 下载 简单说来,该函数取出一个进程的句柄表中的项目,并将该项目拷贝到另一个进程的句柄 表中。DuplicateHandle函数配有若干个参数,但是实际上它是非常简单的。 DuplicateHandle函 数最普通的用法要涉及系统中运行的 3个不同进程。 当调用 DuplicateHandle函数时,第一和第三个参数 hSourceProcessHandle和h Ta rg e t ProcessHandle是内核对象句柄。这些句柄本身必须与调用 DuplicateHandle函数的进程相关。此 外,这两个参数必须标识进程的内核对象。如果将句柄传递给任何其他类型的内核对象,那么 该函数运行就会失败。第 4章将详细介绍进程的内核对象,而现在只需要知道,每当系统中启 动一个新进程时都会创建一个进程内核对象。 第二个参数 hSourceHandle是任何类型的内核对象的句柄。但是该句柄值与调用 Duplicate Handle的进程并无关系。相反,该句柄必须与 hSourceProcessHandle句柄标识的进程相关。第 四个参数phTargetHandle是HANDLE变量的地址,它将接收获取源进程句柄信息拷贝的项目索 引。返回的句柄值与 hTargetProcessHandle标识的进程相关。 DuplicateHandle的最后3个参数用于指明该目标进程的内核对象句柄表项目中使用的访问 屏蔽值和继承性标志。 dwOptions参数可以是 0(零),也可以是下面两个标志的任何组合: DUPLICATE_SAME_ACCESS和DUPLICATE_CLOSE_SOURCE。 如果设定了DUPLICATE_SAME_ACCESS标志,则告诉DuplicateHandle函数,你希望目标 进程的句柄拥有与源进程句柄相同的访问屏蔽。使用该标志将使 DuplicateHandle忽略它的 dwDesiredAccess参数。 如果设定了 DUPLICATE_CLOSE_SOURCE标志,则可以关闭源进程中的句柄。该标志使 得一个进程能够很容易地将内核对象传递给另一个进程。当使用该标志时,内核对象的使用计 数不会受到影响。 下面用一个例子来说明 DuplicateHandle函数是如何运行的。在这个例子中, Process S是目 前可以访问某个内核对象的源进程, Process T是将要获取对该内核对象的访问权的目标进程。 Process C 是执行对DuplicateHandle调用的催化进程。 Process C的句柄表(表3-4)包含两个句柄值,即 1和2。句柄值1用于标识Process S的进程 内核对象,句柄值2则用于标识Process T 的进程内核对象。 表3-4 Process C的句柄表 索引 1 2 内核对象内存块 的指针 0xF0000000 (Process S 的内核对象) 0xF0000010 (Process T 的内核对象) 访问屏蔽 (标志位的 DWORD) 0x???????? 0x???????? 标志 (标志位的 DWORD) 0x00000000 0x00000000 表3-5是Process S的句柄表,它包含句柄值为 2的单个项目。该句柄可以标识任何类型的内 核对象,就是说它不必是进程的内核对象。 下载 41 第 3章 内 核 对 象计计 表3-5 Process S的句柄表 索引 内核对象内存块 的指针 访问屏蔽 (标志位的 DWORD) 标志 (标志位的 DWORD) 1 0x00000000 2 0xF0000020 (任何内核对象 ) (无) 0x???????? (无) 0x00000000 表3-6显示了Process C调用DuplicateHandle函数之前 Process T的句柄表包含的项目。如你 所见,Process T的句柄表只包含句柄值为 2的单个项目,句柄项目1目前未用。 表3-6 调用DuplicateHandle函数之前Process T 的句柄表 索引 内核对象内存块 的指针 访问屏蔽 (标志位的 DWORD) 标志 (标志位的 DWORD) 1 0x00000000 2 0xF0000030 (任何内核对象 ) (无) 0x???????? (无) 0x00000000 如果Process C现在使用下面的代码来调用 DuplicateHandle,那么只有Process T的句柄表改 变更,如表 3-7所示。 表3-7 调用DuplicateHandle函数之后Process T的句柄表 索引 内核对象内存块 的指针 访问屏蔽 (标志位的 DWORD) 标志 (标志位的 DWORD) 1 0xF00000020 2 0xF00000030 (任何内核对象 ) 0x???????? 0x???????? 0x00000001 0x00000000 Process S的句柄表中的第二项已经被拷贝到 Process T的句柄表中的第一项。 Duplicate Handle也已经将值1填入Process C的hObj变量中。值1是Process T的句柄表中的索引,新项目将 被放入该索引。 由于DUPLICATE_SAME_ACCESS标志被传递给了DuplicateHandle,因此Process T的句柄 表中该句柄的访问屏蔽与 Process S 的句柄表项目中的访问屏蔽是相同的。另外,传递 DUPLICATE_SAME_ACCESS标志将使 DuplicateHandle忽略它的 DesiredAccess参数。最后请注 意,继承位标志已经被打开,因为给 DuplicateHandle的bInheritHandle参数传递的是TRUE。 显然,你永远不会像在这个例子中所做的那样,调用传递硬编码数字值的 DuplicateHandle 函数。这里使用硬编码数字,只是为了展示函数是如何运行的。在实际应用程序中,变量可能 拥有各种不同的句柄值,可以传递该变量,作为函数的参数。 与继承性一样, DuplicateHandle函数存在的奇怪现象之一是,目标进程没有得到关于新内 核对象现在可以访问它的通知。因此, Process C必须以某种方式来通知 Process T,它现在拥有 对内核对象的访问权,并且必须使用某种形式的进程间通信方式,以便将 hObj中的句柄值传递 给Process T。显然,使用命令行参数或者改变 Process T的环境变量是不行的,因为该进程已经 启动运行。因此必须使用窗口消息或某种别的 IPC机制。 上面是DuplicateHandle的最普通的用法。如你所见,它是个非常灵活的函数。不过,它很 少在涉及3个不同进程的情况下被使用(因为 Process C不可能知道对象的句柄值正在被 Process 42计计第一部分 程序员必读 下载 S使用)。通常,当只涉及两个进程时,才调用 DuplicateHandle函数。比如一个进程拥有对另一 个进程想要访问的对象的访问权,或者一个进程想要将内核对象的访问权赋予另一个进程。例 如,Process S拥有对一个内核对象的访问权,并且想要让 Process T能够访问该对象。若要做到 这一点,可以像下面这样调用 DuplicateHandle: 在这个例子中,对 GetCurrentProcess的调用将返回一个伪句柄,该句柄总是用来标识调用 端的进程Process S。一旦DuplicateHandle返回,hObjProcessT就是与Process T相关的句柄,它 所标识的对象与引用 Process S中的代码时hObjProcessS的句柄标识的对象相同。 Process S决不 应该执行下面的代码: 如果Process S要执行该代码,那么对代码的调用可能失败,也可能不会失败。如果 Process S恰好拥有对内核对象的访问权,其句柄值与 hObjProcessT的值相同,那么调用就会成功。该 代码的调用将会关闭某个对象,这样 Process S就不再拥有对它的访问权,这当然会导致应用程 序产生不希望有的行为特性。 下面是使用DuplicateHandle函数的另一种方法。假设一个进程拥有对一个文件映射对象的 读和写访问权。在某个位置上,一个函数被调用,它通过读取文件映射对象来访问它。为了使 应用程序更加健壮,可以使用 DuplicateHandle为现有的对象创建一个新句柄,并确保这个新句 柄拥有对该对象的只读访问权。然后将只读句柄传递给该函数,这样,该函数中的代码就永远 不会偶然对该文件映射对象执行写入操作。下面这个代码说明了这个例子: 下载 43 第 3章 内 核 对 象计计 下载 第二部分 编程的具体方法 第4章 进 程 本章介绍系统如何管理所有正在运行的应用程序。首先讲述什么是进程,以及系统如何创 建进程内核对象,以便管理每个进程。然后将说明如何使用相关的内核对象来对进程进行操作。 接着,要介绍进程的各种不同的属性,以及查询和修改这些属性所用的若干个函数。还要讲述 创建或生成系统中的辅助进程所用的函数。当然,如果不深入说明如何来结束进程的运行,那 么这样的介绍肯定是不完整的。现在就来介绍进程的有关内容。 进程通常被定义为一个正在运行的程序的实例,它由两个部分组成: • 一个是操作系统用来管理进程的内核对象。内核对象也是系统用来存放关于进程的统计 信息的地方。 • 另一个是地址空间,它包含所有可执行模块或 DLL模块的代码和数据。它还包含动态内 存分配的空间。如线程堆栈和堆分配空间。 进程是不活泼的。若要使进程完成某项操作,它必须拥有一个在它的环境中运行的线程, 该线程负责执行包含在进程的地址空间中的代码。实际上,单个进程可能包含若干个线程,所 有这些线程都“同时”执行进程地址空间中 的代码。为此,每个线程都有它自己的一组 线程 线程 CPU寄存器和它自己的堆栈。每个进程至少 线程 线程 拥有一个线程,来执行进程的地址空间中的 代码。如果没有线程来执行进程的地址空间 中的代码,那么进程就没有存在的理由了, 线程 线程 系统就将自动撤消该进程和它的地址空间。 若要使所有这些线程都能运行,操作系 线程 统就要为每个线程安排一定的 CPU时间。它 线程 通过以一种循环方式为线程提供时间片(称 为量程),造成一种假象,仿佛所有线程都是 线程 线程 同时运行的一样。图 4-1显示了在单个CPU的 计算机上是如何实现这种运行方式的。如果 线程 线程 计算机拥有多个 CPU,那么操作系统就要使 用复杂得多的算法来实现 CPU上线程负载的 平衡。 图4-1 操作系统在单个 CPU计算机上用循环 方式为各个线程提供时间片 当创建一个进程时,系统会自动创建它的第一个线程,称为主线程。然后,该线程可以创 建其他的线程,而这些线程又能创建更多的线程。 Windows 2000 Micorsoft Windows 2000能够在拥有多个CPU的计算机上运行。例如, 我用来撰写本书的计算机就包含两个处理器。 Windows 2000可以在每个CPU上运行不 46计计第二部分 编程的具体方法 下载 同的线程,这样,多个线程就真的在同时运行了。 Windows 2000的内核能够在这种类 型的系统上进行所有线程的管理和调度。不必在代码中进行任何特定的设置就能利用 多处理器提供的各种优点。 Windows 98 Windows 98只能在单处理器计算机上运行。即使计算机配有多个处理 器,Windows每次只能安排一个线程运行,而其他的处理器则处于空闲状态。 4.1 编写第一个Windows应用程序 Windows支持两种类型的应用程序。一种是基于图形用户界面( GUI)的应用程序,另一 种是基于控制台用户界面( CUI)的应用程序。基于 GUI的应用程序有一个图形前端程序。它能 创建窗口,拥有菜单,可以通过对话框与用户打交道,并可使用所有的标准“ Windows”组件。 Windows配备的所有应用程序附件(如Notepad、Calculator和WordPad),几乎都是基于GUI的应 用程序。基于控制台的应用程序属于文本操作的应用程序。它们通常不能用于创建窗口或处理 消息,并且它们不需要图形用户界面。虽然基于 CUI的应用程序包含在屏幕上的窗口中,但是 窗口只包含文本。命令外壳程序 CMD.EXE(用于Windows 2000)和COMMAND.COM (用于 Windows 98)都是典型的基于CUI的应用程序。 这两种类型的应用程序之间的界限是非常模糊的。可以创建用于显示对话框的 CUI应用程 序。例如,命令外壳程序可能拥有一个特殊的命令,使它能够显示一个图形对话框,在这个对 话框中,可以选定你要执行的命令,而不必记住该外壳程序支持的各个不同的命令。也可以创 建一个基于 GUI的应用程序,它能将文本字符串输出到一个控制台窗口。我常常创建用于建立 控制台窗口的 GUI应用程序,在这个窗口中,我可以查看应用程序执行时的调试信息。当然你 也可以在应用程序中使用图形用户界面,而不是老式的字符界面,因为字符界面使用起来不太 方便。 当使用Microsoft Visual C++来创建应用程序时,这种集成式环境安装了许多不同的链接程 序开关,这样,链接程序就可以将相应的子系统嵌入产生的可执行程序。用于 CUI应用程序的 链接程序开关是 /SUBSYSTEM:CONDOLE,而用于 GUI 应用程序的链接程序开关是 SUBSYSTEM:WINDOWS。当用户运行一个应用程序时,操作系统的加载程序就会查看可执 行图形程序的标题,并抓取该子系统的值。如果该值指明一个 CUI应用程序,那么加载程序就 会自动保证为该应用程序创建文本控制台窗口。 如果该值指明这是个 GUI应用程序,那么加载程序不创建控制台窗口,而只是加载应用程 序。一旦应用程序启动运行,操作系统就不再考虑应用程序拥有什么类型的用户界面。 Windows应用程序必须拥有一个在应用程序启动运行时调用的进入点函数。可以使用的进 入点函数有 4个: 下载 47 第 4章 进 程计计 操作系统实际上并不调用你编写的进入点函数。它调用的是 C/C++运行期启动函数。该函 数负责对C/C++运行期库进行初始化,这样,就可以调用 malloc和free之类的函数。它还能够确 保已经声明的任何全局对象和静态 C++对象能够在代码执行以前正确地创建。下面说明源代码 中可以实现哪个进入点以及何时使用该进入点 (见表4-1)。 表4-1 应用程序的进入点 应用程序类型 进入点 嵌入可执行文件的启动函数 需要ANSI字符和字符串的 GUI应用程序 需要Unicode字符和字符串的 GUI应用程序 需要ANSI字符和字符串的 CUI应用程序 需要Unicode字符和字符串的 CUI应用程序 Wi n M a i n w Wi n M a i n main wmain Wi n M a i n C RT S t a r t u p w Wi n M a i n C RT S t a r t u p m a i n C RT S t a r t u p w m a i n C RT S t a r t u p 链接程序负责在它连接可执行文件时选择相应的 C/C++运行期启动函数。如果设定了 /SUBSYSTEM:WINDOWS链接程序开关,那么该链接程序期望找到一个 WinMain或wWinmain 函数。如果这两个函数都不存在,链接程序便返回一个“未转换的外部符号”的错误消息。否 则,它可以分别选择 WinMainCRTStartup函数或 wWinMainCRTStartup函数。 同样,如果设定了 /SUBSYSTEM:CONSOLE链接程序开关,那么该链接程序便期望找到 main或wmain函数,并且可以分别选择 mainCRTStartup函数或wmainCRTStartup函数。同样, 如果main或wmain都不存在,那么链接程序返回一条“未转换外部符号”的消息。 但是,人们很少知道这样一个情况,即可以从应用程序中全部删除 /SUBSYSTEM链接程 序开关。当这样做的时候,链接程序能够自动确定应用程序应该连接到哪个子系统。当进行链 接时,链接程序要查看代码中存在 4个函数(WinMain、wWinMain、main或wmain)中的哪一 个。然后确定可执行程序应该是哪一个子系统,并且确定可执行程序中应该嵌入哪个 C/C++启 动函数。 Windows/Visual C++编程新手常犯的错误之一是,当创建新的应用程序时,不小心选择了 错误的应用程序类型。例如,编程员可能创建一个新的 Win32应用程序项目,但是创建了一个 进入点函数main。当创建应用程序时,编程员会看到一个链接程序错误消息,因为 win32应用 程序项目设置了/SUBSYSTEM:WINDOWS链接程序开关,但是不存在 WinMain或wWinMain函 数。这时,编程员可以有 4个选择: • 将main函数改为 WinMain。通常这不是最佳的选择,因为编程员可能想要创建一个控制 台应用程序。 • 用Visual C++创建一个新的Win32 控制台应用程序,并将现有的源代码添加给新应用程 48计计第二部分 编程的具体方法 下载 序项目。这个选项冗长而乏味,因为它好像是从头开始创建应用程序,而且必须删除原 始的应用程序文件。 • 单击 Project Settings 对话框的 Link选项卡,将 /SUBSYSTEM:WINDOWS开关改为 /SUBSYSTEM:CONSOLE。这是解决问题的一种比较容易的方法,很少有人知道他们只 需要进行这项操作就行了。 • 单击Project Settings对话框的Link选项卡,然后全部删除 /SUBSYSTEM:WINDOWS开关。 这是我喜欢选择的方法,因为它提供了最大的灵活性。现在,连接程序将根据源代码中 实现的函数进行正确的操作。当用 Visual C++的Developer Studio创建新Win32应用程序或 Win32控制台应用程序项目时,我不知道为什么这没有成为默认设置。 所有的 C/C++运行期启动函数的作用基本上都是相同的。它们的差别在于,它们究竟是处 理ANSI字符串还是Unicode字符串,以及它们在对 C运行期库进行初始化后它们调用哪个进入 点函数。Visual C++配有C运行期库的源代码。可以在CR t0.c文件中找到这4个启动函数的代码。 现在将启动函数的功能归纳如下: • 检索指向新进程的完整命令行的指针。 • 检索指向新进程的环境变量的指针。 • 对C/C++运行期的全局变量进行初始化。如果包含了 StdLib.h文件,代码就能访问这些变 量。表4-1列出了这些变量。 • 对C运行期内存单元分配函数( malloc和calloc)和其他低层输入 /输出例程使用的内存栈 进行初始化。 • 为所有全局和静态 C++类对象调用构造函数。 当所有这些初始化操作完成后, C/C++启动函数就调用应用程序的进入点函数。如果编写 了一个wWinMain函数,它将以下面的形式被调用: 如果编写了一个WinMain函数,它将以下面的形式被调用: 如果编写了一个wmain函数,它将以下面的形式被调用: 如果编写了一个main函数,它将以下面的形式被调用: 当进入点函数返回时,启动函数便调用 C运行期的 exit函数,将返回值( nMainRetVal)传 递给它。 Exit函数负责下面的操作: • 调用由_onexit函数的调用而注册的任何函数。 • 为所有全局的和静态的C++类对象调用析构函数。 • 调用操作系统的ExitProcess函数,将nMainRetVal传递给它。这使得该操作系统能够撤消 进程并设置它的 exit代码。 下载 49 第 4章 进 程计计 表4-2显示了程序能够使用的 C/C++运行期全局变量。 表4-2 程序能够使用的 C/C++运行期全局变量 变量名 _osver _winmajor _winminor _winver - - a rg c - - a rg v - - w a rg v _environ _wenviron _pgmptr _wpgmptr 类型 unsigned int unsigned int unsigned int unsigned int unsigned int char** wchar_t** char** wchar_t** char* wchar_t* 说明 操作系统的测试版本。例如, Windows 2000 Beta 3是测 试版本2031。因此_osver的值是2031 采用十六进制表示法的 Windows主要版本。对于 Windows 2000来说,它的值是 5 采用十六进制表示法的 Windows次要版本。对于 Windows 2000来说,它的值是 0 ( _ w i n m a j o r < < 8 ) + _ w i n m i n o r在命令行上传递的参数号 带有指向 ANSI/Unicode字符串的指针的 __argc大小的数组 每个数组项均指向一个命令行参数 指向A N S I / U n i c o d e字符串的指针的数组。每个数组项指向 一个环境字符串 正在运行的程序的 ANSI/Unicode全路径和名字 4.1.1 进程的实例句柄 加载到进程地址空间的每个可执行文件或 DLL文件均被赋予一个独一无二的实例句柄。可 执行文件的实例作为 (w)WinMain的第一个参数 hinstExe来传递。对于加载资源的函数调用来说, 通常都需要该句柄的值。例如,若要从可执行文件的映象来加载图标资源,需要调用下面这个 函数: LoadIcon的第一个参数用于指明哪个文件(可执行文件或 DLL文件)包含你想加载的资源。 许多应用程序在全局变量中保存 (w)WinMain的hinstExe参数,这样,它就很容易被所有可执行 文件的代码访问。 Platform SDK文档中说,有些函数需要HMODULE类型的一个参数。它的例子是下面所示 的GetModuleFileName函数: 注意 实际情况说明, HMODULE与HINSTANCE是完全相同的对象。如果函数的文 档指明需要一个 HMODULE,那么可以传递一个 HINSTANCE,反过来,如果需要一 个HINSTANCE,也可以传递一个 HMODULE。之所以存在两个数据类型,原因是在 16位Windows中,HMODULE和HINSTANCE用于标识不同的东西。 (w)WinMain的hinstExe参数的实际值是系统将可执行文件的映象加载到进程的地址空间时 使用的基本地址空间。例如,如果系统打开了可执行文件并且将它的内容加载到地址 0x00400000中,那么(w)WinMain的hinstExe参数的值就是0x00400000。 可执行文件的映像加载到的基地址是由链接程序决定的。不同的链接程序可以使用不同的 50计计第二部分 编程的具体方法 下载 默认基地址。Visual C++链接程序使用的默认基地址是 0x00400000,因为这是在运行 Windows 98时可执行文件的映象可以加载到的最低地址。可以改变应用程序加载到的基地址,方法是使 用Microsoft的链接程序中的/BASE:address链接程序开关。 如果你想在Windows上加载的可执行文件的基地址小于 0x00400000,那么Windows 98加载 程序必须将可执行文件重新加载到另一个地址。这会增加加载应用程序所需的时间,不过,这 样一来,至少该应用程序能够运行。如果开发的应用程序将要同时在 Windows 98和Windows 2000上运行,应该确保应用程序的基地址是 0x00400000或者大于这个地址。 下面的GetModuleHandle函数返回可执行文件或 DLL文件加载到进程的地址空间时所用的 句柄/基地址: 当调用该函数时,你传递一个以 0结尾的字符串,用于设定加载到调用进程的地址空间的 可执行文件或 D L L 文件的名字。如果系统找到了指定的可执行文件或 D L L 文件名, GetModuleHandle便返回该可执行文件或 DLL文件映象加载到的基地址。如果系统没有找到该 文件,则返回 NULL。也可以调用 GetModuleHandle,为 pszModule参数传递 NULL, GetModuleHandle返回调用的可执行文件的基地址。这正是 C运行期启动代码调用 (w)WinMain 函数时该代码执行的操作。 请记住 GetModuleHandle 函数的两个重要特性。首先,它只查看调用进程的地址空间。如 果调用进程不使用常用的对话框函数,那么调用 GetModuleHandle并为它传递“ComDlg32”后, 就会返回 NULL,尽管 ComDlg32.dll可能加载到了其他进程的地址空间。第二,调用 GetModuleHandle并传递 NULL值,就会返回进程的地址空间中可执行文件的基地址。因此, 即使通过包含在 DLL中的代码来调用( NULL),返回的值也是可执行文件的基地址,而不是 DLL文件的基地址。 4.1.2 进程的前一个实例句柄 如前所述, C/C++运行期启动代码总是将 NULL传递给 (w)WinMain的hinstExePrev参数。该 参数用在16位Windows中,并且保留了(w)WinMain的一个参数,目的仅仅是为了能够容易地转 用16位Windows应用程序。决不应该在代码中引用该参数。由于这个原因,我总是像下面这样 编写(w)WinMain函数: 由于没有为第二个参数提供参数名,因此编译器不会发出“没有引用参数”的警告。 4.1.3 进程的命令行 当一个新进程创建时,它要传递一个命令行。该命令行几乎永远不会是空的,至少用于创 建新进程的可执行文件的名字是命令行上的第一个标记。但是在后面介绍 CreateProcess函数时 我们将会看到,进程能够接收由单个字符组成的命令行,即字符串结尾处的零。当 C运行期的 启动代码开始运行的时候,它要检索进程的命令行,跳过可执行文件的名字,并将指向命令行 其余部分的指针传递给 WinMain的pszCmdLine参数。 值得注意的是, pszCmdLine参数总是指向一个 ANSI字符串。但是,如果将 WinMain改为 下载 51 第 4章 进 程计计 wWinMain,就能够访问进程的 Unicode版本命令行。 应用程序可以按照它选择的方法来分析和转换命令行字符串。实际上可以写入 pszCmdLine 参数指向的内存缓存,但是在任何情况下都不应该写到缓存的外面去。我总是将它视为只读缓 存。如果我想修改命令行,首先我要将命令行拷贝到应用程序的本地缓存中,然后再修改本地 缓存。 也可以获得一个指向进程的完整命令行的指针,方法是调用 GetCommandLine函数: 该函数返回一个指向包含完整命令行的缓存的指针,该命令行包括执行文件的完整路径 名。 许多应用程序常常拥有转换成它的各个标记的命令行。使用全局性 __argc(或__wargv) 变量,应用程序就能访问命令行的各个组成部分。下面这个函数 CommandLineToArgvW将 Unicode字符串分割成它的各个标记: 正如该函数名的结尾处的 W所暗示的那样,该函数只存在于 Unicode版本中( W是英文单 词‘Wide’的缩写)。第一个参数 pszCmdLine指向一个命令行字符串。这通常是较早时调用 GetCommandLineW而返回的值。 PNumArgs参数是个整数地址,该整数被设置为命令行中的参 数的数目。CommandLineToArgvW将地址返回给一个 Unicode字符串指针的数组。 CommaneLineToArgvW负责在内部分配内存。大多数应用程序不释放该内存,它们在进程 运行终止时依靠操作系统来释放内存。这是完全可行的。但是如果想要自己来释放内存,正确 的方法是像下面这样调用 HeapFree函数: 4.1.4 进程的环境变量 每个进程都有一个与它相关的环境块。环境块是进程的地址空间中分配的一个内存块。每 个环境块都包含一组字符串,其形式如下: 每个字符串的第一部分是环境变量的名字,后跟一个等号,等号后面是要赋予变量的值。 52计计第二部分 编程的具体方法 下载 环境块中的所有字符串都必须按环境变量名的字母顺序进行排序。 由于等号用于将变量名与变量的值分开,因此等号不能是变量名的一部分。另外,变量中 的空格是有意义的。例如,如果声明下面两个变量,然后将 XYZ的值与ABC的值进行比较,那 么系统将报告称,这两个变量是不同的,因为紧靠着等号的前面或后面的任何空格均作为比较 时的条件被考虑在内。 例如,如果将下面两个字符串添加给环境块,后面带有空格的环境变量 XYZ包含Home, 而没有空格的环境变量 XYZ则包含 Work。 最后,必须将一个 0字符置于所有环境变量的结尾处,以表示环境块的结束。 Windows98 若要为 Windows 98 创建一组初始环境变量,必须修改系统的 AutoExec.bat文件,将一系列SET行放入该文件。每个 SET行都必须采用下面的形式: 当重新引导系统时, AutoExec.bat文件的内容被分析,设置的任何环境变量均可 供在Windows 98 会话期间启动的任何进程使用。 Windows 2000 当用户登录到Windows 2000中时,系统创建一个外壳进程并将 一组环境字符串与它相关联。通过查看注册表中的两个关键字,系统可以获得一组初 始环境字符串。 第一个关键字包含一个适用于系统的所有环境变量的列表: 第二个关键字包含适用于当前登录的用户的所有环境变量的列表: 用户可以对这些项目进行增加、删除或修改,方法是选定控制面板的System小应用 程序,单击Advanced选项卡,再单击Environment Variables按钮,打开图4-2所示的对话框: 图4-2 使用Environment Variables 对话框修改变量 下载 53 第 4章 进 程计计 只有拥有管理员权限的用户才能修改系统变量列表中的变量。 应用程序也可以使用各种注册表函数来修改这些注册表项目。但是,若要使这些 修改在所有应用程序中生效,用户必须退出系统,然后再次登录。有些应用程序,如 Explorer、 Task Manager和 Control Panel等 , 在 它 们 的 主 窗 口 收 到 WM_ SETTINGCHANGE消息时,用新注册表项目来更新它们的环境块。例如,如果要更新 注册表项目,并且想让有关的应用程序更新它们的环境块,可以调用下面的代码: 通常,子进程可以继承一组与父进程相同的环境变量。但是,父进程能够控制子进程继承 什么环境变量,后面介绍 CreateProcess函数时就会看到这个情况。所谓继承,指的是子进程获 得它自己的父进程的环境块拷贝,子进程与父进程并不共享相同的环境块。这意味着子进程能 够添加、删除或修改它的环境块中的变量,而这个变化在父进程的环境块中却得不到反映。 应用程序通常使用环境变量来使用户能够调整它的行为特性。用户创建一个环境变量并对 它进行初始化。然后,当用户启动应用程序运行时,该应用程序要查看环境块,找出该变量。 如果找到了变量,它就分析变量的值,调整自己的行为特性。 环境变量存在的问题是,用户难以设置或理解这些变量。用户必须正确地拼写变量的名字, 而且必须知道变量值期望的准确句法。另一方面,大多数图形应用程序允许用户使用对话框来 调整应用程序的行为特性。这种方法对用户来说更加友好。 如果仍然想要使用环境变量,那么有几个函数可供应用程序调用。使用 GetEnvironment Variable函数,就能够确定某个环境变量是否存在以及它的值: 当调用GetEnvironmentVariable时,pszName指向需要的变量名, pszValue指向用于存放变 量值的缓存, cchValue用于指明缓存的大小(用字符数来表示)。该函数可以返回拷贝到缓存 的字符数,如果在环境中找不到该变量名,也可以返回 0。 许多字符串包含了里面可取代的字符串。例如,我在注册表中的某个地方找到了下面的字 符串: 百分数符号之间的部分表示一个可取代的字符串。在这个例子中,环境变量的值 USERPROFILE应该被放入该字符串中。在我的计算机中,我的USERPROFILE环境变量的值是: 因此,在执行字符串替换后,产生的字符串就成为: 由于这种类型的字符串替换是很常用的,因此 Windows提供了ExpandEnvironmentStrings函 数: 当调用该函数时,pszSrc参数是包含可替换的环境变量字符串的这个字符串的地址。 pszDst 54计计第二部分 编程的具体方法 下载 参数是接收已展开字符串的缓存的地址,nSize参数是该缓存的最大值(用字符数来表示)。 最后,可以使用SetEnvironmentVariable函数来添加变量、删除变量或者修改变量的值: 该函数用于将 pszName参数标识的变量设置为 pszValue参数标识的值。如果带有指定名字 的变量已经存在,SetEnvironmentVariable就修改该值。如果指定的变量不存在,便添加该变量, 如果pszValue是NULL,便从环境块中删除该变量。 应该始终使用这些函数来操作进程的环境块。前面讲过,环境块中的字符串必须按变量名 的字母顺序来存放,这样, SetEnvironmentVariable就会很容易地找到它们。 SetEnvironment Variable函数具有足够的智能,使环境变量保持有序排列。 4.1.5 进程的亲缘性 一般来说,进程中的线程可以在主计算机中的任何一个 CPU上执行。但是一个进程的线程 可能被强制在可用 CPU的子集上运行。这称为进程的亲缘性,将在第 7章详细介绍。子进程继 承了父进程的亲缘性。 4.1.6 进程的错误模式 与每个进程相关联的是一组标志,用于告诉系统,进程对严重的错误应该如何作出反映, 这包括磁盘介质故障、未处理的异常情况、文件查找失败和数据没有对齐等。进程可以告诉系 统如何处理每一种错误。方法是调用 SetErrorMode函数: fuErrorMode参数是表4-3的任何标志按位用 OR连接在一起的组合。 表4-3 fuError Mode 参数的标志 标志 S E M _ FA I L C R I T I C A L E R R O R S S E M _ N O G O FA U LT E R R O R B O X SEM_NOOPENFILEERRORBOX S E M _ N O A L I G N M E N T FA U LT E X C E P T 说明 系统不显示关键错误句柄消息框,并将错误返回给调用进程 系统不显示一般保护故障消息框。本标志只应该由采用异常情 况处理程序来处理一般保护( GP)故障的调试应用程序来设定 当系统找不到文件时,它不显示消息框。 系统自动排除内存没有对齐的故障,并使应用程序看不到这些 故障。本标志对 x86 处理器不起作用 默认情况下,子进程继承父进程的错误模式标志。换句话说,如果一个进程的 SEM_NOGPFAULTERRORBOX标志已经打开,并且生成了一个子进程,该子进程也拥有这个 打开的标志。但是,子进程并没有得到这一情况的通知,它可能尚未编写以便处理 GP故障的 错误。如果 GP故障发生在子进程的某个线程中,该子进程就会终止运行,而不通知用户。父 进程可以防止子进程继承它的错误模式,方法是在调用CreateProcess时设定 CREATE_DEFAULT_ERROR_MODE标志(本章后面部分的内容将要介绍 CreateProcess函数)。 4.1.7 进程的当前驱动器和目录 当不提供全路径名时, Windows的各个函数就会在当前驱动器的当前目录中查找文件和目 下载 55 第 4章 进 程计计 录。例如,如果进程中的一个线程调用 CreateFile来打开一个文件(不设定全路径名),那么系 统就会在当前驱动器和目录中查找该文件。 系统将在内部保持对进程的当前驱动器和目录的跟踪。由于该信息是按每个进程来维护的, 因此改变当前驱动器或目录的进程中的线程,就可以为该进程中的所有线程改变这些信息。 通过调用下面两个函数,线程能够获得和设置它的进程的当前驱动器和目录: 4.1.8 进程的当前目录 系统将对进程的当前驱动器和目录保持跟踪,但是它不跟踪每个驱动器的当前目录。不过, 有些操作系统支持对多个驱动器的当前目录的处理。这种支持是通过进程的环境字符串来提供 的。例如,进程能够拥有下面所示的两个环境变量: 这些变量表示驱动器 C的进程的当前目录是 \Utility\Bin,并且指明驱动器 D的进程的当前目 录是\Program Files。 如果调用一个函数,传递一个驱动器全限定名,以表示一个驱动器不是当前驱动器,那么 系统就会查看进程的环境块,找出与指定驱动器名相关的变量。如果该驱动器的变量存在,系 统将该变量的值用作当前驱动器。如果该变量不存在,系统将假设指定驱动器的当前目录是它 的根目录。 例如,如果进程的当前目录是 C:\Utility|Bin,并且你调用CreateFile来打开D:ReadMe.Txt, 那么系统查看环境变量 =D。因为 =D变量存在,因此系统试图从 D:\Program Files目录打开该 ReadMe.Txt文件。如果 =D变量不存在,系统将试图从驱动器 D的根目录来打开 ReadMe.Txt。 Windows的文件函数决不会添加或修改驱动器名的环境变量,它们只是读取这些变量。 注意 可以使用C运行期函数_chdir,而不是使用Windows的SetCurrentDirectory函数来 变更当前目录。_chdir函数从内部调用SetCurrentDirectory,但是_chdir 也能够添加或 修改该环境变量,这样,不同驱动器的当前目录就可以保留。 如果父进程创建了一个它想传递给子进程的环境块,子进程的环境块不会自动继承父进程 的当前目录。相反,子进程的当前目录将默认为每个驱动器的根目录。如果想要让子进程继承 父进程的当前目录,该父进程必须创建这些驱动器名的环境变量。并在生成子进程前将它们添 加给环境块。通过调用 GetFullPathName,父进程可以获得它的当前目录: 例如,若要获得驱动器C的当前目录,可以像下面这样调用 GetFullPathName: 记住,进程的环境变量必须始终按字母顺序来排序。因此驱动器名的环境变量通常必须置 56计计第二部分 编程的具体方法 下载 于环境块的开始处。 4.1.9 系统版本 应用程序常常需要确定用户运行的是哪个 Windows版本。例如,通过调用安全性函数,应 用程序就能利用它的安全特性。但是这些函数只有在 Windows 2000上才能得到全面的实现。 Windows API拥有下面的GetVersion函数: 该函数已经有相当长的历史了。最初它是为 16位Windows设计的。它的作用很简单,在高 位字中返回MS-DOS版本号,在低位字中返回 Windows版本号。对于每个字来说,高位字节代 表主要版本号,低位字节代表次要版本号。 但是,编写该代码的程序员犯了一个小小的错误,函数的编码结果使得 Windows的版本号 颠倒了,即主要版本号位于低位字节,而次要版本号位于高位字节。由于许多程序员已经开始 使用该函数,Microsoft不得不保持函数的原样,并修改了文档,以说明这个错误。 由于围绕着 G e t Ve r s i o n 函数存在着各种混乱,因此 M i c r o s o f t 增加了一个新函数 G e t Ve r s i o n E x : 该函数要求在应用程序中指定一个 OSVERSIONINFOEX结构,并将该结构的地址传递给 GetVersionEx。OSVERSIONINFOEX的结构如下所示: OSVERSIONINFOEX结构在Windows 2000中是个新结构。 Windows的其他版本使用较老 的OSVERSIONINFO结构,它没有服务程序包、程序组屏蔽、产品类型和保留成员。 注意,对于系统的版本号中的每个成分来说,该结构拥有不同的成员。这样做的目的是, 程序员不必提取低位字、高位字、低位字节和高位字节,因此应用程序能够更加容易地对它们 期望的版本号与主机系统的版本号进行比较。表 4-4描述了OSVERSIONINFOEX结构的成员。 表4-4 OSVERSIONINFOEX结构的成员 成员 d w O S Ve r s i o n I n f o S i z e d w M a j o r Ve r s i o n d w M i n o r Ve r s i o n dwBuildNumber 描述 在调用 GetVersionEx函数之前 ,必须置为 sizeof(OSVERSIONINFO)或 s i z e o f ( O S V E R S I O N I N F O E X )。 主系统的主要版本号 主系统的次要版本号 当前系统的构建号 下载 57 第 4章 进 程计计 成员 (续) 描述 dw Platform Id s z C S D Ve r s i o n wServicePackMajor wServicePackMinor wSuiteMask w P r o d u c t Ty p e wReserved 用于标识当前系统支持的平台。它可以是 VER_PLATFORM_WIN32 (Win32),VER_PLATFORM_WIN32_WINDOWS(Windows 95/Windows 98),VER_PLATFORM_WIN32_NT(Windows NT/Windows 2000 )或 VER_PLATFORM_WIN32_CEHH(Windows CE) 本域包含了附加文本,用于提供关于已经安装的操作系统的详细信息 最新安装的服务程序包的主要版本号 最新安装的服务程序包的次要版本号 用于标识系统上存在哪个程序组( VER_SUITE_SMALLBUSINESS, VER_SUITE_ENTERPRISE,VER_SUITE_BACKOFFICE,VER_SUITE_ COMMUNICATIONS,VER_SUITE_TERMINAL,VER_SUITE_ SMALLBUSINESS_ RESTRICTED, VER_SUITE_EMBEDDEDNT和VER_SUITE_ DATACENTER) 用于标识安装了下面的哪个操作系统: VER_NT_WORKSTATION, VER_NT_SERVER或VER_NT_DOMAIN_CONTROLLER 留作将来使用 为了使操作更加容易, Windows 2000提供了一个新的函数,即 VerifyVersionInfo,用于对 主机系统的版本与你的应用程序需要的版本进行比较: 若要使用该函数,必须指定一个OSVERSIONINFOEX结构,将它的dwOSVersionInfoSize成 员初始化为该结构的大小,然后对该结构中的其他成员(这些成员对你的应用程序来说很重要) 进行初始化。当调用 VerifyVersionInfo时,dwTypeMask参数用于指明该结构的哪些成员已经进 行了初始化。 d w Ty p e M a s k 参数是用 O R连接在一起的下列标志中的任何一个标志: VER_MINORVERSION,VER_MAJORVERSION,VER_BUILDNUMBER,VER_PLATFORMID, VER_ SERVICEPACKMINOR, VER_SERVICEPACKMAJOR, VER_SUITENAME, VER_PRODUCT_ TYPE。最后一个参数dwlConditionMask是个64位值,用于控制该函数如何将 系统的版本信息与需要的信息进行比较。 d w l C o n d i t i o n M a s k描述了如何使用一组复杂的位组合进行的比较。若要创建需要的位组合, 可以使用 VER_SET_CONDITION宏: 第一个参数 dwlConditionMask用于标识一个变量,该变量的位是要操作的那些位。请注意, 不必传递该变量的地址,因为 V E R _ S E T _ C O N D I T I O N是个宏,不是一个函数。 dwTypeBitMask参数用于指明想要比较的 OSVERSIONINFOEX结构中的单个成员。若要比较多 个成员,必须多次调用 VER_SET_CONDITION宏,每个成员都要调用一次。传递给 VerifyVersionInfo的dwTypeMask参数(VER_MINORVERSION,VER_BUILDNUMBER等)的 标志与用于 VER_SET_CONDITION的dwTypeBitMask参数的标志是相同的。 VER_SET_CONDITION的最后一个参数 dwConditionMask用于指明想如何进行比较。它可 以是下列值之一: VER_EQUAL,VER_GREATER,VER_GREATER_EQUAL,VER_LESS或 VER_LESS_EQUAL。请注意,当比较VER_PRODUCT_TYPE信息时,可以使用这些值。例如, 58计计第二部分 编程的具体方法 下载 VER_NT_WORKSTATION小于VER_NT_SERVER。但是对于VER_SUITENAME信息来说,不 能使用这些测试值。相反,必须使用 VER_AND(所有程序组都必须安装)或 VER_OR(至少 必须安装程序组产品中的一个产品)。 当建立一组条件后,可以调用 VerifyVersionInfo函数,如果调用成功(如果主机系统符合 应用程序的所有要求),则返回非零值。如果 VerifyVersionInfo返回0,那么主机系统不符合要 求,或者表示对该函数的调用不正确。通过调用 GetLastError函数,就能确定该函数为什么返 回0。如果 GetLastError返回ERROR_OLD_WIN_VERSION,那么对该函数的调用是正确的, 但是系统没有满足要求。 下面是如何测试主机系统是否正是 Windows 2000的一个例子: 4.2 CreateProcess函数 可以用CreateProcess函数创建一个进程: 当一个线程调用CreateProcess时,系统就会创建一个进程内核对象,其初始使用计数是 1。 该进程内核对象不是进程本身,而是操作系统管理进程时使用的一个较小的数据结构。可以将 进程内核对象视为由进程的统计信息组成的一个较小的数据结构。然后,系统为新进程创建一 个虚拟地址空间,并将可执行文件或任何必要的 DLL文件的代码和数据加载到该进程的地址空 间中。 下载 59 第 4章 进 程计计 然后,系统为新进程的主线程创建一个线程内核对象(其使用计数为 1)。与进程内核对象 一样,线程内核对象也是操作系统用来管理线程的小型数据结构。通过执行 C/C++运行期启动 代码,该主线程便开始运行,它最终调用 WinMain、wWinMain、main或wmain函数。如果系 统成功地创建了新进程和主线程, CreateProcess便返回TRUE。 注意 在进程被完全初始化之前,CreateProcess返回TRUE。这意味着操作系统加载程 序尚未试图找出所有需要的 DLL。如果一个 DLL无法找到,或者未能正确地初始化, 那么该进程就终止运行。由于 CreateProcess返回TRUE,因此父进程不知道出现的任 何初始化问题。 这就是总的概述。下面各节将分别介绍 CreateProcess的各个参数。 4.2.1 pszApplicationName和pszCommandLine pszApplicationName和pszCommandLine参数分别用于设定新进程将要使用的可执行文件的 名字和传递给新进程的命令行字符串。下面首先让我们谈一谈 pszCommandLine参数。 注意 请注意,pszCommandLine参数的原型是PTSTR。这意味着CreateProcess期望你 将传递一个非常量字符串的地址。从内部来讲, CreateProcess实际上并不修改你传递 给它的命令行字符串。不过,在 CreateProcess返回之前,它将该字符串恢复为它的原 始形式。 这个问题很重要,因为如果命令行字符串不包含在文件映象的只读部分中,就会 出现违规访问的问题。例如,下面的代码就会导致违规访问的问题,因为 Visual C++ 将“NOTEPAD”字符串放入了只读内存: 当CreateProcess试图修改该字符串时,就会发生违规访问(较早的 Visual C++版 本将该字符串放入读 /写内存,因此调用CreateProcess不会导致违规访问的问题)。 解决这个问题的最好办法是在调用 CreateProcess之前像下面这样将常量字符串拷 贝到临时缓存中: 也可以考虑使用 Visual C++的/Gf和/GF编译器开关,这些开关用于控制重复字符 串的删除和确定这些字符串是否被放入只读内存部分(另外请注意, /ZI开关允许使用 Visual Studio的Edit &Continue调试特性,它包含了/GF开关的功能)。能做的最好工作 是使用/GF 编译器开关和临时缓存。 Microsoft能做的最好事情是安装好 Create-Process, 使它能够制作一个该字符串的临时拷贝,这样我们就不必进行这项操作。也许将来的 Windows版本能够做到这一点。 另外,如果调用Windows 2000上的CreateProcess的ANSI版本,就不会违规访问, 因为系统已经制作了一个命令行字符串的临时拷贝(详细信息请见第 2章)。 60计计第二部分 编程的具体方法 下载 可以使用 pszCommandLine参数设定一个完整的命令行,以便 CreateProcess用来创建新进 程。当CreateProcess分析pszCommandLine字符串时,它将查看字符串中的第一个标记,并假 设该标记是想运行的可执行文件的名字。如果可执行文件的文件名没有扩展名,便假设它的扩 展名为.exe。CreateProcess也按下面的顺序搜索该可执行文件: 1) 包含调用进程的.exe文件的目录。 2) 调用进程的当前目录。 3) Windows的系统目录。 4) Windows目录。 5) PATH环境变量中列出的目录。 当然,如果文件名包含全路径,系统将使用全路径来查看可执行文件,并且不再搜索这些 目录。如果系统找到了可执行文件,那么它就创建一个新进程,并将可执行文件的代码和数据 映射到新进程的地址空间中。然后系统将调用 C/C++运行期启动例程。正如前面我们讲过的那 样,C/C++运行期启动例程要查看进程的命令行,并将地址作为 (w)WinMain的pszCmdLine参 数传递给可执行文件的名字后面的第一个参数。 这一切都是在pszApplicationName参数是NULL(99%以上的时候都应该属于这种情况)时 发生的。如果不传递 NULL,可以将地址传递给pszApplicationName参数中包含想运行的可执行 文件的名字的字符串。请注意,必须设定文件的扩展名,系统将不会自动假设文件名有一 个.exe扩展名。CreateProcess假设该文件位于当前目录中,除非文件名前面有一个路径。如果在 当前目录中找不到该文件, CreateProcess将不会在任何其他目录中查找该文件,它运行失败了。 但是,即使在 pszApplicationName 参数中设定了文件名, CreateProcess 也会将 p s z C o m m a n d L i n e参数的内容作为它的命令行传递给新进程。例如,可以像下面这样调用 CreateProcess: 系统启动Notepad应用程序,但是 Notepad的命令行是 WORDPAD README.TXT。这种变 异情况当然有些奇怪,不过这正是 CreateProcess运行的样子。这个由 pszApplicationName参数 提供的能力实际上被添加给了 CreateProcess,以支持Windows 2000的POSIX子系统。 4.2.2 psaProcess、psaThread和binheritHandles 若要创建一个新进程,系统必须创建一个进程内核对象和一个线程内核对象(用于进程的 主线程),由于这些都是内核对象,因此父进程可以得到机会将安全属性与这两个对象关联起 来。可以使用 psaProcess和psaThread参数分别设定进程对象和线程对象需要的安全性。可以为 这些参数传递NULL,在这种情况下,系统为这些对象赋予默认安全性描述符。也可以指定两 个SECURITY_ATTRIBUTES结构,并对它们进行初始化,以便创建自己的安全性权限,并将 它们赋予进程对象和线程对象。 将SECURITY_ATTRIBUTES结构用于 psaProcess和psaThread参数的另一个原因是,父进 程将来生成的任何子进程都可以继承这两个对象句柄中的任何一个(第 3章已经介绍了内核对 象句柄的继承性的有关理论)。 清单4-1显示了一个说明内核对象继承性的简单程序。假设 Process A创建了Process B,方 下载 61 第 4章 进 程计计 法是调用CreateProcess,为psaProcess参数传递一个 SECURITY_ATTRIBUTES结构的地址,在 这个结构中, bInheritHandles成员被置为 TRUE。在同样这个函数调用中, psaThread参数指向 另一个 SECURITY_ATTRIBUTES结构,在这个结构中, bInheritHandles成员被置为 FALSE。 当系统创建Process B时,它同时指定一个进程内核对象和一个线程内核对象,并且将句柄 返回给ppiProcInfo参数(很快将介绍该参数)指向的结构中的 Process A 。这时,使用这些句柄, Process A就能够对新创建的进程对象和线程对象进行操作。 现在,假设Process A第二次调用CreateProcess函数,以便创建Process C。Process A可以决 定是否为Process C赋予对Process A能够访问的某些内核对象进行操作的能力。 BInheritHandles 参数可以用于这个目的。如果 bInheritHandles被置为TRUE,系统就使 Process C继承Process A 中的任何可继承句柄。在这种情况下, Process B 的进程对象的句柄是可继承的。无论 CreateProcess的bInheritHandles参数的值是什么, Process B的主线程对象的句柄均不能继承。 同样,如果 Process A调用CreateProcess,为bInheritHandles传递FALSE,那么Process C将不能 继承Process A 目前使用的任何句柄。 清单4-1 内核对象句柄继承性的一个示例 62计计第二部分 编程的具体方法 下载 4.2.3 fdwCreate fdwCreate参数用于标识标志,以便用于规定如何来创建新进程。如果将标志逐位用 OR操 作符组合起来的话,就可以设定多个标志。 • EBUG_PROCESS标志用于告诉系统,父进程想要调试子进程和子进程将来生成的任何 进程。本标志还告诉系统,当任何子进程(被调试进程)中发生某些事件时,将情况通 知父进程(这时是调试程序)。 • DEBUG_ONLY_THIS_PROCESS标志与DEBUG_PROCESS标志相类似,差别在于,调 试程序只被告知紧靠父进程的子进程中发生的特定事件。如果子进程生成了别的进程, 那么将不通知调试程序在这些别的进程中发生的事件。 • CREATE_SUSPENDED标志可导致新进程被创建,但是,它的主线程则被挂起。这使得 父进程能够修改子进程的地址空间中的内存,改变子进程的主线程的优先级,或者在进 程有机会执行任何代码之前将进程添加给一个作业。一旦父进程修改了子进程,父进程 将允许子进程通过调用ResumeThread函数来执行代码(第 7章将作详细介绍)。 • DETACHED_PROCESS标志用于阻止基于CUI的进程对它的父进程的控制台窗口的访问, 并告诉系统将它的输出发送到新的控制台窗口。如果基于 CUI的进程是由另一个基于CUI 的进程创建的,那么按照默认设置,新进程将使用父进程的控制台窗口(当通过命令外 壳程序来运行 C编译器时,新控制台窗口并不创建,它的输出将被附加在现有控制台窗 口的底部)。通过设定本标志,新进程将把它的输出发送到一个新控制台窗口。 • CREATE_NEW_CONSOLE标志负责告诉系统,为新进程创建一个新控制台窗口。如果 同时设定 CREATE_NEW_CONSOLE和DETACHED_PROCESS标志,就会产生一个错误。 下载 63 第 4章 进 程计计 • CREATE_NO_WINDOW标志用于告诉系统不要为应用程序创建任何控制台窗口。可以 使用本标志运行一个没有用户界面的控制台应用程序。 • CREATE_NEW_PROCESS_GROUP标志用于修改用户在按下 Ctrl+C或Ctrl+Break键时得 到通知的进程列表。如果在用户按下其中的一个组合键时,你拥有若干个正在运行的 CUI进程,那么系统将通知进程组中的所有进程说,用户想要终止当前的操作。当创建 一个新的 CUI进程时,如果设定本标志,可以创建一个新进程组。如果该进程组中的一 个进程处于活动状态时用户按下 Ctrl+C或Ctrl_Break键,那么系统只通知用户需要这个进 程组中的进程。 • CREATE_DEFAULT_ERROR_MODE标志用于告诉系统,新进程不应该继承父进程使用 的错误模式(参见本章前面部分中介绍的 SetErrorMode函数)。 • CREATE_SEPARATE_WOW_VDM标志只能当你在Windows 2000上运行16位Windows应 用程序时使用。它告诉系统创建一个单独的 DOS虚拟机(VDM),并且在该 VDM中运行 16位Windows应用程序。按照默认设置,所有 16位Windows应用程序都在单个共享的 VDM中运行。在单独的 VDM 中运行应用程序的优点是,如果应用程序崩溃,它只会使 单个VDM停止工作,而在别的 VDM中运行的其他程序仍然可以继续正常运行。另外, 在单独的VDM中运行的16位Windows应用程序有它单独的输入队列。这意味着如果一个 应用程序临时挂起,在各个 VDM中的其他应用程序仍然可以继续接收输入信息。运行多 个VDM的缺点是,每个 VDM都要消耗大量的物理存储器。 Windows 98在单个VDM中运 行所有的 16位Windows应用程序,不能改变这种情况。 • CREATE_SHARED_WOW_VDM标志只能当你在 Windows 2000上运行16位Windows应用 程序时使用。按照默认设置,除非设定了 CREATE_SEPARATE_WOW_VDM标志,否则 所有 1 6位Wi n d o w s 应用程序都必须在单个 V D M 中运行。但是,通过在注册表中将 HKEY_LOCAL_MACHINE\system\CurrentControlSet\Control\WOW下的DefaultSeparate V D M 设置为“ y e s ”,就可以改变该默认行为特性。这时, C R E AT E _ S H A R E D _ WOW_VDM标志就在系统的共享 VDM中运行16位Windows应用程序。 • CREATE_UNICODE_ENVIRONMENT标志用于告诉系统,子进程的环境块应该包含 Unicode字符。按照默认设置,进程的环境块包含的是 ANSI字符串。 • CREATE_FORCEDOS标志用于强制系统运行嵌入 16位OS/2应用程序的MOS-DOS应用程 序。 • CREATE_BREAKAWAY_FROM_JOB标志用于使作业中的进程生成一个与作业相关联的 新进程(详细信息见第 5章)。 fdwCreate参数也可以用来设定优先级类。不过用不着这样做,并且对于大多数应用程序来说 不应该这样做,因为系统会为新进程赋予一个默认优先级。表4-5显示了各种可能的优先级类别。 表4-5 优先级类别 优先级类别 空闲 低于正常 正常 高于正常 高 实时 标志的标识符 IDLE_PRIORITY_CLASS BELOW_NORMAL_PRIORITY_CLASS NORMAL_PRIORITY_CLASS ABOVE_NORMAL_PRIORITY_CLASS HIGH_PRIORITY_CLASS R E A LT I M E _ P R I O R I T Y _ C L A S S 64计计第二部分 编程的具体方法 下载 这些优先级类将会影响进程中包含的线程如何相对于其他进程的线程来进行调度。详细说 明请见第 7章。 注意 BELOW_NORMAL_PRIORITY_CLASS和ABOVE_NORMAL_PRIORITY_CLASS 这两个优先级类在Windows 2000中是新类,Windows NT 4(或更早的版本)、Windows 95 或Windows 98 均不支持这两个类。 4.2.4 pvEnvironment pvEnvironment参数用于指向包含新进程将要使用的环境字符串的内存块。在大多数情况 下,为该参数传递 NULL,使子进程能够继承它的父进程正在使用的一组环境字符串。也可以 使用GetEnvironmentStrings函数: 该函数用于获得调用进程正在使用的环境字符串数据块的地址。可以使用该函数返回的地 址,作为CreateProcess的pvEnvironment参数。如果为pvEnvironment参数传递NULL,那么这正 是CreateProcess函数所做的操作。当不再需要该内存块时,应该调用 FreeEnvironmentStrings函 数将内存块释放: 4.2.5 pszCurDir pszCurDir参数允许父进程设置子进程的当前驱动器和目录。如果本参数是 NULL,则新进 程的工作目录将与生成新进程的应用程序的目录相同。如果本参数不是 NULL,那么pszCurDir 必须指向包含需要的工作驱动器和工作目录的以 0结尾的字符串。注意,必须设定路径中的驱 动器名。 4.2.6 psiStartInfo psiStartInfo参数用于指向一个 STARTUPINFO结构: 下载 65 第 4章 进 程计计 当Windows创建新进程时,它将使用该结构的有关成员。大多数应用程序将要求生成的应 用程序仅仅使用默认值。至少应该将该结构中的所有成员初始化为零,然后将 cb成员设置为该 结构的大小: 如果未能将该结构的内容初始化为零,那么该结构的成员将包含调用线程的堆栈上的任何 无用信息。将该无用信息传递给 CreateProcess,将意味着有时会创建新进程,有时则不能创建 新进程,完全取决于该无用信息。有一点很重要,那就是将该结构的未用成员设置为零,这样, CreateProcess就能连贯一致地运行。不这样做是开发人员最常见的错误。 这时,如果想要对该结构的某些成员进行初始化,只需要在调用 CreateProcess之前进行这 项操作即可。我们将依次介绍每个成员。有些成员只有在子应用程序创建一个重叠窗口时才有 意义,而另一些成员则只有在子应用程序执行基于 CUI的输入和输出时才有意义。表 4-6描述了 每个成员的作用。 表4-6 STARTUPINFO结构的成员 成员 cb lpReserved lpDesktop 窗口,控制台 还是两者兼有 两者兼有 两者兼有 两者兼有 l p Ti t l e dwX dwY dwXSize d wYsize dwXCountChars dwYCountChars dwFillAttribute dwFlags w S h o w Wi n d o w 控制台 两者兼有 两者兼有 控制台 控制台 两者兼有 窗口 作用 包含STARTUPINFO结构中的字节数。如果 Microsoft将来 扩展该结构,它可用作版本控制手段。应用程序必须将 cb初 始化为sizeof(STARTUPINFO) 保留。必须初始化为 NULL 用于标识启动应用程序所在的桌面的名字。如果该桌面存 在,新进程便与指定的桌面相关联。如果桌面不存在,便创 建一个带有默认属性的桌面,并使用为新进程指定的名字。 如果 lpDesktop是NULL (这是最常见的情况),那么该进程 将与当前桌面相关联 用于设定控制台窗口的名称。如果 lpTitle是NULL,则可 执行文件的名字将用作窗口名 用于设定应用程序窗口在屏幕上应该放置的位置的 x和y坐 标(以像素为单位)。只有当子进程用 CW_USEDEFAULT作 为C r e a t e Wi n d o w的x参数来创建它的第一个重叠窗口时,才 使用这两个坐标。若是创建控制台窗口的应用程序,这些成 员用于指明控制台窗口的左上角 用于设定应用程序窗口的宽度和长度(以像素为单位)只有 当子进程将 CW_USEDEFAULT用作CreateWindow的nWidth 参数来创建它的第一个重叠窗口时,才使用这些值。若是创 建控制台窗口的应用程序,这些成员将用于指明控制台窗口 的宽度 用于设定子应用程序的控制台窗口的宽度和高度(以字符 为单位) 用于设定子应用程序的控制台窗口使用的文本和背景颜色 请参见下一段和表 4-7的说明 用于设定如果子应用程序初次调用的 ShowWindow 将 SW_SHOWDEFAULT作为nCmdShow参数传递时,该应用程 序的第一个重叠窗口应该如何出现。本成员可以是通常用于 Show Window函数的任何一个SW_*标识符 66计计第二部分 编程的具体方法 下载 成员 窗口,控制台 还是两者兼有 (续) 作用 cbReserved2 lpReserved2 hStdInput hStdOutput hStdError 两者兼有 两者兼有 控制台 保留。必须被初始化为 0 保留。必须被初始化为 NULL 用于设定供控制台输入和输出用的缓存的句柄。按照默认 设置,hStdInput用于标识键盘缓存, hStdOutput和hStdError 用于标识控制台窗口的缓存 现在介绍 dwFlags的成员。该成员包含一组标志,用于修改如何来创建子进程。大多数标 志只是告诉CreateProcess,STARTUPINFO结构的其他成员是否包含有用的信息,或者某些成 员是否应该忽略。表 4-7标出可以使用的标志及其含义。 表4-7 使用标志及含义 标志 含义 S TA RT F _ U S E S I Z E S TA RT F _ U S E S H O W W I N D O W S TA RT F _ U S E P O S I T I O N S TA RT F _ U S E C O U N T C H A R S S TA RT F _ U S E F I L L AT T R I B U T E S TA RT F _ U S E S T D H A N D L E S S TA RT F _ R U N _ F U L L S C R E E N 使用dwXSize和dwYSize成员 使用wShowWindow成员 使用dwX和dwY成员 使用dwXCountChars和dwYCount Chars成员 使用dwFillAttribute成员 使用hStdInput、hStdOutput和hStdError成员 强制在x86计算机上运行的控制台应用程序以全屏幕方式启动运行 另外还有两个标志,即 STARTF_FORCEONFEEDBACK和STARTF_+FORCEOFFFEEDBACK,当启动一个新进程时,它们可以用来控制鼠标的光标。由于 Windows支持真正的 多任务抢占式运行方式,因此可以启动一个应用程序,然后在进程初始化时,使用另一个程序。 为了向用户提供直观的反馈信息, CreateProcess能够临时将系统的箭头光标改为一个新光标, 即沙漏箭头光标: 该光标表示可以等待出现某种情况,也可以继续使用系统。当启动另一个进程时, CreateProcess函数使你能够更好地控制光标。当设定 STARTF_FORCEOFFFEEDBACK标志时, CreateProcess并不将光标改为沙漏。 STARTF_FORCEONFEEDBACK可使CreateProcess能够监控新进程的初始化,并可根据结 果来改变光标。当使用该标志来调用 CreateProcess时,光标改为沙漏。过 2s后,如果新进程没 有调用GUI,CreateProcess 将光标恢复为箭头。 如果该进程在 2s内调用了 GUI,CreateProcess将等待该应用程序显示一个窗口。这必须在 进程调用 GUI后5s内发生。如果没有显示窗口, CreateProcess就会恢复原来的光标。如果显示 了一个窗口, CreateProcess将使沙漏光标继续保留 5s。如果某个时候该应用程序调用了 GetMessage函数,指明它完成了初始化,那么 CreateProcess就会立即恢复原来的光标,并且停 止监控新进程。 在结束这一节内容的介绍之前,我想讲一讲STARTUPINFO的wShowWindow成员。你将该成 员初始化为传递给(w)WinMain的最后一个参数nCmdShow的值。该成员显示你想要传递给新进程 的(w)WinMain函数的最后一个参数nCmdShow的值。它是可以传递给ShowWindow函数的标识符之 一。通常,nCmdShow的值既可以是SW_SHOWNORMAL,也可以是SW_ SHOWMINNOACTIVE。 下载 67 第 4章 进 程计计 但是,它有时可以是 SW_SHOWDEFAULT。 当在 Explorer中启动一个应用程序时,该 应用程序的 ( w ) Wi n M a i n 函数被调用,而 SW_SHOWNORMAL 则作为 nCmdShow参数来 传递。如果为该应用程序创建了一个快捷方式, 可以使用快捷方式的属性页来告诉系统,应用 程序的窗口最初应该如何显示。图 4-3显示了运 行Notepad的快捷方式的属性页。注意,使用 R u n 选项的组合框,就能够设定如何显示 Notepad的窗口。 当使用 Explorer 来启动该快捷方式时, Explorer会正确地准备 STARTUPINFO结构并调 用CreateProcess。这时 Notepad开始运行,并且 为nCmdShow参数将SW_SHOWMINNOACTIVE 传递给它的 (w)WinMain函数。 运用这样的方法,用户能够很容易地启动 一个应用程序,其主窗口可以用正常状态、最 图4-3 运行Notepad的快捷方式的属性页 小或最大状态进行显示。 最后,应用程序可以调用下面的函数,以便获取由父进程初始化的 STARTUPINFO结构的 拷贝。子进程可以查看该结构,并根据该结构的成员的值来改变它的行为特性。 注意 虽然Windows文档没有明确地说明,但是在调用 GetStartInfo函数之前,必须像 下面这样对该结构的 cb成员进行初始化: 4.2.7 ppiProcInfo ppiProcInfo参数用于指向你必须指定的 PROCESS_INFORMATION结构。CreateProcess在 返回之前要对该结构的成员进行初始化。该结构的形式如下面所示: 如前所述,创建新进程可使系统建立一个进程内核对象和一个线程内核对象。在创建进程 的时候,系统为每个对象赋予一个初始使用计数值 1。然后,在createProcess返回之前,该函数 打开进程对象和线程对象,并将每个对象的与进程相关的句柄放入 PROCESS_INFORMATION 结构的hProcess和hThread成员中。当 CreateProcess在内部打开这些对象时,每个对象的使用计 数就变为 2。 68计计第二部分 编程的具体方法 下载 这意味着在系统能够释放进程对象前,该进程必须终止运行(将使用计数递减为 1),并且 父进程必须调用 CloseHandle(再将使用计数递减 1,使之变为 0)。同样,若要释放线程对象, 该线程必须终止运行,父进程必须关闭线程对象的句柄(关于释放线程对象的详细说明,请参 见本章后面“子进程”一节的内容)。 注意 必须关闭子进程和它的主线程的句柄,以避免在应用程序运行时泄漏资源。当 然,当进程终止运行时,系统会自动消除这些泄漏现象,但是,当进程不再需要访问 子进程和它的线程时,编写得较好的软件能够显式关闭这些句柄(通过调用 CloseHandle函数来关闭)。不能关闭这些句柄是开发人员最常犯的错误之一。 由于某些原因,许多开发人员认为,关闭进程或线程的句柄,会促使系统撤消该 进程或线程。实际情况并非如此。关闭句柄只是告诉系统,你对进程或线程的统计数 据不感兴趣。进程或线程将继续运行,直到它自己终止运行。 当进程内核对象创建后,系统赋予该对象一个独一无二的标识号,系统中的其他任何进程 内核对象都不能使用这个相同的 ID号。线程内核对象的情况也一样。当一个线程内核对象创建 时,该对象被赋予一个独一无二的、系统范围的 ID号。进程 ID和线程 ID共享相同的号码池。 这意味着进程和线程不可能拥有相同的 I D 。另外,对象决不会被赋予 0 作为其 I D。在 CreateProcess返回之前,它要用这些 ID填入PROCESS_INFORMATION结构的dwProcessId和 dwThreadId成员中。ID使你能够非常容易地识别系统中的进程和线程。一些实用工具(如 Task Manager)对ID使用得最多,而高效率的应用程序则使用得很少。由于这个原因,大多数应用 程序完全忽略ID。 如果应用程序使用 ID来跟踪进程和线程,必须懂得系统会立即复用进程 ID和线程ID。例 如,当一个进程被创建时,系统为它指定一个进程对象,并为它赋予 ID值122。如果创建了一 个新进程对象,系统不会将相同的 ID赋予给它。但是,如果第一个进程对象被释放,系统就可 以将 122 赋予创建的下一个进程对象。记住这一点后,就能避免编写引用不正确的进程对象或 线程对象的代码。获取进程 ID是很容易的,保存该 ID也不难,但是,接下来你应该知道,该 ID标识的进程已被释放,新进程被创建并被赋予相同的 ID。当使用已经保存的进程 ID时,最 终操作的是新进程,而不是原先获得 ID的进程。 有时,运行的应用程序想要确定它的父进程。首先应该知道只有在生成子进程时,才存在 进程之间的父子关系。在子进程开始执行代码前, Windows不再考虑存在什么父子关系。较早 的 Wi n d o w s 版本没有提供让进程查询其父进程的函数。现在, To o l H e l p 函数通过 PROCESSENTRY32结构使得这种查询成为可能。在这个结构中有一个 th32ParentProcessID成 员,根据文档的说明,它能返回进程的父进程的 ID。 系统无法记住每个进程的父进程的 ID,但是,由于ID是被立即重复使用的,因此,等到获 得父进程的 ID时,该 ID可能标识了系统中一个完全不同的进程。父进程可能已经终止运行。如 果应用程序想要与它的“创建者”进行通信,最好不要使用 ID。应该定义一个持久性更好的机 制,比如内核对象和窗口句柄等。 若要确保进程 ID或线程 ID不被重复使用,唯一的方法是保证进程或线程的内核对象不会 被撤消。如果刚刚创建了一个新进程或线程,只要不关闭这些对象的句柄,就能够保证进程 对象不被撤消。一旦应用程序结束使用该 ID,那么调用 CloseHandle就可以释放内核对象,要 记住,这时使用或依赖进程 ID,对来说将不再安全。如果使用的是子进程,将无法保证父进 程或父线程的有效性,除非父进程复制了它自己的进程对象或线程对象的句柄,并让子进程 继承这些句柄。 下载 69 第 4章 进 程计计 4.3 终止进程的运行 若要终止进程的运行,可以使用下面四种方法: • 主线程的进入点函数返回(最好使用这个方法)。 • 进程中的一个线程调用ExitProcess函数(应该避免使用这种方法)。 • 另一个进程中的线程调用TerminateProcess函数(应该避免使用这种方法)。 • 进程中的所有线程自行终止运行(这种情况几乎从未发生)。 这一节将介绍所有这四种方法,并且说明进程结束时将会发生什么情况。 4.3.1 主线程的进入点函数返回 始终都应该这样来设计应用程序,即只有当主线程的进入点函数返回时,它的进程才终止 运行。这是保证所有线程资源能够得到正确清除的唯一办法。 让主线程的进入点函数返回,可以确保下列操作的实现: • 该线程创建的任何 C++对象将能使用它们的析构函数正确地撤消。 • 操作系统将能正确地释放该线程的堆栈使用的内存。 • 系统将进程的退出代码(在进程的内核对象中维护)设置为进入点函数的返回值。 • 系统将进程内核对象的返回值递减 1。 4.3.2 ExitProcess函数 当进程中的一个线程调用 ExitProcess函数时,进程便终止运行: 该函数用于终止进程的运行,并将进程的退出代码设置为 fuExitCode。ExitProcess函数并 不返回任何值,因为进程已经终止运行。如果在调用 ExitProcess之后又增加了什么代码,那么 该代码将永远不会运行。 当主线程的进入点函数( WinMain、wWinMain、main或wmain)返回时,它将返回给 C/C++运行期启动代码,它能正确地清除该进程使用的所有的 C运行期资源。当 C运行期资源被 释放之后,C运行期启动代码就显式调用 ExitProcess,并将进入点函数返回的值传递给它。这 解释了为什么只需要主线程的进入点函数返回,就能够终止整个进程的运行。请注意,进程中 运行的任何其他线程都随着进程而一道终止运行。 Windows Platform SDK文档声明,进程要等到所有线程终止运行之后才终止运行。就操作 系统而言,这种说法是对的。但是, C/C++运行期对应用程序采用了不同的规则,通过调用 ExitProcess ,使得 C/C++运行期启动代码能够确保主线程从它的进入点函数返回时,进程便终 止运行,而不管进程中是否还有其他线程在运行。不过,如果在进入点函数中调用 ExitThread, 而不是调用 ExtiProcess或者仅仅是返回,那么应用程序的主线程将停止运行,但是,如果进程 中至少有一个线程还在运行,该进程将不会终止运行。 注意,调用 ExitProcess或ExitThread可使进程或线程在函数中就终止运行。就操作系统而 言,这很好,进程或线程的所有操作系统资源都将被全部清除。但是, C/C++应用程序应该避 免调用这些函数,因为 C/C++运行期也许无法正确地清除。请看下面的代码: 70计计第二部分 编程的具体方法 下载 当上面的代码运行时,将会看到: 它创建了两个对象,一个是全局对象,另一个是局部对象。不过决不会看到 Destructor这 个单词出现, C++对象没有被正确地撤消,因为 ExitProcess函数强制进程在现场终止运行, C/C++运行期没有机会进行清除。 如前所述,决不应该显式调用 ExitProcess函数。如果在上面的代码中删除了对 ExitProcess 的调用,那么运行该程序产生的结果如下: 只要让主线程的进入点函数返回, C/C++运行期就能够执行它的清除操作,并且正确地撤 消任何或所有的 C++对象。顺便讲一下,这个说明不仅仅适用于 C++对象。C++运行期能够代 表进程执行许多操作,最好允许运行期正确地将它清除。 注意 显式调用ExitProcess和ExitThread是导致应用程序不能正确地将自己清除的常见 原因。在调用 ExitThread时,进程将继续运行,但是可能会泄漏内存或其他资源。 4.3.3 TerminateProcess函数 调用TerminateProcess函数也能够终止进程的运行: 该函数与ExitProcess有一个很大的差别,那就是任何线程都可以调用 TerminateProcess来终 止另一个进程或它自己的进程的运行。 hProcess参数用于标识要终止运行的进程的句柄。当进 程终止运行时,它的退出代码将成为你作为 fuExitCode参数来传递的值。 只有当无法用另一种方法来迫使进程退出时,才应该使用 TerminateProcess。终止运行的 进程绝对得不到关于它将终止运行的任何通知,因为应用程序无法正确地清除,并且不能避免 自己被撤消(除非通过正常的安全机制)。例如,进程无法将内存中它拥有的任何信息迅速送 往磁盘。 下载 71 第 4章 进 程计计 虽然进程确实没有机会执行自己的清除操作,但是操作系统可以在进程之后进行全面的清 除,使得所有操作系统资源都不会保留下来。这意味着进程使用的所有内存均被释放,所有打 开的文件全部关闭,所有内核对象的使用计数均被递减,同时所有的用户对象和 GDI对象均被 撤消。 一旦进程终止运行(无论采用何种方法),系统将确保该进程不会将它的任何部分遗留下 来。绝对没有办法知道该进程是否曾经运行过。进程一旦终止运行,它绝对不会留下任何蛛丝 马迹。希望这是很清楚的。 注意 TerminateProcess函数是个异步运行的函数,也就是说,它会告诉系统,你想要 进程终止运行,但是当函数返回时,你无法保证该进程已经终止运行。因此,如果想 要确切地了解进程是否已经终止运行,必须调用WaitForSingleObject函数(第9章介绍) 或者类似的函数,并传递进程的句柄。 进程中的线程何时全部终止运行 如果进程中的所有线程全部终止运行(因为它们调用了 ExitThread函数,或者因为它们已 经用TerminateProcess函数终止运行),操作系统就认为没有理由继续保留进程的地址空间。这 很好,因为在地址空间中没有任何线程执行任何代码。当系统发现没有任何线程仍在运行时, 它就终止进程的运行。出现这种情况时,进程的退出代码被设置为与终止运行的最后一个线程 相同的退出代码。 4.3.4 进程终止运行时出现的情况 当进程终止运行时,下列操作将启动运行: 1) 进程中剩余的所有线程全部终止运行。 2) 进程指定的所有用户对象和 GDI对象均被释放,所有内核对象均被关闭(如果没有其他 进程打开它们的句柄,那么这些内核对象将被撤消。但是,如果其他进程打开了它们的句柄, 内核对象将不会撤消)。 3) 进程的退出代码将从STILL_ACTIVE改为传递给ExitProcess或TerminateProcess的代码。 4) 进程内核对象的状态变成收到通知的状态(关于传送通知的详细说明,参见第 9章)。系 统中的其他线程可以挂起,直到进程终止运行。 5) 进程内核对象的使用计数递减 1。 注意,进程的内核对象的寿命至少可以达到进程本身那么长,但是进程内核对象的寿命可 能大大超过它的进程寿命。当进程终止运行时,系统能够自动确定它的内核对象的使用计数。 如果使用计数降为 0,那么没有其他进程拥有该对象打开的句柄,当进程被撤消时,对象也被 撤消。 不过,如果系统中的另一个进程拥有正在被撤消的进程的内核对象的打开句柄,那么该进 程内核对象的使用计数不会降为 0。当父进程忘记关闭子进程的句柄时,往往就会发生这样的 情况。这是个特性,而不是错误。记住,进程内核对象维护关于进程的统计信息。即使进程已 经终止运行,该信息也是有用的。例如,你可能想要知道进程需要多少 CPU时间,或者,你想 通过调用 GetExitCodeProcess来获得目前已经撤消的进程的退出代码: 该函数查看进程的内核对象(由 hProcess参数来标识),取出内核对象的数据结构中用于标 72计计第二部分 编程的具体方法 下载 识进程的退出代码的成员。该退出代码的值在 pdwExitCode参数指向的DWORD中返回。 可以随时调用该函数。如果调用 GetExitCodeProcess函数时进程尚未终止运行,那么该函 数就用STILL_ACTIVE标识符(定义为 0x103)填入DWORD。如果进程已经终止运行,便返 回数据的退出代码值。 也许你会认为,你可以编写代码,通过定期调用 GetExitCodeProcess函数并且检查退出代 码来确定进程是否已经终止运行。大多数情况下,这是可行的,但是效率不高。下一段将介绍 用什么正确的方法来确定进程何时终止运行。 再一次提醒你,应该通过调用 CloseHandle函数,告诉系统你对进程的统计数据已经不再 感兴趣。如果进程已经终止运行, CloseHandle将递减内核对象的使用计数,并将它释放。 4.4 子进程 当你设计应用程序时,可能会遇到这样的情况,即想要另一个代码块来执行操作。通过调 用函数或子例程,你可以一直象这样分配工作。当调用一个函数时,在函数返回之前,代码将 无法继续进行操作。大多数情况下,需要实施这种单任务同步。让另一个代码块来执行操作的 另一种方法是在进程中创建一个新线程,并让它帮助进行操作。这样,当其他线程在执行需要 的操作时,代码就能继续进行它的处理。这种方法很有用,不过,当线程需要查看新线程的结 果时,它会产生同步问题。 另一个解决办法是生成一个新进程,即子进程,以便帮助你进行操作。比如说,需要进行 的操作非常复杂。若要处理该操作,只需要在同一个进程中创建一个新线程。你编写一些代码, 对它进行测试,但是得到一些不正确的结果。也许你的算法存在错误,也可能间接引用的对象 不正确,并且不小心改写了地址空间中的某些重要内容。进行操作处理时,如果要保护地址空 间,方法之一是让一个新进程来执行这项操作。然后,在继续进行工作之前,可以等待新进程 终止运行,或者可以在新进程工作时,继续进行工作。 不过,新进程可能需要对地址空间中包含的数据进行操作。这时最好让进程在它自己的地 址空间中运行,并且只让它访问父进程地址空间中的相关数据,这样就能保护与手头正在执行 的任务无关的全部数据。 Windows提供了若干种方法,以便在不同的进程中间传送数据,比如 动态数据交换( DDE)、OLE、管道和邮箱等。共享数据最方便的方法之一是,使用内存映射 文件(关于内存映射文件的详细说明请参见第 17章)。 如果想创建新进程,让它进行一些操作,并且等待结果,可以使用类似下面的代码: 下载 73 第 4章 进 程计计 在上面的代码段中,你创建了一个新进程,如果创建成功,可以调用 WaitForSingleObject 函数: 第9章将全面介绍 WaitForSingleObject函数。现在,必须知道的情况是,它会一直等到 hObject参数标识的对象得到通知的时候。当进程对象终止运行时,它们才会得到通知。因此 对 WaitForSingleObject的调用会将父进程的线程挂起,直到子进程终止运行。当 WaitForSingleObject返回时,通过调用 GetExitCodeProcess函数,就可以获得子进程的退出代 码。 在上面的代码段中调用CloseHandle函数,可使系统为线程和进程对象的使用计数递减为 0, 从而使对象的内存得以释放。 你会发现,在这个代码段中,在 CreateProcess返回后,立即关闭了子进程的主线程内核对 象的句柄。这并不会导致子进程的主线程终止运行,它只是递减子进程的主线程对象的使用计 数。这种做法的优点是,假设子进程的主线程生成了另一个线程,然后主线程终止运行,这时, 如果父进程不拥有子进程的主线程对象的句柄,那么系统就可以从内存中释放子进程的主线程 对象。但是,如果父进程拥有子进程的线程对象的句柄,那么在父进程关闭句柄前,系统将不 能释放该对象。 运行独立的子进程 大多数情况下,应用程序将另一个进程作为独立的进程来启动。这意味着进程创建和开始 运行后,父进程并不需要与新进程进行通信,也不需要在完成它的工作后父进程才能继续运行。 这就是Explorer的运行方式。当Explorer为用户创建一个新进程后,它并不关心该进程是否继续 运行,也不在乎用户是否终止它的运行。 若要放弃与子进程的所有联系, Explorer必须通过调用 CloseHandle来关闭它与新进程及它 的主线程之间的句柄。下面的代码示例显示了如何创建新进程以及如何让它以独立方式来运 行: 4.5 枚举系统中运行的进程 许多软件开发人员都试图为 Windows编写需要枚举正在运行的一组进程的工具或实用程 序。Windows API原先没有用于枚举正在运行的进程的函数。不过, Windows NT一直在不断 74计计第二部分 编程的具体方法 下载 更新称为 Performance Data的数据库。该数据库包含大量的信息,并且可以通过注册表函数来 访问(比如以 HKEY_PERFORMANCE_DATA为根关键字的 RegQueryValueEx函数)。由于下列 原因,很少有Windows程序员知道性能数据库的情况: • 它没有自己特定的函数,它只是使用现有的注册表函数。 • Windows 95和Windows 98 没有配备该数据库。 • 该数据库中的信息布局比较复杂,许多软件开发人员都不愿使用它。这妨碍了人们通过 言传口说来传播它的存在。 为了使该数据库的使用变得更加容易, Microsoft开发了一组 Performance Data Helper函数 (包含在 PDH.dll文件中)。若要了解它的详细信息,请查看 Platform SDK文档中的 Performance Data Helper的内容。 如前所述, Windows 95和Windows 98没有配备该数据库。不过它们有自己的一组函数, 可以用于枚举关于它们的进程和信息。这些函数均在 ToolHelp API 中。详细信息请参见 Platform SDK文档中的Process32First和Process32Next函数。 更加有趣的是, Microsoft的Windows NT开发小组因为不喜欢 ToolHelp函数,所以没有将 这些函数添加给 Windows NT 。相反,他们开发了自己的 Process Status函数,用于枚举进程 (这些函数包含在PSAPI.dll文件中)。关于这些函数的详细说明,请参见 Platform SDK文档中的 EnumProcesses函数。 M i c r o s o f t似乎使得工具和实用程序开发人员的日子很不好过,不过我高兴地告诉他们, Microsoft已经将ToolHelp函数添加给Windows 2000。最后,开发人员终于有了一种方法,可以 为Windows 95 、Windows 98和Windows 2000编写具有公用源代码的工具和实用程序。 进程信息示例应用程序 ProcessInfo应用程序“04 ProcessInfo.exe”(本章结尾处的清单 4-2列出了该文件)显示了 如何使用ToolHelp函数来开发非常有用的实用程序。用于应用程序的源代码和资源文件均放在 本书所附光盘上 04-ProcessInfo目录中。当启动该程序时,便会出现图 4-4所示的窗口。 ProcessInfo首先枚举目前正在运行的一组进程,并在顶部的组合框中列出每个进程的名字 和ID。然后,第一个进程被选定,并在较大的编辑控件中显示关于该进程的信息。可以看到, 与该进程的ID一道显示的还有它的父进程的 ID,进程的优先级类,以及该进程环境中当前正在 运行的线程数目。这些信息中的大多数不在本章介绍的范围之内,将在本章后面的内容中加以 说明。 当查看这个进程列表时,可以使用 VMMap菜单项(当查看模块信息时,该菜单项禁用)。 如果选定VMMap菜单项,可使 VMMap示例应用程序(参见第14章)启动运行。该应用程序将 在指定进程的地址空间中运行。 模块信息部分显示了映射到进程的地址空间中的模块的列表(可执行文件和 DLL文件)。 固定模块是指进程初始化时隐含加载的模块。如果是显式加载的 DLL模块,则显示 DLL的使 用计数。第二个域显示映射模块的地址。如果模块不是在它的首选基地址上映射的,那么首 选基地址显示在括号中。第三个域显示模块的大小(用字节数表示)。最后显示的是模块的全 路径名。线程信息部分显示了该进程中当前运行的一组线程。每个线程 ID和优先级均被显示。 除了进程信息外,可以选择 Modules!菜单项。这将使得 ProcessInfo能够枚举当前通过系统 加载的模块,并将每个模块的名字放入顶部的组合框。然后 ProcessInfo可以选定第一个模块, 并显示关于它的信息,如图 4-5所示。 下载 75 第 4章 进 程计计 图4-4 运行中的ProcessInfo 图4-5 ProcessInfo显示User32.dll加载到它们的地址空间的所有进程 当以这种方法使用 ProcessInfo实用程序时,能够方便地确定哪些进程正在使用某个模块。 如你所见,模块的全路径名显示在顶部。然后,进程信息部分显示包含该模块的进程列表。除 76计计第二部分 编程的具体方法 下载 了每个进程的ID和名字外,还显示每个进程中模块加载到的地址。 ProcessInfo应用程序显示的所有信息基本上都是通过调用 ToolHelp的各个函数而产生的。 为了使ToolHelp函数的使用更加容易,我创建了一个 C++类(包含在ToolHelp.h文件中)。这个 C++类封装了一个ToolHelp快照,使得调用其他ToolHelp函数稍稍容易一些。 ProcessInfo.cpp中的GetModulePreferredBaseAddr函数是个特别有意思的函数: 该函数接受一个进程 ID和该进程中的一个模块的地址。然后它查看该进程的地址空间,找 出该模块,并读取模块的标题信息,以确定该模块首选的基地址,一个模块始终应该加载到它 的首选基地址中,否则,使用该模块的应用程序将需要更多的内存,并且在初始化时会对性能 产生影响。由于这是个非常可怕的情况,因此我增加了这个函数并且显示何时模块没有加载到 它的首选基地址中。第20章将要进一步介绍首选基地址和这次 /内存性能。 清单4-2 ProcessInfo应用程序 下载 77 第 4章 进 程计计 78计计第二部分 编程的具体方法 下载 下载 79 第 4章 进 程计计 80计计第二部分 编程的具体方法 下载 下载 81 第 4章 进 程计计 82计计第二部分 编程的具体方法 下载 下载 83 第 4章 进 程计计 84计计第二部分 编程的具体方法 下载 下载 85 第 4章 进 程计计 86计计第二部分 编程的具体方法 下载 下载 87 第 4章 进 程计计 88计计第二部分 编程的具体方法 下载 下载 89 第 4章 进 程计计 90计计第二部分 编程的具体方法 下载 下载 第5章 作 业 通常,必须将一组进程当作单个实体来处理。例如,当让 Microsoft Developer Studio为你 创建一个应用程序项目时,它会生成 Cl.exe,Cl.exe则必须生成其他的进程(比如编译器的各 个函数传递)。如果用户想要永远停止该应用程序的创建,那么 Developer Studio必须能够终止 Cl.exe和它的所有子进程的运行。在 Windows中解决这个简单(和常见的)的问题是极其困难 的,因为 Windows并不维护进程之间的父 /子关系。即使父进程已经终止运行,子进程仍然会继 续运行。 当设计一个服务器时,也必须将一组进程作为单个进程组来处理。例如,客户机可能要求 服务器执行一个应用程序(这可以生成它自己的子应用程序),并给客户机返回其结果。由于 可能有许多客户机与该服务器相连接,如果服务器能够限制客户机的要求,即用什么手段来防 止任何一个客户机垄断它的所有资源,那么这是非常有用的。这些限制包括:可以分配给客户 机请求的最大 CPU时间,最小和最大的工作区的大小,防止客户机的应用程序关闭计算机,以 及安全性限制等。 Microsoft Windoss 2000提供了一个新的作业内核对象,使你能够将进程组合在一起,并且 创建一个“沙框”,以便限制进程能够进行的操作。最好将作业对象视为一个进程的容器。但 是,创建包含单个进程的作业是有用的,因为这样一来,就可以对该进程加上通常情况下不能 加的限制。 我的StartRestrictedProcess函数(见清单 5-1)将一个进程放入一个作业,以限制该进程进 行某些操作的能力。 Windows 98 Windows 98 不支持作业的操作。 清单5-1 StartRestrictedProcess函数 92计计第二部分 编程的具体方法 下载 现在,解释一下StartRestrictedProcess函数是如何工作的。首先,调用下面的代码,创建一 个新作业内核对象: 下载 93 第 5章 作 业计计 与所有的内核对象一样,它的第一个参数将安全信息与新作业对象关联起来,并且告诉系 统,是否想要使返回的句柄成为可继承的句柄。最后一个参数用于给作业对象命名,使它可以 供另一个进程通过下面所示的 OpenJobObject函数进行访问。 与平常一样,如果知道你将不再需要访问代码中的作业对象,那么就必须通过调用 CloseHandle来关闭它的句柄。可以在我的 StartRestrictedProcess函数的结尾处看到这个代码的 情况。应该知道,关闭作业对象并不会迫使作业中的所有进程终止运行。该作业对象实际上做 上了删除标记,只有当作业中的所有进程全部终止运行之后,该作业对象才被自动撤消。 注意,关闭作业的句柄后,尽管该作业仍然存在,但是该作业将无法被所有进程访问。请 看下面的代码: 5.1 对作业进程的限制 进程创建后,通常需要设置一个沙框(设置一些限制),以便限制作业中的进程能够进行 的操作。可以给一个作业加上若干不同类型的限制: • 基本限制和扩展基本限制,用于防止作业中的进程垄断系统的资源。 • 基本的UI限制,用于防止作业中的进程改变用户界面。 • 安全性限制,用于防止作业中的进程访问保密资源(文件、注册表子关键字等)。 通过调用下面的代码,可以给作业加上各种限制: 第一个参数用于标识要限制的作业。第二个参数是个枚举类型,用于指明要使用的限制类 型。第三个参数是包含限制设置值的数据结构的地址,第四个参数用于指明该结构的大小(用 于确定版本)。表5-1列出了如何来设置各种限制条件。 94计计第二部分 编程的具体方法 下载 表5-1 设置限制条件 限 制类 型 基本限制 扩展基本限制 基本UI限制 安全性限制 第二个参数的值 JobObjectBasicLimitInformation JobObjectExtendedLimitInformation JobObjectBasicUIRestrictions JobObjectSecurityLimitInformation 第三个参数的结构 JOBOBJECT_BASIC_ L I M I T _ I N F O R M AT I O N JOBOBJECT_EXTENDED_ L I M I T _ I N F O R M AT I O N JOBOBJECT_BASIC_ UI_RESTRICTIONS JOBOBJECT_SECURITY_ L I M I T _ I N F O R M AT I O N 在S t a r t R e s t r i c t e d P r o c e s s函数中,我只对作业设置了一些最基本的限制。指定了一个 J O B _ O B J E C T _ B A S I C _ L I M I T _ I N F O R M AT I O N 结构,对它进行了初始化,然后调用 SetInformationJobObject 函数。 JOB_OBJECT_BASIC_LIMIT_INFORMATION结构类似下面的 样子: 表5-2简单地描述了它的各个成员的情况。 表5-2 JOB_OBJECT_BASIC_LIMIT_INFORMATION结构的成员 成员 PerProcessUser Ti m e L i m i t PerJobUser Ti m e L i m i t LimitFlags Minimum Wo r k i n g S e t S i z e / Maximum Working SetSize ActiveProcessLimit 描述 说明 设定分配给每个进 程的用户 任何进程占用的时间如果超过了分配给它的时间, 方式的最大时间(以 100ns为 系统将自动终止它的运行。若要设置这个限制条件, 间隔时间) 请在 LimitFlags成员中设定 JOB_OBJECT_LIMIT_ PROCESS_TIME 设定该作业中可以使用多少用 按照默认设置,当达到该时间限制时,系统将自动 户方式的时间(以100ns为间隔时间) 终止所有进程的运行。可以在作业运行时定期改变 这个值。若要设置该限制条件,请在 LimitFlags成员 中设定JOB_OBJECT_ LIMIT_JOB_TIME 指明哪些限制适用于该作业 详细说明参见本表下面的一段 设定每个进程(不是作业中的 通常,进程的工作区可能扩大而超过它的最小值。 所有进程)的最小和最大工作区 设置MaximumWorkingSetSize后,就可以实施硬限 的大小 制。一旦进程的工作区达到该限制值,进程就会对 此作出页标记。各个进程对 S e t P r o c e s s Wo r k i n g - SetSize的调用将被忽略,除非该进程只是试图清空 它的工作区。若要设置该限制,请在 LimitFlags成员 中设定JOB_OBJECT_LIMIT_WORKINGSET标志 设定作业中可以同时运行的进 超过这个限制的任何尝试都会导致新进程被迫终止 下载 95 第 5章 作 业计计 成员 描述 说明 ( 续) A ff i n i t y PriorityClass SchedulingClass 程的最大数量 设定能够运行的进程的CPU 子集 设定所有进程使用的优先级 设定分配给作业中的线程 的相对时段差 运行,并产生一个“定额不足”的错误。若要设置 这个限制,请在 L i m i t F l a g s 成员中设定 J O B _ OBJECT_LIMIT_ACTIVE_PROCESS 单个进程甚至能够进一步对此作出限制。若要设置 这个限制,请在 L i m i t F l a g s成员中设定 J O B _ OBJECT_LIMIT_AFFINITY 如果进程调用 SetPriorityClass函数,即使该函数调 用失败,它也能成功地返回。如果进程调用 G e t P r i o r i t y C l a s s函数,该函数将返回进程已经设置的 优先级类,尽管这可能不是进程的实际优先级类。 此外, SetThreadPriority 无法将线程的优先级提高到 正常的优先级之上,不过它可以用于降低线程的优 先级。若要设置这个限制,请在 LimitFlags成员中设 定J O B _ O B J E C T _ L I M I T _ P R I O R I T Y _ C L A S S 它的值可以在 0到9之间(包括 0和9),默认值是 5。 详细说明参见本表后面的文字。若要设置这个限制, 请在 L i m i t F l a g s 成员中设定 J O B _ O B J E C T _ LIMIT_SCHEDULING_CLASS 关于这个结构的某些问题在 Platform SDK文档中并没有说清楚,因此在这里作一些说明。 你在 L i m i t F l a g s 成员中设置了一些信息,来指明想用于作业的限制条件。我设置了 JOB_OBJECT_LIMIT_PRIORITY_CLASS和JOB_OBJECT_LIMIT_JOB_TIME这两个标志。这 意味着它们是我用于该作业的唯一的两个限制条件。我没有对 CPU的亲缘关系、工作区的大小、 每个进程占用的CPU时间等作出限制。 当作业运行时,它会维护一些统计信息,比如作业中的进程已经使用了多少 CPU时间。每 次使用 JOB_OBJECT_LIMIT_JOB_TIME标志来设置基本限制时,作业就会减去已经终止运行 的进程的CPU时间的统计信息。这显示当前活动的进程使用了多少 CPU时间。如果想改变作业 运行所在的CPU的亲缘关系,但是没有重置 CPU时间的统计信息,那将要如何处理呢?为了处 理这种情况,必须使用 JOB_OBJECT_LIMIT_AFFINITY 标志来设置新的基本限制条件,并且 必须退出JOB_OBJECT_LIMIT_JOB_TIME标志的设置。这样一来 , 就告诉作业, 不再想要使用 CPU的时间限制。这不是你想要的。 你想要的是改变CPU亲缘关系的限制,保留现有的 CPU时间限制。你只是不想减去已终止 运行的进程的 CPU时间的统计信息。为了解决这个问题,可以使用一个特殊标志,即 JOB_OBJECT_LIMIT_PRESERVE_JOB_TIME。这个标志与 JOB_OBJECT_LIMIT_JOB_TIME 标志是互斥的。 JOB_OBJECT_LIMIT_PRESERVE_JOB_TIME标志表示你想改变限制条件,而 不减去已经终止运行的进程的 CPU时间的统计信息。 现在介绍一下 JOBOBJECT_BASIC_LIMIT_INFORMATION结构的SchedulingClass成员。 假如你有两个正在运行的作业,你将两个作业的优先级类都设置为 NORMAL_PRIORITY_ CLASS。但是你还想让一个作业中的进程获得比另一个进程多的 CPU 时间。可以使用 SchedulingClass成员来改变拥有相同优先级的作业的相对调度关系。可以设置一个 0至9之间的 值(包括 0和9),5是默认值。在 Windows 2000上,如果这个设置值比较大,那么系统就会给 某个作业的进程中的线程提供较长的 CPU时间量。如果设置的值比较小,就减少该线程的 CPU 时间量。 96计计第二部分 编程的具体方法 下载 例如,我有两个拥有正常优先级类的作业。每个作业包含一个进程,每个进程只有一个 (拥有正常优先级的)线程。在正常环境下,这两个线程将按循环方式进行调度,每个线程获 得相同的 CPU时间量。但是,如果将第一个作业的 SchedulingClass成员设置为3,那么,当该 作业中的线程被安排 CPU时间时,它得到的时间量将比第二个作业中的线程少。 如果使用SchedulingClass成员,应该避免使用大数字即较大的时间量,因为较大的时间量 会降低系统中的其他作业、进程和线程的总体响应能力。另外,我只是介绍了 Windows 2000中 的情况。Microsoft计划在将来的 Windows版本中对线程调度程序进行更重要的修改,因为它认 为操作系统应该为作业、进程和线程提供更宽松的线程调度环境。 需要特别注意的最后一个限制是 JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_ EXCEPTION限制标志。这个限制可使系统为与作业相关的每个进程关闭“未处理的异常情况”对 话框。系统通过调用SetErrorMode函数,将作业中的每个进程的SEM_NOGPFAULTERRORBOX标 志传递给它。作业中产生未处理的异常情况的进程会立即终止运行,不显示任何用户界面。对 于服务程序和其他面向批处理的作业来说,这是个非常有用的限制标志。如果没有这个标志, 作业中的进程就会产生一个异常情况,并且永远不会终止运行,从而浪费了系统资源。 除了基本限制外,还可以使用 JOBOBJECT_EXTENDED_LIMIT_INFORMATION结构对作 业设置扩展限制: 如你所见,该结构包含一个 JOBOBJECT_BASIC_LIMIT_INFORMATION结构,它构成了 基本限制的一个超集。这个结构有一点儿特殊,因为它包含的成员与设置作业的限制毫无关系。 首先,IoInfo成员保留不用,无论如何不能访问它。本章后面将要介绍如何查询 I/O计数器信息。 此外, PackProcessMemoryUsed 和PackJobMemoryUsed成员是只读成员,分别告诉你作业中的 任何一个进程和所有进程需要使用的已确认的内存最大值。 另外两个成员ProcessMemoryLimit和JobMemoryLimit分别用于限制作业中的任何一个进程 和所有进程使用的已确认的内存量。若要设置这些限制值,可以在 LimitFlags成员中分别设定 JOB_OBJECT_LIMIT_JOB_MEMORY和JOB_OBJECT_LIMIT_PROCESS_MEMORY两个标 志。 现在看一下可以对作业设置的另一些限制。下面是JOBOBJECT_BASIC_UI_RESTRICTIONS 结构的样子: 这个结构只有一个数据成员,即 UIRestrictionsClass,它用于存放表 5-3中简单描述的一组 位标志。 最后一个标志 JOB_OBJECT_UILIMIT_HANDLES是特别有趣的。这个限制意味着作业中 没有一个进程能够访问该作业外部的进程创建的 USER对象。因此,如果试图在作业内部运行 Microsoft Spy++,那么除了 Spy++自己创建的窗口外,你看不到任何别的窗口。图 5-1显示的 下载 97 第 5章 作 业计计 Spy++中打开了两个 MDI子窗口。注意, Threads 1的窗口包含一个系统中的线程列表。这些线 程中只有一个线程,即000006AC SPYXX似乎创建了一些窗口。这是因为我是在它自己的作业 中运行 Spy++的,并且限制了它对 UI句柄的使用。在同一个窗口中,可以看到 MSDEV和 EXPLORER两个线程,但是看来它们尚未创建任何窗口。可以保证,这些线程肯定创建了窗 口,但是 Spy++无法访问它们。在对话框的右边,可以看到 Windows 3窗口,在这个窗口中, Spy++显示了桌面上存在的所有窗口的层次结构。注意,它只有一个项目,即 00000000。 Spy++必须将它作为占位符放在这里。 表5-3 用于作业对象的基本用户界面限制的位标志 标志 JOB_OBJECT_UILIMIT_EXITWINDOWS JOB_OBJECT_UILIMIT_READCLIPBOARD JOB_OBJECT_UILIMIT_WRITECLIPBOARD J O B _ O B J E C T _ U I L I M I T _ S Y S T E M PA R A M E T E R S J O B _ O B J E C T _ U I L I M I T _ D I S P L AY S E T T I N G S JOB_OBJECT_UILIMIT_GLOBALATOMS J O B _ O B J E C T _ U I L I M I T _ D E S K TO P JOB_OBJECT_UILIMIT_HANDLES 描述 用于防止进程通过 ExitWindowsEx函数退出、关闭、重新 引导或关闭系统电源 防止进程读取剪贴板的内容 防止进程删除剪贴板的内容 防止进程通过 SystemParametersInfor函数来改变系统参数 防止进程通过 ChangeDisplaySettings函数来改变显示设置 为作业赋予它自己的基本结构表,使作业中的进程只能 访问该作业的表 防止进程使用 CreateDesktop或SwitchDesktop函数创建或 转换桌面 防止作业中的进程使用同一作业外部的进程创建的 USER 对象(如HWND) 图5-1 在作业中运行的 Microsoft Spy++可以限制对UI句柄的访问 注意,这个 UI限制是单向的。这就是说,作业外部的进程能够看到作业内部的进程创建的 USDR对象。例如,如果我在一个作业中运行 Notepad,并在作业的外部运行 Spy++,那么,如 98计计第二部分 编程的具体方法 下载 果Notepad所在的作业设定了 JOB_OBJECT_UILIMIT_HANDLES标志, Spy++将能够看到 Notepad的窗口。同样,如果 Spy++在它自己的作业中,那么它也可以看到 Notepad的窗口,只 要它设定了 JOB_OBJECT_UILIMIT_HANDLES 标志。 如果想为作业进程的操作创建真正的沙框,那么限制 UI句柄是可怕的。但是,如果作为作 业组成部分的一个进程要与作业外部的进程进行通信,就可以使用这种限制。 实现这个目的有一个简便的方法,那就是使用窗口消息,但是,如果作业的进程不能访问 UI句柄,那么作业中的进程就无法将窗口消息发送或显示在作业外部的进程创建的窗口中。不 过,可以使用下面这个新函数来解决这个问题: hUserObj参数用于指明一个 USER对象,可以为作业中的进程赋予或者撤消对该对象的访 问权。它几乎总是一个窗口句柄,但是它可以是另一个 USER对象,比如桌面、挂钩、图标或 菜单。最后两个参数 hjob和fGrant用于指明你赋予或撤消对哪个作业的访问权。注意,如果从 hjob标识的作业中的一个进程来调用该函数,该函数的运行就会失败—这可以防止作业中的 进程总是为它自己赋予访问一个对象的权限。 对作业施加的最后一种限制类型与安全性相关(注意,一旦使用这种限制,就无法取消安 全性限制)。JOBOBJECT_SECURITY_LIMIT_INFORMATION的结构类似下面的形式: 表5-4简单地描述了它的各个成员。 表5-4 JOBOBJECT_SECURITY_LIMIT_INFORMATIDN 的成员 成员 SecurityLimitFlags J o b To k e n S i d s To D i s a b l e P r i v i l e g e s To D e l e t e RestrictedSids 描述 指明是否不允许管理员访问、不允许无限制的标记访问、强 制使用特定的访问标记,或者停用某些安全性标识符和优先权 作业中的所有进程使用的访问标记 指明为访问检查停用哪些 SID 指明要从访问标记中删除哪些优先权 指明应该添加给访问标记的一组仅为拒绝 (deny only )的SID 当然,一旦给作业设置了限制条件,就可以查询这些限制。通过调用下面的代码,就可以 进行这一操作 你为该函数传递作业的句柄(就像你对 SetInformationJobObject操作时那样),这些句柄包 下载 99 第 5章 作 业计计 括用于指明你想要的限制信息的枚举类型,函数要进行初始化的数据结构的地址,以及包含该 结构的数据块的长度。最后一个参数是 pdwReturnLength,用于指向该函数填写的 DWORD, 它告诉你有多少字节放入了缓存。如果你愿意的话,可以(并且通常)为该参数传递 NULL。 注意 作业中的进程可以调用QueryInformationJobObject,以便通过为作业的句柄参数 传递N U L L,获取关于该进程所属的作业的信息。由于它使进程能够看到已经对它实施 了哪些限制,所以这个函数非常有用。但是,如果为作业句柄参数传递 NULL,那么 SetInformationJobObject函数运行就会失败,因为这将允许进程删除对它实施的限制。 5.2 将进程放入作业 上面介绍的是设置和查询限制方面的信息。现在回到 StartRestrictedProcess这个函数的操作 上来。当对作业实施一些限制之后,通过调用 CreateProcess,生成了一个进程,我想将它放入 作业。但是,注意,当调用 CreateProcess时,我使用了 CREATE_SUSPENDED标志。这样,创 建了一个新进程,但是不允许它执行任何代码。由于 Start-ReatrictedProcess函数是从不属于作 业组成部分的进程来执行的,因此子进程也不属于作业的组成部分。如果准备立即允许子进程 开始执行代码,那么它将跑出我的沙框,并且能够成功地执行我想限制它做的工作。因此,当 创建子进程之后,在我允许它开始运行之前,我必须显式地将该进程放入我新创建的作业,方 法是调用下面的代码: 该函数告诉系统,将该进程(由 hProcess标识)视为现有作业(由 hJob标识)的一部分。 注意,该函数只允许将尚未被赋予任何作业的进程赋予一个作业。一旦进程成为一个作业的组 成部分,它就不能转到另一个作业,并且不能是无作业的进程。另外,当作为作业的一部分的 进程生成另一个进程的时候,新进程将自动成为父作业的组成部分。不过可以用下面的方法改 变它的行为特性: • 打开JOBOBJECT_BASIC_LIMIT_INFORMATION的LimitFlags成员中的 JOB_OBJECT_ BREAKAWAY_OK标志,告诉系统,新生成的进程可以在作业外部运行。若要做到这一 点,必须用新的 CREATE_BREAKAWAY_FROM_JOB标志来调用 CreateProcess。如果用 CREATE_BREAKAWAY_FROM_JOB标志调用CreateProcess函数,但是该作业并没有打 开CREATE_BREAKAWAY_FROM_JOB这个标志,那么CreateProcess函数运行就会失败。 如果新生成的进程也能控制作业,那么这个机制是有用的。 • 打开JOBOBJECT_BASIC_LIMIT_INFORMATION的LimitFlags成员中的 JOB_OBJECT_ SILENT_BREAKAWAY_OK标志。该标志也告诉系统,新生成的进程不应该是作业的组 成部分。但是没有必要将任何其他标志传递给 CreateProcess。实际上,该标志将使新进 程不能成为作业的组成部分。该标志可以用于原先对作业对象一无所知的进程。 至于StartRestrictedProcess函数,当调用 AssignProcessToJobObject后,新进程就成为受限 制的作业的组成部分。然后调用 ResumeThread,这样,进程的线程就可以在作业的限制下执行 代码。这时,也可以关闭线程的句柄,因为不再需要它了。 5.3 终止作业中所有进程的运行 当然,想对作业进行的最经常的操作是撤消作业中的所有进程。本章开头讲过, Developer 100计计第二部分 编程的具体方法 下载 Studio没有配备任何便于使用的方法,来停止进程中的某个操作,因为它不知道哪个进程是由 第一个进程生成的(这非常复杂。我在 Microsoft Systems Journal期刊1998年9月号上Win32 问 与答栏中介绍了 Developer Studio是如何做到这一点的)。我认为,Developer Studio的将来版本 将会改用作业来进行操作,因为代码的编写要容易得多,可以用它做更多的工作。 若要撤消作业中的进程,只需要调用下面的代码: 这类似为作业中的每个进程调用 TerminateProcess函数,将它们的所有退出代码设置为 uExitCode。 5.4 查询作业统计信息 前面已经介绍了如何使用 QueryInformationJobObject函数来获取对作业的当前限制信息。 也可以使用它来获取关于作业的统计信息。例如,若要获取基本的统计信息,可以调用 QueryInformationJobObject,为第二个参数传递 JobObjectBasicAccountingInformation,并传递 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION结构的地址: 表5-5简要描述了它的各个成员。 表5-5 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION的成员 成员 To t a l U s e r Ti m e To t a l K e r n e l Ti m e T h i s P e r i o d To t a l U s e r Ti m e T h i s P e r i o d To t a l K e r n e l Ti m e To t a l P a g e F a u l t C o u n t To t a l P r o c e s s e s ActiveProcesses To t a l Te r m i n a t e d P r o c e s s e s 描述 设定作业中的进程已经使用多少用户方式 CPU时间 设定作业中的进程已经使用多少内核方式 CPU时间 与TotalUserTime的作用相同,差别是,当调用 SetInformationJobObject以便改变基本限制信息并且不使用 JOB_OBJECT_LIMIT_ PRESERVE_JOB_TIME限制标记时,本值复置为 0 与ThisPeriodTotalUserTime相同,差别是,本值显示的是内核方式时间 设定作业中的进程已经产生的页面故障数量 设定曾经成为作业组成部分的进程总数 设定当前作为作业的组成部分的进程的数量 设定由于超过分配给它们的 CPU时间限制而被撤消的进程的数量 除了查询这些基本统计信息外,可以进行一次函数调用,以同时查询基本统计信息和 I/O 统计信息。为此,必须为第二个参数传递 JobObjectBasicAndIoAccountingInformation,并传递 JOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION结构的地址: 101 第 5章 作 业计计 下载 如你所见,这个结构只返回一个 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION结 构和IO_COUNTERS结构: 该结构告诉你作业中的进程已经执行的读、写和非读 /写操作的数量(以及在这些操作期 间传送的字节数)。另外,可以使用下面这个新的 GetProcessIoCounters函数,以便获取不是这 些作业中的进程的这些信息: 也可以随时调用 QueryInformationJobObject函数,以便获取当前在作业中运行的进程的一 组进程ID。若要进行这项操作,首先必须确定你想在作业中看到多少进程,然后必须分配足够 的内存块,来放置这些进程ID的数组,并指定JOBOBJECT_BASIC_PROCESS_ID_LIST结构的 大小: 因此,若要获得当前作业中的一组进程 ID,必须执行类似下面的代码: 102计计第二部分 编程的具体方法 下载 这就是你使用这些函数的所有实现方法,不过操作系统实际上保存了更多的关于作业的信 息。它是使用性能计数器来进行这项操作的。可以使用 Performance Data Helper 函数库 (PDH.dll)中的函数来检索这些信息。也可以使用 Microsoft Management Console(MMC)的 Performance Monitor Snap-In来查看作业信息。图 5-2显示了系统中的作业对象可以使用的一些 计数器。图 5-3显示了可以使用的一些作业对象的明细计数器。也可以看到 Jeff的作业里有4个 进程,即 calc、cmd、notepad和workpad。 图5-2 MMC的性能监控器:作业对象计数器 图5-3 MMC 的性能监控器:作业对象明细计数器 103 第 5章 作 业计计 下载 注意,当调用 CreateJobObject函数时,只能为已经赋予名字的作业获取性能计数器信息。 由于这个原因,即使不打算按名字来共享跨越进程的作业对象,也应该创建带有名字的这些对 象。 5.5 作业通知信息 现在,已经知道了关于作业对象的基本知识,剩下要介绍的内容是关于通知的问题。例如, 是否想知道作业中的所有进程何时终止运行或者分配的全部 CPU时间是否已经到期呢?也许想 知道作业中何时生成新进程或者作业中的进程何时终止运行。如果不关心这些通知信息(而且 许多应用程序也不关心这些信息),作业的操作非常容易。如果关心这些事件,那么还有一些 工作要做。 如果关心的是分配的所有 CPU时间是否已经到期,那么可以非常容易地得到这个通知信息。 当作业中的进程尚未用完分配的 CPU时间时,作业对象就得不到通知。一旦分配的所有 CPU时 间已经用完, Windows就强制撤消作业中的所有进程,并将情况通知作业对象。通过调用 WaitForSingleObject(或类似的函数 ),可以很容易跟踪这个事件。有时,可以在晚些时候调用 SetInformationJobObject函数,使作业对象恢复未通知状态,并为作业赋予更多的 CPU时间。 当开始对作业进行操作时,我觉得当作业中没有任何进程运行时,应该将这个事件通知作 业对象。毕竟当进程和线程停止运行时,进程和线程对象就会得到通知。因此,当作业停止运 行时它也应该得到通知。这样,就能够很容易确定作业何时结束运行。但是, Microsoft选择在 分配的 CPU时间到期时才向作业发出通知,因为这显示了一个错误条件。由于许多作业启动时 有一个父进程始终处于工作状态,直到它的所有子进程运行结束,因此只需要在父进程的句柄 上等待,就可以了解整个作业何时运行结束。 StartRestrictedProcess函数用于显示分配给作业 的CPU时间何时到期,或者作业中的进程何时终止运行。 前面介绍了如何获得某些简单的通知信息,但是尚未说明如何获得更高级的通知信息,如 进程创建 /终止运行等。如果想要得到这些通知信息,必须将更多的基础结构放入应用程序。 特别是,必须创建一个 I/O完成端口内核对象,并将作业对象或多个作业对象与完成端口关联 起来。然后,必须让一个或多个线程在完成端口上等待作业通知的到来,这样它们才能得到处 理。 一旦创建了 I/O完成端口,通过调用 SetInformationJobObject函数,就可以将作业与该端口 关联起来,如下面的代码所示: 当上面的代码运行时,系统将监视该作业的运行,当事件发生时,它将事件送往 I/O完成 端口(顺便说一下,可以调用 QueryInformatiomJobObject函数来检索完成关键字和完成端口句 柄。但是,这样做的机会很少)。线程通过调用 GetQueuedCompletionStatus函数来监控 I/O完成 端口: 104计计第二部分 编程的具体方法 下载 当该函数返回一个作业事件通知时, *pCompletionKey包含了调用SetInformationJobObject时 设置的完成关键字值,用于将作业与完成端口关联起来。它使你能够知道哪个作业存在一个事件。 *pNumBytesTransferred中的值用于指明发生了哪个事件。根据事件(见表5-6),*pOverlapped 中 的值将指明一个进程 ID。 表5-6 系统可以发送给作业的相关完成端口的作业事件通知 事件 描述 JOB_OBJECT_MSG_ACTIVE PROCESS_ZERO 当作业中没有进程运行时发送 JOB_OBJECT_MSG_END_OF_PROCESS_TIME 当超过分配给进程的 CPU时间时发送。进程终止运行, 并赋予进程的 ID JOB_OBJECT_MSG_ACTIVE_PROCESS_LIMIT 当试图超过作业中运行的进程数量时发送 JOB_OBJECT_MSG_PROCESS MEMORY_LIMIT 当进程试图占用超过限额的内存时发送。给出进程的 ID JOB_OBJECT_MSG_JOB_MEMORY_LIMIT 当进程试图占用的内存超过作业的内存限制时发送。给 出进程的ID JOB_OBJECT_MSG_NEW_PROCESS 当一个进程添加给作业时发送。给出进程的 ID JOB_OBJECT_MSG_EXIT_PROCESS 当进程终止运行时发送。给出进程的 ID JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS 当进程由于未处理的异常事件而终止运行时发送。给出 进程的ID JOB_OBJECT_MSG_END_OF_JOB_TIME 当超过分配给作业的 CPU时间时发送。这些进程没有终 止运行。可以允许它们继续运行,设置一个新的时间限制, 或者自己调用 TerminateJobObject函数 最后要说明的一点是,按照默认设置,作业对象是这样配置的:当分配给作业的 CPU时间 已经到期时,作业的所有进程均自动停止运行,而 JOB_OBJECT_MSG_END_OF_JOB_TIME 通知尚未发送。如果想要防止作业对象撤消进程而只是通知你时间已经超过,必须执行下面这 样的代码: 为作业设定结束时间而使用的另一个值是 JOB_OBJECT_TERMINATE_AT_END_OF_JOB, 这是作业创建时的默认值。 5.6 JobLab示例应用程序 JobLab应用程序“05JobLab.exe”(在本章未尾处清单 5-2中列出)使你能够很容易地对作 业进行实验性操作。该应用程序的源代码和资源文件放在本书所附光盘上 05-JobLab目录中。 当启动该程序时,出现图 5-4所示的窗口。 当进程被初始化时,它创建一个作业对象。我创建的这个作业对象的名字是 JobLab,这样, 就可以使用MMC的Performance Monitor Snap-In来观察和监控它的性能。该应用程序还创建了 105 第 5章 作 业计计 下载 一个 I/O 完成端口,并将作业对象与它相关联。这样就可以对来自作业的通知进行监控,并可 显示在窗口底部的列表框中。 开始时,该作业不包含进程,也没有各种限制条件。顶部的各个域用于设定对作业的基本 限制和扩展限制条件。要做的工作是用有效值填写这些域,然后点击 Apply Limits按钮。如果 将一个域置空,那么该限制条件就不起作用。除了基本限制和扩展限制条件外,还可以打开和 关闭各种UI限制。注意, Preserve Job Time When Applying Limits(当运用各个限制时保留作 业时间)复选框并不用于设置限制条件。它只是让你在查询基本统计信息时可以改变作业的限 制条件,而不重置 ThisPeriodTotalUserTime和ThisPeriod-TotalKernelTime成员。当运用单个作 业的时间限制时,该复选框不起作用。 图5-4 JobLab示例应用程序 其余的按钮供你用其他方式对作业进行操作。 Terminate Processes按钮用于撤消作业中的 所有进程。Spawn CMD In Job按钮用于生成与作业相关的命令外壳进程。从该命令外壳程序中, 可以生成更多的子进程,并且可以看到它们如何作为作业的组成部分来运行。我发现这对试验 操作是非常有用的。最后一个按钮是 Put PID In Job,它用于将现有的无作业进程与作业相关 联。 窗口底部的列表框显示了更新的关于作业的状态信息。每隔 10s,该窗口显示一次基本统 计信息和 I/O统计信息,以及进程 /作业的内存峰值使用量。同时也显示作业中当前的每个进程 的ID。 最后要说明的是,如果修改了源代码,并且创建一个没有名字的作业内核对象,那么可以 运行该应用程序的多个拷贝,以便在同一台机器上创建两个或多个作业对象,并且进行更多的 试验。 就源代码而言,没有什么特殊的东西需要介绍,因为源代码已经做了非常完善的说明。不 过,我创建了一个 Job.h文件,它定义了一个 Cjob C++类,用于封装操作系统的作业对象。这 106计计第二部分 编程的具体方法 下载 使得操作起来更加容易,因为不必到处传递作业的句柄。这个类还减少了平常调用 InformationJobObject和 SetInformatiomJobObject函数时需要进行的转换工作量。 清单5-2 JobLab示例应用程序 Query 107 第 5章 作 业计计 下载 108计计第二部分 编程的具体方法 下载 109 第 5章 作 业计计 下载 110计计第二部分 编程的具体方法 下载 111 第 5章 作 业计计 下载 112计计第二部分 编程的具体方法 下载 113 第 5章 作 业计计 下载 114计计第二部分 编程的具体方法 下载 115 第 5章 作 业计计 下载 116计计第二部分 编程的具体方法 下载 117 第 5章 作 业计计 下载 118计计第二部分 编程的具体方法 下载 119 第 5章 作 业计计 下载 120计计第二部分 编程的具体方法 下载 下载 第6章 线程的基础知识 理解线程是非常关键的,因为每个进程至少需要一个线程。本章将更加详细地介绍线程的 知识。尤其是要讲述进程与线程之间存在多大的差别,它们各自具有什么作用。还要介绍系统 如何使用线程内核对象来管理线程。与进程内核对象一样,线程内核对象也拥有属性,我们将 要观察许多用于查询和修改这些属性的函数。此外还要介绍可以在进程中创建和生成更多的线 程时所用的函数。 第4章介绍了进程是由两个部分构成的,一个是进程内核对象,另一个是地址空间。同样, 线程也是由两个部分组成的: • 一个是线程的内核对象,操作系统用它来对线程实施管理。内核对象也是系统用来存放 线程统计信息的地方。 • 另一个是线程堆栈,它用于维护线程在执行代码时需要的所有函数参数和局部变量(第 16章将进一步介绍系统如何管理线程堆栈)。 第4章中讲过,进程是不活泼的。进程从来不执行任何东西,它只是线程的容器。线程总 是在某个进程环境中创建的,而且它的整个寿命期都在该进程中。这意味着线程在它的进程地 址空间中执行代码,并且在进程的地址空间中对数据进行操作。因此,如果在单进程环境中, 你有两个或多个线程正在运行,那么这两个线程将共享单个地址空间。这些线程能够执行相同 的代码,对相同的数据进行操作。这些线程还能共享内核对象句柄,因为句柄表依赖于每个进 程而不是每个线程存在。 如你所见,进程使用的系统资源比线程多得多,原因是它需要更多的地址空间。为进程创 建一个虚拟地址空间需要许多系统资源。系统中要保留大量的记录,这要占用大量的内存。另 外,由于 .exe和.dll文件要加载到一个地址空间,因此也需要文件资源。而线程使用的系统资源 要少得多。实际上,线程只有一个内核对象和一个堆栈,保留的记录很少,因此需要很少的内 存。 由于线程需要的开销比进程少,因此始终都应该设法用增加线程来解决编程问题,而要避 免创建新的进程。但是,这个建议并不是一成不变的。许多程序设计用多个进程来实现会更好 些。应该懂得权衡利弊,经验会指导你的编程实践。 在详细介绍线程之前,首先花一点时间讲一讲如何正确地在应用程序结构中使用线程。 6.1 何时创建线程 线程用于描述进程中的运行路径。每当进程被初始化时,系统就要创建一个主线程。该线 程与C/C++运行期库的启动代码一道开始运行,启动代码则调用进入点函数( main、wmain、 WinMain或wWinMain),并且继续运行直到进入点函数返回并且 C/C++运行期库的启动代码调 用ExitProcess为止。对于许多应用程序来说,这个主线程是应用程序需要的唯一线程。不过, 进程能够创建更多的线程来帮助执行它们的操作。 每个计算机都拥有一个功能非常强大的资源,即 CPU。让CPU闲置起来是绝对没有道理的 (如果忽略节省电能问题的话)。为了使CPU处于繁忙状态之中,可以让它执行各种不同的工作。 下面是一些例子: 122计计第二部分 编程的具体方法 下载 • 可以打开Microsoft Windows 2000配备的内容索引服务程序。它能够创建一个低优先级的 线程,以便定期打开你的磁盘驱动器上的文件内容并给内容做索引。若要找到一个文件, 可以打开 Search Result(搜索结果)窗口(方法是单击 Start按钮,从Search菜单中选定 For Files Or Folders),再将你的搜索条件输入Containing Text域。这时就可以搜索到索引, 相关的文件就会立即显示出来。内容索引服务程序大大改进了性能,因为每次搜索不必 打开、扫描和关闭磁盘驱动器上的每个文件。 • 可以使用Windows 2000配备的磁盘碎片整理软件。通常情况下,这种类型的实用程序拥 有许多管理选项,一般用户可能不懂,比如该实用程序应该相隔多长时间运行一次,何 时运行。使用低优先级线程,可以在后台运行该实用程序,并且在系统空闲时对驱动器 进行碎片整理。 • 可以很容易地设想将来版本的编译器,每当暂停键入时,它就可以自动编译你的源代码 文件。输出窗口可以向你(几乎)实时显示警告和出错信息。当键入变量和函数名时出 现错误时,就能立即发现。在某种程度上讲, Microsoft Visual Studio已经实现了这个功 能,使用 Workspace的ClassView窗格,就能够看到这些信息。 • 电子表格应用程序能够在后台执行各种计算。 • 字处理程序能够执行重新分页、拼写和语法检查及在后台进行打印。 • 文件可以在后台拷贝到其他介质中。 • Web浏览器在后台与它们的服务器进行通信。因此,在来自当前 Web站点的结果输入之 前,用户可以缩放浏览器的窗口或者转到另一个 Web站点。 这些例子中,有一个重要问题应该注意,那就是多线程能够简化应用程序的用户界面。如 果每当停止键入时,编译器建立了你的应用程序,那么就没有必要提供 Build菜单选项。文字 处理应用程序不需要Check Spelling(拼写检查)和Check Grammar(语法检查)菜单选项。 在Web浏览器的例子中,注意,将不同的线程用于 I/O(网络、文件或其他),应用程序的 用户界面就能够始终保持工作状态。比如有一个应用程序负责给数据库记录进行排序、打印文 档或拷贝文件。如果将独立的线程用于处理这个与 I/O相关的任务,用户就可以在进程中继续 使用应用程序界面来取消操作。 设计一个拥有多线程的应用程序,就会扩大该应用程序的功能。我们在下一章中可以看到, 每个线程被分配了一个 CPU。因此,如果你的计算机拥有两个 CPU,你的应用程序中有两个线 程,那么两个 CPU都将处于繁忙状态。实际上,你是让两个任务在执行一个任务的时间内完成 操作。 每个进程至少拥有一个线程。因此,如果你在应用程序中不执行任何特殊的操作,在多进 程操作系统上运行,就能够得到许多好处。例如,可以建立一个应用程序,并同时使用文字处 理程序(我常常这样做)。如果计算机拥有两个 CPU,那么该应用程序就可以在一个处理器上 执行,而另一个处理器则负责处理文档。另外,如果编译器出现一个错误,导致它的线程进入 一个无限循环,仍然可以使用其他的进程( 16位Windows和MS-DOS应用程序则不行)。 6.2 何时不能创建线程 至今为止,一直在讨论多线程应用程序的优点。虽然多线程应用程序的优点很多,但是它 也存在某些不足之处。有些开发人员认为,解决问题的方法是将它分割成多个线程。这种想法 是完全错误的。 线程确实是非常有用的,但是,当使用线程时,在解决原有的问题时可能产生新的问题。 下载 123 第 6章 线程的基础知识计计 例如,你开发了一个文字处理应用程序,并且想要让打印函数作为它自己的线程来运行。这听 起来是个很好的主意,因为用户可以在打印文档时立即回头着手编辑文档。但是,这意味着文 档中的数据可能在文档打印时变更。也许最好是不要让打印操作在它自己的线程中发生,不过 这种“方案”看起来有点儿极端。如果你让用户编辑另一个文档,但是锁定正在打印的文档, 使得打印结束前该文档不能修改,那将会怎样呢?这里还有第三种思路,将文档拷贝到一个临 时文件,然后打印该临时文件的内容,并让用户修改原始文档。当包含该文档的临时文件结束 打印时,删除临时文件。 如你所见,线程能够解决某些问题,但是却又会产生新的问题。在开发应用程序的用户界 面时,很可能出现对线程的另一种误用。几乎在所有的应用程序中,所有用户界面的组件(窗 口)应该共享同一个线程。单个线程应该创建窗口的所有子窗口。有时在不同的线程上创建不 同的窗口是有用的,不过这种情况确实非常少见。 通常情况下,一个应用程序拥有一个用户界面线程,用于创建所有窗口,并且有一个 GetMessage循环。进程中的所有其他线程都是工作线程,它们与计算机或 I/O相关联,但是这 些线程从不创建窗口。另外,一个用户界面线程通常拥有比工作线程更高的优先级,因此用户 界面负责向用户作出响应。 虽然单个进程拥有多个用户界面线程的情况并不多见,但是这种情况有着某种有效的用途。 Windows Explorer为每个文件夹窗口创建了一个独立的线程。它使你能够将文件从一个文件夹 拷贝到另一个文件夹,并且仍然可以查看你的系统上的其他文件夹。另外,如果 Explorer中存 在一个错误,那么负责处理文件夹的线程可能崩溃,但是仍然能够对其他文件夹进行操作,至 少在执行的操作导致其他文件夹也崩溃之前,仍然可以对它们进行操作(关于线程和用户界面 的详细说明,参见第 26和27章)。 上述内容的实质是应该慎重地使用多线程。不要想用就用。仅仅使用赋予进程的主线程, 就能够编写出许多非常有用的和功能强大的应用程序。 6.3 编写第一个线程函数 每个线程必须拥有一个进入点函数,线程从这个进入点开始运行。前面已经介绍了主线程 的进入点函数:即 main、wmain、WinMain或wWinMain。如果想要在你的进程中创建一个辅 助线程,它必定也是个进入点函数,类似下面的样子: 你的线程函数可以执行你想要它做的任何任务。最终,线程函数到达它的结尾处并且返回。 这时,线程终止运行,该堆栈的内存被释放,同时,线程的内核对象的使用计数被递减。如果 使用计数降为0,线程的内核对象就被撤消。与进程内核对象的情况相同,线程内核对象的寿命 至少可以达到它们相关联的线程那样长,不过,该对象的寿命可以远远超过线程本身的寿命。 下面对线程函数的几个问题作一说明: • 主线程的进入点函数的名字必须是main、wmain、WinMain或wWinMain,与这些函数不同 的是,线程函数可以使用任何名字。实际上,如果在应用程序中拥有多个线程函数,必须 为它们赋予不同的名字,否则编译器/链接程序会认为你为单个函数创建了多个实现函数。 124计计第二部分 编程的具体方法 下载 • 由于给你的主线程的进入点函数传递了字符串参数,因此可以使用 ANSI/Unicode版本的 进入点函数: main/wmain和WinMain/wWinMain。可以给线程函数传递单个参数,参数 的含义由你而不是由操作系统来定义。因此,不必担心 ANSI/Unicode问题。 • 线程函数必须返回一个值,它将成为该线程的退出代码。这与 C/C++运行期库关于让主 线程的退出代码作为进程的退出代码的原则是相似的。 • 线程函数(实际上是你的所有函数)应该尽可能使用函数参数和局部变量。当使用静态 变量和全局变量时,多个线程可以同时访问这些变量,这可能破坏变量的内容。然而, 参数和局部变量是在线程堆栈中创建的,因此它们不太可能被另一个线程破坏。 既然懂得了实现线程函数的方法,下面讲述如何让操作系统来创建能够执行线程函数的线程。 6.4 CreateThread函数 前面已经讲述了调用 CreateProcess函数时如何创建进程的主线程。如果想要创建一个或多 个辅助函数,只需要让一个已经在运行的线程来调用 CreateThread: 当CreateThread被调用时,系统创建一个线程内核对象。该线程内核对象不是线程本身, 而是操作系统用来管理线程的较小的数据结构。可以将线程内核对象视为由关于线程的统计信 息组成的一个小型数据结构。这与进程和进程内核对象之间的关系是相同的。 系统从进程的地址空间中分配内存,供线程的堆栈使用。新线程运行的进程环境与创建线程 的环境相同。因此,新线程可以访问进程的内核对象的所有句柄、进程中的所有内存和在这个相 同的进程中的所有其他线程的堆栈。这使得单个进程中的多个线程确实能够非常容易地互相通信。 注意 CreateThread函数是用来创建线程的 Windows函数。不过,如果你正在编写 C/C++代码,决不应该调用 CreateThread。相反,应该使用 Visual C++运行期库函数 _beginthreadex。如果不使用 Microsoft的Visual C++编译器,你的编译器供应商有它自 己的CreateThred替代函数。不管这个替代函数是什么,你都必须使用。本章后面将要 介绍_beginthreadex能够做什么,它的重要性何在。 这就是Create Thread函数的概述,下面各节将要具体介绍 CreateThread的每个参数。 6.4.1 psa psa参数是指向SECURITY_ATTRIBUTES结构的指针。如果想要该线程内核对象的默认安 全属性,可以(并且通常能够)传递 NULL。如果希望所有的子进程能够继承该线程对象的句 柄,必须设定一个 SECURITY_ATTRIBUTES结构,它的bInheritHandle成员被初始化为TRUE。 详细信息参见第 3章。 6.4.2 cbStack c b S t a c k参数用于设定线程可以将多少地址空间用于它自己的堆栈。每个线程拥有它自己 的堆栈。当 CreateProcess启动一个进程时,它就在内部调用 CreateThread来对进程的主线程进 下载 125 第 6章 线程的基础知识计计 行初始化。对于cbStack参数来说,CreateProcess使用存放在可执行文件中的一个值。可以使用 链接程序的 /STACK开关来控制这个值: reserve参数用于设定系统应该为线程堆栈保留的地址空间量。默认值是 1 MB。Commit参 数用于设定开始时应该承诺用于堆栈保留区的物理存储器的容量。默认值是 1页。当线程中的 代码执行时,可能需要多个页面的存储器。当线程溢出它的堆栈时,就生成一个异常条件(关 于线程堆栈和堆栈溢出的异常条件的详细说明,参见第 16章,关于一般异常条件的处理的详细 说明,参见第 23章)。系统抓取该异常条件,并且将另一页(或者你为 commit参数设定的任何 值)用于保留空间,这使得线程的堆栈能够根据需要动态地扩大。 当调用CreateThread时,如果传递的值不是 0,就能使该函数将所有的存储器保留并分配给 线程的堆栈。由于所有的存储器预先作了分配,因此可以确保线程拥有指定容量的可用堆栈存 储器。保留空间的容量既可以是 /STACK链接程序设定的容量,也可以是 CbStack的值,谁大就 用谁。分配的存储器容量应该与传递的 cbStack值相一致。如果将 0传递给 CbStack参数, CreateThread就保留一个区域,并且将链接程序嵌入 .exe文件的/STACK链接程序开关信息指明 的存储器容量分配给线程堆栈。 保留空间的容量用于为堆栈设置一个上限,这样就可以抓住代码中的循环递归错误。例如, 你编写一个递归自调用函数,该函数也包含导致循环递归的一个错误。每次函数调用自己的时候, 堆栈上就创建一个新的堆栈框。如果系统不设定堆栈的最大值,该递归函数就永远不会停止对自 己的调用。进程的所有地址空间将被分配,大量的物理存储器将被分配给该堆栈。通过设置一个 堆栈限制值,就可以防止应用程序用完大量的物理存储器,同时,也可以更快地知道何时程序中 出现了错误(第16章中的Summation示例应用程序显示了如何跟踪和处理应用程序中的堆栈溢出)。 6.4.3 pfnStartAddr和pvParam pfnStartAddr参数用于指明想要新线程执行的线程函数的地址。线程函数的 pvParam参数与 原先传递给 CreateThread的pvParam参数是相同的。CreateThread使用该参数不做别的事情,只 是在线程启动执行时将该参数传递给线程函数。该参数提供了一个将初始化值传递给线程函数 的手段。该初始化数据既可以是数字值,也可以是指向包含其他信息的一个数据结构的指针。 创建多个线程,使这些线程拥有与起始点相同的函数地址,这是完全合乎逻辑的并且是非常 有用的。例如,可以实现一个Web服务器,以便创建一个新线程来处理每个客户机的请求。每个 线程都知道它正在处理哪个客户机的请求,因为当创建线程时,你传递了一个不同的pzParam值。 记住,Windows是个抢占式多线程系统,这意味着新线程和调用 CreateThread的线程可以 同时执行。由于线程可以同时运行,就会出现一些问题。请看下面的代码: 126计计第二部分 编程的具体方法 下载 在上面这个代码中,FirstThread可以在SecondThread将5分配给FirstThread的x之前结束它的 操作。如果出现这种情况, SecondThread将不知道FirstThread已经不再存在,并且仍然试图修改 现在已经是个无效地址的内容。这会导致 SecondThread产生一次访问违规,因为FirstThread的堆 栈已经在FirstThread终止运行时被撤消。解决这个问题的方法之一是将 x声明为一个静态变量, 这样,编译器就为应用程序的数据部分中的x创建一个存储区,而不是在堆栈上创建存储区。 但是这使得函数成为不可重新进入的函数。换句话说,无法创建两个执行相同函数的线程, 因为两个线程将共享该静态变量。解决这个问题(和它的更复杂的变形)的另一种方法是使用 正确的线程同步技术(第8、9章和10章介绍)。 6.4.4 fdwCreate fdwCreate参数可以设定用于控制创建线程的其他标志。它可以是两个值中的一个。如果 该值是0,那么线程创建后可以立即进行调度。如果该值是 CREATE_SUSPENDED,系统可以 完整地创建线程并对它进行初始化,但是要暂停该线程的运行,这样它就无法进行调度。 CREATE_SUSPENDED标志使得应用程序能够在它有机会执行任何代码之前修改线程的某 些属性。由于这种必要性很少,因此该标志并不常用。第 5章介绍的 JobLab应用程序说明了该 标志的正确方法。 6.4.5 pdwThreadID CreateThread的最后一个参数是pdwThreadID,它必须是DWORD的一个有效地址,CreateThread 使用这个地址来存放系统分配给新线程的ID(进程和线程的ID已经在第4章中作了介绍)。 注意 在Windows 2000(和Windows NT 4)下,可以(并且通常是这样做的)为该参 数传递 NULL。它告诉函数,你对线程的 ID不感兴趣,但是线程已经创建了。在 Windows 95和Windows 98下,为该参数传递NULL会导致函数运行失败,因为函数试 图将ID写入地址 NULL(这是不合法的)。因此线程不能创建。 当然,操作系统之间的不一致现象会给编程人员带来一些问题。例如,在Windows 2000下(即使为pdwThreadID参数传递了NULL,它也创建了该线程)编写和测试了一 下载 127 第 6章 线程的基础知识计计 个应用程序,当后来在Windows 98上运行该应用程序时,CreateThread将不创建新的线 程。必须始终在你声称支持的所有操作系统(和所有版本)上充分测试应用程序。 6.5 终止线程的运行 若要终止线程的运行,可以使用下面的方法: • 线程函数返回(最好使用这种方法)。 • 通过调用ExitThread函数,线程将自行撤消(最好不要使用这种方法)。 • 同一个进程或另一个进程中的线程调用 TerminateThread函数(应该避免使用这种方法)。 • 包含线程的进程终止运行(应该避免使用这种方法)。 下面将介绍终止线程运行的方法,并且说明线程终止运行时会出现什么情况。 6.5.1 线程函数返回 始终都应该将线程设计成这样的形式,即当想要线程终止运行时,它们就能够返回。这是 确保所有线程资源被正确地清除的唯一办法。 如果线程能够返回,就可以确保下列事项的实现: • 在线程函数中创建的所有C++对象均将通过它们的撤消函数正确地撤消。 • 操作系统将正确地释放线程堆栈使用的内存。 • 系统将线程的退出代码(在线程的内核对象中维护)设置为线程函数的返回值。 • 系统将递减线程内核对象的使用计数。 6.5.2 ExitThread函数 可以让线程调用ExitThread函数,以便强制线程终止运行: 该函数将终止线程的运行,并导致操作系统清除该线程使用的所有操作系统资源。但是, C++资源(如 C++类对象)将不被撤消。由于这个原因,最好从线程函数返回,而不是通过调 用ExitThread来返回(详细说明参见第 4章)。 当然,可以使用 ExitThread的dwExitThread参数告诉系统将线程的退出代码设置为什么。 ExitThread函数并不返回任何值,因为线程已经终止运行,不能执行更多的代码。 注意 终止线程运行的最佳方法是让它的线程函数返回。但是,如果使用本节介绍的 方法,应该知道 ExitThread函数是Windows用来撤消线程的函数。如果编写C/C++代码, 那么决不应该调用 ExitThread。应该使用Visual C++运行期库函数 _endthreadex。如果 不使用Microsoft的Visual C++编译器,你的编译器供应商有它自己的 ExitThread的替 代函数。不管这个替代函数是什么,都必须使用。本章后面将说明 _endthreadex的作 用和它的重要性。 6.5.3 TerminateThread函数 调用TerminateThread函数也能够终止线程的运行: 128计计第二部分 编程的具体方法 下载 与ExitThread不同,ExitThread总是撤消调用的线程,而TerminateThread能够撤消任何线程。 hThread参数用于标识被终止运行的线程的句柄。当线程终止运行时,它的退出代码成为你作 为dwExitCode参数传递的值。同时,线程的内核对象的使用计数也被递减。 注意 TerminateThread函数是异步运行的函数,也就是说,它告诉系统你想要线程终 止运行,但是,当函数返回时,不能保证线程被撤消。如果需要确切地知道该线程已 经终止运行,必须调用 WaitForSingleObject(第9章介绍)或者类似的函数,传递线程的 句柄。 设计良好的应用程序从来不使用这个函数,因为被终止运行的线程收不到它被撤消的通知。 线程不能正确地清除,并且不能防止自己被撤消。 注意 当使用返回或调用 ExitThread的方法撤消线程时,该线程的内存堆栈也被撤消。 但是,如果使用TerminateThread,那么在拥有线程的进程终止运行之前,系统不撤消该 线程的堆栈。Microsoft故意用这种方法来实现TerminateThread。如果其他仍然正在执行 的线程要引用强制撤消的线程堆栈上的值,那么其他的线程就会出现访问违规的问题。 如果将已经撤消的线程的堆栈留在内存中,那么其他线程就可以继续很好地运行。 此外,当线程终止运行时, DLL通常接收通知。如果使用 Terminate Thread 强迫 线程终止,DLL就不接收通知,这能阻止适当的清除(详细信息参见第 20章)。 6.5.4 在进程终止运行时撤消线程 第4章介绍的ExitProcess和TerminateProcess函数也可以用来终止线程的运行。差别在于这 些线程将会使终止运行的进程中的所有线程全部终止运行。另外,由于整个进程已经被关闭, 进程使用的所有资源肯定已被清除。这当然包括所有线程的堆栈。这两个函数会导致进程中的 剩余线程被强制撤消,就像从每个剩余的线程调用 TerminateThread一样。显然,这意味着正确 的应用程序清除没有发生,即 C++对象撤消函数没有被调用,数据没有转至磁盘等等。 6.5.5 线程终止运行时发生的操作 当线程终止运行时,会发生下列操作: • 线程拥有的所有用户对象均被释放。在 Windows中,大多数对象是由包含创建这些对象 的线程的进程拥有的。但是一个线程拥有两个用户对象,即窗口和挂钩。当线程终止运 行时,系统会自动撤消任何窗口,并且卸载线程创建的或安装的任何挂钩。其他对象只 有在拥有线程的进程终止运行时才被撤消。 • 线程的退出代码从 STILL_ACTIVE改为传递给ExitThread或TerminateThread的代码。 • 线程内核对象的状态变为已通知。 • 如果线程是进程中最后一个活动线程,系统也将进程视为已经终止运行。 • 线程内核对象的使用计数递减 1。 当一个线程终止运行时,在与它相关联的线程内核对象的所有未结束的引用关闭之前,该 内核对象不会自动被释放。 一旦线程不再运行,系统中就没有别的线程能够处理该线程的句柄。然而别的线程可以调 用GetExitcodeThread来检查由 hThread标识的线程是否已经终止运行。如果它已经终止运行, 则确定它的退出代码: 下载 129 第 6章 线程的基础知识计计 退出代码的值在 pdwExitCode指向的DWORD中返回。如果调用 GetExitCodeThread时线程 尚未终止运行,该函数就用 STILL_ACTIVE标识符(定义为 0x103)填入DWORD。如果该函 数运行成功,便返回 TRUE(第9章将详细地介绍如何使用线程的句柄来确定何时线程终止运 行)。 6.6 线程的一些性质 到现在为止,讲述了如何实现线程函数和如何让系统创建线程以便执行该函数。本节将要 介绍系统如何使这些操作获得成功。 图6-1显示了系统在创建线程和对线程进行初始化时必须做些什么工作。让我们仔细看一 看这个图,以便确切地了解发生的具体情况。调用 CreateThread可使系统创建一个线程内核对 象。该对象的初始使用计数是 2(在线程停止运行和从 CreateThread返回的句柄关闭之前,线程 内核对象不会被撤消)。线程的内核对象的其他属性也被初始化,暂停计数被设置为 1,退出代 码始终为 STILL_ACTIVE(0x103),该对象设置为未通知状态。 线程内核对象 上下文 SP IP 其他CPU寄存器 其他属性和统计信息 使用计数=2 暂停次数=1 退出代码=STILL_ACTIVE 已通知=FALSE 线程堆栈 pvParam pfnStartAddrj 高位地址 Kernel32. d11 低位地址 图6-1 线程的创建和初始化的示意图 一旦内核对象创建完成,系统就分配用于线程的堆栈的内存。该内存是从进程的地址空间 分配而来的,因为线程并不拥有它自己的地址空间。然后系统将两个值写入新线程的堆栈的上 端(线程堆栈总是从内存的高地址向低地址建立)。写入堆栈的第一个值是传递给 CreateThread 的pvParam参数的值。紧靠它的下面是传递给 CreateThread的pfnStartAddr参数的值。 每个线程都有它自己的一组 CPU寄存器,称为线程的上下文。该上下文反映了线程上次运 行时该线程的 CPU寄存器的状态。线程的这组 CPU寄存器保存在一个 CONTEXT结构(在 WinNT.h头文件中作了定义)中。 CONTEXT结构本身则包含在线程的内核对象中。 指令指针和堆栈指针寄存器是线程上下文中两个最重要的寄存器。记住,线程总是在进程 的上下文中运行的。因此,这些地址都用于标识拥有线程的进程地址空间中的内存。当线程的 内核对象被初始化时, CONTEXT结构的堆栈指针寄存器被设置为线程堆栈上用来放置 pfnStartAddr的地址。指令指针寄存器置为称为 BaseThreadStart的未文档化(和未输出)的函数的地址 中。该函数包含在 Kernel32.dll模块中(这也是实现 CreateThread函数的地方)。图6-1显示了它 130计计第二部分 编程的具体方法 的全部情况。 下面是BaseThreadStart函数执行的基本操作: 下载 当线程完全初始化后,系统就要查看 C R E AT E _ S U S P E N D E D 标志是否已经传递给 CreateThread。如果该标志没有传递,系统便将线程的暂停计数递减为 0,该线程可以调度到一 个进程中。然后系统用上次保存在线程上下文中的值加载到实际的 CPU寄存器中。这时线程就 可以执行代码,并对它的进程的地址空间中的数据进行操作。 由于新线程的指令指针被置为 BaseThreadStart,因此该函数实际上是线程开始执行的地方。 BaseThreadStart的原型会使你认为该函数接收了两个参数,但是这表示该函数是由另一个函数 来调用的,而实际情况并非如此。新线程只是在此处产生并且开始执行。 BaseThreadStart认为 它是由另一个函数调用的,因为它可以访问两个函数。但是,之所以可以访问这些参数,是因 为操作系统将值显式写入了线程的堆栈(这就是参数通常传递给函数的方法)。注意,有些 CPU结构使用 CPU寄存器而不是堆栈来传递参数。对于这些结构来说,系统将在允许线程执行 BaseThreadStart函数之前对相应的寄存器正确地进行初始化。 当新线程执行 BaseThreadStart函数时,将会出现下列情况: • 在线程函数中建立一个结构化异常处理(SEH)帧,这样,在线程执行时产生的任何异常情 况都会得到系统的某种默认处理(关于结构化异常处理的详细说明参见第23、24和25章)。 • 系统调用线程函数,并将你传递给 CreateThread函数的pvParam参数传递给它。 • 当线程函数返回时, BaseThreadStart调用ExitThread,并将线程函数的返回值传递给它。 该线程内核对象的使用计数被递减,线程停止执行。 • 如果线程产生一个没有处理的异常条件,由 BaseThreadStart函数建立的SEH帧将负责处理 该异常条件。通常情况下,这意味着向用户显示一个消息框,并且在用户撤消该消息框时, BzsethreadStart调用ExitThread,以终止整个进程的运行,而不只是终止线程的运行。 注意,在 BaseThreadStart函数中,线程要么调用 ExitThread,要么调用 ExitProcess。这意味 着线程不能退出该函数,它总是在函数中被撤消。这就是 BaseThreadStart的原型规定返回 VOID,而它从来不返回的原因。 另外,由于使用 BaseThreadStart,线程函数可以在它完成处理后返回。当 BaseThreadStart 调用线程函数时,它会把返回地址推进堆栈,这样,线程函数就能知道在何处返回。但是, BaseThreadStart不允许返回。如果它不强制撤消线程,而只是试图返回,那么几乎可以肯定会 引发访问违规,因为线程堆栈上不存在返回地址,并且 BaseThreadStart将试图返回到某个随机 内存位置。 当进程的主线程被初始化时,它的指令指针被设置为另一个未文档化的函数,称为 BaseProcessStart。该函数几乎与 BaseThreadStart相同,形式类似下面的样子: 下载 131 第 6章 线程的基础知识计计 这两个函数之间的唯一差别是,BaseProcessStart没有引用pvParam参数。当BaseProcessStart 开始执行时,它调用 C/C++运行期库的启动代码,该启动代码先要初始化 main、wmain、 WinMain或wWinMain函数,然后调用这些函数。当进入点函数返回时, C/C++运行期库的启 动代码就调用ExitProcess。因此,对于C/C++应用程序来说,主线程从不返回 BaseProcessStart 函数。 6.7 C/C++运行期库的考虑 Visual C++配有6个C/C++运行期库。表6-1对它们进行了描述。 表6-1 C/C++运行期库 库名 描述 LibC.lib LibCD.lib LibCMt.lib LibCMtD.lib MSVCRt.lib MSVCRtD.lib 用于单线程应用程序的静态链接库(当创建新应用程序时,它是默认库) 用于单线程应用程序的静态链接库的调试版 用于多线程应用程序的静态链接库的发行版 用于多线程应用程序的静态链接库的调试版 用于动态链接 MSVCRt.dll库的发行版的输入库 用于动态链接 MSVCRtD.dll的调试版的输入库。该库同时支持单线程应用程 序和多线程应用程序 当实现任何类型的编程项目时,必须知道将哪个库与你的项目相链接。可以使用图 6-2所 示的Project Settings对话框来选定一个库。在C/C++选项卡上,在Code Generation(生成的代码) 类别中,从Use run-time library(使用运行期库)组合框中选定 6个选项中的一个。 图6-2 Project Settings 对话框 132计计第二部分 编程的具体方法 下载 应该考虑的第一件事情是,“为什么必须将一个库用于单线程应用程序,而将另一个库用 于多线程应用程序?”,原因是,标准 C运行期库是 1970年问世的,它远远早于线程在任何应 用程序上的应用。运行期库的发明者没有考虑到将 C运行期库用于多线程应用程序的问题。 考虑一下标准C运行期的全局变量 errno。有些函数在发生错误时设置该变量。假设拥有下 面这个代码段: 现在,假设在调用 system函数之后和调用 if语句之前,执行上面代码的线程中断运行,同 时假设,该线程中断运行是为了让同一进程中的第二个线程开始执行,而这个新线程将执行另 一个负责设置全局变量 errno的C运行期函数。当 CPU在晚些时候重新分配给第一个线程时, errno的值将不再能够反映调用上面代码中的 system函数时的错误代码。为了解决这个问题,每 个线程都需要它自己的 errno变量。此外,必须有一种机制,使得线程能够引用它自己的 errno 变量,但是又不触及另一个线程的 errno变量。 这是标准C/C++运行期库原先并不是设计用于多线程应用程序的唯一一个例子。在多线程 环境中存在问题的 C/C++运行期库变量和函数包括 errno、_doserrno 、strtok、_wcstok、strerror、 _strerror、tmpnam、tmpfile、asctime、_wasctime、gmtime、_ecvt和_fcvt等。 若要使多线程 C和C++程序能够正确地运行,必须创建一个数据结构,并将它与使用 C/C++运行期库函数的每个线程关联起来。当你调用 C/C++运行期库时,这些函数必须知道查 看调用线程的数据块,这样就不会对别的线程产生不良影响。 那么系统是否知道在创建新线程时分配该数据块呢?回答是它不知道。系统根本不知道你 得到的应用程序是用 C/C++编写的,也不知道你调用函数的线程本身是不安全的。问题在于你 必须正确地进行所有的操作。若要创建一个新线程,绝对不要调用操作系统的 CreateThread函 数,必须调用C/C++运行期库函数_beginthreadex: _beginthreadex函数的参数列表与 CreateThread函数的参数列表是相同的,但是参数名和类 型并不完全相同。这是因为 Microsoft的C/C++运行期库的开发小组认为, C/C++运行期函数不 应该对Windows数据类型有任何依赖。 _beginthreadex函数也像CreateThread那样,返回新创建 下载 133 第 6章 线程的基础知识计计 的线程的句柄。因此,如果调用源代码中的 CreateThread,就很容易用对_beginthreadex的调用 全局取代所有这些调用。不过,由于数据类型并不完全相同,所以必须进行某种转换,使编译 器运行得顺利些。为了使操作更加容易,我在源代码中创建了一个宏 chBEGINTHREADEX: 注意,_beginthreadex函数只存在于 C/C++运行期库的多线程版本中。如果链接到单线程运 行期库,就会得到一个链接程序报告的“未转换的外部符号”错误消息。当然,从设计上讲, 这个错误的原因是单线程库在多线程应用程序中不能正确地运行。另外需要注意,当创建一个 新项目时, Visual Studio默认选定单线程库。这并不是最安全的默认设置,对于多线程应用程 序来说,必须显式转换到多线程的 C/C++运行期库。 由于Microsoft为C/C++运行期库提供了源代码,因此很容易准确地确定 CreateThread究竟 无法执行哪些 _beginthreadex能执行的操作。实际上,我搜索了 Visual Studio 的光盘,发现 _beginthreadex的源代码在Threadex.c中。代换重新打印它的源代码,这里提供了它的伪代码版 本,并且列出它的一些令人感兴趣的要点: 134计计第二部分 编程的具体方法 下载 下面是关于 _beginthreadex的一些要点: • 每个线程均获得由 C/C++运行期库的堆栈分配的自己的 tiddata内存结构。(tiddata结构位 于Mtdll.h文件中的Visual C++源代码中)。我在清单6-1中重建了它的结构。 • 传递给_beginthreadex的线程函数的地址保存在 tiddata内存块中。传递给该函数的参数也 保存在该数据块中。 • _beginthreadex确实从内部调用 CreateThread,因为这是操作系统了解如何创建新线程的 唯一方法。 • 当调用CreatetThread时,它被告知通过调用 _threadstartex而不是pfnStartAddr来启动执行 新线程。还有,传递给线程函数的参数是 tiddata结构而不是pvParam的地址。 • 如果一切顺利,就会像 CreateThread那样返回线程句柄。如果任何操作失败了,便返回 NULL。 清单6-1 C/C++运行期库的线程局部 tiddata结构 下载 135 第 6章 线程的基础知识计计 既然为新线程指定了 tiddata结构,并且对该结构进行了初始化,那么必须了解该结构与线 程之间是如何关联起来的。让我们观察一下 _threadstartex函数(它也位于 C/C++运行期库的 Threadex.c文件中)。这里是该函数的伪代码版本: 136计计第二部分 编程的具体方法 下载 下面是关于 _threadstartex的一些重点: • 新线程开始从 BasethreadStart函数(在 kernel32.dll文件中)执行,然后转移到 _ t h r e a d s t a r t e x。 • 到达该新线程的tiddata块的地址作为其唯一参数被传递给 _threadstartex。 • TlsSetValue是个操作系统函数,负责将一个值与调用线程联系起来。这称为线程本地存 储器( TLS),将在第 21章介绍。 _threadstartex函数将tiddata块与线程联系起来。 • 一个SEH帧被放置在需要的线程函数周围。这个帧负责处理与运行期库相关的许多事情 — 例如,运行期错误(比如放过了没有抓住的 C++异常条件)和 C/C++运行期库的 signal函数。这是特别重要的。如果用 CreateThread函数来创建线程,然后调用 C/C++运 行期库的 signal函数,那么该函数就不能正确地运行。 • 调用必要的线程函数,传递必要的参数。记住,函数和参数的地址由 _beginthreadex保存 在tiddata块中。 • 必要的线程函数返回值被认为是线程的退出代码。注意, _threadstartex并不只是返回到 BaseThreadStart。如果它准备这样做,那么线程就终止运行,它的退出代码将被正确地 设置,但是线程的 tiddata内存块不会被撤消。这将导致应用程序中出现一个漏洞。若要 防止这个漏洞,可以调用另一个 C/C++运行期库函数_endthreadex,并传递退出代码。 需要介绍的最后一个函数是 _endthreadex(位于C运行期库的 Threadex.c文件中)。下面是 该函数的伪代码版本: 下载 137 第 6章 线程的基础知识计计 下面是关于 _endthreadex的一些要点: • C运行期库的_getptd函数内部调用操作系统的 TlsGetValue函数,该函数负责检索调用线 程的tiddata内存块的地址。 • 然后该数据块被释放,而操作系统的ExitThread函数被调用,以便真正撤消该线程。当然, 退出代码要正确地设置和传递。 本章前面说过,始终都应该设法避免使用 ExitThread函数。这一点完全正确,我并不想收 回我已经说过的话。 ExitThread 函数将撤消调用函数,并且不允许它从当前执行的函数返回。 由于该函数不能返回,所以创建的任何 C++对象都不会被撤消。避免调用 ExitThread的另一个 原因是,它会使得线程的 tiddata内存块无法释放,这样,应用程序将会始终占用内存(直到整 个进程终止运行为止)。 Microsoft的Visual C++开发小组认识到编程人员喜欢调用 ExitThread,因此他们实现了他 们的愿望,并且不会让应用程序始终占用内存。如果真的想要强制撤消线程,可以让它调用 _endthreadex(而不是调用ExitThread)以便释放线程的tiddata块,然后退出。不过建议不要调 用_endthreadex函数。 现在应该懂得为什么 C/C++运行期库的函数需要为它创建的每个线程设置单独的数据块, 同时,也应该了解如何通过调用 _beginthreadex来分配数据块,再对它进行初始化,将该数据 块与你创建的线程联系起来。你还应该懂得 _endthreadex函数是如何在线程终止运行时释放数 据块的。 一旦数据块被初始化并且与线程联系起来,线程调用的任何需要单线程实例数据的 C/C++ 运行期库函数都能很容易地(通过 TlsGetValue)检索调用线程的数据块地址,并对线程的数据 进行操作。这对于函数来说很好,但是你可能想知道它对 errno之类的全局变量效果如何。 Errno定义在标准的C头文件中,类似下面的形式: 如果创建一个多线程应用程序,必须在编译器的命令行上设定 /MT(指多线程应用程序) 或/MD(指多线程DLL)开关。这将使编译器能够定义 _MT标识符。然后,每当引用 errno时, 实际上是调用内部的 C/C++运行期库函数_errno。该函数返回调用线程的相关数据块中的 errno 数据成员的地址。你将会发现, errno宏被定义为获取该地址的内容的宏。这个定义是必要的, 因为可以编写类似下面形式的代码: 如果内部函数 _errno只返回 errno的值,那么上面的代码将不进行编译。 138计计第二部分 编程的具体方法 下载 多线程版本的 C/C++运行期库还给某些函数设置了同步的基本要素。例如,如果两个线程 同时调用 malloc,那么内存堆栈就可能遭到破坏。多线程版本的 C/C++运行期库能够防止两个 线程同时从堆栈中分配内存。为此,它要让第二个线程等待,直到第一个线程从 malloc返回。 然后第二个线程才被允许进入(关于线程同步的问题将在第 8、9章和10章详细介绍)。 显然,所有这些附加操作都会影响多线程版本的 C/C++运行期库的性能。这就是为什么 Microsoft公司除了多线程版本外,还提供单线程版本的静态链接的 C/C++运行期库的原因。 C/C++运行期库的动态连接版本编写成为一种通用版本。这样它就可以被使用 C/C++运行 期库函数的所有正在运行的应用程序和 DLL共享。由于这个原因,运行期库只存在于多线程版 本中。由于 DLL中提供了 C/C++运行期库,因此应用程序( .exe文件)和 DLL不需要包含 C/C++ 运行期库函数的代码,结果它们的规模就比较小。另外,如果 Microsoft排除了C/C++运行期库 DLL中的错误,应用程序中的错误也会自动得到解决。 正如希望的那样, C/C++运行期库的启动代码为应用程序的主线程分配了数据块,并且对 数据块进行了初始化,这样,主线程就能安全地调用 C/C++运行期函数中的任何函数。当主线 程从它的进入点函数返回时, C/C++运行期库就会释放相关的数据块。此外,启动代码设置了 相应的结构化异常处理代码,以便主线程能够成功地调用 C/C++运行期库的signal函数。 6.7.1 Oops—错误地调用了 CreateThread 也许你想知道,如果调用 CreateThread,而不是调用C/C++运行期库的 _beginthreadex来创 建新线程,将会发生什么情况。当一个线程调用要求 tiddata结构的C/C++运行期库函数时,将 会发生下面的一些情况(大多数 C/C++运行期库函数都是线程安全函数,不需要该结构)。首 先, C/C++运行期库函数试图 (通过调用 TlsGetValue)获取线程的数据块的地址。如果返回 NULL作为tiddata块的地址,调用线程就不拥有与该地址相关的 tiddata块。这时,C/C++运行期 库函数就在现场为调用线程分配一个 tiddata块,并对它进行初始化。然后该 tiddata块(通过 TlsSetValue)与线程相关联。此时,只要线程在运行,该 tiddata将与线程待在一起。这时, C/C++运行期库函数就可以使用线程的 tiddata块,而且将来被调用的所有 C/C++运行期函数也 能使用tiddata块。 当然,这看来有些奇怪,因为线程运行时几乎没有任何障碍。不过,实际上还是存在一些 问题。首先,如果线程使用 C/C++运行期库的 signal函数,那么整个进程就会终止运行,因为 结构化异常处理帧尚未准备好。第二,如果不是调用 _endthreadex来终止线程的运行,那么数 据块就不会被撤消,内存泄漏就会出现(那么谁还为使用 CreateThread函数创建的线程来调用 _endthreadex呢?)。 注意 如果程序模块链接到多线程 DLL版本的C/C++运行期库,那么当线程终止运行 并释放 tiddata块(如果已经分配了 tiddata块的话)时,该运行期库会收到一个 DLL_THREAD_DETACH通知。尽管这可以防止 tiddata块的泄漏,但是强烈建议使用 _bdginthreadex而不是使用 Createthread来创建线程。 6.7.2 不应该调用的C/C++运行期库函数 C / C + +运行期库也包含另外两个函数: 下载 139 第 6章 线程的基础知识计计 和 创建这两个函数的目的是用来执行 _beginthreadex和_endthreadex函数的功能。但是,如你 所见,_beginthread函数的参数比较少,因此比特性全面的 _beginthreadex函数受到更大的限制。 例如,如果使用 _beginthread,就无法创建带有安全属性的新线程,无法创建暂停的线程,也 无法获得线程的 ID值。_endthread函数的情况与之类似。它不带参数,这意味着线程的退出代 码必须硬编码为0。 endthread函数还存在另一个很难注意到的大问题。在 _endthread调用ExitThread之前,它调 用CloseHandle,传递新线程的句柄。若要了解为什么这是个大问题,请看下面的代码: 新创建的线程可以在第一个线程调用 GetExitCodeThread之前运行、返回和终止。如果出现 这种情况,hThread中的值将无效,因为_endthread已经关闭了新线程的句柄。不用说,由于相 同的原因,对CloseHandle的调用也将失败。 新的_endthreadex函数并不关闭线程的句柄,因此,如果用调用 beginthreadex来取代调用 _beginthread,那么上面的代码段将能正确运行。记住,当线程函数返回时, _beginthrteadex调 用_endthreadex,而_beginthread则调用_endthread。 6.8 对自己的ID概念应该有所了解 当线程运行时,它们常常想要调用 Windows函数来改变它们的运行环境。例如,线程可能 想要改变它的优先级或它的进程的优先级(优先级将在第 7章中介绍)。由于线程常常要改变它 的(或它的进程的)环境,因此 Windows提供了一些函数,使线程能够很容易引用它的进程内 核对象,或者引用它自己的线程内核对象: 上面这两个函数都能返回调用线程的进程的伪句柄或线程内核对象的伪句柄。这些函数并 不在创建进程的句柄表中创建新句柄。还有,调用这些函数对进程或线程内核对象的使用计数 没有任何影响。如果调用 CloseHandle,将伪句柄作为参数来传递,那么 CloseHandle就会忽略 该函数的调用并返回 FALSE。 当调用一个需要进程句柄或线程句柄的 Windows函数时,可以传递一个伪句柄,使该函数 执行它对调用进程或线程的操作。例如,通过调用下面的 GetProcessTimes函数,线程可以查询 它的进程的时间使用情况: 同样,通过调用GetThreadTimes函数,线程可以查询它自己的线程时间: 少数Windows函数允许用进程或线程在系统范围内独一无二的 ID来标识某个进程或线程。 140计计第二部分 编程的具体方法 下面这两个函数使得线程能够查询它的进程的唯一 ID或它自己的唯一ID: 下载 这两个函数通常不像能够返回伪句柄的函数那样有用,但是有的时候用起来还是很方便的。 将伪句柄转换为实句柄 有时可能需要获得线程的实句柄而不是它的伪句柄。所谓“实句柄”,我是指用来明确标 识一个独一无二的线程的句柄。请看下面的代码: 你能发现这个代码段存在的问题吗?这个代码的目的是让父线程给子线程传递一个线程句 柄,以标识父线程。但是,父线程传递了一个伪句柄,而不是一个实句柄。当子线程开始运行 时,它将一个伪句柄传递给 GetThreadTime函数,使子线程得到它自己的 CPU时间,而不是父 线程的 CPU时间。出现这种情况的原因是线程的伪句柄是当前线程的句柄,也就是说,它是调 用函数的线程的句柄。 为了修改这个代码,必须将伪句柄变成实句柄。 DuplicateHandle函数能够执行这一转换: 通常可以使用这个函数,用与另一个进程相关的内核对象来创建一个与进程相关的新句柄。 然而,可以用一种特殊的方法来使用这个函数,以便修改上面介绍的代码段。正确的代码段应 该是下面的样子: 下载 141 第 6章 线程的基础知识计计 当父线程运行时,它就将标识父线程所用的伪句柄转换成明确标识父线程所用的新的实句 柄。同时它将这个实句柄传递给 CreateThread。当子线程启动运行时,它的 pvParam参数包含了 线程的实句柄。对传递该句柄的函数的任何调用都将影响父线程而不是子线程。 由于DuplicateHandle会递增特定对象的使用计数,因此当完成对复制对象句柄的使用时, 应该将目标句柄传递给 CloseHandle,从而递减对象的使用计数,这一点很重要。上面的代码 段已经显示出这一点。在调用 GetThreadTimes之后,紧接着子线程调用CloseHandle,以便递减 父线程对象的使用计数。在这个代码段中,我假设子线程不使用该句柄来调用任何其他函数。 如果其他函数被调用,以便传递父线程的句柄,那么在子线程不再需要该句柄之前,不应该调 用CloseHandle。 还要指出,DuplicateHandle可以用来将进程的伪句柄转换成进程的实句柄,如下面的代码 所示: 下载 第7章 线程的调度、优先级和亲缘性 抢占式操作系统必须使用某种算法来确定哪些线程应该在何时调度和运行多长时间。本章 将要介绍Microsoft Windows 98和Windows 2000使用的一些算法。 上一章介绍了每个线程是如何拥有一个上下文结构的,这个结构维护在线程的内核对象中。 这个上下文结构反映了线程上次运行时该线程的 CPU寄存器的状态。每隔 20ms左右,Windows 要查看当前存在的所有线程内核对象。在这些对象中,只有某些对象被视为可以调度的对象。 Windows选择可调度的线程内核对象中的一个,将它加载到 CPU的寄存器中,它的值是上次保 存在线程的环境中的值。这项操作称为上下文转换。 Windows实际上保存了一个记录,它说明 每个线程获得了多少个运行机会。使用 Microsoft Spy++这个工具,就可以了解这个情况。图 7-1显 示了一个线程的属性。注意,该线程已经被调度 了37 379次。 目前,线程正在执行代码,并对它的进程的 地址空间中的数据进行操作。再过 20ms左右, Windows就将 CPU的寄存器重新保存到线程的上 下文中。线程不再运行。系统再次查看其余的可 调度线程内核对象,选定另一个线程的内核对象, 将该线程的上下文加载到 CPU的寄存器中,然后 继续运行。当系统引导时,便开始加载线程的上 下文,让线程运行,保存上下文和重复这些操作, 图7-1 线程的属性 直到系统关闭。 总之,这就是系统对线程进行调度的过程。这很简单,是不是? Windows被称为抢占式多 线程操作系统,因为一个线程可以随时停止运行,随后另一个线程可进行调度。如你所见,可 以对它进行一定程度的控制,但是不能太多。记住,无法保证线程总是能够运行,也不能保证 线程能够得到整个进程,无法保证其他线程不被允许运行等等。 注意 程序员常常问我,如何才能保证线程在某个事件的某个时间段内开始运行,比 如,如何才能确保某个线程在数据从串行端口传送过来的 1ms内开始运行呢?我的回 答是,办不到。实时操作系统才能作出这样的承诺,但是 Windows不是实时操作系统。 实时操作系统必须清楚地知道它是在什么硬件上运行,这样它才能知道它的硬盘控制 器和键盘等的等待时间。 Microsoft对Windows规定的目标是,使它能够在各种不同的 硬件上运行,即能够在不同的 CPU、不同的驱动器和不同的网络上运行。简而言之, Windows没有设计成为一种实时操作系统。 尽管应强调这样一个概念,即系统只调度可以调度的线程,但是实际情况是,系统中的大 多数线程是不可调度的线程。例如,有些线程对象的暂停计数大于 1。这意味着该线程已经暂 停运行,不应该给它安排任何 C P U 时间。通过调用使用 C R E AT E _ S U S P E N D E D 标志的 CreateProcess或CreateThread函数,可以创建一个暂停的线程。(本章后面还要介绍 Suspend 下载 143 第 7章 线程的调度、优先级和亲缘性计计 Thread和ResumeThread函数。) 除了暂停的线程外,其他许多线程也是不可调度的线程,因为它们正在等待某些事情的发 生。例如,如果运行 Notepad,但是并不键入任何数据,那么 Notepad的线程就没有什么事情要 做。系统不给无事可做的线程分配 CPU时间。当移动 Notepad的窗口时,或者Notepad的窗口需 要刷新它的内容,或者将数据键入Notepad,系统就会自动使Notepad的线程成为可调度的线程。 这并不意味着 Notepad的线程立即获得了 CPU时间。它只是表示 Notepad的线程有事情可做,系 统将设法在某个时间(不久的将来)对它进行调度。 7.1 暂停和恢复线程的运行 在线程内核对象的内部有一个值,用于指明线程的暂停计数。当调用 CreateProcess或 CreateThread函数时,就创建了线程的内核对象,并且它的暂停计数被初始化为 1。这可以防止 线程被调度到 CPU中。当然,这是很有用的,因为线程的初始化需要时间,你不希望在系统做 好充分的准备之前就开始执行线程。 当线程完全初始化好了之后, CreateProcess或CreateThread要查看是否已经传递了 CREATE_ SUSPENDED 标志。如果已经传递了这个标志,那么这些函数就返回,同时新 线程处于暂停状态。如果尚未传递该标志,那么该函数将线程的暂停计数递减为 0。当线程 的暂停计数是 0的时候,除非线程正在等待其他某种事情的发生,否则该线程就处于可调度 状态。 在暂停状态中创建一个线程,就能够在线程有机会执行任何代码之前改变线程的运行环境 (如优先级)。一旦改变了线程的环境,必须使线程成为可调度线程。要进行这项操作,可以调 用ResumeThread,将调用 CreateThread函数时返回的线程句柄传递给它(或者是将传递给 CreateProcess的ppiProcInfo参数指向的线程句柄传递给它): 如果 R e s u m e T h r e a d 函数运行成功,它将返回线程的前一个暂停计数,否则返回 0 x F F F F F F F F。 单个线程可以暂停若干次。如果一个线程暂停了 3次,它必须恢复3次,然后它才可以被分 配给一个 CPU 。当创建线程时,除了使用 CREATE_SUSPENDED外,也可以调用 Suspend Thread函数来暂停线程的运行: 任何线程都可以调用该函数来暂停另一个线程的运行(只要拥有线程的句柄)。不用说, 线程可以自行暂停运行,但是不能自行恢复运行。与 ResumeThread一样,SuspendThread返回 的是线程的前一个暂停计数。线程暂停的最多次数可以是 MAXIMUM_SUSPEND_COUNT次 (在WinNT.h中定义为127)。注意, SuspendThread与内核方式的执行是异步进行的,但是在线 程恢复运行之前,不会发生用户方式的执行。 在实际环境中,调用SuspendThread时必须小心,因为不知道暂停线程运行时它在进行什么 操作。如果线程试图从堆栈中分配内存,那么该线程将在该堆栈上设置一个锁。当其他线程试 图访问该堆栈时,这些线程的访问就被停止,直到第一个线程恢复运行。只有确切知道目标线 程是什么(或者目标线程正在做什么),并且采取强有力的措施来避免因暂停线程的运行而带来 的问题或死锁状态,SuspendThread才是安全的(死锁和其他线程同步问题将在第 8、9和10章介 绍)。 144计计第二部分 编程的具体方法 下载 7.2 暂停和恢复进程的运行 对于Windows来说,不存在暂停或恢复进程的概念,因为进程从来不会被安排获得 CPU时 间。但是,曾经有人无数次问我如何暂停进程中的所有线程的运行。 Windows确实允许一个进 程暂停另一个进程中的所有线程的运行,但是从事暂停操作的进程必须是个调试程序。特别是, 进程必须调用WaitForDebugEvent和ContinueDebugEvent之类的函数。 由于竞争的原因, Windows没有提供其他方法来暂停进程中所有线程的运行。例如,虽然 许多线程已经暂停,但是仍然可以创建新线程。从某种意义上说,系统必须在这个时段内暂停 所有新线程的运行。 Microsoft已经将这项功能纳入了系统的调试机制。 虽然无法创建绝对完美的 SuspendProcess函数,但是可以创建一个该函数的实现代码,它 能够在许多条件下出色地运行。下面是我的 SuspendProcess函数的实现代码: 我的SuspendProcess函数使用 ToolHelp函数来枚举系统中的线程列表。当我找到作为指定 进程的组成部分的线程时,我调用 OpenThread: 下载 145 第 7章 线程的调度、优先级和亲缘性计计 这个新Windows 2000函数负责找出带有匹配的线程 ID的线程内核对象,对内核对象的使 用计数进行递增,然后返回对象的句柄。运用这个句柄,我调用 SuspendThread(或 ResumeThread)。由于OpenThread在Windows 2000中是个新函数,因此我的 SuspendProcess函 数在Windows 95或Windows 98上无法运行,在 Windows NT 4.0或更早的版本上也无法运行。 也许你懂得为什么 SuspendProcess不能总是运行,原因是当枚举线程组时,新线程可以被 创建和撤消。因此,当我调用 CreateToolhelp32Snapshot后,一个新线程可能会出现在目标进程 中,我的函数将无法暂停这个新线程。过了一些时候,当调用 SuspendProcess函数来恢复线程 的运行时,它将恢复它从未暂停的一个线程的运行。更糟糕的是,当枚举线程 ID时,一个现有 的线程可能被撤消,一个新线程可能被创建,这两个线程可能拥有相同的 ID。这将会导致该函 数暂停任意些个(也许在目标进程之外的一个进程中的)线程的运行。 当然,这些情况不太可能出现。如果非常了解目标进程是如何运行的,那么这些问题也许 根本不是问题。我提供这个函数供酌情使用。 7.3 睡眠方式 线程也能告诉系统,它不想在某个时间段内被调度。这是通过调用 Sleep函数来实现的: 该函数可使线程暂停自己的运行,直到 dwMilliseconds过去为止。关于 Sleep函数,有下面 几个重要问题值得注意: • 调用Sleep,可使线程自愿放弃它剩余的时间片。 • 系统将在大约的指定毫秒数内使线程不可调度。不错,如果告诉系统,想睡眠 100ms, 那么可以睡眠大约这么长时间,但是也可能睡眠数秒钟或者数分钟。记住, Windows不 是个实时操作系统。虽然线程可能在规定的时间被唤醒,但是它能否做到,取决于系统 中还有什么操作正在进行。 • 可以调用Sleep,并且为dwMilliseconds参数传递INFINITE。这将告诉系统永远不要调度 该线程。这不是一件值得去做的事情。最好是让线程退出,并还原它的堆栈和内核对象。 • 可以将0传递给 Sleep。这将告诉系统,调用线程将释放剩余的时间片,并迫使系统调度 另一个线程。但是,系统可以对刚刚调用 Sleep的线程重新调度。如果不存在多个拥有相 同优先级的可调度线程,就会出现这种情况。 7.4 转换到另一个线程 系统提供了一个称为 SwitchToThread的函数,使得另一个可调度线程(如果存在能够运 行): 当调用这个函数的时候,系统要查看是否存在一个迫切需要 CPU时间的线程。如果没有线 程迫切需要CPU时间,SwitchToThread就会立即返回。如果存在一个迫切需要 CPU时间的线程, SwitchToThread就对该线程进行调度(该线程的优先级可能低于调用 SwitchToThread的线程)。 这个迫切需要CPU时间的线程可以运行一个时间段,然后系统调度程序照常运行。 该函数允许一个需要资源的线程强制另一个优先级较低、而目前却拥有该资源的线程放弃 该资源。如果调用 SwitchToThread函数时没有其他线程能够运行,那么该函数返回 FALSE,否 则返回一个非0值。 146计计第二部分 编程的具体方法 下载 调用SwitchToThread函数与调用 Sleep是相似的,并且传递给它一个 0ms的超时。差别是 SwitchToThread允许优先级较低的线程运行。即使低优先级线程迫切需要 CPU时间,Sleep也能 够立即对调用线程重新进行调度。 Windows 98 Windows 98 没有配备该函数的非常有用的实现代码。 7.5 线程的运行时间 有时想要计算线程执行某个任务需要多长的时间。许多人采取的办法是编写类似下面的代 码: 这个代码做了一个简单的假设:即它不会被中断。但是,在抢占式操作系统中,永远无法 知道线程何时被赋予 CPU时间。当取消线程的CPU时间时,就更难计算线程执行不同任务时所 用的时间。我们需要一个函数,以便返回线程得到的 CPU时间的数量。幸运的是, Windows提 供了一个称为GetThreadTimes的函数,它能返回这些信息: GetThreadTimes函数返回 4个不同的时间值,这些值如表 7-1所示。 表7-1 GetThreadTimes 函数的返回时间值 时间值 创建时间 退出时间 内核时间 用户时间 含义 用英国格林威治时间 1601年1月1日午夜后100ns的时间间隔表示 的英国绝对值,用于指明线程创建的时间 用英国格林威治时间 1601年1月1日午夜后100ns的时间间隔表示 的英国绝对值,用于指明线程退出的时间。如果线程仍然在运行, 退出时间则未定义 一个相对值,用于指明线程执行操作系统代码已经经过了多少 个100ns的CPU时间 一个相对值,用于指明线程执行应用程序代码已经经过了多少 个100ns的CPU时间 使用这个函数,可以通过使用下面的代码确定执行复杂的算法时需要的时间量: 下载 147 第 7章 线程的调度、优先级和亲缘性计计 注意,GetProcessTimes是个类似GetThreadTimes的函数,适用于进程中的所有线程: GetProcessTimes返回的时间适用于某个进程中的所有线程(甚至是已经终止运行的线程)。 例如,返回的内核时间是所有进程的线程在内核代码中经过的全部时间的总和。 Windows 98 遗憾的是, GetThreadTimes和GetProcessTimes这两个函数在Windows 98中不起作用。在Windows 98中,没有一个可靠的机制可供应用程序来确定线程或进 程已经使用了多少CPU时间。 对于高分辨率的配置文件来说, GetThreadTimes并不完美。 Windows确实提供了一些高分 辨率性能函数: 虽然这些函数认为,正在执行的线程并没有得到抢占的机会,但是高分辨率的配置文件是 148计计第二部分 编程的具体方法 下载 为短期存在的代码块设置的。为了使这些函数运行起来更加容易一些,我创建了下面这个 C++ 类: 使用这个类如下: 7.6 运用结构环境 现在应该懂得环境结构在线程调度中所起的重要作用了。环境结构使得系统能够记住线程 的状态,这样,当下次线程拥有可以运行的 CPU时,它就能够找到它上次中断运行的地方。 知道这样低层的数据结构也会完整地记录在 Platform SDK文档中确实使人吃惊。不过如果 查看该文档中的 CONTEXT结构,会看到下面这段文字: “CONTEXT结构包含了特定处理器的寄存器数据。系统使用 CONTEXT结构执行各种内部 操作。目前,已经存在为 Intel、MIPS、Alpha和PowerPC处理器定义的CONTEXT结构。若要 了解这些结构的定义,参见头文件 WinNT.h”。 该文档并没有说明该结构的成员,也没有描述这些成员是谁,因为这些成员要取决于 Windows 2000在哪个CPU上运行。实际上,在 Windows定义的所有数据结构中, CONTEXT结 构是特定于 CPU的唯一数据结构。 那么CONTEXT结构中究竟存在哪些东西呢?它包含了主机 CPU上的每个寄存器的数据结 构。在x86计算机上,数据成员是 Eax、Ebx、Ecx、Edx等等。如果是 Alpha处理器,那么数据 成员包括 IntV0、IntT0、IntT1、IntS0、IntRa和IntZero等等。下面这个代码段显示了 x86 CPU 的完整的 CONTEXT结构: 下载 149 第 7章 线程的调度、优先级和亲缘性计计 150计计第二部分 编程的具体方法 下载 CONTEXT结构可以分成若干个部分。 CONTEXT_CONTROL包含CPU的控制寄存器,比 如指令指针、堆栈指针、标志和函数返回地址(与 x86处理器不同, Alpya CPU在调用函数时, 将该函数的返回地址放入一个寄存器中)。CONTEXT_INTEGER用于标识CPU的整数寄存器。 CONTEXT_FLOATING_POINT用于标识CPU的浮点寄存器。 CONTEXT_SEGMENTS用于标识 CPU的段寄存器(仅为 x86处理器)。CONTEXT_DEBUG_ REGISTER用于标识 CPU的调试寄 存器(仅为 x86处理器)。CONTEXT_EXTENDED_ REGISTERS用于标识 CPU的扩展寄存器 (仅为x86处理器)。 Windows实际上允许查看线程内核对象的内部情况,以便抓取它当前的一组 CPU寄存器。 若要进行这项操作,只需要调用 GetThreadContext函数: 若要调用该函数,只需指定一个CONTEXT结构,对某些标志(该结构的ContextFlags成员) 进行初始化,指明想要收回哪些寄存器,并将该结构的地址传递给 GetThreadContext。然后该 函数将数据填入你要求的成员。 在调用GetThreadContext函数之前,应该调用 SuspendThread,否则,线程可能被调度,而 且线程的环境可能与你收回的不同。一个线程实际上有两个环境。一个是用户方式,一个是内 核方式。GetThreadContext只能返回线程的用户方式环境。如果调用 SuspendThread来停止线程 的运行,但是该线程目前正在用内核方式运行,那么,即使 SuspendThread实际上尚未暂停该 下载 151 第 7章 线程的调度、优先级和亲缘性计计 线程的运行,它的用户方式仍然处于稳定状态。线程在恢复用户方式之前,它无法执行更多的 用户方式代码,因此可以放心地将线程视为处于暂停状态, GetThreadContext函数将能正常运 行。 CONTEXT结构的ContextFlags成员并不与任何 CPU寄存器相对应。无论是何种 CPU结构, 该成员存在于所有 CONTEXT结构定义中。 ContextFlags成员用于向GetThreadContext函数指明 你想检索哪些寄存器。例如,如果想要获得线程的控制寄存器,可以编写下面的代码: 注意,在调用 GetThreadContext之前,首先必须对 CONTEXT结构中的ContextFlags成员进 行初始化。如果想要获得线程的控制寄存器和整数寄存器,应该像下面这样对 ContexFlags进行 初始化: 下面是一些标识符,使用这些标识符可以获得线程的所有重要的寄存器(即 Microsoft视为 最常用的那些寄存器): 在WinNT.h文件中,定义了CONTEXT_FULL,请看表7-2。 表7-2 CONTEXT-FULL的意义 C P U类型 C O N T E X T _ F U L L的定义 X86 Alpha CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS CONTEXT_CONTROL | CONTEXT_FLOATING_POINT | CONTEXT_INTEGER 当G e t T h r e a d C o n t e x t返回时,能够很容易地查看线程的任何寄存器的值,但是要记住,这 意味着必须编写与 CPU相关的代码。表 7-3根据 CPU类型列出了 CONTEXT结构的指令指针和堆 栈指针。 表7-3 CONTEXT 结构的指令指针和堆栈指针 C P U类型 X86 Alpha 指令指针 C O N T E X T. E i p C O N T E X T. F i r 堆栈指针 C O N T E X T. E s p C O N T E X T. I n t S p Windows为编程人员提供了多么强大的功能啊!如果你认为它确实不错,那么你一定会喜 152计计第二部分 编程的具体方法 下载 欢它的,因为 Windows使你能够修改CONTEXT结构中的成员,然后通过调用 SetThreadContext 将新寄存器值放回线程的内核对象中: 同样,修改其环境的线程应该首先暂停,否则其结果将无法预测。 在调用SetThreadContext之前,必须再次对 CONTEXT的ContextFlags成员进行初始化,如 下面的代码所示: 这有可能导致远程线程中的访问违规,向用户显示未处理的异常消息框,同时,远程进程 终止运行。你将成功地终止另一个进程的运行,而你的进程则可以继续很好地运行。 GetThreadContext和SetThreadContext函数使你能够对线程进行许多方面的控制,但是在使 用它们时应该小心。实际上,几乎没有应用程序调用这些函数。增加这些函数是为了增强调试 程序和其他工具的功能。任何应用程序都可以调用它们。 第24章将详细地介绍CONTEXT结构。 7.7 线程的优先级 本章开头讲述了 CPU是如何只使线程运行 20ms,然后调度程序将另一个可调度的线程分 配给CPU的。如果所有线程具有相同的优先级,那么就会发生这种情况,但是,在现实环境 中,线程被赋予许多不同的优先级,这会影响到调度程序将哪个线程取出来作为下一个要运 下载 153 第 7章 线程的调度、优先级和亲缘性计计 行的线程。 每个线程都会被赋予一个从 0(最低)到31(最高)的优先级号码。当系统确定将哪个线 程分配给CPU时,它首先观察优先级为 31的线程,并以循环方式对它们进行调度。如果优先级 为31的线程可以调度,那么就将该线程赋予一个 CPU。在该线程的时间片结束时,系统要查看 是否还有另一个优先级为 31的线程可以运行,如果有,它将允许该线程被赋予一个 CPU。 只要优先级为31的线程是可调度的,系统就绝对不会将优先级为 0到30的线程分配给CPU。 这种情况称为渴求调度( starvation)。当高优先级线程使用大量的 CPU时间,从而使得低优先 级线程无法运行时,便会出现渴求情况。在多处理器计算机上出现渴求情况的可能性要少得多, 因为在这样的计算机上,优先级为 31和优先级为30的线程能够同时运行。系统总是设法使 CPU 保持繁忙状态,只有当没有线程可以调度的时候, CPU才处于空闲状态。 人们可能认为,在这样的系统中,低优先级线程永远得不到机会运行。不过正像前面指出 的那样,在任何一个时段内,系统中的大多数线程是不能调度的。例如,如果进程的主线程调 用GetMessage函数,而系统发现没有线程可以供它使用,那么系统就暂停进程的线程运行,释 放该线程的剩余时间片,并且立即将 CPU分配给另一个等待运行的线程。 如果没有为 GetMessage函数显示可供检索的消息,那么进程的线程将保持暂停状态,并且 决不会被分配给 CPU。但是,当消息被置于线程的队列中时,系统就知道该线程不应该再处于 暂停状态。此时,如果没有更高优先级的线程需要运行,系统就将该线程分配给一个 CPU。 现在考虑另一个问题。高优先级线程将抢在低优先级线程之前运行,不管低优先级线程正 在运行什么。例如,如果一个优先级为 5的线程正在运行,系统发现一个高优先级的线程准备 要运行,那么系统就会立即暂停低优先级线程的运行(即使它处于它的时间片中),并且将 CPU分配给高优先级线程,使它获得一个完整的时间片。 还有,当系统引导时,它会创建一个特殊的线程,称为 0页线程。该线程被赋予优先级 0, 它是整个系统中唯一的一个在优先级 0上运行的线程。当系统中没有任何线程需要执行操作时, 0页线程负责将系统中的所有空闲 RAM页面置0。 7.8 对优先级的抽象说明 当Microsoft的开发人员设计线程调度程序时,他们发现该调度程序无法在所有时间适应所 有人的需要。他们还发现,计算机的“作用”是不断变化的。当 Windows NT问世时,对象链 接和嵌入(OLE)应用程序还刚刚开始编写。现在, OLE应用程序已经司空见惯。游戏软件已 经相当流行。当然,在Windows NT 的早期,并没有更多地考虑Internet的问题。 调度算法对用户运行的应用程序类型有着相当大的影响。从一开始, Microsoft的开发人员 就认识到,随着系统的用途的变化,他们必须不断修改调度算法。但是,软件开发人员需要在 今天编写软件,而Microsoft则要保证软件能够在将来的系统版本上运行。那么 Microsoft如何改 变系统工作的方式并仍然保证软件能够运行呢?下面是解决这个问题的一些办法: • Microsoft没有将调度程序的行为特性完全固定下来。 • Microsoft没有让应用程序充分利用调度程序的特性。 • Microsoft声称调度程序的算法是变化的,在编写代码时应有所准备。 Windows API展示了系统的调度程序上的一个抽象层,这样就永远不会直接与调度程序进 行通信。相反,要调用 Windows函数,以便根据运行的系统版本“转换”参数。本章将介绍这 个抽象层。 当设计一个应用程序时,你应该考虑到还有什么别的应用程序会与你的应用程序一道运行。 154计计第二部分 编程的具体方法 下载 然后,应该根据你的应用程序中的线程应该具备何种响应性,选择一个优先级类。这听起来有 些费解,不过情况确实如此。 Microsoft不想作出任何将来可能影响你的代码运行的承诺。 Windows支持6个优先级类:即空闲、低于正常、正常、高于正常、高和实时。当然,正 常优先级是最常用的优先级类, 99%的应用程序均使用这个优先级类。表 7-4描述了这些优先 级类。 表7-4 Windows 支持的优先级类 优先级类 实时 高 高于正常 正常 低于正常 空闲 描述 进程中的线程必须立即对事件作出响应,以便执行关键时间的任务。 该进程中的线程还会抢先于操作系统组件之前运行。使用本优先级类 时必须极端小心 进程中的线程必须立即对事件作出响应,以便执行关键时间的任务。 Task Manager(任务管理器)在这个类上运行,以便用户可以撤消脱 离控制的进程 进程中的线程在正常优先级与高优先级之间运行(这是 Windows 2000中的新优先级类) 进程中的线程没有特殊的调度需求 进程中的线程在正常优先级与空闲优先级之间运行(这是 Windows 2 0 0 0中的新优先级类) 进程中的线程在系统空闲时运行。该进程通常由屏幕保护程序或后 台实用程序和搜集统计数据的软件使用 当系统什么也不做的时候,将空闲优先级类用于应用程序的运行是最恰当不过的。没有用 交互方式使用的计算机有可能仍然很繁忙(比如作为文件服务器),不应该与屏幕保护程序争 用CPU时间。定期更新系统的某些状态的统计信息跟踪应用程序不应该干扰关键任务的运行。 只有当绝对必要的时候,才可以使用高优先级类。你会惊奇地发现, Windows Explorer是 在高优先级上运行的。大多数时间 Explorer的线程是暂停的,等待用户按下操作键或者点击鼠 标按钮时被唤醒。当 Explorer的线程处于暂停状态时,系统不将它的线程分配给 CPU。因为这 将使低优先级线程得以运行。但是一旦用户按下一个操作键或组合键,如 Ctrl+Esc,系统就会 唤醒Explorer的线程(当用户按下 Ctrl+Esc组合键时,也会出现 Start菜单)。如果低优先级线程 正在运行,系统会立即抢在这些线程的前面,让 Explorer的线程优先运行。 Microsoft就是按这种方法设计 Explorer的,因为用户希望无论系统中正在运行什么,外壳 程序都具有极强的响应能力。实际上,即使低优先级线程在无限循环中暂停运行,也能显示 Explorer的窗口。由于 Explorer的线程拥有较高的优先级,因此执行无限循环的线程被抢占, Explorer让用户终止挂起进程的运行。 Explorer的运行特性非常出色,大部分时间它的线程无事 可做,不必占用 CPU时间。如果情况不是如此,那么整个系统的运行速度就会慢得多,许多应 用程序就不会作出响应。 应该尽可能避免使用实时优先级类。实际上 Windows NT 3.1的早期测试版并没有向应用程 序展示这个优先级类,尽管该操作系统支持这个类。实时优先级是很高的优先级,它可能干扰 操作系统任务的运行,因为大多数操作系统线程均以较低的优先级来运行。因此实时线程可能 阻止必要的磁盘 I/O信息和网络信息的产生。此外,键盘和鼠标输入将无法及时得到处理,用 户可能以为系统已经暂停运行。大体来说,必须有足够的理由才能使用实时优先级,比如需要 以很短的等待时间来响应硬件事件,或者执行某些不能中断的短期任务。 注意 除非用户拥有“提高调度优先级”的权限,否则进程不能用实时优先级类来运 下载 155 第 7章 线程的调度、优先级和亲缘性计计 行。凡是被指定为管理员或特权用户的用户,均默认拥有该权限。 当然,大多数进程都属于正常优先级类。低于正常和高于正常的优先级类是 Windows 2000 中的新增优先级。 Microsoft增加这些优先级类的原因是,有若干家公司抱怨现有的优先级类无 法提供足够的灵活性。 一旦选定了优先级类之后,就不必考虑你的应用程序与其他应用程序之间的关系,只需要 集中考虑你的应用程序中的各个线程。 Windows支持7个相对的线程优先级:即空闲、最低、 低于正常、正常、高于正常、最高和关键时间优先级。这些优先级是相对于进程的优先级类而 言的。大多数线程都使用正常线程优先级。表 7-5描述了这些相对的线程优先级。 表7-5 相对的线程优先级 相对的线程优先级 关键时间 最高 高于正常 正常 低于正常 最低 空闲 描述 对于实时优先级类来说,线程在优先级 31上运行,对于其他优先 级类来说,线程在优先级 15上运行 线程在高于正常优先级的上两级上运行 线程在正常优先级的上一级上运行 线程在进程的优先级类上正常运行 线程在低于正常优先级的下一级上运行 线程在低于正常优先级的下两级上运行 对于实时优先级类来说,线程在优先级 16上运行对于其他优先级 类来说,线程在优先级 1上运行 概括起来说,进程是优先级类的一个组成部分,你为进程中的线程赋予相对线程优先级。 这里没有讲到 0到31的优先级的任何情况。应用程序开发人员从来不必具体设置优先级。相反, 系统负责将进程的优先级类和线程的相对优先级映射到一个优先级上。正是这种映射方式, Microsoft不想拘泥不变。实际上这种映射方式是随着系统的版本的升级而变化的。 表7-6显示了这种映射方式是如何用于 Windows 2000的,注意,Windows NT的早期版本和 某些Windows 95和Windows 98版本采用了不同的映射方式。未来的 Windows版本中的映射方式 也会变化。 例如,正常进程中的正常线程被赋予的优先级是 8。由于大多数进程属于正常优先级类, 而大多数线程属于正常线程优先级,因此系统中的大多数线程的优先级是 8。 如果高优先级进程中有一个正常线程,该线程的优先级将是 13。如果将进程的优先级类改 为8,那么线程的优先级就变为 4。如果改变了进程的优先级类,线程的相对优先级不变,但是 它的优先级的等级却发生了变化。 表7-6 进程优先级类和线程相对优先级的映射 相对线程 优先级 关键时间 最高 高于正常 正常 低于正常 最低 空闲 空闲 15 6 5 4 3 2 1 低于 正常 15 8 7 6 5 4 1 正常 5 10 9 8 7 6 1 高于 正常 15 12 11 10 9 8 1 高 实时 15 31 15 26 14 25 13 24 12 23 11 22 1 16 156计计第二部分 编程的具体方法 下载 注意,表7-6并没有显示优先级的等级为 0的线程。这是因为 0优先级保留供零页线程使用, 系统不允许任何其他线程拥有 0优先级。另外,下列优先级等级是无法使用的: 17、18、19、 20、21、27、28、29和30。如果编写一个以内核方式运行的设备驱动程序,可以获得这些优先 级等级,而用户方式的应用程序则不能。另外还要注意,实时优先级类中的线程不能低于优先 级等级16。同样,非实时优先级类中的线程的等级不能高于 15。 注意 有些人常常搞不清进程优先级类的概念。他们认为这可能意味着进程是可以调 度的。但是进程是根本不能调度的,只有线程才能被调度。进程优先级类是个抽象概 念,Microsoft提出这个概念的目的,是为了帮助你将它与调度程序的内部运行情况区 分开来。它没有其他目的。 注意 一般来说,大多数时候高优先级的线程不应该处于可调度状态。当线程要进行 某种操作时,它能迅速获得 CPU时间。这时线程应该尽可能少地执行 CPU指令,并返 回睡眠状态,等待再次变成可调度状态。相反,低优先级的线程可以保持可调度状态, 执行大量的CPU指令来进行它的操作。如果按照这些原则来办,整个操作系统就能正 确地对用户作出响应。 7.9 程序的优先级 进程是如何被赋予优先级类的呢?当调用 CreateProcess时,可以在fdwCreate参数中传递需 要的优先级类。表7-7显示了优先级类的标识符。 表7-7 优先级类的标识类 优先级类 实时 高 高于正常 正常 低于正常 空闲 标识符 R E A LT I M E _ P R I O R I T Y _ C L A S S HIGH_PRIORITY_CLASS ABOVE_NORMAL_PRIORITY_CLASS NORMAL_PRIORITY_CLASS BELOW_NORMAL_PRIORITY_CLASS IDLE_PRIORITY_CLASS 创建子进程的进程负责选择子进程运行的优先级类,这看起来有点奇怪。让我们以 Explorer为例来说明这个问题。当使用 Explorer来运行一个应用程序时,新进程按正常优先级运 行。 Explorer不知道进程在做什么,也不知道隔多长时间它的线程需要进行调度。但是,一旦 子进程运行,它就能够通过调用 SetPriorityClass来改变它自己的优先级类: 该函数将 hProcess标识的优先级类改为 fdwPriority参数中设定的值。 fdwPriority参数可以是 表7-7显示的标识符之一。由于该函数带有一个进程句柄,因此,只要拥有该进程的句柄和足 够的访问权,就能够改变系统中运行的任何进程的优先级类。 一般来说,进程将试图改变它自己的优先级类。下面是如何使一个进程将它自己的优先级 类设置为空闲的例子: 下载 157 第 7章 线程的调度、优先级和亲缘性计计 下面是用来检索进程的优先级类的补充函数: 正如你所期望的那样,该函数将返回表 7-7中列出的标识符之一。 当使用命令外壳启动一个程序时,该程序的起始优先级是正常优先级。但是,如果使用 Start命令来启动该程序,可以使用一个开关来设定应用程序的起始优先级。例如,在命令外壳 输入下面的命令可使系统启动 Calculator,并在开始时按空闲优先级来运行它: Start命令还能识别 /BELOWNORMAL、/NORMAL、/ABOVENORMAL、/HIGH和 /REALTIME等开关,以便按它们各自的优先级启动执行一个应用程序。当然,一旦应用程序 启动运行,它就可以调用 SetPriorityClass函数,将它自己的优先级改为它选择的任何优先级。 Windows 98 Windows 98的Start命令并不支持这些开关中的任何一个。 Windows 98 命令外壳启动的进程总是使用正常优先级类来运行。 Windows 2000 的Task Manager 使得用户可以改变进程的优先级类。图 7-2显示了 Task Manager的Processes选项卡,它显示了当前运行的所有进程。 Base Pri列显示了每个进程的优先 级类。可以改变进程的优先级类,方法是选定一个进程,然后从上下文菜单的 Set Priority(设 置优先级)子菜单中选择一个选项。 图7-2 Windows Task Manager 对话框 当一个线程刚刚创建时,它的相对线程优先级总是设置为正常优先级。我总感到有些奇怪, C r e a t e T h r e a d没有为调用者提供一个设置新线程的相对优先级的方法。若要设置和获得线程的 相对优先级,必须调用下面的这些函数: 158计计第二部分 编程的具体方法 下载 当然,hThread参数用于标识想要改变优先级的单个线程, nPriority参数是表7-8列出的7个 标识符之一。 表7-8 线程相对优先级的标识符常量 相对线程优先级 标识符常量 关键时间 最高 高于正常 正常 低于正常 最低 空闲 THREAD_PRIORITY_TIME_CRITICAL THREAD_PRIORITY_HIGHEST THREAD_PRIORITY_ABOVE_NORMAL THREAD_PRIORITY_NORMAL THREAD_PRIORITY_BELOW_NORMAL THREAD_PRIORITY_LOWEST THREAD_PRIORITY_IDLE 下面是检索线程的相对优先级的补充函数: 该函数返回表 7-8列出的标识符之一。 若要创建一个带有相对优先级为空闲的线程,可以执行类似下面的代码: 注意, CreateThread 函数创建的新函数带有的相对优先级总是正常优先级。若要使线程以 空闲优先级来运行,应该将 CREATE_SUSPENDED标志传递给CreateThread函数,这可以防止 线程执行任何代码。然后可以调用 SetThreadPriority,将线程的优先级改为相对空闲优先级。 这时可以调用ResumeThread,使得线程成为可调度的线程。你不知道线程何时能够获得 CPU时 间,但是调度程序会考虑这样一个情况,即该线程拥有一个空闲优先级。最后,可以关闭新线 程的句柄,一旦线程终止运行,内核对象就能被撤消。 注意 Windows 没有提供返回线程的优先级的函数。这是故意进行的。记住, M i c r o s o f t保留了随时修改调度算法的权利。你不会设计需要调度算法专门知识的应用 程序。如果坚持使用进程优先级类和相对线程优先级,你的应用程序不仅现在能够顺 利地运行,而且在系统的将来版本上也能很好地运行。 7.9.1 动态提高线程的优先级等级 通过将线程的相对优先级与线程的进程优先级类综合起来考虑,系统就可以确定线程的优 先级等级。有时这称为线程的基本优先级等级。系统常常要提高线程的优先级等级,以便对窗 口消息或读取磁盘等 I/O事件作出响应。 例如,在高优先级类进程中的一个正常优先级等级的线程的基本优先级等级是 13。如果用 户按下一个操作键,系统就会将一个 WM_KEYDOWN消息放入线程的队列中。由于一个消息 已经出现在线程的队列中,因此该线程就是可调度的线程。此外,键盘设备驱动程序也能够告 诉系统暂时提高线程的优先级等级。该线程的优先级等级可能提高 2级,其当前优先级等级改 为15。 下载 159 第 7章 线程的调度、优先级和亲缘性计计 系统在优先级为 15时为一个时间片对该线程进行调度。一旦该时间片结束,系统便将线程 的优先级递减 1,使下一个时间片的线程优先级降为 14。该线程的第三个时间片按优先级等级 13来执行。如果线程要求执行更多的时间片,均按它的基本优先级等级 13来执行。 注意,线程的当前优先级等级决不会低于线程的基本优先级等级。此外,导致线程成为可 调度线程的设备驱动程序可以决定优先级等级提高的数量。 Microsoft并没有规定各个设备驱动 程序可以给线程的优先级提高多少个等级。这样就使得 Microsoft可以不断地调整线程优先级提 高的动态等级,以确定最佳的总体响应性能。 系统只能为基本优先级等级在 1至15之间的线程提高其优先级等级。实际上这是因为这个 范围称为动态优先级范围。此外,系统决不会将线程的优先级等级提高到实时范围(高于 15)。 由于实时范围中的线程能够执行大多数操作系统的函数,因此给等级的提高规定一个范围,就 可以防止应用程序干扰操作系统的运行。另外,系统决不会动态提高实时范围内的线程优先级 等级。 有些编程人员抱怨说,系统动态提高线程优先级等级的功能对他们的线程性能会产生一种 不良的影响,为此 Microsoft增加了下面两个函数,这样就能够使系统的动态提高线程优先级等 级的功能不起作用: S e t P r o c e s s P r i o r i t y B o o s t负责告诉系统激活或停用进行中的所有线程的优先级提高功能,而 SetThreadPriorityBoost则让你激活或停用各个线程的优先级提高功能。这两个函数具有许多相 似的共性,可以用来确定是激活还是停用优先级提高功能: 对于这两个函数中的每个函数,可以传递想要查询的进程或线程的句柄,以及由函数设置 的BOOL的地址。 Windows 98 Windows 98 没有提供这 4个函数的有用的实现代码。它们全部返回 FALSE,后来对GetLastError的调用将返回ERROR_CALL_NOT_IMPLEMENTED。 另一种情况也会导致系统动态地提高线程的优先级等级。比如有一个优先级为 4的线程准 备运行但是却不能运行,因为一个优先级为 8的线程正连续被调度。在这种情况下,优先级为 4 的线程就非常渴望得到 CPU时间。当系统发现一个线程在大约 3至4s内一直渴望得到CPU时间, 它就将这个渴望得到 CPU时间的线程的优先级动态提高到 15,并让该线程运行两倍于它的时间 量。当到了两倍时间量的时候,该线程的优先级立即返回到它的基本优先级。 7.9.2 为前台进程调整调度程序 当用户对进程的窗口进行操作时,该进程就称为前台进程,所有其他进程则称为后台进程。 当然,用户希望他正在使用的进程比后台进程具有更强的响应性。为了提高前台进程的响应性, 160计计第二部分 编程的具体方法 下载 Windows能够为前台进程中的线程调整其调度算法。对于 Windows 2000来说,系统可以为前台 进程的线程提供比通常多的 CPU时间量。这种调整只能在前台进程属于正常优先级类的进程时 才能进行。如果它属于其他任何优先级类,就无法进行任何调整。 Windows 2000实际上允许用户对这种调整进行相应的配置。在 System Properties(系统属 性)对话框的Advanced选项卡上,用户可以单击 Performance Options(性能选项)按钮,打开图 7 - 3所示的对话框。 图7-3 Performance Options 对话框 如果用户选择优化应用程序的性能,系统就执行配置的调整。如果用户选择优化后台服务 程序的性能,系统就不进行调整。当安装 Windows 2000的专业版时, Applications就会被默认 选定。对于Windows 2000的所有其他版本,则默认选定 Background Services,因为计算机将主 要由非交互式用户使用。 当进程移到前台时, Windows 98也会对正常优先级类的进程中的线程调度算法进行调整。 当一个优先级为正常的进程移到前台时,系统便将最低、低于正常、正常、高于正常和最高等 优先级的线程的优先级提高 1,优先级为空闲和关键时间的线程的优先级则不予提高。因此, 在正常优先级类的进程中运行的、其相对优先级为正常的线程,它的优先级等级是 9而不是8。 当进程返回后台时,进程中的线程便自动返回它们定义好的基本优先级等级。 Windows 98 Windows 98没有提供允许用户配置这种调整手段的任何用户界面,因 为Windows 98 不是作为专用服务器来运行的。 将进程改为前台进程的原因是,使它们能够对用户的输入更快地作出响应。如果不改为前 台进程,那么在后台的正常打印进程与在后台接收用户输入的正常进程就会平等地争用 CPU时 间。用户会发现文本无法在前台应用程序中顺利地显示。但是,由于系统改变了前台进程的线 程优先级,前台进程的线程就能对用户的输入更好地作出响应。 7.9.3 Scheduling Lab示例应用程序 使用Scheduling Lab应用程序“ 07SchedLab.exe”(见后面的清单 7-1),可以对进程优先级 类和相对线程优先级进行操作试验,以了解它们对系统的总体性能产生的影响。该应用程序的 源代码和源文件位于本书所附光盘上的 07-SchedLab目录中。当启动该程序时,就会出现图 7-4 所示的窗口。 开始时,主线程总是处于繁忙状态,因此 CPU的使用量立即跳到 100%。该主线程连续递 增一个数字,并将它添加给右边的列表框。这个数字并没有任何意义,它只是显示线程正在忙 下载 161 第 7章 线程的调度、优先级和亲缘性计计 于进行什么操作。若要了解线程调度对系统会产 生什么实际影响,建议至少要同时运行该示例应 用程序的两个实例,看一看改变一个实例的优先 级会对另一个实例带来的影响。也可以运行 Task Manager ,以便监控所有实例的 CPU使用量。 当进行这些测试时, CPU的使用量开始时将 上升为100%,该应用程序的所有实例将获得大约 图7-4 进程优先级类和相对线程优先级的试验窗口 相等的CPU时间(Task Manager应该显示应用程序的所有实例大致相同的CPU使用量百分比)。 如果将一个实例的优先级类改为高于正常或高优先级类,那么应该看到它得到了大部分的 CPU使用量。而其他实例中的数字滚动则没有规律。但是其他实例的数字不会完全停止滚动, 因为系统将为渴求 CPU时间的线程自动执行优先级的动态提高。不管怎样,可以随意调整优先 级类和相对线程优先级,以了解它们对其他实例的影响。我有目的地对 Scheduling Lab应用程 序进行了编码,这样就无法将进程改为实时优先级类,这可以防止操作系统线程的不正常的运 行。如果想要试用实时优先级,必须自己修改源代码。 可以使用Sleep域,使主线程在 0到9999之间的任意毫秒内无法调度。请试用这项功能,并 观察传递仅为1ms的睡眠值时可以重新获得多少 CPU时间。在我的300MHz Pentium II笔记本电 脑上,我赢得了99%的CPU时间。 单击Suspend(暂停)按钮,可使主线程产生一个子线程。这个子线程能够暂停主线程的 运行,并显示图7-5所示的消息框。 图7-5 消息框 当这个消息框显示时,主线程将完全暂停运行,并且不使用任何 CPU时间。子线程也不使 用任何 CPU时间,因为它是在等待用户执行某种操作。当消息框显示时,可以将它移到应用程 序的主窗口,然后将它移开,这样就能够看到主窗口。由于主线程已经暂停运行,因此主窗口 将无法接收任何窗口消息(包括 WM_PAINT)。这证明该线程已经暂停运行。当关闭该消息框 时,主线程就恢复运行, CPU使用量回到100%。 若要再进行一次试验,请打开前一节介绍的 Performance Options对话框,将 Application改 为Background Services,或者将Background Services改为Application。然后打开 SchedLab程序 的多个实例,将它们全部设置为正常优先级类,并激活其中的一个,使之成为一个前台进程。 这时就能够看到性能的设置对前台 /后台进程产生的影响。 清单7-1 SchedLab示例应用程序 162计计第二部分 编程的具体方法 下载 下载 163 第 7章 线程的调度、优先级和亲缘性计计 164计计第二部分 编程的具体方法 下载 下载 165 第 7章 线程的调度、优先级和亲缘性计计 166计计第二部分 编程的具体方法 下载 下载 167 第 7章 线程的调度、优先级和亲缘性计计 7.10 亲缘性 按照默认设置,当系统将线程分配给处理器时, Windows 2000使用软亲缘性来进行操作。 这意味着如果所有其他因素相同的话,它将设法在它上次运行的那个处理器上运行线程。让线 程留在单个处理器上,有助于重复使用仍然在处理器的内存高速缓存中的数据。 168计计第二部分 编程的具体方法 下载 有一种新的计算机结构,称为 NUMA(非统一内存访问),在该结构中,计算机包含若干 块插件板,每个插件板上有 4个CPU和它自己的内存区。图 7-6显示了一台配有 3块插件板的计 算机,总共有12个CPU,这样,任何一个线程都可以在 12个CPU中的任何一个上运行。 插件板1 NUMA 计算机 插件板2 插件板3 CPU 0 CPU 2 CPU 1 CPU 3 CPU 4 CPU 6 CPU 5 CPU 7 CPU 8 CPU 9 CPU 10 CPU 11 内存 内存 内存 图7-6 NUMA 计算机结构示意图 当CPU访问的内存是它自己的插件板上的内存时, NUMA系统运行的性能最好。如果 CPU 需要访问位于另一个插件板上的内存时,性能就会大大降低。在这样的环境中,就需要来自一 个进程中的线程在 CPU 0至3上运行,让另一个进程中的线程在 CPU 4至7上运行,依次类推。 为了适应这种计算机结构的需要, Windows 2000允许设置进程和线程的亲缘性。换句话说,可 以控制哪个 CPU能够运行某些线程。这称为硬亲缘性。 计算机在引导时,系统要确定机器中有多少个 CPU可供使用。通过调用GetSystemInfo函数 (第14章介绍),应用程序就能查询机器中的 CPU数量。按照默认设置,任何线程都可以调度到 这些CPU中的任何一个上去运行。为了限制在可用CPU的子集上运行的单个进程中的线程数量, 可以调用 SetProcessAffinityMask: 第一个参数 hProcess用于指明要影响的是哪个进程。第二个参数 dwProcessAffinityMask是 个位屏蔽,用于指明线程可以在哪些 CPU上运行。例如,传递0x00000005表示该进程中的线程 可以在CPU 0和CPU 2上运行,但是不能在CPU 1和CPU3至31上运行。 注意,子进程可以继承进程的亲缘性。因此,如果一个进程的亲缘性屏蔽是 0x00000005, 那么它的子进程中的任何线程都拥有相同的位屏蔽,并共享相同的 CPU。此外,可以使用作业 内核对象将一组进程限制在要求的一组 CPU上运行。 当然,还有一个函数也能够返回进程的亲缘性位屏蔽,它就是 GetProcessAffinityMask,如 下面的代码所示: 这里也可以传递想要亲缘性屏蔽的进程句柄,该函数填入 pdwProcessAffinityMask指向的 变量。该函数还能返回系统的亲缘性屏蔽(在 pdwSystemAffinityMask指向的变量中)。系统的 亲缘性屏蔽用于指明系统的哪个 CPU能够处理线程。进程的亲缘性屏蔽始终是一个系统的亲缘 性屏蔽的正确子集。 下载 169 第 7章 线程的调度、优先级和亲缘性计计 Windows 98 无论计算机中实际拥有多少个 CPU,Windows 98只使用一个CPU。因 此,GetProcessAffinityMask总是用1填入两个变量中。 到现在为止,已经介绍了如何将进程的多个线程限制到一组 CPU上去运行。有时可能想要 将进程中的一个线程限制到一组 CPU上去运行。例如,可能有一个包含 4个线程的进程,它们 在拥有4个CPU的计算机上运行。如果这些线程中的一个线程正在执行非常重要的操作,而你 想增加某个 CPU始终可供它使用的可能性,为此你对其他 3个线程进行了限制,使它们不能在 CPU 0上运行,而只能在 CPU 1、2和3上运行。 通过调用SetThreadAffinityMask,就能为各个线程设置亲缘性屏蔽: 该函数中的hThread参数用于指明要限制哪个线程, dwThreadAffinityMask用于指明该线程 能够在哪个 CPU上运行。 dwThreadAffinityMask必须是进程的亲缘性屏蔽的相应子集。返回值 是线程的前一个亲缘性屏蔽。因此,若要将 3个线程限制到 CPU 1、2和3上去运行,可以这样 操作: Windows 98 由于计算机中无论配有多少个 CPU,Windows 98只使用一个CPU,因 此dwThreadAffinityMask参数必须始终是1。 当一个x86系统引导时,系统要执行相应的代码,以便测定主机上的哪些 CPU遇到了著名 的Pentium浮点错误。系统必须为每个 CPU测试其浮点错误,方法是将线程的亲缘性设置为第 一个CPU,执行潜在的故障分割操作,并将结果与已知的正确答案进行比较。然后对下一个 CPU进行上述同样的操作,如此等等。 注意 在大多数环境中,改变线程的亲缘性就会影响调度程序有效地在各个 CPU之间 移植线程的能力,而这种能力可以最有效地使用 CPU时间。表7-9显示了一个例子。 表7-9 线程的亲缘性示例 线程 优先级 亲缘性屏蔽 结果 A 4 0x00000001 CPU 0 B 8 0x00000003 CPU 1 C 6 0x00000002 不能运行 当线程A被唤醒时,调度程序发现该线程可以在 CPU 0上运行,因此它被分配给 CPU 0。然后线程 B被唤醒,调度程序发现该线程可以被分配给 CPU 0或1,但是,由 于CPU 0正在使用之中,因此调度程序将线程 B分配给了CPU 1。至此,一切进行得都 很顺利。 这时线程C被唤醒,调度程序发现它只能在 CPU 1上运行。但是CPU 1正在被线程 170计计第二部分 编程的具体方法 下载 B使用着,它是个优先级为 8的线程。由于线程 C的优先级为6,因此它不能抢在线程 B 的前面运行。线程 C可以抢在线程A的前面运行,因为线程 A的优先级是 4,但是调度 程序不会使它抢在线程A的前面运行,因为线程C不能在CPU 0上运行。 这显示出为线程设置硬亲缘性将会对调度程序的优先级设置方案产生什么样的影 响。 有时强制将一个线程分配给特定的 CPU的做法是不妥当的。例如,有 3个线程都只能在 CPU 0上运行,而 CPU 1、2和3则闲着无事可做。在这种情况下,如果告诉系统想让一个线程 在某个CPU上运行,但是允许该线程在可能的情况下移到另一个 CPU上去运行,那么这种办法 会更好些。 若要为线程设置一个理想的 CPU,可以调用SetThreadIdealProcessor: hThread用于指明要为哪个线程设置首选的 CPU。与我们已经介绍的其他函数不同, dwIdealProcessor函数不是个位屏蔽函数,它是个从 0到31的整数,用于指明供线程使用的首选 CPU。可以传递一个 MAXIMUM_PROCESSORS的值(在 WinNT.h中定义为 32),用于指明不 存在理想的 CPU。如果没有为该线程设置理想的 CPU,那么该函数返回前一个理想的 CPU或 MAXIMUM_PROCESSORS。 也可以在一个可执行文件的头上设置处理器亲缘性。奇怪的是,似乎不存在它的链接程序 开关,不过可以使用类似下面的代码: 这里不想详细说明所有这些函数的情况,如果有兴趣的话,可以在 Platform SDK文档中查 看这些函数的具体情况。另外,可以使用称为 ImageCfg.exe的实用程序,以便改变可执行程序 模块的头上的某些标志。当运行 ImageCfg.exe时,它会显示下面的使用情况: 下载 171 第 7章 线程的调度、优先级和亲缘性计计 若要修改应用程序的可允许的亲缘性屏 蔽,可以执行 ImageCfg.exe来设定 -a开关。 当然,该实用程序所做的工作只是调用上面 这个代码段中显示的各个函数。还要注意的 是-u,它负责告诉系统,可执行程序只能在 单个CPU系统上运行。 最后,Windows 2000的Task Manager允 许用户改变进程的 CPU亲缘性,方法是选定 一个进程,显示它的上下文菜单。如果在多 处理器 计 算 机 上 运 行 , 会 看 到 一个 S e t A ff i n i t y菜单项(该菜单项在单处理器计算机 中没有)。当选择该菜单项时,会看到图 7-7 所示的对话框,在这个对话框中,可以选定 进程中的线程能够在上面运行的 CPU。 图7-7 CPU 亲缘性对话框 Windows 2000 当Windows 2000在x86计算机上引导时,可以限制系统能够使用的 CPU的数量。在引导过程中,系统要查看称为 Boot.ini的文件,该文件位于引导驱动 器的根目录中。下面是我的双处理器计算机上的 Boot.ini文件: 这个Boot.ini文件是Windows 2000安装时产生的,不过我使用Notepad加上了最后一行代码。 这行代码告诉系统,在系统引导时,我只应该使用机器中的一个处理器。 /NumProcs=1这个开 关是用来实现这一点的关键。我常常发现它对调试非常有用。 注意,由于只考虑到打印方面的需要,因此上面的程序清单中的各个选项都是在单独的一 行上列出的。Boot.ini文件要求各个选项和到达根分区的 ARC路径必须出现在一行上。 下载 第8章 用户方式中线程的同步 当所有的线程在互相之间不需要进行通信的情况下就能够顺利地运行时, Microsoft Wi n d o w s的运行性能最好。但是,线程很少能够在所有的时间都独立地进行操作。通常情况下, 要生成一些线程来处理某个任务。当这个任务完成时,另一个线程必须了解这个情况。 系统中的所有线程都必须拥有对各种系统资源的访问权,这些资源包括内存堆栈,串口, 文件,窗口和许多其他资源。如果一个线程需要独占对资源的访问权,那么其他线程就无法完 成它们的工作。反过来说,也不能让任何一个线程在任何时间都能访问所有的资源。如果在一 个线程从内存块中读取数据时,另一个线程却想要将数据写入同一个内存块,那么这就像你在 读一本书时另一个人却在修改书中的内容一样。这样,书中的内容就会被搞得乱七八糟,结果 什么也看不清楚。 线程需要在下面两种情况下互相进行通信: • 当有多个线程访问共享资源而不使资源被破坏时。 • 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时。 线程的同步包括许多方面的内容,下面几章将分别对它们进行介绍。值得高兴的是, Windows提供了许多方法,可以非常容易地实现线程的同步。但是,要想随时了解一连串的线 程想要做什么,那是非常困难的。我们的头脑的工作不是异步的,我们希望以一种有序的方式 来思考许多事情,每次前进一步。不过多线程环境不是这样运行的。 我是在大约1992年的时候开始从事多线程的编程工作的。最初,我犯过许多编程错误,在 我出版的书籍和杂志文章中实际上都存在着与线程同步相关的错误。现在我的编程工作熟练了 许多,但是并未做到完美无缺。希望本书中的全部内容不存在任何错误(尽管现在我知道我可 以做得更好些)。要搞好线程的同步,唯一的办法是通过实践。下面几章将要介绍系统是如何 运行的,并展示如何实现线程的正确同步,不过应该面对这样一个问题:即取得经验的同时, 难免要犯错误。 8.1 原子访问:互锁的函数家族 线程同步问题在很大程度上与原子访问有关,所谓原子访问,是指线程在访问资源时能够 确保所有其他线程都不在同一时间内访问相同的资源。让我们来看一看下面这个简单例子: 下载 173 第 8章 用户方式中线程的同步计计 在这个代码中,声明了一个全局变量 g_x,并将它初始化为 0。现在,假设创建两个线程, 一个线程执行 ThreadFunc1,另一个线程执行 ThreadFunc2。这两个函数中的代码是相同的,它 们都将1添加给全局变量 g_x。因此,当两个线程都停止运行时,你可能希望在 g_x中看到2这个 值。但是你真的看到了吗?回答是,也许看到了。根据代码的编写方法,你无法说明 g_x中最 终包含了什么东西。下面我们来说明为什么会出现这种情况。假设编译器生成了下面这行代码, 以便将g_x递增1: 两个线程不可能在完全相同的时间内执行这个代码。因此,如果一个线程在另一个线程的 后面执行这个代码,那么下面就是实际的执行情况: 当两个线程都将 g_x的值递增之后, g_x中的值就变成了2。这很好,并且正是我们希望的: 即取出零( 0),两次将它递增1,得出的值为 2。太好了。不过不要急, Windows是个抢占式多 线程环境。一个线程可以随时中断运行,而另一个线程则可以随时继续执行。这样,上面的代 码就无法完全按编写的那样来运行。它可能按下面的形式运行: 如果代码按这种形式来运行, g_x中的最后值就不是 2,而是你预期的 1。这使人感到非常 担心,因为你对调度程序的控制能力非常小。实际上,如果有 100个线程在执行相同的线程函 数,当它们全部退出之后, g_x中的值可能仍然是1。显然,软件开发人员无法在这种环境中工 作。我们希望在所有情况下两次递增 0产生的结果都是 2。另外,不要忘记,编译器生成代码的 方法,哪个CPU在执行这个代码,以及主计算机中安装了多少个 CPU等因素,决定了产生的结 果可能是不同的。这就是该环境的运行情况,我们对此无能为力。但是, Windows确实提供了 一些函数,如果正确地使用这些函数,就能确保产生应用程序的代码得到的结果。 为了解决上面的问题,需要某种比较简单的方法。我们需要一种手段来保证值的递增能够 以原子操作方式来进行,也就是不中断地进行。互锁的函数家族提供了我们需要的解决方案。 互锁的函数尽管用处很大,而且很容易理解,却有些让人望而生畏,大多数软件开发人员用得 很少。所有的函数都能以原子操作方式对一个值进行操作。让我们看一看下面这个 Interlocked E x c h a n g e A d d函数: 174计计第二部分 编程的具体方法 下载 这是个最简单的函数了。只需调用这个函数,传递一个长变量地址,并指明将这个值递增 多少即可。但是这个函数能够保证值的递增以原子操作方式来完成。因此可以将上面的代码重 新编写为下面的形式: 通过这个小小的修改, g_x就能以原子操作方式来递增,因此可以确保 g_x中的值最后是 2。 这样是不是感到好一些?注意,所有线程都应该设法通过调用这些函数来修改共享的长变量, 任何线程都不应该通过调用简单的 C语句来修改共享的变量: 互锁函数是如何运行的呢?答案取决于运行的是何种 CPU平台。对于x86家族的CPU来说, 互锁函数会对总线发出一个硬件信号,防止另一个 CPU访问同一个内存地址。在 Alpha平台上, 互锁函数能够执行下列操作: 1) 打开CPU中的一个特殊的位标志,并注明被访问的内存地址。 2) 将内存的值读入一个寄存器。 3) 修改该寄存器。 4) 如果CPU中的特殊位标志是关闭的,则转入第二步。否则,特殊位标志仍然是打开的, 寄存器的值重新存入内存。 你也许会问,执行第 4步时CPU中的特殊位标志是如何关闭的呢?答案是:如果系统中的 另一个CPU试图修改同一个内存地址,那么它就能够关闭 CPU的特殊位标志,从而导致互锁函 数返回第二步。 不必清楚地了解互锁函数是如何工作的。重要的是要知道,无论编译器怎样生成代码,无 论计算机中安装了多少个 CPU,它们都能保证以原子操作方式来修改一个值。还必须保证传递 给这些函数的变量地址正确地对齐,否则这些函数就会运行失败(第 13章将介绍数据对齐问 题)。 对于互锁函数,需要了解的另一个重要问题是,它们运行的速度极快。调用一个互锁函数 下载 175 第 8章 用户方式中线程的同步计计 通常会导致执行几个 CPU周期(通常小于 50),并且不会从用户方式转换为内核方式(通常这 需要执行 1000个CPU周期)。 当然,可以使用 InterlockedExchangeAdd减去一个值 —只要为第二个参数传递一个负值。 InterlockedExchangeAdd将返回在 *plAddend中的原始值。 下面是另外两个互锁函数: I n t e r l o c k e d E x c h a n g e和I n t e r l o c k e d E x c h a n g e P o i n t e r能够以原子操作方式用第二个参数中传 递的值来取代第一个参数中传递的当前值。如果是 32位应用程序,两个函数都能用另一个 32位 值取代一个 32位值。但是,如果是个 64位应用程序,那么 InterlockedExchange能够取代一个 32 位值,而InterlockedExchangePointer则取代64位值。两个函数都返回原始值。当实现一个循环 锁时,InterlockedExchange是非常有用的: while循环是循环运行的,它将 g_fResourceInUse中的值改为TRUE,并查看它的前一个值, 以了解它是否是 TRUE。如果这个值原先是 FALSE,那么该资源并没有在使用,而是调用线程 将它设置为在用状态并退出该循环。如果前一个值是 TRUE,那么资源正在被另一个线程使用, while循环将继续循环运行。 如果另一个线程要执行类似的代码,它将在 while循环中运行,直到 g_fResourceInUse重新 改为FALSE。调用函数结尾处的 InterlockedExchange,可显示应该如何将 g_fResourceInUse重 新设置为 FALSE。 当使用这个方法时必须格外小心,因为循环锁会浪费 CPU时间。CPU必须不断地比较两个 值,直到一个值由于另一个线程而“奇妙地”改变为止。另外,该代码假定使用循环锁的所有 线程都以相同的优先级等级运行。也可以把执行循环锁的线程的优先级提高功能禁用(通过调 用SetProcessPriorityBoost或setThreadPriorityBoost函数来实现之)。 此外,应该保证将循环锁变量和循环锁保护的数据维护在不同的高速缓存行中(本章后面 部分介绍)。如果循环锁变量与数据共享相同的高速缓存行,那么使用该资源的 CPU将与试图 访问该资源的任何 CPU争用高速缓存行。 176计计第二部分 编程的具体方法 下载 应该避免在单个 CPU计算机上使用循环锁。如果一个线程正在循环运行,它就会浪费前一 个CPU时间,这将防止另一个线程修改该值。我在上面的 while循环中使用了Sleep,从而在某种 程度上解决了浪费 CPU时间的问题。如果使用 Sleep,你可能想睡眠一个随机时间量;每次请 求访问该资源均被拒绝时,你可能想进一步延长睡眠时间。这可以防止线程浪费 CPU时间。根 据情况,最好是全部删除对 Sleep的调用。或者使用对SwitchToThread(Windows 98中没有这个 函数)的调用来取代它。勇于试验和不断纠正错误,是学习的最好方法。 循环锁假定,受保护的资源总是被访问较短的时间。这使它能够更加有效地循环运行,然 后转为内核方式并进入等待状态。许多编程人员循环运行一定的次数(比如 400次),如果对资 源的访问仍然被拒绝,那么该线程就转为内核方式,在这种方式下,它要等待(不消耗 CPU时 间),直到该资源变为可供使用为止。这就是关键部分实现的方法。 循环锁在多处理器计算机上非常有用,因为当一个线程循环运行的时候,另一个线程可以 在另一个CPU上运行。但是,即使在这种情况下,也必须小心。不应该让线程循环运行太长的 时间,也不能浪费更多的 CPU时间。本章后面将进一步介绍循环锁。第 10章将介绍如何使用循 环锁。 下面是最后两个互锁函数: 这两个函数负责执行一个原子测试和设置操作。如果是 32位应用程序,那么两个函数都在 32位值上运行,但是,如果是 64位应用程序,InterlockedCompareExchange函数在32位值上运 行,而InterlockedCompareExchangePointer函数则在64位值上运行。在伪代码中,它的运行情 况如下面所示: 该函数对当前值( plDestination参数指向的值)与 lComparand参数中传递的值进行比较。 如果两个值相同,那么 *plDestination改为lExchange参数的值。如果 *plDestination中的值与 lExchange的值不匹配, *plDestination保持不变。该函数返回 *plDestination中的原始值。记住, 所有这些操作都是作为一个原子执行单位来进行的。 没有任何互锁函数仅仅负责对值进行读取操作(而不改变这个值),因为这样的函数根本 是不需要的。如果线程只是试图读取值的内容,而这个值始终都由互锁函数来修改,那么被读 取的值总是一个很好的值。虽然你不知道你读取的是原始值还是更新值,但是你知道它是这两 个值中的一个。对于大多数应用程序来说,这一点很重要。此外,当要对共享内存区域(比如 下载 177 第 8章 用户方式中线程的同步计计 内存映象文件)中的值的访问进行同步时,互锁函数也可以供多进程中的线程使用(第 9章中 包含了几个示例应用程序,以显示如何正确地使用互锁函数)。 虽然Windows还提供了另外几个互锁函数,但是上面介绍的这些函数能够实现其他函数能 做的一切功能,甚至更多。下面是两个其他的函数: I n t e r l o c k e d E x c h a n g e A d d函数能够取代这些较老的函数。新函数能够递增或递减任何值, 老的函数只能加1或减1。 8.2 高速缓存行 如果想创建一个能够在多处理器计算机上运行的高性能应用程序,必须懂得 CPU的高速缓 存行。当一个 CPU从内存读取一个字节时,它不只是取出一个字节,它要取出足够的字节来填 入高速缓存行。高速缓存行由 32或64个字节组成(视 CPU而定),并且始终在第 32个字节或第 64个字节的边界上对齐。高速缓存行的作用是为了提高 CPU运行的性能。通常情况下,应用程 序只能对一组相邻的字节进行处理。如果这些字节在高速缓存中,那么 CPU就不必访问内存总 线,而访问内存总线需要多得多的时间。 但是,在多处理器环境中,高速缓存行使得内存的更新更加困难,下面这个例子就说明了 这一点: 1) CPU1 读取一个字节,使该字节和它的相邻字节被读入 CPU1的高速缓存行。 2) CPU2 读取同一个字节,使得第一步中的相同的各个字节读入 CPU2的高速缓存行。 3) CPU1修改内存中的该字节,使得该字节被写入 CPU1的高速缓存行。但是该信息尚未写 入RAM。 4) CPU2再次读取同一个字节。由于该字节已经放入 CPU2的高速缓存行,因此它不必访问 内存。但是 CPU2将看不到内存中该字节的新值。 这种情况会造成严重的后果。当然,芯片设计者非常清楚这个问题,并且设计它们的芯片 来处理这个问题。尤其是,当一个 CPU修改高速缓存行中的字节时,计算机中的其他 CPU会被 告知这个情况,它们的高速缓存行将变为无效。因此,在上面的情况下, CPU2的高速缓存在 CPU1修改字节的值时变为无效。在第 4步中, CPU1必须将它的高速缓存内容迅速转入内存, CPU2必须再次访问内存,重新将数据填入它的高速缓存行。如你所见,高速缓存行能够帮助 提高运行的速度,但是它们也可能是多处理器计算机上的一个不利因素。 这一切意味着你应该将高速缓存行存储块中的和高速缓存行边界上的应用程序数据组合在 一起。这样做的目的是确保不同的 CPU能够访问至少由高速缓存行边界分开的不同的内存地址。 还有,应该将只读数据(或不常读的数据)与读写数据分开。同时,应该将同一时间访问的数 据组合在一起。 下面是设计得很差的数据结构的例子: 下面是该结构的改进版本: 178计计第二部分 编程的具体方法 下载 上面定义的 CACHE_ALIGN宏是不错的,但是并不很好。问题是必须手工将每个成员变量 的字节值输入该宏。如果增加、移动或删除数据成员,也必须更新对 CACHE_PAD宏的调用。 将来, Microsoft的C/C++编译器将支持一种新句法,该句法可以更容易地调整数据成员。它的 形式类似 __declspec(align(32))。 注意 最好是始终都让单个线程来访问数据(函数参数和局部变量是确保做到这一点 的最好方法),或者始终让单个 CPU访问这些数据(使用线程亲缘性)。如果采取其中 的一种方法,就能够完全避免高速缓存行的各种问题。 8.3 高级线程同步 当必须以原子操作方式来修改单个值时,互锁函数家族是相当有用的。你肯定应该先试试 它们。但是大多数实际工作中的编程问题要解决的是比单个 32位或64位值复杂得多的数据结构。 为了以原子操作方式使用更加复杂的数据结构,必须将互锁函数放在一边,使用 Windows提供 的其他某些特性。 前面强调了不应该在单处理器计算机上使用循环锁,甚至在多处理器计算机上,也应该小 心地使用它们。原因是 CPU时间非常宝贵,决不应该浪费。因此需要一种机制,使线程在等待 访问共享资源时不浪费 CPU时间。 当线程想要访问共享资源,或者得到关于某个“特殊事件”的通知时,该线程必须调用一 个操作系统函数,给它传递一些参数,以指明该线程正在等待什么。如果操作系统发现资源可 供使用,或者该特殊事件已经发生,那么函数就返回,同时该线程保持可调度状态(该线程可 以不必立即执行,它处于可调度状态,可以使用前一章介绍的原则将它分配给一个 CPU)。 下载 179 第 8章 用户方式中线程的同步计计 如果资源不能使用,或者特殊事件还没有发生,那么系统便使该线程处于等待状态,使该 线程无法调度。这可以防止线程浪费 CPU时间。当线程处于等待状态时,系统作为一个代理, 代表你的线程来执行操作。系统能够记住你的线程需要什么,当资源可供使用的时候,便自动 使该线程退出等待状态,该线程的运行将与特殊事件实现同步。 从实际情况来看,大多数线程几乎总是处于等待状态。当系统发现所有线程有若干分钟均 处于等待状态时,系统的强大的管理功能就会发挥作用。 要避免使用的一种方法 如果没有同步对象,并且操作系统不能发现各种特殊事件,那么线程就不得不使用下面要 介绍的一种方法使自己与特殊事件保持同步。不过,由于操作系统具有支持线程同步的内置特 性,因此决不应该使用这种方法。 运用这种方法时,一个线程能够自己与另一个线程中的任务的完成实现同步,方法是不断 查询多个线程共享或可以访问的变量的状态。下面的代码段说明了这个情况: 如你所见,当主线程(执行 WinMain)必须使自己与 RecalcFunc函数的完成运行实现同步 时,它并没有使自己进入睡眠状态。由于主线程没有进入睡眠状态,因此操作系统继续为它调 度CPU时间,这就要占用其他线程的宝贵时间周期。 前面代码段中使用的查询方法存在的另一个问题是, BOOL变量g_f FinishedCalculation从 来没有被设置为 TRUE。当主线程的优先级高于执行 RecalcFunc函数的线程时,就会发生这种 情况。在这种情况下,系统决不会将任何时间片分配给 RecalcFunc线程。如果执行 WinMain函 数的线程被置于睡眠状态,而不是进行查询,那么这就不是已调度的时间。系统可以将时间调 度给低优先级的线程,如 RecalcFunc线程,使它们得以运行。 应该说,有时查询迟早都可以进行,毕竟是循环锁执行的操作。不过有些方法进行这项操 作是恰当的,而有些方法是不恰当的。一般来说,应该调用一些函数,使线程进入睡眠状态, 直到线程需要的资源可供使用为止。下一节将介绍一种正确的方法。 首先,在前面介绍的代码段的开头,你会发现它使用了 volatile一词。为了使这个代码段更 加接近工作状态,必须有一个 volatile类型的限定词。它告诉编译器,变量可以被应用程序本身 以外的某个东西进行修改,这些东西包括操作系统,硬件或同时执行的线程等。尤其是, 180计计第二部分 编程的具体方法 下载 volatile限定词会告诉编译器,不要对该变量进行任何优化,并且总是重新加载来自该变量的内 存单元的值。比如,编译器为前面的代码段中的 while语句生成了下面的伪代码: 如果不使布尔变量具备易变性,编译器就能像上面所示的那样优化你的 C代码。为了实现 这样的优化,编译器只需将 BOOL变量的值装入一个 CPU寄存器一次。然后,它对该 CPU寄存 器反复进行测试。这样得出的性能当然要比不断地重复读取内存地址中的值并对它进行重复测 试要好,因此,优化编译器能够编写上面所示的那种代码。但是,如果编译器进行这样的操作, 线程就会进入一个无限循环,永远无法唤醒。另外,使一个结构具备易变性,可以确保它的所 有成员都具有易变性,当它们被引用时,总是可以从内存中读取它们。 你也许会问,循环变量 g_fResourceInUse是否应该声明为 volatile变量。答案是不必,因为 我们将该变量的地址传递给各个不同的互锁函数,而不是传递变量值本身。当将一个变量地址 传递给一个函数时,该函数必须从内存读取该值。优化程序不会对它产生任何影响。 8.4 关键代码段 关键代码段是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。 这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。所谓原子操作方式,是指 该代码知道没有别的线程要访问该资源。当然,系统仍然能够抑制你的线程的运行,而抢先安 排其他线程的运行。不过,在线程退出关键代码段之前,系统将不给想要访问相同资源的其他 任何线程进行调度。 下面是个有问题的代码,它显示了不使用关键代码段会发生什么情况: 如果分开来看,这两个线程函数将会产生相同的结果,不过每个函数的编码略有不同。如 果FirstThread函数自行运行,它将用递增的值填入 g_dwTimes数组。如果SecondThread函数也 下载 181 第 8章 用户方式中线程的同步计计 是自行运行,那么情况也一样。在理想的情况下,我们希望两个线程能够同时运行,并且仍然 使g_dwTimes数组能够产生递增的值。但是,上面的代码存在一个问题,那就是 g_dwTimes不 会被正确地填入数据,因为两个线程函数要同时访问相同的全局变量。 下面是如何出现这种情况的一个例子。比如说,我们刚刚在只有一个 CPU的系统上启动执 行两个线程。操作系统首先启动运行 SecondThread(这种情况很可能出现),当SecondThread 将g_nIndex递增为1之后,系统就停止该线程的运行,而让 FirstThread运行。这时FirstThread将 g _ d w Ti m e s [ 1 ] 设置为系统时间,然后系统停止该线程的运行,将 C P U 时间重新赋予 SecondThread线程。然后 SecondThread将g_dwTimes[1 -1]设置为新的系统时间。由于这个操 作发生在较晚的时间,因此新系统时间的值大于放入 FirstThread数组中的时间值,另外要注意, g_dwTimes的索引1填在索引0的前面。数组中的数据被破坏了。 应该说明的是,这个例子的设计带有一定的故意性,因为要设计一个实际工作中的例子而 不使用好几页的源代码是很难的。不过,通过这个例子,能够看到这个问题在实际工作中有些 什么表现。考虑一下管理一个链接对象列表的情况。如果对该链接列表的访问没有取得同步, 那么一个线程可以将一个项目添加给这个列表,而另一个线程则试图搜索该列表中的一个项目。 如果两个线程同时给这个列表添加项目,那么这种情况会变得更加复杂。通过运用关键代码段, 就能够确保在各个线程之间协调对数据结构的访问。 既然已经了解了存在的所有问题,那么下面让我们用关键代码段来修正这个代码: 这里指定了一个CRITICAL_SECTION数据结构g_cs,然后在对EnterCriticalSection和LeaveCritical Section函数调用中封装了要接触共享资源(在这个例子中为g_nIndex和g_dwTimes)的任何代码。 注意,在对EnterCriticalSection和LeaveCriticalSection的所有调用中,我传递了g_cs的地址。 182计计第二部分 编程的具体方法 下载 有一个关键问题必须记住。当拥有一项可供多个线程访问的资源时,应该创建一个 C R I T I C A L _ S E C T I O N结构。由于我是在飞行旅途上编写这个代码的,让我描绘下面这个模拟 情况。 CRITICAL_SECTION就像飞机上的厕所,抽水马桶是你要保护的数据。由于厕所很小, 每次只能一个人(线程)进入厕所(关键代码段)使用抽水马桶(受保护的资源)。 如果有多个资源总是被一道使用,可以将它们全部放在一个厕所里,也就是说可以创建一 个CRITICAL_SECTION结构来保护所有的资源。 如果有多个不是一道使用的资源,比如线程 1和线程2访问一个资源,而线程 1和线程3访问 另一个资源,那么应该为每个资源创建一个独立的厕所,即 CRITICAL_SECTION结构。 现在,无论在何处拥有需要访问资源的代码,都必须调用 EnterCriticalSection函数,为它 传递用于标识该资源的 CRITICAL_SECTION结构的地址。这就是说,当一个线程需要访问一 个资源时,它首先必须检查厕所门上的“有人”标志。 CRITICAL_SECTION结构用于标识线 程想要进入哪个厕所,而 EnterCriticalSection函数则是线程用来检查“有人”标志的函数。 如果 E n t e r C r i t i c a l S e c t i o n 函 数 发 现 厕 所 中 没 有 任 何 别 的 线 程 ( 门 上 的 标 志 显 示 “ 无 人 ”), 那么调用线程就可以使用该资源。如果 EnterCriticalSection发现厕所中有另一个线程正在使用, 那么调用函数必须在厕所门的外面等待,直到厕所中的另一个线程离开厕所。 当一个线程不再执行需要访问资源的代码时,它应该调用 LeaveCriticalSection函数。这样, 它就告诉系统,它准备离开包含该资源的厕所。如果忘记调用 LeaveCriticalSection,系统将认 为该线程仍然在厕所中,因此不允许其他正在等待的线程进入厕所。这就像离开了厕所但没有 换上“无人”的标志。 注意 最难记住的一件事情是,编写的需要使用共享资源的任何代码都必须封装在 EnterCriticalSection和LeaveCriticalSection函数中。如果忘记将代码封装在一个位置, 共享资源就可能遭到破坏。例如,如果我删除了 FristThread线程对EnterCriticalSection 和LeaveCriticalSection的调用, g_nIndex和g_dwTimes变量就会遭到破坏。即使 SecondThread线程仍然正确地调用 EnterCriticalSection和LeaveCriticalSection,也会出 现这种情况。 忘记调用 EnterCriticalSection 和LeaveCriticalSection函数就像是不请求允许进入厕 所。线程只是想努力挤入厕所并对资源进行操作。可以想象,只要有一个线程表现出 这种相当粗暴的行为,资源就会遭到破坏。 当无法用互锁函数来解决同步问题时,你应该试用关键代码段。关键代码段的优点在于它 们的使用非常容易,它们在内部使用互锁函数,这样它们就能够迅速运行。关键代码的主要缺 点是无法用它们对多个进程中的各个线程进行同步。不过在第 19章中,我将要创建我自己的同 步对象,称为 Optex。这个对象将显示操作系统如何来实现关键代码段,它也能用于多个进程 中的各个线程。 8.4.1 关键代码段准确的描述 现在你已经从理论上对关键代码段有了一定的了解。已经知道为什么它们非常有用,以及 它们是如何实现“以原子操作方式”对共享资源进行访问的。下面让我们更加深入地看一看关 键代码段是如何运行的。首先介绍一下 CRITICAL_SECTION数据结构。如果想查看一下 Platform SDK文档中关于该结构的说明,也许你会感到无从下手。那么问题究竟何在呢? 并不是CRITICAL_SECTION结构没有完整的文档,而是 Microsoft认为没有必要了解该结 下载 183 第 8章 用户方式中线程的同步计计 构的全部情况,这是对的。对于我们来说,这个结构是透明的,该结构有文档可查,但是该结 构中的成员变量没有文档。当然,由于这只是个数据结构,可以在 Windows头文件中查找这些 信息,可以看到这些数据成员( CRITICAL_SECTION在WinNT.h中定义为 RTL_CRITICAL_ SECTION;RTL_CRITICAL_SECTION结构在 WinBase.h中作了定义)。但是决不应该编写引用 这些成员的代码。 若要使用 CRITICAL_SECTION结构,可以调用一个 Windows函数,给它传递该结构的地 址。该函数知道如何对该结构的成员进行操作,并保证该结构的状态始终一致。因此下面让我 们将注意力转到这些函数上去。 通常情况下,CRITICAL_SECTION结构可以作为全局变量来分配,这样,进程中的所有 线程就能够很容易地按照变量名来引用该结构。但是, CRITICAL_SECTION结构也可以作为 局部变量来分配,或者从堆栈动态地进行分配。它只有两个要求,第一个要求是,需要访问该 资源的所有线程都必须知道负责保护资源的 CRITICAL_SECTION结构的地址,你可以使用你 喜欢的任何机制来获得这些线程的这个地址;第二个要求是, CRITICAL_SECTION结构中的 成员应该在任何线程试图访问被保护的资源之前初始化。该结构通过调用下面的函数来进行初 始化: 该函数用于对( pcs指向的) CRITICAL_SECTION 结构的各个成员进行初始化。由于该函 数只是设置了某些成员变量。因此它的运行不会失败,并且它的原型采用了 VOID的返回值。 该函数必须在任何线程调用 EnterCriticalSection函数之前被调用。 Platform SDK的文档清楚地 说明,如果一个线程试图进入一个未初始化的 CRTICAL_SECTION,那么结果将是很难预计 的。 当知道进程的线程不再试图访问共享资源时,应该通过调用下面的函数来清除该 C R I T I C A L _ S E C T I O N结构: DeleteCriticalSection函数用于对该结构中的成员变量进行删除。当然,如果有任何线程仍 然使用关键代码段,那么不应该删除该代码段。同样, Platform SDK文档清楚地说明如果删除 了关键代码段,其结果就无法知道。当编写要使用共享资源的代码时,必须在该代码的前面放 置对下面的函数的调用: E n t e r C r i t i c a l S e c t i o n函数负责查看该结构中的成员变量。这些变量用于指明当前是哪个变 量正在访问该资源。 EnterCriticalSection负责进行下列测试: • 如果没有线程访问该资源, EnterCriticalSection便更新成员变量,以指明调用线程已被赋 予访问权并立即返回,使该线程能够继续运行(访问该资源)。 • 如果成员变量指明,调用线程已经被赋予对资源的访问权,那么 EnterCriticalSection便更 新这些变量,以指明调用线程多少次被赋予访问权并立即返回,使该线程能够继续运行。 这种情况很少出现,并且只有当线程在一行中两次调用 EnterCriticalSection而不影响对 LeaveCriticalSection的调用时,才会出现这种情况。 • 如果成员变量指明,一个线程(除了调用线程之外)已被赋予对资源的访问权,那么 EnerCriticalSection将调用线程置于等待状态。这种情况是极好的,因为等待的线程不会 浪费任何CPU时间。系统能够记住该线程想要访问该资源并且自动更新 CRITICAL_SECTION的成员变量,一旦目前访问该资源的线程调用 LeaveCriticalSection 184计计第二部分 编程的具体方法 下载 函数,该线程就处于可调度状态。 从内部来讲, EnterCriticalSection函数并不十分复杂。它只是执行一些简单的测试。为什 么这个函数是如此有用呢?因为它能够以原子操作方式来执行所有的测试。如果在多处理器计 算机上有两个线程在完全相同的时间同时调用 EnterCriticalSection函数,该函数仍然能够正确 地起作用,一个线程被赋予对资源的访问权,而另一个线程则进入等待状态。 如果EnterCriticalSection将一个线程置于等待状态,那么该线程在很长时间内就不能再次 被调度。实际上,在编写得不好的应用程序中,该线程永远不会再次被赋予 CPU时间。如果出 现这种情况,该线程就称为渴求 CPU时间的线程。 Windows 2000 在实际操作中,等待关键代码段的线程绝对不会渴求 CPU时间。对 E n t e r C r i t i c a l S e c t i o m的调用最终将会超时,导致产生一个异常条件。这时可以将一个 调试程序附加给应用程序,以确定究竟出了什么问题。超时的时间量是由下面的注册 表子关键字中包含的 CriticalSectionTimeout数据值来决定的。 这个值以秒为单位,默认为 2 592 000s ,即大约 30天。不要将这个值设置得太小 (比如小于 3s),否则就会对系统中正常等待关键代码段超过 3s的线程和其他应用程序 产生不利的影响。 可以使用下面这个函数来代替 EnterCriticalSection: Tr y E n t e r C r i t i c a l S e c t i o n函数决不允许调用线程进入等待状态。相反,它的返回值能够指明 调用线程是否能够获得对资源的访问权。因此,如果 TryEnterCriticalSection发现该资源已经被 另一个线程访问,它就返回 FALSE。在其他所有情况下,它均返回 TRUE。 运用这个函数,线程能够迅速查看它是否可以访问某个共享资源,如果不能访问,那么它 可以继续执行某些其他操作,而不必进行等待。如果 TryEnterCriticalSection函数确实返回了 TRUE ,那么 CRITICAL_SECTION的成员变量已经更新,以便反映出该线程正在访问该资源。 因此,对返回TRUE的TryEnterCriticalSection函数的每次调用都必须与对 LeaveCriticalSection函 数的调用相匹配。 Windows 98 Windows 98 没有可以使用的TryEnterCriticalSection函数的实现代码。调 用该函数总是返回FALSE。 在接触共享资源的代码结尾处,必须调用下面这个函数: LeaveCriticalSection要查看该结构中的成员变量。该函数每次计数时要递减 1,以指明调用 线程多少次被赋予对共享资源的访问权。如果该计数大于 0,那么LeaveCriticalSection不做其他 任何操作,只是返回而已。 如果该计数变为0,它就要查看在调用 EnterCriticalSection中是否有别的线程正在等待。如 果至少有一个线程正在等待,它就更新成员变量,并使等待线程中的一个线程(“公正地”选 定)再次处于可调度状态。如果没有线程正在等待, LeaveCriticalSection函数就更新成员变量, 以指明没有线程正在访问该资源。 与EnterCriticalSection函数一样,LeaveCriticalSection函数也能以原子操作方式执行所有这 些测试和更新。不过, LeaveCriticalSection从来不使线程进入等待状态,它总是立即返回。 下载 185 第 8章 用户方式中线程的同步计计 8.4.2 关键代码段与循环锁 当线程试图进入另一个线程拥有的关键代码段时,调用线程就立即被置于等待状态。这意 味着该线程必须从用户方式转入内核方式(大约 1000个CPU周期)。这种转换是要付出很大代 价的。在多处理器计算机上,当前拥有资源的线程可以在不同的处理器上运行,并且能够很快 放弃对资源的控制。实际上拥有资源的线程可以在另一个线程完成转入内核方式之前释放资源。 如果出现这种情况,就会浪费许多 CPU时间。 为了提高关键代码段的运行性能, Microsoft将循环锁纳入了这些代码段。因此,当 EnterCriticalSection函数被调用时,它就使用循环锁进行循环,以便设法多次取得该资源。只 有当为了取得该资源的每次试图都失败时,该线程才转入内核方式,以便进入等待状态。 若要将循环锁用于关键代码段,应该调用下面的函数,以便对关键代码段进行初始化: 与InitializeCriticalSection中的情况一样, InitializeCriticalSectionAndSpinCount的第一个参 数是关键代码段结构的地址。但是在第二个参数 dwSpinCount中,传递的是在使线程等待之前 它试图获得资源时想要循环锁循环迭代的次数。这个值可以是0至0x00FFFFFF之间的任何数字。 如果在单处理器计算机上运行时调用该函数, dwSpinCount参数将被忽略,它的计数始终被置 为0。这是对的,因为在单处理器计算机上设置循环次数是毫无用处的,如果另一个线程正在 循环运行,那么拥有资源的线程就不能放弃它。 通过调用下面的函数,就能改变关键代码段的循环次数: 同样,如果主计算机只有一个处理器,那么 dwSpinCount的值将被忽略。我认为,始终都 应该将循环锁用于关键代码段,因为这样做有百利而无一害。难就难在确定为 dwSpinCount参 数传递什么值。为了实现最佳的性能,只需要调整这些数字,直到对性能结果满意为止。作为 一个指导原则,保护对进程的堆栈进行访问的关键代码段使用的循环次数是 4000次。 第1 0章将要介绍如何实现关键代码段。这种实现将包括循环锁。 8.4.3 关键代码段与错误处理 InitializeCriticalSection函数的运行可能失败(尽管可能性很小)。Microsoft在最初设计该 函数时并没有真正想到这个问题,正因为这个原因,该函数的原型才设计为返回 VOID。该函 数的运行可能失败,因为它分配了一个内存块以便系统得到一些内部调试信息。如果该内存的 分配失败,就会出现一个 STATUS_NO_MEMORY异常情况。可以使用结构化异常处理(第 23、 24和25章介绍)来跟踪代码中的这种异常情况。 使用更新的 InitializeCriticalSectionAndSpinCount函数,就能够更加容易地跟踪这个问题。 该函数也为调试信息分配了内存块,如果内存无法分配,那么它就返回 FALSE。 当使用关键代码段时还会出现另一个问题。从内部来说,如果两个或多个线程同时争用关 键代码段,那么关键代码段将使用一个事件内核对象(第 10章介绍Coptex C++类时,我将要说 明如何使用该内核对象)。由于争用的情况很少发生,因此,在初次需要之前,系统将不创建 事件内核对象。这可以节省大量的系统资源,因为大多数关键代码段从来不被争用。 186计计第二部分 编程的具体方法 下载 在内存不足的情况下,关键代码段可能被争用,同时系统可能无法创建必要的事件内核对 象。这时 EnterCriticalSection函数将会产生一个 EXCEPTION_INVALID_HANDLE异常。大多 数编程人员忽略了这个潜在的错误,在他们的代码中没有专门的处理方法,因为这个错误非常 少见。但是,如果想对这种情况有所准备,可以有两种选择。 可以使用结构化异常处理方法来跟踪错误。当错误发生时,既可以不访问关键代码段保护 的资源,也可以等待某些内存变成可用状态,然后再次调用 EnterCriticalSection函数。 另一种选择是使用 InitializeCriticalSectionAndSpinCount函数创建关键代码段,确保设置了 dwSpinCount参数的高信息位。当该函数发现高信息位已经设置时,它就创建该事件内核对象, 并在初始化时将它与关键代码段关联起来。如果事件无法创建,该函数返回 FALSE。可以更加 妥善地处理代码中的这个事件。如果事件创建成功,你知道 EnterCriticalSection将始终都能运 行,并且决不会产生异常情况(如果总是预先分配事件内核对象,就会浪费系统资源。只有当 你的代码不能容许 EnterCriticalSection运行失败,或者你有把握会出现争用现象,或者你预计 进程将在内存非常短缺的环境中运行时,你才能预先分配事件内核对象)。 8.4.4 非常有用的提示和技巧 当使用关键代码段时,有些很好的方法可以使用,而有些方法则应该避免。下面是在使用 关键代码段时对你有所帮助的一些提示和技巧。这些技巧也适用于内核对象的同步(下一章介 绍)。 1. 每个共享资源使用一个CRITICAL_SECTION变量 如果应用程序中拥有若干个互不相干的数据结构,应该为每个数据结构创建一个 CRITICAL_SECTION变量。这比只有单个 CRITICAL_SECTION结构来保护对所有共享资源的 访问要好,请观察下面这个代码段: 这个代码使用单个关键代码段,以便在 g_nNums数组和g_cChars数组初始化时对它们同时 实施保护。但是,这两个数组之间毫无关系。当这个循环运行时,没有一个线程能够访问任何 一个数组。如果 ThreadFunc函数按下面的形式来实现,那么两个数组将分别被初始化: 下载 187 第 8章 用户方式中线程的同步计计 从理论上讲,当 g_nNums数组初始化后,另一个只需要访问 g_nNums数组而不需要访问 g_cChars数组的线程就可以开始执行,同时 ThreadFunc可以继续对 g_cChars数组进行初始化。 但是实际上这是不可能的,因为有一个关键代码段保护着这两个数据结构。为了解决这个问题, 可以创建下面两个关键代码段: 运用这个实现代码,一旦 ThreadFunc完成对g_nNums数组的初始化,另一个线程就可以开 始使用g_nNums数组。也可以考虑让一个线程对 g_nNums数组进行初始化,而另一个线程函数 对g_nChars数组进行初始化。 2. 同时访问多个资源 有时需要同时访问两个资源。如果这是 ThreadFunc的要求,可以用下面的代码来实现: 188计计第二部分 编程的具体方法 假定下面这个函数的进程中的另一个线程也要求访问这两个数组: 下载 在上面这个函数中我只是切换了对 EnterCriticalSection和LeaveCriticalSection函数的调用顺 序。但是,由于这两个函数是按上面这种方式编写的,因此可能产生一个死锁状态。假定 ThreadFunc开始执行,并且获得了 g_csNums关键代码段的所有权,那么执行 OtherThreadFunc 函数的线程就被赋予一定的 CPU时间,并可获得 g_csChars关键代码段的所有权。这时就出现 了一个死锁状态。当 ThreadFunc或OtherThreadFunc中的任何一个函数试图继续执行时,这两个 函数都无法取得对它需要的另一个关键代码段的所有权。 为了解决这个问题,必须始终按照完全相同的顺序请求对资源的访问。注意,当调用 L e a v e C r i t i c a l S e c t i o n函数时,按照什么顺序访问资源是没有关系的,因为该函数决不会使线程 进入等待状态。 3. 不要长时间运行关键代码段 当一个关键代码段长时间运行时,其他线程就会进入等待状态,这会降低应用程序的运行 性能。下面这个方法可以用来最大限度地减少关键代码段运行所花费的时间。这个代码能够防 止其他线程在WM_SOMEMSG消息发送到一个窗口之前改变 g_s的值: 无法确定窗口过程处理 WM_SOMEMSG消息时需要花费多长时间,它可能是几个毫秒, 也可能需要几年时间。在这个时间内,其他线程都不能访问 g_s结构。这个代码最好编写成下 面的形式: 下载 189 第 8章 用户方式中线程的同步计计 这个代码将该值保存在临时变量 sTemp中。也许你能够猜到 CPU需要多长时间来执行这行 代码—只需要几个CPU周期。当该临时变量保存后, LeaveCriticalSection函数就立即被调用, 因为这个全局结构不再需要保护。上面的第二个实现代码比第一个要好得多,因为其他线程只 是在几个 CPU周期内被停止使用 g_s结构,而不是无限制地停止使用该结构。当然,这个方法 的前提是该结构的“瞬态图”应当做到非常好才行,以方便于窗口过程读取。此外,窗口过程 不需要改变该结构中的成员。 下载 第9章 线程与内核对象的同步 上一章介绍了如何使用允许线程保留在用户方式中的机制来实现线程同步的方法。用户方 式同步的优点是它的同步速度非常快。如果强调线程的运行速度,那么首先应该确定用户方式 的线程同步机制是否适合需要。 虽然用户方式的线程同步机制具有速度快的优点,但是它也有其局限性。对于许多应用程 序来说,这种机制是不适用的。例如,互锁函数家族只能在单值上运行,根本无法使线程进入 等待状态。可以使用关键代码段使线程进入等待状态,但是只能用这些代码段对单个进程中的 线程实施同步。还有,使用关键代码段时,很容易陷入死锁状态,因为在等待进入关键代码段 时无法设定超时值。 本章将要介绍如何使用内核对象来实现线程的同步。你将会看到,内核对象机制的适应性 远远优于用户方式机制。实际上,内核对象机制的唯一不足之处是它的速度比较慢。当调用本 章中提到的任何新函数时,调用线程必须从用户方式转为内核方式。这个转换需要很大的代价: 往返一次需要占用 x86平台上的大约 1000个CPU周期,当然,这还不包括执行内核方式代码, 即实现线程调用的函数的代码所需的时间。 本书介绍了若干种内核对象,包括进程,线程和作业。可以将所有这些内核对象用于同步目 的。对于线程同步来说,这些内核对象中的每种对象都可以说是处于已通知或未通知的状态之中。 这种状态的切换是由Microsoft为每个对象建立的一套规则来决定的。例如,进程内核对象总是在 未通知状态中创建的。当进程终止运行时,操作系统自动使该进程的内核对象处于已通知状态。 一旦进程内核对象得到通知,它将永远保持这种状态,它的状态永远不会改为未通知状态。 当进程正在运行的时候,进程内核对象处于未通知状态,当进程终止运行的时候,它就变 为已通知状态。进程内核对象中是个布尔值,当对象创建时,该值被初始化为 FALSE(未通知 状态)。当进程终止运行时,操作系统自动将对应的对象布尔值改为 TRUE,表示该对象已经得 到通知。 如果编写的代码是用于检查进程是否仍在运行,那么只需要调用一个函数,让操作系统去 检查进程对象的布尔值,这非常简单。你也可能想要告诉系统使线程进入等待状态,然后当布 尔值从FALSE改为TRUE时自动唤醒该线程。这样,你可以编写一个代码,在这个代码中,需 要等待子进程终止运行的父进程中的线程只需要使自己进入睡眠状态,直到标识子进程的内核 对象变为已通知状态即可。你将会看到, Microsoft的Windows提供了一些能够非常容易地完成 这些操作的函数。 刚才讲了 Microsoft为进程内核对象定义了一些规则。实际上,线程内核对象也遵循同样的 规则。即线程内核对象总是在未通知状态中创建。当线程终止运行时,操作系统会自动将线程 对象的状态改为已通知状态。因此,可以将相同的方法用于应用程序,以确定线程是否不再运 行。与进程内核对象一样,线程内核对象也可以处于已通知状态或未通知状态。 下面的内核对象可以处于已通知状态或未通知状态: ■ 进程 ■ 文件修改通知 ■ 线程 ■ 事件 ■ 作业 ■ 可等待定时器 下载 191 第 9章 线程与内核对象的同步计计 ■ 文件 ■ 信标 ■ 控制台输入 ■ 互斥对象 线程可以使自己进入等待状态,直到一个对象变为已通知状态。注意,用于控制每个对象 的已通知 /未通知状态的规则要根据对象的类型而定。前面已经提到进程和线程对象的规则及 作业的规则。 本章将要介绍允许线程等待某个内核对象变为已通知状态所用的函数。然后我们将要讲述 Windows提供的专门用来帮助实现线程同步的各种内核对象、如事件、等待计数器,信标和互 斥对象。 当我最初开始学习这项内容时,我设想内核对象包含了一面旗帜(在空中飘扬的旗帜,不 是耷拉下来的旗帜),这对我很有帮助。当内核对象得到通知时,旗帜升起来;当对象未得到 通知时,旗帜就降下来(见图 9-1)。 当线程等待的对象处于未通知状态(旗帜降下)中时,这些线程不可调度。但是一旦对象 变为已通知状态(旗帜升起),线程看到该标志变为可调度状态,并且很快恢复运行(见图9-2)。 内核对象 内核对象 图9-1 内核对象中的旗帜状态 内核对象 内核对象 图9-2 内核对象中旗帜状态与线程可调度性示意图 9.1 等待函数 等待函数可使线程自愿进入等待状态,直到一个特定的内核对象变为已通知状态为止。这 些等待函数中最常用的是 WaitForSingleObject: 192计计第二部分 编程的具体方法 下载 当线程调用该函数时,第一个参数 hObject标识一个能够支持被通知 /未通知的内核对象 (前面列出的任何一种对象都适用)。第二个参数 dwMilliseconds 允许该线程指明,为了等待该 对象变为已通知状态,它将等待多长时间。 调用下面这个函数将告诉系统,调用函数准备等待到hProcess句柄标识的进程终止运行为止: 第二个参数告诉系统,调用线程愿意永远等待下去(无限时间量),直到该进程终止运行。 通常情况下, INFINITE是作为第二个参数传递给 WaitForSingleObject的,不过也可以传递 任何一个值(以毫秒计算)。顺便说一下, INFINITE已经定义为0xFFFFFFFF(或-1)。当然, 传递INFINITE有些危险。如果对象永远不变为已通知状态,那么调用线程永远不会被唤醒, 它将永远处于死锁状态,不过,它不会浪费宝贵的 CPU时间。 下面是如何用一个超时值而不是 INFINITE来调用WaitForSingleObject的例子: 上面这个代码告诉系统,在特定的进程终止运行之前,或者在 5000ms时间结束之前,调 用线程不应该变为可调度状态。因此,如果进程终止运行,那么这个函数调用将在不到 5000ms的时间内返回,如果进程尚未终止运行,那么它在大约 5000ms时间内返回。注意,不 能为dwMillisecond传递0。如果传递了0,WaitForSingleObject函数将总是立即返回。 Wa i t F o r S i n g l e O b j e c t的返回值能够指明调用线程为什么再次变为可调度状态。如果线程等 待的对象变为已通知状态,那么返回值是 WAIT_OBJECT_0。如果设置的超时已经到期,则返 回值是 WAIT_TIMEOUT。如果将一个错误的值(如一个无效句柄)传递给 WaitForSingle Object,那么返回值将是WAIT_FAILED(若要了解详细信息,可调用 GetLastError)。 下面这个函数 WaitForMultipleObjects与WaitForSingleObject函数很相似,区别在于它允许 调用线程同时查看若干个内核对象的已通知状态: dwCount参数用于指明想要让函数查看的内核对象的数量。这个值必须在 1与MAXIMUM_ WAIT_OBJECTS(在Windows头文件中定义为 64)之间。 phObjects参数是指向内核对象句柄 的数组的指针。 可以以两种不同的方式来使用 WaitForMultipleObjects函数。一种方式是让线程进入等待状 下载 193 第 9章 线程与内核对象的同步计计 态,直到指定内核对象中的任何一个变为已通知状态。另一种方式是让线程进入等待状态,直 到所有指定的内核对象都变为已通知状态。 fWaitAll参数告诉该函数,你想要让它使用何种方 式。如果为该参数传递 TRUE,那么在所有对象变为已通知状态之前,该函数将不允许调用线 程运行。 dwMilliseconds参数的作用与它在 WaitForSingleObject中的作用完全相同。如果在等待的时 候规定的时间到了,那么该函数无论如何都会返回。同样,通常为该参数传递 INFINITE,但 是在编写代码时应该小心,以避免出现死锁情况。 WaitForMultipleObjects函数的返回值告诉调用线程,为什么它会被重新调度。可能的返回值 是WAIT_FAILED和WAIT_TIMEOUT,这两个值的作用是很清楚的。如果为 fWaitAll参数传递 TRUE,同时所有对象均变为已通知状态,那么返回值是WAIT_OBJECT_0。如果为fWaitAll传递 FALSE,那么一旦任何一个对象变为已通知状态,该函数便返回。在这种情况下,你可能想要知 道哪个对象变为已通知状态。返回值是 WAIT_OBJECT_0与(WAIT_OBJECT_0+dwCount-1)之 间的一个值。换句话说,如果返回值不是WAIT_TIMEOUT,也不是WAIT_FAILED,那么应该从 返回值中减去WAIT_OBJECT_0。产生的数字是作为第二个参数传递给WaitForMultipleObjects的 句柄数组中的索引。该索引说明哪个对象变为已通知状态。下面是说明这一情况的一些示例代 码: 如果为 fWaitAll参数传递 FALSE,WaitForMultipleObjects就从索引 0开始向上对句柄数组进 行扫描,同时已通知的第一个对象终止等待状态。这可能产生一些你不希望有的结果。例如, 通过将3个进程句柄传递给该函数,你的线程就会等待 3个子进程终止运行。如果数组中索引为 0的进程终止运行, WaitForMultipleObjects 就会返回。这时该线程就可以做它需要的任何事情, 然后循环反复,等待另一个进程终止运行。如果该线程传递相同的 3个句柄,该函数立即再次 194计计第二部分 编程的具体方法 下载 返回WAIT_OBJECT_0。除非删除已经收到通知的句柄,否则代码就无法正确地运行。 9.2 成功等待的副作用 对于有些内核对象来说,成功地调用 WaitForSingleObject和WaitForMultipleObjects,实际 上会改变对象的状态。成功地调用是指函数发现对象已经得到通知并且返回一个相对于 WAIT_OBJECT_0的值。如果函数返回 WAIT_TIMEOUT或WAIT_FAILED,那么调用就没有成 功。如果函数调用没有成功,对象的状态就不可能改变。 当一个对象的状态改变时,我称之为成功等待的副作用。例如,有一个线程正在等待自动 清除事件对象(本章后面将要介绍)。当事件对象变为已通知状态时,函数就会发现这个情况, 并将WAIT_OBJECT_0返回给调用线程。但是就在函数返回之前,该事件将被置为未通知状态, 这就是成功等待的副作用。 这个副作用将用于自动清除内核对象,因为它是 Microsoft为这种类型的对象定义的规则之 一。其他对象拥有不同的副作用,而有些对象则根本没有任何副作用。进程和线程内核对象就 根本没有任何副作用,也就是说,在这些对象之一上进行等待决不会改变对象的状态。由于本 章要介绍各种不同的内核对象,因此我们将要详细说明它们的成功等待的副作用。 究竟是什么原因使得 WaitForMultipleObjects函数如此有用呢,因为它能够以原子操作方式 来执行它的所有操作。当一个线程调用 WaitForMultipleObjects函数时,该函数能够测试所有对 象的通知状态,并且能够将所有必要的副作用作为一项操作来执行。 让我们观察一个例子。两个线程以完全相同的方式来调用 WaitForMultipleObjects: 当WaitForMultipleObjects函数被调用时,两个事件都处于未通知状态,这就迫使两个线程 都进入等待状态。然后 hAutoResetEvent1对象变为已通知状态。两个线程都发现,该事件已经 变为已通知状态,但是它们都无法被唤醒,因为 hAutoResetEvent2仍然处于未通知状态。由于 两个线程都没有等待成功,因此没有对 hAutoResetEvent1对象产生任何副作用。 接着, hAutoResetEvent2变为已通知状态。这时,两个线程中的一个发现,两个对象都变 为已通知状态。等待取得了成功,两个事件对象均被置为未通知状态,该线程变为可调度的线 程。但是另一个线程的情况如何呢?它将继续等待,直到它发现两个事件对象都处于已通知状 态。尽管它原先发现 hAutoResetEvent1处于已通知状态,但是现在它将该对象视为未通知状 态。 前面讲过,有一个重要问题必须注意,即WaitForMultipleObjects是以原子操作方式运行的。 当它检查内核对象的状态时,其他任何线程都无法背着对象改变它的状态。这可以防止出现死 锁情况。试想,如果一个线程看到 hAutoResetEvent1已经得到通知并将事件重置为未通知状态, 然后,另一个线程发现 hAutoResetEvent2已经得到通知并将该事件重置为未通知状态,那么这 两个线程均将被冻结:一个线程将等待另一个线程已经得到的对象,另一个线程将等待该线程 已经得到的对象。 WaitForMultipleObjects能够确保这种情况永远不会发生。 这会产生一个非常有趣的问题,即如果多个线程等待单个内核对象,那么当该对象变成已 通知状态时,系统究竟决定唤醒哪个线程呢? Microsoft对这个问题的正式回答是:“算法是公 平的。”M i c r o s o f t不想使用系统使用的内部算法。它只是说该算法是公平的,这意味着如果多 下载 195 第 9章 线程与内核对象的同步计计 个线程正在等待,那么每当对象变为已通知状态时,每个线程都应该得到它自己的被唤醒的机 会。 这意味着线程的优先级不起任何作用,即高优先级线程不一定得到该对象。这还意味着等 待时间最长的线程不一定得到该对象。同时得到对象的线程有可能反复循环,并且再次得到该 对象。但是,这对于其他线程来说是不公平的,因此该算法将设法防止这种情况的出现。但是 这不一定做得到。 在实际操作中, Microsoft使用的算法是常用的“先进先出”的方案。等待了最长时间的线 程将得到该对象。但是系统中将会执行一些操作,以便改变这个行为特性,使它不太容易预测。 这就是为什么Microsoft没有明确说明该算法如何起作用的原因。操作之一是让线程暂停运行。 如果一个线程等待一个对象,然后该线程暂停运行,那么系统就会忘记该线程正在等待该对象。 这是一个特性,因为没有理由为一个暂停运行的线程进行调度。当后来该线程恢复运行时,系 统将认为该线程刚刚开始等待该对象。 当调试一个进程时,只要到达一个断点,该进程中的所有线程均暂停运行。因此,调试一 个进程会使“先进先出”的算法很难预测其结果,因为线程常常暂停运行,然后再恢复运行。 9.3 事件内核对象 在所有的内核对象中,事件内核对象是个最基本的对象。它们包含一个使用计数(与所有 内核对象一样),一个用于指明该事件是个自动重置的事件还是一个人工重置的事件的布尔值, 另一个用于指明该事件处于已通知状态还是未通知状态的布尔值。 事件能够通知一个操作已经完成。有两种不同类型的事件对象。一种是人工重置的事件, 另一种是自动重置的事件。当人工重置的事件得到通知时,等待该事件的所有线程均变为可调 度线程。当一个自动重置的事件得到通知时,等待该事件的线程中只有一个线程变为可调度线 程。 当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,事件使用得最多。 事件初始化为未通知状态,然后,当该线程完成它的初始化操作后,它就将事件设置为已通知 状态。这时,一直在等待该事件的另一个线程发现该事件已经得到通知,因此它就变成可调度 线程。这第二个线程知道第一个线程已经完成了它的操作。 下面是CreateEvent函数,用于创建事件内核对象: 第3章已经介绍了内核对象的操作技巧,比如,如何设置它们的安全性,如何进行使用计 数,如何继承它们的句柄,如何按名字共享对象等。由于现在你对所有这些对象都已经熟悉了, 所以不再介绍该函数的第一个和最后一个参数。 FMannualReset参数是个布尔值,它能够告诉系统是创建一个人工重置的事件( TRUE)还 是创建一个自动重置的事件( FALSE)。fInitialState参数用于指明该事件是要初始化为已通知 状态(TRUE)还是未通知状态( FALSE)。当系统创建事件对象后, createEvent就将与进程相 关的句柄返回给事件对象。其他进程中的线程可以获得对该对象的访问权,方法是使用在 pszName参数中传递的相同值,使用继承性,使用 DuplicateHandle函数等来调用 CreateEvent, 或者调用 OpenEvent,在pszName参数中设定一个与调用 CreateEvent时设定的名字相匹配的名字: 196计计第二部分 编程的具体方法 下载 与所有情况中一样,当不再需要事件内核对象时,应该调用 CloseHandle函数。 一旦事件已经创建,就可以直接控制它的状态。当调用 SetEvent时,可以将事件改为已通 知状态: 当调用ResetEvent函数时,可以将该事件改为未通知状态: 就是这么容易。 Microsoft为自动重置的事件定义了应该成功等待的副作用规则,即当线程成功地等待到该 对象时,自动重置的事件就会自动重置到未通知状态。这就是自动重置的事件如何获得它们的 名字的方法。通常没有必要为自动重置的事件调用 ResetEvent函数,因为系统会自动对事件进 行重置。但是, Microsoft没有为人工重置的事件定义成功等待的副作用。 让我们观察一个简单的例子,以便说明如何使用事件内核对象对线程进行同步。下面就是 这个代码: 下载 197 第 9章 线程与内核对象的同步计计 当这个进程启动时,它创建一个人工重置的未通知状态的事件,并且将句柄保存在一个全 局变量中。这使得该进程中的其他线程能够非常容易地访问同一个事件对象。现在 3个线程已 经产生。这些线程要等待文件的内容读入内存,然后每个线程都要访问它的数据。一个线程进 行单词计数,另一个线程运行拼写检查器,第三个线程运行语法检查器。这 3个线程函数的代 码的开始部分都相同,每个函数都调用 WaitForSingleObject,这将使线程暂停运行,直到文件 的内容由主线程读入内存为止。 一旦主线程将数据准备好,它就调用 SetEvent,给事件发出通知信号。这时,系统就使所 有这3个辅助线程进入可调度状态,它们都获得了 CPU时间,并且可以访问内存块。注意,这 3 个线程都以只读方式访问内存。这就是所有 3个线程能够同时运行的唯一原因。还要注意,如 何计算机上配有多个 CPU,那么所有 3个线程都能够真正地同时运行,从而可以在很短的时间 内完成大量的操作。 如果你使用自动重置的事件而不是人工重置的事件,那么应用程序的行为特性就有很大的 差别。当主线程调用 SetEvent之后,系统只允许一个辅助线程变成可调度状态。同样,也无法 保证系统将使哪个线程变为可调度状态。其余两个辅助线程将继续等待。 已经变为可调度状态的线程拥有对内存块的独占访问权。让我们重新编写线程的函数,使 得每个函数在返回前调用 SetEvent函数(就像WinMain函数所做的那样)。这些线程函数现在变 成下面的形式: 198计计第二部分 编程的具体方法 下载 当线程完成它对数据的专门传递时,它就调用 SetEvent函数,该函数允许系统使得两个正 在等待的线程中的一个成为可调度线程。同样,我们不知道系统将选择哪个线程作为可调度线 程,但是该线程将进行它自己的对内存块的专门传递。当该线程完成操作时,它也将调用 S e t E v e n t函数,使第三个即最后一个线程进行它自己的对内存块的传递。注意,当使用自动重 置事件时,如果每个辅助线程均以读 /写方式访问内存块,那么就不会产生任何问题,这些线 程将不再被要求将数据视为只读数据。这个例子清楚地展示出使用人工重置事件与自动重置事 件之间的差别。 为了完整起见,下面再介绍一个可以用于事件的函数: P u l s e E v e n t函数使得事件变为已通知状态,然后立即又变为未通知状态,这就像在调用 SetEvent后又立即调用 ResetEvent函数一样。如果在人工重置的事件上调用 PulseEvent函数,那 么在发出该事件时,等待该事件的任何一个线程或所有线程将变为可调度线程。如果在自动重 置事件上调用PulseEvent函数,那么只有一个等待该事件的线程变为可调度线程。如果在发出 事件时没有任何线程在等待该事件,那么将不起任何作用。 P u l s e E v e n t函数并不非常有用。实际上我在自己的应用程序中从未使用它,因为根本不知 道什么线程将会看到事件的发出并变成可调度线程。由于在调用 PulseEvent时无法知道任何线 程的状态,因此该函数并不那么有用。我相信在有些情况下,虽然 PulseEvent函数可以方便地 供你使用,但是你根本想不起要去使用它。关于 PulseEvent函数的比较详细的说明,请参见本 章后面对 SingleObjectAndWait函数的介绍。 Handshake示例应用程序 清单9-1中列出了 Handshake(“09 Handshake.exe”)应用程序,它展示了自动重置事件的 使用情况。该应用程序的源代码文件和资源文件均在本书所附光盘上的 09-Handshake目录下。 当运行Handshake应用程序时,就会出现图 9-3所示的对话框。 Handshake应用程序接受一个请求字符串,再将该字符串中的所有字符反转,然后将结果放 入Result域。Handshake应用程序的出色之处在于它完成这个重要任务时所用的方法不同一般。 H a n d s h a k e能够解决常见的编程问题。现在有一个客户机和一个服务器,它们之间需要互 下载 199 第 9章 线程与内核对象的同步计计 相进行通信。开始时,服务器无事可做,因此它进入等待状态。当客户机准备将一个请求提交 给服务器时,它将该请求放入一个共享内存缓冲区中,然后发出一个事件通知,这样,服务器 线程就会知道查看数据缓冲区并处理客户机的请求。当服务器线程忙于处理该请求的时候,客 户机的线程必须进入等待状态,直到服务器准备好请求的结果为止。因此客户机进入等待状态, 直到服务器发出另一个事件通知,指明结果已经准备好,可供客户机进行处理。当客户机再次 被唤醒的时候,它就知道结果已经放入共享数据缓冲区中,并且可以将结果显示给用户。 图9-3 Handshake 对话框 当该应用程序启动运行时,它立即创建两个未通知的自动重置的事件对象。一个事件是 g_ hevtRequestSubmitted,用于指明何时为服务器准备一个请求。该事件由服务器线程等待,并 由客户机线程发出通知。第二个事件是 g_hevtResultReturned,用来指明何时为客户机准备好结 果。客户机线程等待该事件,而服务器线程则负责发出该事件的通知。 当各个事件创建后,服务器线程就产生并且执行 ServerThread函数。该函数立即让服务器 等待客户机的请求。与此同时,主线程(它也是客户机线程)调用 DialogBox函数,该函数负 责显示应用程序的用户界面。可以将一些文字输入 Request域,然后,当点击Submit Request To Server(将请求提交给服务器 )时,请求字符串将被放入由客户机和服务器线程共享的一个缓冲区, 并发出g_hevtRequestSubmitted事件的通知。然后客户机线程通过等待 g_hevtResultReturened事 件来等待服务器的结果。 服务器醒来,将共享内存缓冲区中的字符串反转,然后发出 g_hevtResultReturned事件的通 知。服务器的线程循环运行,以便等待客户机的另一个请求。注意,该应用程序决不会调用 ResetEvent函数,因为没有必要。自动重置的事件在等待成功后会自动恢复未通知状态。与此 同时,客户机线程发现 g_hevtResultReturned事件已经变为已通知状态。它醒来,并将字符串从 共享内存缓冲区拷贝到用户界面的 Result域。 也许这个应用程序剩下的唯一的一个值得注意的特性是它如何关闭。若要关闭该应用程序, 只需要关闭它的对话框。这会导致调用的 _tWinMain中的DialogBox函数返回。这时,主线程将 一个特殊字符串拷贝到共享缓冲区,并唤醒服务器的线程,以便处理该特殊请求。主线程等待 服务器线程确认请求已经收到,并等待服务器线程终止运行。当服务器线程发现该特殊的客户 机请求字符串时,它就退出循环,而该线程则终止运行。 我选择让主线程等待服务器线程终止运行,方法是调用 WaitForMultipleObjects函数,这样, 就可以看到该函数是如何使用的。实际上,也可以调用 WaitForSingleObject函数,传递服务器 线程的句柄,一切将以完全相同的方式来运行。 一旦主线程知道服务器线程已经停止运行后,我将 3次调用CloseHandle函数,以便正确地 撤消应用程序正在使用的所有内核对象。当然,系统能够自动执行这项操作,但是如果我自己 进行操作,我的感觉会更好些。我喜欢能够随时控制我的代码。 200计计第二部分 编程的具体方法 清单9-1 Handshake示例应用程序 下载 下载 201 第 9章 线程与内核对象的同步计计 202计计第二部分 编程的具体方法 下载 下载 203 第 9章 线程与内核对象的同步计计 204计计第二部分 编程的具体方法 下载 9.4 等待定时器内核对象 等待定时器是在某个时间或按规定的间隔时间发出自己的信号通知的内核对象。它们通常 用来在某个时间执行某个操作。 若要创建等待定时器,只需要调用 CreateWaitableTimer函数: psa和pszName这两个参数在第 3章中做过介绍。当然,进程可以获得它自己的与进程相关 的现有等待定时器的句柄,方法是调用 OpenWaitableTimer函数: 下载 205 第 9章 线程与内核对象的同步计计 与事件的情况一样, fManualReset参数用于指明人工重置的定时器或自动重置的定时器。 当发出人工重置的定时器信号通知时,等待该定时器的所有线程均变为可调度线程。当发出自 动重置的定时器信号通知时,只有一个等待的线程变为可调度线程。 等待定时器对象总是在未通知状态中创建。必须调用 SetWaitableTimer函数来告诉定时器 你想在何时让它成为已通知状态: 这个函数带有若干个参数,使用时很容易搞混。显然, hTimer参数用于指明你要设置的定 时器。pDueTime和lPeriod两个参数是一道使用的。 PDueTimer参数用于指明定时器何时应该第 一次报时,而 lPeriod参数则用于指明此后定时器应该间隔多长时间报时一次。下面的代码用于 将定时器的第一次报时的时间设置在 2002年1月1日的下午1点钟,然后每隔6小时报时一次: 代码首先对SYSTEMTIME结构进行初始化,该结构用于指明定时器何时第一次报时(发出 信号通知)。我将该时间设置为本地时间,即计算机所在时区的正确时间。 SetWaitableTimer的 206计计第二部分 编程的具体方法 下载 第二个参数的原型是个常量LARGE_INTEGER *,因此它不能直接接受SYSTEMTIME结构。但 是,FILETIME结构和LARGE_INTEGER结构拥有相同的二进制格式,都包含两个 32位的值。 因此,我们可以将SYSTEMTIME结构转换成FILETIME结构。再一个问题是,SetWaitableTimer 希望传递给它的时间始终都采用世界协调时( UTC)的时间。调用LocalFileTimeToFileTime函 数,就可以很容易地进行时间的转换。 由于FILETIME和LARGE_INTEGER结构具有相同的二进制格式,因此可以像下面这样将 FILETIME结构的地址直接传递给 SetWaitableTimer: 实际上,这是我最初的做法。但是这是个大错误。虽然 FILETIME和LARGE_INTEGER结 构采用相同的二进制格式,但是这两个结构的调整要求不同。所有 FILETIME结构的地址必须 从一个 32位的边界开始,而所有 LARGE_INTEGER结构的地址则必须从 64位的边界开始。调 用SetWaitableTimer函数和给它传递一个 FILETIME结构时是否能够正确地运行,取决于 FILETIME结构是否恰好位于 64位的边界上。但是,编译器能够确保 LARGE_INTEGER结构总 是从64位的边界开始,因此要进行的正确操作(也就是所有时间都能保证起作用的操作)是将 FILETIME的成员拷贝到 LARGE_INTEGER的成员中,然后将LARGE_INTEGER的地址传递给 SetWaitableTimer。 注意 x86处理器能够悄悄地处理未对齐的数据引用。因此当应用程序在 x86 CPU上运 行时,将 FILETIME的地址传递给 SetWaitablrTimer总是可行的。但是,其他处理器, 如Alpha处理器,则无法像 x86处理器那样悄悄地处理未对齐的数据引用。实际上,大 多数其他处理器都会产生一个 EXCEPTION_DATATYPE_MISALIGNMENT异常,它 会导致进程终止运行。当你将 x86计算机上运行的代码移植到其他处理器时,产生问 题的最大原因是出现了对齐错误。如果现在注意对齐方面的问题,就能够在以后省去 几个月的代码移植工作。关于对齐问题的详细说明,参见第 13章。 现在,若要使定时器在 2002年1月1日下午1点之后每隔6h进行一次报时,我们应该将注意 力转向 lPeriod参数。该参数用于指明定时器在初次报时后每隔多长时间(以毫秒为单位)进行 一次报时。如果是每隔 6h进行一次报时,那么我传递 21 600 000(6h×每小时 60min×每分钟 60s×每秒1000ms)。另外,如果给它传递了以前的一个绝对时间,比如 1975年1月1日下午1点, 那么SetWaitableTimer的运行就不会失败。 如果不设置定时器应该第一次报时的绝对时间,也可以让定时器在一个相对于调用 SetWaitableTimer的时间进行报时。只需要在 pDueTime参数中传递一个负值。传递的值必须是 以100ns为间隔。由于我们通常并不以 100ns的间隔来思考问题,因此我们要说明一下 100ns的 具体概念:1s=1000ms=1000000µs=1000000000ns 。 下面的代码用于将定时器设置为在调用 SetWaitableTimer函数后5s第一次报时: 下载 207 第 9章 线程与内核对象的同步计计 通常情况下,你可能想要一个一次报时的定时器,它只是发出一次报时信号,此后再也不 发出报时信号。若要做到这一点,只需要为 lPeriod参数传递0即可。然后可以调用 CloseHandle 函数,关闭定时器,或者再次调用 SetWaitableTimer函数,重新设置时间,为它规定一个需要 遵循的新条件。 SetWaitableTimer的最后一个参数是 fResume,它可以用于支持暂停和恢复的计算机。通常 可以为该参数传递 FALSE,就像我在上面这个代码段中设置的那样。但是,如果你编写了一个 会议安排类型的应用程序,在这个应用程序在中,你想设置一个为用户提醒会议时间安排的定 时器,那么应该传递 TRUE。当定时器报时的时候,它将使计算机摆脱暂停方式(如果它处于 暂停状态的话),并唤醒等待定时器报时的线程。然后该应用程序运行一个波形文件,并显示 一个消息框,告诉用户即将举行的会议。如果为 fResume参数传递FALSE,定时器对象就变为 已通知状态,但是它唤醒的线程必须等到计算机恢复运行(通常由用户将它唤醒)之后才能得 到CPU时间。 除了上面介绍的定时器函数外,最后还有一个 CancelWaitableTimer函数: 这个简单的函数用于取出定时器的句柄并将它撤消,这样,除非接着调用 SetWaitableTimer 函数以便重新设置定时器,否则定时器决不会进行报时。如果想要改变定时器的报时条件,不 必在调用SetWaitableTimer函数之前调用CancelWaitableTimer函数。每次调用SetWaitableTimer 函数,都会在设置新的报时条件之前撤消定时器原来的报时条件。 9.4.1 让等待定时器给 APC项排队 到现在为止,你已经学会了如何创建定时器和如何设置定时器。你还知道如何通过将定时 器的句柄传递给 WaitForSingleObject或WaitForMultipleObjects函数,以便等待定时器报时。 Microsoft还允许定时器给在定时器得到通知信号时调用 SetWaitableTimer函数的线程的异步过 程调用( APC)进行排队。 一般来说,当调用 S e t Wa i t a b l e Ti m e r 函数时,你将同时为 p f n C o m p l e t i o n R o u t i n e和 pvArgCompletionRoutine参数传递 NULL。当 SetWaitableTime函数看到这些参数的 NULL时,它 就知道,当规定的时间到来时,就向定时器发出通知信号。但是,如果到了规定的时间,你愿 意让定时器给一个 APC排队,那么你必须传递定时器 APC例程的地址,而这个例程是你必须实 现的。该函数应该类似下面的形式: 208计计第二部分 编程的具体方法 下载 我已经将该函数命名为TimerAPCRoutine,不过可以根据需要给它赋予任何一个名字。该函 数可以在定时器报时的时候由调用 SetWaitableTimer函数的同一个线程来调用,但是只有在调用 线程处于待命状态下才能调用。换句话说,该线程必须正在 SleepEx,WaitForSngleObjectEx, WaitForMultipleObjectsEx,MsgWaitForMultipleObjectsEx或SingleObject-AndWait等函数的调 用中等待。如果该线程不在这些函数中的某个函数中等待,系统将不给定时器 APC例程排队。 这可以防止线程的APC队列中塞满定时器 APC通知,这会浪费系统中的大量内存。 当定时器报时的时候,如果你的线程处于待命的等待状态中,系统就使你的线程调用回调 例程。回调例程的第一个参数的值与你传递给SetWaitableTimer函数的pvArgToCompletionRoutine 参数的值是相同的。你可以将某些上下文信息(通常是你定义的某个结构的指针)传递给 TimerAPCRoutine。剩余的两个参数 dwTimerLowValue和dwTimerHighValue用于指明定时器何 时报时。下面的代码使用了该信息,并将它显示给用户: 只有当所有的 APC项都已经处理之后,待命的函数才会返回。因此,必须确保定时器再次 变为已通知状态之前, TimerAPCRoutine函数完成它的运行,这样, APC项的排队速度就不会 比它被处理的速度快。 下面的代码显示了使用定时器和 APC项的正确方法: 下载 209 第 9章 线程与内核对象的同步计计 最后要说明的是,线程不应该等待定时器的句柄,也不应该以待命的方式等待定时器。请 看下面的代码: 不应该编写上面的代码,因为调用 WaitForSingleObjectEx函数实际上是两次等待该定时器, 一次是以待命方式等待,一次是等待内核对象句柄。当定时器变为已通知状态时,等待就成功 了,线程被唤醒,这将使该线程摆脱待命状态,而 APC例程则没有被调用。前面讲过,通常没 有理由使用带有等待定时器的 APC例程,因为你始终都可以等待定时器变为已通知状态,然后 做你想要做的事情。 9.4.2 定时器的松散特性 定时器常常用于通信协议中。例如,如果客户机向服务器发出一个请求,而服务器没有在 规定的时间内作出响应,那么客户机就会认为无法使用服务器。目前,客户机通常要同时与许 多服务器进行通信。如果你为每个请求创建一个定时器内核对象,那么系统的运行性能就会受 到影响。可以设想,对于大多数应用程序来说,可以创建单个定时器对象,并根据需要修改定 时器报时的时间。 定时器报时时间的管理方法和定时器时间的重新设定是非常麻烦的,只有很少的应用程序 采用这种方法。但是在新的线程共享函数(第 11 章中介绍)中有一个新函数,称为 C r e a t e Ti m e r Q u e u e Ti m e r,它能够为你处理所有的操作。如果你发现自己创建和管理了若干个 定时器对象,那么应该观察一下这个函数,以减少应用程序的开销。 虽然定时器能够给 APC项进行排队是很好的,但是目前编写的大多数应用程序并不使用 APC,它们使用 I/O完成端口机制。过去,我自己的线程池中(由一个 I/O完成端口负责管理) 有一个线程,它按照特定的定时器间隔醒来。但是,等待定时器没有提供这个方法。为了做到 这一点,我创建了一个线程,它的唯一工作是设置而后等待一个等待定时器。当定时器变为已 通知状态时,线程就调用 PostQueuedCompletionStatus函数,将一个事件强加给线程池中的一 个线程。 最后要说明的是,凡是称职的 Windows编程员都会立即将等待定时器与用户定时器(用 SetTimer函数进行设置)进行比较。它们之间的最大差别是,用户定时器需要在应用程序中设 置许多附加的用户界面结构,这使定时器变得资源更加密集。另外,等待定时器属于内核对象, 这意味着它们可以供多个线程共享,并且是安全的。 用户定时器能够生成 WM_TIMER消息,这些消息将返回给调用 SetTimer(用于回调定时 器)的线程和创建窗口(用于基于窗口的定时器)的线程。因此,当用户定时器报时的时候, 只有一个线程得到通知。另一方面,多个线程可以在等待定时器上进行等待,如果定时器是个 人工重置的定时器,则可以调度若干个线程。 210计计第二部分 编程的具体方法 下载 如果要执行与用户界面相关的事件,以便对定时器作出响应,那么使用用户定时器来组织 代码结构可能更加容易些,因为使用等待定时器时,线程必须既要等待各种消息,又要等待内 核对象(如果要改变代码的结构,可以使用 MsgWaitForMultipleObjects函数)。最后,运用等 待定时器,当到了规定时间的时候,更有可能得到通知。正如第 2 7章介绍的那样, W M _ T I M E R消息始终属于最低优先级的消息,当线程的队列中没有其他消息时,才检索该消 息。等待定时器的处理方法与其他内核对象没有什么差别,如果定时器发出报时信息,而你的 线程正在等待之中,那么你的线程就会醒来。 9.5 信标内核对象 信标内核对象用于对资源进行计数。它们与所有内核对象一样,包含一个使用数量,但是 它们也包含另外两个带符号的 32位值,一个是最大资源数量,一个是当前资源数量。最大资源 数量用于标识信标能够控制的资源的最大数量,而当前资源数量则用于标识当前可以使用的资 源的数量。 为了正确地说明这个问题,让我们来看一看应用程序是如何使用信标的。比如说,我正在 开发一个服务器进程,在这个进程中,我已经分配了一个能够用来存放客户机请求的缓冲区。 我对缓冲区的大小进行了硬编码,这样它每次最多能够存放 5个客户机请求。如果 5个请求尚未 处理完毕时,一个新客户机试图与服务器进行联系,那么这个新客户机的请求就会被拒绝,并 出现一个错误,指明服务器现在很忙,客户机应该过些时候重新进行联系。当我的服务器进程 初始化时,它创建一个线程池,里面包含 5个线程,每个线程都准备在客户机请求到来时对它 进行处理。 开始时,没有客户机提出任何请求,因此我的服务器不允许线程池中的任何线程成为可调 度线程。但是,如果 3个客户机请求同时到来,那么线程池中应该有 3个线程处于可调度状态。 使用信标,就能够很好地处理对资源的监控和对线程的调度,最大资源数量设置为 5,因为这 是我进行硬编码的缓冲区的大小。当前资源数量最初设置为 0,因为没有客户机提出任何请求。 当客户机的请求被接受时,当前资源数量就递增,当客户机的请求被提交给服务器的线程池时, 当前资源数量就递减。 信标的使用规则如下: • 如果当前资源的数量大于 0,则发出信标信号。 • 如果当前资源数量是0,则不发出信标信号。 • 系统决不允许当前资源的数量为负值。 • 当前资源数量决不能大于最大资源数量。 当使用信标时,不要将信标对象的使用数量与它的当前资源数量混为一谈。 下面的函数用于创建信标内核对象: psa和pszName两个参数在第3章中作过介绍。当然,通过调用 OpenSemaphore函数,另一 个进程可以获得它自己的进程与现有信标相关的句柄: 下载 211 第 9章 线程与内核对象的同步计计 lMaximumCount参数用于告诉系统,应用程序处理的最大资源数量是多少。由于这是个带 符号的32位值,因此最多可以拥有 2 147 483 647个资源。lInitialCount参数用于指明开始时(当 前)这些资源中有多少可供使用。当我的服务器进程初始化时,没有任何客户机请求,因此我 调用下面这个CreateSemaphore函数: 该函数创建了一个信标,其最大资源数量为 5,但是开始时可以使用的资源为 0(由于偶然 的原因,该内核对象的使用数量是1,因为我刚刚创建了这个内核对象,请不要与计数器搞混)。 由于当前资源数量被初始化为 0,因此不发出信标信号。等待信标的所有线程均进入等待状态。 通过调用等待函数,传递负责保护资源的信标的句柄,线程就能够获得对该资源的访问权。 从内部来说,该等待函数要检查信标的当前资源数量,如果它的值大于 0(信标已经发出信号), 那么计数器递减 1,调用线程保持可调度状态。信标的出色之处在于它们能够以原子操作方式 来执行测试和设置操作,这就是说,当向信标申请一个资源时,操作系统就要检查是否有这个 资源可供使用,同时将可用资源的数量递减,而不让另一个线程加以干扰。只有当资源数量递 减后,系统才允许另一个线程申请对资源的访问权。 如果该等待函数确定信标的当前资源数量是 0(信标没有发出通知信号),那么系统就调用 函数进入等待状态。当另一个线程将对信标的当前资源数量进行递增时,系统会记住该等待线 程(或多个线程),并允许它变为可调度状态(相应地递减它的当前资源数量)。 通过调用ReleaseSemaphore函数,线程就能够对信标的当前资源数量进行递增: 该函数只是将 lReleaseCount中的值添加给信标的当前资源数量。通常情况下,为 lReleaseCount参数传递1,但是,不一定非要传递这个值。我常常传递 2或更大的值。该函数也 能够在它的 *plPreviousCount中返回当前资源数量的原始值。实际上几乎没有应用程序关心这 个值,因此可以传递 NULL,将它忽略。 有时,有必要知道信标的当前资源数量而不修改这个数量,但是没有一个函数可以用来查 询信标的当前资源数量的值。起先我认为调用 ReleaseSemaphore并为lReleaseCount参数传递0, 也许会在*plPreviousCount中返回资源的实际数量。但是这样做是不行的, ReleaseSemaphore用 0填入这个长变量。接着,我试图传递一个非常大的数字,作为第二个参数,希望它不会影响 当前资源数量,因为它将取代最大值。同样, ReleaseSemaphore用0填入*plPrevious。可惜,如 果不对它进行修改,就没有办法得到信标的当前资源数量。 9.6 互斥对象内核对象 互斥对象( mutex)内核对象能够确保线程拥有对单个资源的互斥访问权。实际上互斥对 象是因此而得名的。互斥对象包含一个使用数量,一个线程 ID和一个递归计数器。互斥对象的 行为特性与关键代码段相同,但是互斥对象属于内核对象,而关键代码段则属于用户方式对象。 这意味着互斥对象的运行速度比关键代码段要慢。但是这也意味着不同进程中的多个线程能够 访问单个互斥对象,并且这意味着线程在等待访问资源时可以设定一个超时值。 ID用于标识系统中的哪个线程当前拥有互斥对象,递归计数器用于指明该线程拥有互斥对 212计计第二部分 编程的具体方法 下载 象的次数。互斥对象有许多用途,属于最常用的内核对象之一。通常来说,它们用于保护由多 个线程访问的内存块。如果多个线程要同时访问内存块,内存块中的数据就可能遭到破坏。互 斥对象能够保证访问内存块的任何线程拥有对该内存块的独占访问权,这样就能够保证数据的 完整性。 互斥对象的使用规则如下: • 如果线程ID是0(这是个无效 ID),互斥对象不被任何线程所拥有,并且发出该互斥对象 的通知信号。 • 如果ID是个非 0数字,那么一个线程就拥有互斥对象,并且不发出该互斥对象的通知信 号。 • 与所有其他内核对象不同, 互斥对象在操作系统中拥有特殊的代码,允许它们违反正常 的规则(后面将要介绍这个异常情况)。 若要使用互斥对象,必须有一个进程首先调用 CreateMutex,以便创建互斥对象: psa和pszName参数在第 3章中做过介绍。当然,通过调用 OpenMutex,另一个进程可以获 得它自己进程与现有互斥对象相关的句柄: fInitialOwner参数用于控制互斥对象的初始状态。如果传递 FALSE(这是通常情况下传递 的值),那么互斥对象的ID和递归计数器均被设置为 0。这意味着该互斥对象没有被任何线程所 拥有,因此要发出它的通知信号。 如果为fInitialOwner参数传递 TRUE,那么该对象的线程 ID被设置为调用线程的 ID,递归 计数器被设置为 1。由于ID是个非0数字,因此该互斥对象开始时不发出通知信号。 通过调用一个等待函数,并传递负责保护资源的互斥对象的句柄,线程就能够获得对共享 资源的访问权。在内部,等待函数要检查线程的 ID,以了解它是否是 0(互斥对象发出通知信 号)。如果线程 ID是0,那么该线程 ID被设置为调用线程的 ID,递归计数器被设置为 1,同时, 调用线程保持可调度状态。 如果等待函数发现 ID不是0(不发出互斥对象的通知信号),那么调用线程便进入等待状态。 系统将记住这个情况,并且在互斥对象的 ID重新设置为 0时,将线程 ID设置为等待线程的 ID, 将递归计数器设置为 1,并且允许等待线程再次成为可调度线程。与所有情况一样,对互斥内 核对象进行的检查和修改都是以原子操作方式进行的。 对于互斥对象来说,正常的内核对象的已通知和未通知规则存在一个特殊的异常情况。比 如说,一个线程试图等待一个未通知的互斥对象。在这种情况下,该线程通常被置于等待状态。 然而,系统要查看试图获取互斥对象的线程的 ID是否与互斥对象中记录的线程 ID相同。如果两 个线程ID相同,即使互斥对象处于未通知状态,系统也允许该线程保持可调度状态。我们不认 为该“异常”行为特性适用于系统中的任何地方的其他内核对象。每当线程成功地等待互斥对 象时,该对象的递归计数器就递增。若要使递归计数器的值大于 1,唯一的方法是线程多次等 待相同的互斥对象,以便利用这个异常规则。 下载 213 第 9章 线程与内核对象的同步计计 一旦线程成功地等待到一个互斥对象,该线程就知道它已经拥有对受保护资源的独占访问 权。试图访问该资源的任何其他线程(通过等待相同的互斥对象)均被置于等待状态中。当目 前拥有对资源的访问权的线程不再需要它的访问权时,它必须调用 ReleaseMutex函数来释放该 互斥对象: 该函数将对象的递归计数器递减 1。如果线程多次成功地等待一个互斥对象,在互斥对象 的递归计数器变成 0之前,该线程必须以同样的次数调用 ReleaseMutex函数。当递归计数器到 达0时,该线程 ID 也被置为 0,同时该对象变为已通知状态。 当该对象变为已通知状态时,系统要查看是否有任何线程正在等待互斥对象。如果有,系 统将“按公平原则”选定等待线程中的一个,为它赋予互斥对象的所有权。当然,这意味着线 程ID被设置为选定的线程的 ID,并且递归计数器被置为 1。如果没有其他线程正在等待互斥对 象,那么该互斥对象将保持已通知状态,这样,等待互斥对象的下一个线程就立即可以得到互 斥对象。 9.6.1 释放问题 互斥对象不同于所有其他内核对象,因为互斥对象有一个“线程所有权”的概念。本章介 绍的其他内核对象中,没有一种对象能够记住哪个线程成功地等待到该对象,只有互斥对象能 够对此保持跟踪。互斥对象的线程所有权概念是互斥对象为什么会拥有特殊异常规则的原因, 这个异常规则使得线程能够获取该互斥对象,尽管它没有发出通知。 这个异常规则不仅适用于试图获取互斥对象的线程,而且适用于试图释放互斥对象的线程。 当一个线程调用ReleaseMutex函数时,该函数要查看调用线程的 ID是否与互斥对象中的线程 ID 相匹配。如果两个ID相匹配,递归计数器就会像前面介绍的那样递减。如果两个线程的 ID不匹 配,那么ReleaseMutex函数将不进行任何操作,而是将 FALSE(表示失败)返回给调用者。此 时调用 GetLastError,将返回 ERROR_NOT_OWNER(试图释放不是调用者拥有的互斥对象)。 因此,如果在释放互斥对象之前,拥有互斥对象的线程终止运行(使用 ExitThread、 TerminateThread、ExitProcess或TerminateProcess函数),那么互斥对象和正在等待互斥对象的 其他线程将会发生什么情况呢?答案是,系统将把该互斥对象视为已经被放弃——拥有互斥对 象的线程决不会释放它,因为该线程已经终止运行。 由于系统保持对所有互斥对象和线程内核对象的跟踪,因此它能准确的知道互斥对象何时 被放弃。当一个互斥对象被放弃时,系统将自动把互斥对象的 ID复置为0,并将它的递归计数 器复置为 0。然后,系统要查看目前是否有任何线程正在等待该互斥对象。如果有,系统将 “公平地”选定一个等待线程,将 ID设置为选定的线程的 ID,并将递归计数器设置为 1,同时, 选定的线程变为可调度线程。 这与前面的情况相同,差别在于等待函数并不将通常的 WAIT_OBJECT_0值返回给线程。 相反,等待函数返回的是特殊的 WAIT_ABANDONED值。这个特殊的返回值(它只适用于互 斥对象)用于指明线程正在等待的互斥对象是由另一个线程拥有的,而这另一个线程已经在它 完成对共享资源的使用前终止运行。这不是可以进入的最佳情况。新调度的线程不知道目前资 源处于何种状态,也许该资源已经完全被破坏了。在这种情况下必须自己决定应用程序应该怎 么办。 在实际运行环境中,大多数应用程序从不明确检查 WAIT_ABANDONED返回值,因为线 程很少是刚刚终止运行(上面介绍的情况提供了另一个例子,说明为什么决不应该调用 214计计第二部分 编程的具体方法 下载 Te r m i n a t e T h r e a d函数)。 9.6.2 互斥对象与关键代码段的比较 就等待线程的调度而言,互斥对象与关键代码段之间有着相同的特性。但是它们在其他属 性方面却各不相同。表 9-1对它们进行了各方面的比较。 表9-1 互斥对象与关键代码段的比较 特性 运行速度 是否能够跨进程 边界来使用 声明 初始化 清除 无限等待 0等待 任意等待 释放 是否能够等待 其他内核对象 互斥对象 慢 是 HANDLE hmtx; hmtx=CreateMutex (NULL,FALSE,NULL); CloseHandle(hmtx); Wa i t F o r S i n g l e O b j e c t (hmtx,INFINITE); Wa i t F o r S i n g l e O b j e c t (hmtx,0); Wa i t F o r S i n g l e O b j e c t (hmtx,dwMilliseconds); ReleaseMutex(hmtx); 是 (使用WaitForMultipleObjects或类似的函数) 关键代码段 快 否 CRITICAL_SECTION cs; InitializeCriticalSection(&es); DeleteCriticalSection(&cs); EnterCriticalSection(&cs); TryEnterCriticalSection(&cs); 不能 LeaveCriticalSection(&cs); 否 9.6.3 Queue示例应用程序 后面的清单9-2列出的Queue(“09 Queue.exe)应用程序使用一个互斥对象和一个信标来控 制数据元素的队列。该应用程序的源代码和资源文件位于本书所附光盘中的 09-Queue目录下。 当运行 Queue应用程序时,就会出现图 9-4对话框。 当Queue 初始化时,它创建 4个客户机线程和两个服务器线程。每个客户机线程均会睡眠一 定的时间周期,然后将一个请求元素附加给队列。当每个元素排队时, Client Threads(客户机 线程)列表框就被更新。列表框的每一项表示哪个客户机线程附加了这个项,以及它是个什么 项。例如,列表框中的第一项表示客户机线程 0附加了它的第一个请求。然后客户机线程 1至3 附加它们的第一个请求,接着客户机线程 0又附加它的第二个请求,如此类推。 服务器线程在队列中出现第一个元素之前一直无事可做。当第一个元素出现时,一个服 务器线程醒来,对该请求进程处理。 Server Threads(服务器线程)列表框显示了服务器线程 的状态。它的第一项显示服务器线程 0正在处理来自客户机线程 0的一个请求。正在处理的请 求是客户机线程的第一个请求。第二项显示服务器线程 1正在处理客户机线程 1的第一个请 求。 在这个例子中,服务器线程无法以足够快的速度来处理客户程序的请求,队列中的请求达 到了最大的容量。我对队列数据结构进行了初始化,使它一次能够存放的元素不能超过 10个, 这将导致队列很快被填满。另外,客户机线程有 4个,而服务器线程只有两个。我们看到,当 客户机线程3试图将它的第5个请求附加给队列时,队列已经填满了。 下载 215 第 9章 线程与内核对象的同步计计 图9-4 Queue 对话框 好了,这就是你看到的情况,更有意思的是它的运行情况。这个队列是由一个 C++类 CQueue进行管理和控制的: 这个类中的公有 ELEMENT结构用于定义队列数据元素是个什么样子。它的实际内容并不 重要。对于这个示例应用程序,客户程序将它们的线程号和它们的请求号放入这个元素,这样, 当服务器处理检索出来的元素时,就能在服务器的列表框中显示这些信息。实际运行的应用程 序通常不需要这些信息。 至于私有成员,我们有 m_pElements,它指向一个固定大小的 ELEMENT结构的数组。这 是需要保护使之不受多个客户机 /服务器线程影响的数据。 M_nMaxElements成员用于表示创建 216计计第二部分 编程的具体方法 下载 Cqueue对象时该数组被初始化为多大的规模。下一个成员 m_h是由两个内核对象句柄组成的数 组。为了正确地保护队列的数据元素,需要两个内核对象,一个是互斥对象,另一个是信标对 象。在CQueue构造函数中,这两个对象被创建,它们的句柄放入该数组。 下面很快就会看到,该代码有时调用 WaitForMultipleObjects函数,传递句柄数组的地址。 也会看到,有时该代码只需要引用这些内核对象句柄中的一个句柄。为了使代码更容易阅读和 维护,我还声明了两个句柄参考成员,即 m_hmtxQ和m_hsemNumElements。当CQueue构造函 数运行时,它就将这些句柄参考成员分别初始化为 m_h[0]和m_h[1]。 现在应该能够毫无困难地理解 CQueue的构造函数与析构函数的方法了,因此让我们将注 意力转向Append方法。这个方法试图将一个 ELEMENT附加给队列。但是,线程首先必须确保 它拥有对该队列的独占访问权。 Append方法通过调用 WaitForSingleObject函数,传递m_hmtxQ 互斥对象的句柄,实现其独占访问权。如果返回 WAIT_OBJECT_0,那么该线程就拥有对该队列 的独占访问权。 接着,Append方法必须调用 ReleaseSemaphore函数,传递释放数量 1,以便使队列中的元 素数量递增。如果 ReleaseSemaphore函数调用成功,队列中的元素没有放满,那么新元素就可 以附加给队列。幸好 ReleaseSemaphore函数返回了 lPreviousCount 变量中的队列元素的前一个数 量。这确切地告诉你新元素应该放入哪个数组索引中。当该元素被拷贝到队列的数组中后,该 函数就返回。一旦该元素完全附加给队列, Append便调用Release互斥对象,这样其他线程就 能够访问该队列。 Append函数的剩余部分与故障情况和错误处理有关。 现在让我们来看一看服务器线程如何调用 Remove方法,以便从队列中取出元素的。首先, 线程必须确保它拥有对队列的独占访问权,同时队列中至少必须拥有一个元素。当然,如果队 列中没有任何元素,那么服务器线程就没有理由被唤醒。因此, Remove方法首先要调用 WairForMultipleObjects,并且同时传递互斥对象和信标的句柄。只有当这两个对象都得到通知 时,服务器线程才被唤醒。 如果返回WAIT_OBJECT_0,该线程将拥有对队列的独占访问权,并且队列中至少必须有 一个元素。这时,代码就取出数组中索引号为 0的元素,然后将数组中的剩余元素下移一位。 这不是使用队列的最有效的方法,因为用这种方法进行内存的拷贝要占用大量的资源,但是我 们在这里是想展示线程同步的情况,所以就使用了这种方法。最后,要调用 ReleaseMutex函数, 这样其他线程就能安全地访问该队列。 注意,信标对象能够随时跟踪某个时间队列中存在多少个元素。你能够看到这个数字在递 增。当一个新元素被附加给队列时, Append方法就调用ReleaseSemaphore。但是,当一个元素 从队列中删除时,你无法立即看到这个数字递减。递减是由Remove方法调用 WaitForMultipleObjects函数来进行的。记住,成功地等待信标的副作用是它的数量递减 1。这 对我们来说是很方便的。 现在已经懂得 CQueue类是如何运行的,源代码的其余部分很容易理解。 清单9-2 CQueue示例应用程序 下载 217 第 9章 线程与内核对象的同步计计 218计计第二部分 编程的具体方法 下载 下载 219 第 9章 线程与内核对象的同步计计 220计计第二部分 编程的具体方法 下载 下载 221 第 9章 线程与内核对象的同步计计 222计计第二部分 编程的具体方法 下载 下载 223 第 9章 线程与内核对象的同步计计 9.7 线程同步对象速查表 表9-2所示的速查表综合列出了各种内核对象与线程同步之间的相互关系。 表9-2 内核对象与线程同步之间的相互关系 对象 进程 线程 作业 文件 控制台输入 文件修改通知 自动重置事件 人工重置事件 何时处于未通知状态 当进程仍然活动时 当线程仍然活动时 当作业的时间尚未结束 时 当I/O请求正在处理时 不存在任何输入 没有任何文件被修改 ResetEvent,Pulse-Event 或等待成功 R e s e t E v e n t或P u l s e E v e n t 何时处于已通知状态 成功等待的副作用 当进程终止运行时 无 (ExitProcess,TerminateProcess) 当线程终止运行时 无 (ExitThread,TerminateThread) 当作业的时间已经结束时 无 当I/O请求处理完毕时 当存在输入时 当文件系统发现修改时 当调用SetEvent/PulseEvent时 无 无 重置通知 重置事件 当调用SetEvent/PulseEvent时 无 224计计第二部分 编程的具体方法 下载 对象 自动重置等 待定时器 人工重置等待 定时器 信标 互斥对象 关键代码段 (用户方式) 何时处于未通知状态 何时处于已通知状态 C a n c e l Wa i t a b l e Ti m e r 当时间到时 或等待成功 (SetWaitableTimer) C a n c e l Wa i t a b l e Ti m e r 当时间到时 (SetWaitableTimer) 等待成功 当数量>0时 (ReleaseSemaphore) 等待成功 当未被线程拥有时 (Release互斥对象) 等待成功 当未被线程拥有时 ((Try)EnterCriticalSection) (LeaveCriticalSection) (续) 成功等待的副作用 重置定时器 无 数量递减1 将所有权赋予线程 将所有权赋予线程 互锁(用户方式)函数决不会导致线程变为非调度状态,它们会改变一个值并立即返回。 9.8 其他的线程同步函数 WaitForSingleObject和WaitForMultipleObjects是进行线程同步时使用得最多的函数。但是, Windows还提供了另外几个稍有不同的函数。如果理解了 WaitForSingleObject和WaitForMultiple Objects函数,那么要理解其他函数如何运行,就不会遇到什么困难。本节简单地介绍一些这样 的函数。 9.8.1 异步设备I/O 异步设备 I/O使得线程能够启动一个读操作或写操作,但是不必等待读操作或写操作完成。 例如,如果线程需要将一个大文件装入内存,那么该线程可以告诉系统将文件装入内存。然后, 当系统加载该文件时,该线程可以忙于执行其他任务,如创建窗口、对内部数据结构进行初始 化等等。当初始化操作完成时,该线程可以终止自己的运行,等待系统通知它文件已经读取。 设备对象是可以同步的内核对象,这意味着可以调用 WaitForSingleObject函数,传递文件、 套接字和通信端口的句柄。当系统执行异步 I/O时,设备对象处于未通知状态。一旦操作完成, 系统就将对象的状态改为已通知状态,这样,该线程就知道操作已经完成。此时,该线程就可 以继续运行。 9.8.2 WaitForInputIdle 线程也可以调用WaitForInputIdle来终止自己的运行: 该函数将一直处于等待状态,直到 hProcess标识的进程在创建应用程序的第一个窗口的线 程中已经没有尚未处理的输入为止。这个函数可以用于父进程。父进程产生子进程,以便执行 某些操作。当父进程的线程调用 CreateProcess时,该父进程的线程将在子进程初始化时继续运 行。父进程的线程可能需要获得子进程创建的窗口的句柄。如果父进程的线程想要知道子进程 何时完成初始化,唯一的办法是等待,直到子进程不再处理任何输入为止。因此,当调用 CreateProcess 后,父进程的线程就调用 WaitForInputIdle。 当需要将击键输入纳入应用程序时,也可以调用 WaitForInputIdle。比如说,可以将下面的 下载 225 第 9章 线程与内核对象的同步计计 消息显示在应用程序的主窗口: WM_KEYDOWN WM_KEYDOWN WM_KEYUP WM_KEYUP WM_KEYDOWN WM_KEYUP 使用虚拟键 VK_MENU 使用虚拟键 VK_F 使用虚拟键 VK_E 使用虚拟键 VK_MENU 使用虚拟键 VK_O 使用虚拟键 VK_O 这个序列将 Alt+F,O发送给应用程序,对于大多数使用英语的应用程序来说,它从应用程 序的文件菜单中选择 Open命令。该命令打开一个对话框,但是,在对话框出现以前, Windows必须加载来自文件的对话框摸板,遍历摸板中的所有控件,并为每个摸板调用 Create Window。这可能需要花费一定的时间。因此,显示 WM_KEY*消息的应用程序可以调用 WaitForInputIdle, WaitForlnput Idle 将导致应用程序处于等待状态,直到对话框创建完成并准 备接受用户的输入。这时,该应用程序可以将其他的击键输入纳入对话框及其控件,使它能 够继续执行它需要的操作。 编写16位Windows应用程序的编程人员常常要面对这个问题。应用程序想要将消息显示在 窗口中,但是它并不确切知道窗口何时创建完成、作好接受消息的准备。 WaitForInputIdle函数 解决了这个问题。 9.8.3 MsgWaitForMultipleObjects(Ex) 线程可以调用 MsgWaitForMultipleObjects或MsgWaitForMultipleObjectsEx函数,让线程等 待它自己的消息: 这些函数与WaitForMultipleObjects函数十分相似。差别在于它们允许线程在内核对象变成 已通知状态或窗口消息需要调度到调用线程创建的窗口中时被调度。 创建窗口和执行与用户界面相关的任务的线程,应该调用 MsgWaitForMultipleObjectsEx函 数,而不应该调用 WaitForMultipleObjects函数,因为后面这个函数将使线程的用户界面无法对 用户作出响应。该函数将在第 27章中详细介绍。 9.8.4 WaitForDebugEvent Windows将非常出色的调试支持特性内置于操作系统之中。当调试程序启动运行时,它将 自己附加给一个被调试程序。该调试程序只需闲置着,等待操作系统将与被调试程序相关的调 试事件通知它。调试程序通过调用 WaitForDebugEvent函数来等待这些事件的发生: 226计计第二部分 编程的具体方法 下载 当调试程序调用该函数时,调试程序的线程终止运行,系统将调试事件已经发生的情况通 知调试程序,方法是允许调用的 WaitForDebugEvent函数返回。pde参数指向的结构在唤醒调试 程序的线程之前由系统填入信息。该结构包含了关于刚刚发生的调试事件的信息。 9.8.5 SingleObjectAndWait SingleObjectAndWait函数用于在单个原子方式的操作中发出关于内核对象的通知并等待另 一个内核对象: 当调用该函数时, hObjectToSignal参数必须标识一个互斥对象、信标对象或事件。任何其 他 类 型 的 对 象 将 导致 该 函 数 返回 WA I T _ FA I L E D ,并使 G e t L a s t E r r o r 函 数返 回 ERROR_INVALID_HANDLE。在内部,该函数将观察对象的类型,并分别运行 ReleaseMutex、 ReleaseSemaphore(其数量为1) 或ResetEvent中的相应参数。 hObjectToWaitOn参数用于标识下列任何一个内核对象:互斥对象、信标、事件、定时器、 进程、线程、作业、控制台输入和修改通知。与平常一样, dwMilliseconds参数指明该函数为 了等待该对象变为已通知状态,应该等待多长时间,而 fAlertable标志则指明线程等待时该线程 是否应该能够处理任何已经排队的异步过程调用。 该函数返回下列几个值中的一个: WAIT_OBJECT_0、WAIT_TIMEOUT、WAIT_FAILED、 WAIT_ABANDONED(本章前面已经介绍)或 WAIT_IO_COMPLETION。 该函数是对 Windows的令人欢迎的一个补充,原因有二。首先,因为常常需要通知一个对 象,等待另一个对象,用一个函数来执行两个操作可以节省许多处理时间。每次调用一个函数, 使线程从用户方式代码变成内核方式代码,大约需要运行 1000个CPU周期。例如,运行下面的 代码至少需要2000个CPU周期: 在高性能服务器应用程序中, SignalObjectAndWait函数能够节省大量的处理时间。 第二,如果没有 SignalObjectAndWait函数,一个线程将无法知道另一个线程何时处于等待 状态。对于 PluseEvent之类的函数来说,知道这个情况是很有用的。本章前面讲过, PulseEvent函数能够通知一个事件,并且立即对它进行重置。如果目前没有任何线程等待该事 件,那么就没有事件会抓住这个情况。曾经有人编写过类似下面的代码: 下载 227 第 9章 线程与内核对象的同步计计 一个工作线程负责运行一些代码,然后调用 SetEvent,以指明这项工作已经完成。另一个 线程负责执行下面的代码: 这个工作线程的代码段设计得很差,因为它无法可靠地运行。当工作线程调用 SetEvent之 后,另一个线程可能立即醒来,并调用 PulseEvent。该工作线程不得不停止运行,没有机会从 它对 SetEvent 的调用中返回,更不要说调用 WaitForSingleObject函数了。结果, hEvent MoreWorkToBeDone事件的通知就完全被工作线程错过了。 如果像下面所示的那样重新编写工作线程的代码,以便调用 SingleObjectAndWait函数,那 么该代码就能够可靠地运行,因为通知和等待都能够以原子操作方式来进行: 当非工作线程醒来时,它能够百分之百地确定工作线程正在等待 hEventMoreWorkToBe D o n e事件,因此能够确保看到产生该事件。 Windows 98 Windows 98 没有这个函数的可以使用的实现代码。 下载 第10章 线程同步工具包 多年来,我对线程的同步问题进行了许多开发工作,编写了一些 C++类和组件。本章将介 绍这些内容。希望这些代码有用,能够使你节省许多编程时间。 本章首先要介绍如何实现关键代码段和将各种特性添加给它的方法。尤其是,要学习如何 在多个进程中使用关键代码段。然后要学习如何将数据类型包装在 C++类中,使对象成为对线 程安全的对象。使用这些类,将展示一种其行为特性与信标相反的对象。 接着要介绍如何解决一个常见的编程问题,即当多个线程读取一种资源但是只有一个线程 写入资源时如何进行编程。 Windows没有预先内置能够容易地实现这种类型的同步的特性,因 此我编写了一个 C++类以便实现这个特性。 最后要介绍如何实现WaitForMultipleExpressions函数,该函数可以用来创建复杂的表达式, 以便指明应该何时唤醒线程(它的作用很像 WaitForMultipleObjects函数,该函数可以用来等待 任何单个对象变成已通知状态,或者使所有对象处于已通知状态)。 10.1 实现关键代码段:Optex 关键代码段始终对我有着巨大的吸引力。但是,如果它们只是用户方式对象,为什么不能 自己来实现它们呢?为什么需要操作系统的支持特性才能使关键代码段运行呢?另外,如果编 写自己的关键代码段,可能需要将各种特性添加给它,并用某种方法来增强它的性能。至少想 要让它跟踪目前究竟哪个线程拥有该资源。如果有一个关键代码段能够实现这些操作,就能帮 助解决代码中的死锁问题;可以使用一个调试程序来发现哪个线程没有释放该资源。 在进一步的说明之前,让我们来看一看究竟如何来实现关键代码段。我反复说,关键代码 段属于用户方式对象。实际上,这种说法并不是百分之百的正确。如果一个线程试图进入另一 个线程拥有的关键代码段,那么该线程就会被置于等待状态。如果要使它进入等待状态,唯一 的办法是从用户方式转入内核方式。用户方式线程通过循环运行,就能够停止执行有用的操作, 但是这不是个有效的等待方式,因此应该避免使用它。 关键代码段必须包含某个内核对象,以便使线程进入有效的等待状态。关键代码段的运行 速度很快,因为只有当争用该关键代码段的时候,才使用该内核对象。只要线程能够立即获得 对资源的访问权,并且使用该资源,然后释放该资源,而不与其他线程争用该资源,那么就不 使用该内核对象,而且该线程决不会退出用户方式。在大多数应用程序中,两个线程很少会同 时争用关键代码段。 Optex.h和Optex.cpp文件(见后面清单10-1)说明了关键代码段的实现方法。这里称关键代 码段是一个Optex(这是optimized 互斥对象(优化互斥对象)的缩略词),并将它作为一个C++类 来实现。一旦理解了这个代码,就会懂得关键代码段的运行速度为什么比互斥对象内核对象快。 由于实现了关键代码段,因此可以将有用的特性添加给它。例如, COptex类使得不同进程 中的线程能够实现同步。这是个令人叫绝的附加特性,这样就得到了一个高速运行的机制,使 得不同进程中的线程之间能够互相进行通信。 若要使用optex,只需要声明一个 COptex对象。该对象有3个构造函数: 下载 229 第 10章 线程同步工具包计计 第一个构造函数用于创建只能用来对单个进程中的各个线程进行同步的 COptex对象。这种 类型的optex占用的开销比跨进程的 optex要少得多。另外两个构造函数可以用来创建在多个进 程中的线程之间实现同步的 optex。对于pszName参数,必须传递一个 ANSI或Unicode字符串, 该字符串用于对每个共享的 optex进行标识。若要使两个或多个进程共享一个 optex,那么两个 进程必须建立一个 COptex对象的实例,并且传递相同的字符串名字。 如果线程要进入和退出 COptex对象,请调用 Enter和leave方法: 我甚至列入了关键代码段的 TryEnterCriticalSection和SetCriticalSectionSpinCount函数的等 价方法: 如果需要知道 optex是属于单进程optex还是跨进程 optex,可以调用下面所示的最后一种方 法(很少需要调用这个方法,但是内部方法函数有时要调用它)。 这些就是在使用 optex时需要知道的所有(公用)函数。现在我准备介绍 optex是如何运行 的。一般来说, optex(和关键代码段)包含了许多成员变量。这些变量反映了 optex的状态。 在Optex.h文件中,大多数成员变量采用 SHAREDINFO结构,少数成员变量属于类本身的成员。 表10-1描述了每个成员的作用。 表10-1 成员变量描述 成员 描述 m_lLockCount m _ d w T h re a d I d m_lRecurseCount m_hevt m_dwSpinCount m_hfm m_psi 指明线程试图进入 optex的次数。如果没有线程进入 optex,那么这个值是 0 指明拥有optex的线程的唯一ID。如果没有线程拥有 optex,那么这个值是0 指明线程拥有optex的次数。如果optex没有被线程所拥有,则这个值是 0 这是个事件内核对象的句柄,只有当一个线程试图在另一个线程拥有 optex时进 入该optex,才使用这个句柄。内核对象句柄是与进程相关的句柄,这就是该成员 为什么不使用 SHAREDINFO结构的原因 指明试图进入 optex的线程在等待事件内核对象之前应该尝试进入的次数。在单 处理器计算机上,这个值总是 0 这是文件映象内核对象的句柄,当多进程共享一个 optex时,便使用这个句柄。 内核对象句柄属于与进程相关的句柄,这就是为什么该成员不是 SHAREDINFO结 构的原因。对于单进程 optex来说,这个值总是 NULL 这是指向潜在的共享 optex数据成员的指针。内存地址是与进程相关的地址,这 就是为什么该成员不使用 SHAREDINFO结构的原因。对于单进程 optex来说,它 指向一个堆栈分配的内存块。对于多进程 optex来说,它指向一个内存映射文件 该源代码已经作了充分的说明,因此要理解 optex如何来运行,不会遇到什么困难。需要 特别指出的是, optex之所以能够获得较快的运行速度,原因是它大量使用了互锁函数家族。 这使得该代码始终能够以用户方式来运行,并且避免了方式转换操作。 Optex示例应用程序 清单10-1列出的Optex(“10 Optex.exe”)应用程序用于测试COptex类,以便确保它能够正 230计计第二部分 编程的具体方法 下载 确地运行。该应用程序的源代码和源文件位于本书所附光盘上的 10-Optex目录下。我总是在调 试程序中运行该应用程序,因此能够密切注意所有的成员函数和变量。 当运行该应用程序时,它首先要检测它是不是运行该应用程序的第一个实例。我的做法是 创建一个带名字的事件内核对象。在该应用程序的任何地方,实际上并不使用事件对象,创建 这个对象只是为了观察 GetLastError是否返回ERROR_ALREADY_EXISTS。如果它返回了这个 值,那么我就知道它是运行的该应用程序的第二个实例。下面说明为什么要运行该应用程序的 两个实例。 如果这是第一个实例,那么我创建一个单进程 COptex对象,并且调用 FirstFunc函数。该函 数对该optex对象执行一系列的操作。这时创建第二个线程,它也对同一个 optex对象进行操作。 这时,对该 optex进行操作的两个线程都在同一个进程中。可以观察源代码,以便了解我执行 的是什么测试。我设法说明所有可能出现的情况,这样, Coptex类中的所有代码都能够有机会 运行。 测试了单进程optex后,我又测试了跨进程optex。在_tWinMain中,当首次调用的FirstFunc 返回时,我创建了另一个 COptex optex对象,但是这一次我给该 optex赋予了一个字符串名字 CrossOptexTest。只需创建一个带名字的 optex,就可以使它成为一个跨进程 optex。接着,我第 二次调用 FirstFunc函数,给它传递了跨进程 optex 的地址。这时 FirstFunc基本上执行与以前一样 的代码。但是这次它不是产生第二个线程,而是产生了一个子进程。 该子进程只是同一个应用程序的另一个实例。但是,当它启动运行时,它创建了一个事件 内核对象,并且发现该事件对象已经存在。这就是第二个应用程序实例如何知道它是第二个实 例并且执行与第一个实例不同的代码的方法。第二个实例首先做的事情是调用 DebugBreak函 数: 这个使用方便的函数能够强制一个调试程序运行,并且将它自己与该进程连接起来。这样 就可以方便地调试应用程序的这两个实例。然后第二个实例创建一个跨进程 optex,传递相同 的字符串名字。由于字符串名字是相同的,因此两个进程共享该 optex。顺便要说明的是,两 个以上的进程可以共享同一个 optex。 接着,应用程序的第二个实例调用 SecondFunc函数,为它传递跨进程 optex的地址。这时, 进行同一组测试,但是对 optex进行操作的两个线程不在同一进程中。 清单10-1 optex示例应用程序 下载 231 第 10章 线程同步工具包计计 232计计第二部分 编程的具体方法 下载 下载 233 第 10章 线程同步工具包计计 234计计第二部分 编程的具体方法 下载 下载 235 第 10章 线程同步工具包计计 236计计第二部分 编程的具体方法 下载 下载 237 第 10章 线程同步工具包计计 238计计第二部分 编程的具体方法 下载 下载 239 第 10章 线程同步工具包计计 10.2 创建线程安全的数据类型和反信标 有一天我正在编写一些代码,这时需要一个内核对象,这个对象的行为特性必须与信标对 240计计第二部分 编程的具体方法 下载 象的行为特性相反。当它的当前资源数量变为 0时,便通知该对象,当它的当前资源数量大于 0 时,就不向该对象发出通知。 我发现这种类型的对象有许多用途。例如,有一个线程,当将某个操作运行 100次时,需 要将该线程唤醒。为了实现这个要求,需要一个能够将它初始化为 100的内核对象。当该内核 对象的数量大于 0时,该对象不应该被通知。每当执行某个操作时,必须递减该内核对象中的 数量。当该数量降到 0时,该对象应该得到通知,这样,其他线程就能够醒来,以便处理某些 操作。这是个常见的问题,我不知道 Windows为什么没有提供这个内置特性。 实际上, Microsoft只要让一个信标的当前资源数量变为负值,就能很容易解决这个问题。 可以将该信标的数量初始化为 -99,然后在执行每个操作后调用 ReleaseSemaphore函数。当该信 标的数量到达1时,该对象就得到通知,并且其他线程能够醒来执行它的操作。可惜 Microsoft 不允许信标的数量变为负值,在可以预见的将来他们不会修改这个代码。 本节将介绍一组 C++模板类,它们具有反信标的行为特性和一整套其他特性。这些类的代 码都在Interlocked.h文件中(参见后面的清单 10-2)。 当着手解决这个问题的时候,我认识到这个解决方案的核心是需要一个进行变量操作时线 程安全的方法。我想设计一个巧妙的解决方案,以便能够非常容易地编写引用变量的代码。显 然,要使资源成为线程安全的资源,最容易的办法是用关键代码段来保护它。使用 C++类,就 能够非常容易地为数据对象赋予线程安全的特性。要做的工作只不过是创建一个 C++类,它包 含想要保护的变量和一个 CRITICAL_SECTION数据结构。然后,在该构造函数中,调用 InitializeCriticalSection,在析构函数中,调用 DeleteCriticalSection。对于所有其他成员变量, 可以调用EnterCriticalSection,然后对变量进行操作,调用 LeaveCriticalSection。如果用这种方 法实现一个 C++类,就可以很容易编写能用线程安全方式访问数据结构的代码。这是本节介绍 的所有C++类的基本原则(当然可以使用前一节中讲述的 optex,而不使用关键代码段)。 第一个类是个资源保护类,称为 CResGuard。它包含一个CRITICAL_SECTION数据成员和 一个LONG数据成员。LONG数据成员用于跟踪线程进入关键代码段的次数。这个信息可以用 于调试。 CResGuard对象的构造函数和析构函数分别调用 InitializeCriticalSection和 DeleteCriticalSection。由于只有单个线程能够创建对象,因此 C++对象的构造函数和析构函数 不必是线程安全的函数。 IsGuarded成员函数只是返回是否为该对象至少调用了一次 EnterCriticalSection。如前所述,这是用于调试目的的。将 CRITICAL_SECTION放入一个C++ 对象,可以确保关键代码段被正确地初始化和删除。 CResGuard类也提供了一个嵌套的公用 C++类CGuard。CGuard对象包含了一个对 CResGuard对象的引用,并且只提供一个构造函数和析构函数。构造函数调用 CResGuard的 Guard成员函数,而该成员函数则调用 EnterCriticalSection。CGuard的析构函数调用CResGuard 的Unguard成员函数,而该成员函数则调用 LeaveCriticalSection。有这样的方法来建立这些类, 就可以更加容易地操作 CRITICAL_SECTION。下面是使用这些类的一个小代码段: 下载 241 第 10章 线程同步工具包计计 下一个C++类CInterlockedType包含了创建线程安全的数据对象时必要的全部元素。我将 CInterlockedType类做成了一个模板类,这样,它就可以用于使任何数据类型成为线程安全的 数据类型。例如,可以使用这个类建立一个线程安全的整数,一个线程安全的字符串 或一个 线程安全的数据结构。 CInterlockedType对象的每个实例都包含两个数据成员。第一个数据成员是你想要使之成 为线程安全的模板数据类型的实例。该数据成员是私有的,只能使用 CInterlockedType的成员 函数进行操作。第二个数据成员是 CResGuard对象的一个实例,它负责保护对数据成员的访问。 CResGuard对象是个受保护的数据成员,因此,由 CInterlockedType派生的类能够很容易地保护 它的数据。 总是应该将 C I n t e r l o c k e d Ty p e 类用作一个基类,以便派生一个新类。前面说过, C I n t e r l o c k e d Ty p e类提供了创建线程安全的对象时需要的所有元素,但是该派生类负责使用这 些元素,以便用线程安全的方式来正确地访问数据值。 CInterlockedType类只提供了 4个公有函数,一个是不对数据值进行初始化的构造函数,一 个是对数据值进行初始化的构造函数,一个是什么也不做的虚拟析构函数,还有一个是类型转 换操作符函数。类型转换操作符函数只是负责保证对数据值的线程安全访问,方法是保护资源 并返回对象的当前值(当局部变量 x超出规定的范围时,资源将自动失去保护)。类型转换操作 符函数使得你能够更加容易地观察线程安全方式中的这个类包含的数据对象的值。 CInterlockedType类还提供了 3个派生类将要调用的非虚拟保护的函数。两个 GetVal函数返 回数据对象的当前值。在文件的调试代码中,这两个函数首先要查看数据对象是否得到了保护。 如果该对象没有被保护,GetVal可以为该对象返回一个值,然后在原始调用线程查看该值之前, 允许另一个线程修改该对象的值。 我假设调用线程获得了对象的值,因此它能够以某种方法来修改该值。根据这个假设, GetVal函数要求调用线程拥有对数据值的受保护的访问权。如果 GetVal函数确定该数据受到了 保护,那么就返回数据的当前值。这两个 GetVal函数是相同的,不同之处在于其中一个是在对 象的固定版本上运行的。这两个版本允许编写可以对常量数据类型和非常量数据类型进行操作 的代码而不需要编译器生成警告信息。 第三个非虚拟受保护成员函数是 SetVal。当一个派生类的成员函数想要修改数据值时,该 派生类的函数应该保护对该数据的访问权,然后调用 SetVal函数。与GetVal函数一样,SetVal函 数首先要进行调试检查,以确保派生类的代码不会忘记对数据值访问权的保护。然后, SetVal 要查看数据值是否真的正在被修改。如果正在被修改, SetVal就将老的值保存起来,将对象改 为它的新值,然后调用一个虚拟的、受保护的成员函数 OnValChanged,并将老的和新的数据 值传递给它。 CInterlockedType类实现了一个OnValChanged成员函数,但是该函数并不做任何 工作。可以使用 OnValChanged成员函数将一些强大的功能添加给该派生类,就像下面我们介 242计计第二部分 编程的具体方法 下载 绍CWhenZero类时的情况那样。 到现在为止,已经介绍了许多抽象类和它们的概念。下面来说明如何使用所有这些结构。 首先介绍 CInterlockedScalar类,这是由CInterlockedType派生的一个模板类。可以使用这个类 创建线程安全的标量数据类型,如字节、字符、 16位整数、 32位整数、 64位整数和浮点值等。 由于 CInterlockedScalar类是 CInterlockedType类派生而来的,因此它并不拥有它自己的数据类 型。CInterlockedScalar的构造函数只是调用 CInterlockedType的构造函数,为该标量传递一个 初始值。由于 CInterlockedScalar类总是使用数字值,因此将默认构造函数的参数设置为 0,这 样,对象总是在已知状态中创建。 CInterlockedScalar的构造函数根本就不进行任何操作。 CInterlockedScalar的其余成员函数全部用于修改标量值。可以用于对标量值进行的每个操 作都有一个成员函数。为了使该 CInterlockedScalar类能够用线程安全的方式对它的数据对象进 行操作,所有这些成员函数都在操作前对数据值进行了保护。这些成员函数很简单,因此不对 它们进行详细的说明。可以观察其代码,以便了解它们能够做些什么。不过要介绍一下如何使 用这些类。下面的代码声明了线程安全的 BYTE数据类型,并可以对它进行操作: 对线程安全的标量值进行操作就像对非线程安全的标量值的操作一样简单。实际上,由于 C++的操作符被重载,它们的代码是相同的。运用迄今为止介绍的 C++类,可以很容易地将非 线程安全的变量转换成线程安全的变量,只需要对源代码做很少的修改即可。 当我开始设计所有这些类的时候,我的脑子里就已经有了一个特定的目的:想要创建一个 对象,它的行为特性与信标的行为特性正好相反。提供这个行为特性的 C++类是CWhenZero类。 CWhenZero类是由CInterlockedScalar类派生而来的。当标量值是 0时,便通知CWhenZero对象。 当该数据值不是 0时,便不通知CWhenZero对象。这与信标的行为特性正好相反。 如你所知,C++对象不能被通知,只有内核对象才能被通知,并且可以用于线程的同步。 因此, CWhenZero对象必须包含某些别的数据成员,这些成员是事件内核对象的句柄。一个 CWhenZero对象包含两个数据成员,一个是 m_hevtZero,这是当数据值是 0时得到通知的事件 内核对象的句柄,另一个成员是 m_hevtNotZero,这是当数据值不是0时得到通知的事件内核对 象的句柄。 CWhenZero的构造函数接受数据对象的初始值,并且也让你设定这两个事件内核对象是人 工重置的对象(默认值)还是自动重置的对象。然后该构造函数调用 CreateEvent,创建两个事 件内核对象,并且根据数据的初始值是 0还是非 0,将它们设置为已通知状态或未通知状态。 CWhenZero的析构函数仅仅负责关闭两个事件的句柄。由于 CWhenZero的类公开继承了 CInterlockedScalar类,因此CWhenZero对象的用户可以使用重载操作符函数的所有成员函数。 还记得OnValChanged能够保护 CInterlockedType类中声明的成员函数吗? CWhenZero类重 载了该虚拟函数。该函数负责根据数据对象的值,使事件内核对象保持已通知状态或者未通知 状态,每当数据值变更时,便调用 OnValChanged函数。CWhenZero对该函数的实现代码查看 新的值是否是 0。如果是 0,它就设置m_hevtZero事件,并且重置 m_hevtNotZero事件。如果新 值不是0,OnValChanged就不反转。 下载 243 第 10章 线程同步工具包计计 现在,当想要使线程等待数据值变为 0时,只需要编写下面的代码: 可以像前面那样编写对 WaitForSingleObject的调用代码,因为 CWhenZero类也包含一个类 型转换操作符成员函数,它能够将 CWhenZero对象转换成一个内核对象句柄。换句话说,如果 将一个CWhenZero C++对象传递给期望一个 HANDLE对象的函数,该类型转换操作符函数就 会被调用,它的返回值被传递给该函数。 CWhenZero的HANDLE类型转换操作符函数返回了 m_hevtNotZero事件内核对象的句柄。 CWhenZero类中的m_hevtNotZero事件句柄使你能够编写等待数据值不是 0的代码。不幸的 是,我已经拥有一个 HANDLE类型转换操作符函数,因此不能拥有另一个返回 m_hevtNotZero 句柄的函数。为了获得该句柄,我添加了 GetNotZeroHandle成员函数。使用该函数,可以编写 下面的代码: InterlockedType示例应用程序 清单10-2所示的InterlockedType(“10 InterlockedType.exe”)应用程序用于测试我刚刚介绍 的C++类。该应用程序的源代码和资源文件在本书所附光盘上的 10- InterlockedType目录下。我 总是在调试程序中运行该应用程序,因此能够清楚地观察所有的类成员函数和变量的变化。 该代码显示一种常见的编程环境,这个环境是,一个线程产生若干个工作线程,并对内存 块进行初始化。然后主线程唤醒工作线程,这样它们就能够开始处理该内存块。这时,主线程 必须终止自己的运行,直到所有工作线程运行完成。然后主线程用新数据重新对内存块进行初 始化,并唤醒工作线程,以便重新启动整个进程。 通过观察这个代码,可以看到,要用便于阅读和维护的 C++代码来解决这个常见的编程问 题是多么烦琐。如你所见, CWhenZero类为我们提供的行为特性大大超过了信标的相反行为特 性。现在我们有一个线程安全的数字,当它的值是 0 时,它就被通知。虽然你可以递增或递减 信标的值,但是你也可以用 CWhenZero对象对该值进行加、减、乘、除、求模,并且将它设置 为任何值,甚至运行位操作。 CWhenZero对象的功能实际上比信标内核对象的功能大得多。 了解这些C++模板类的概念是非常有用的。例如,可以创建一个由 CInterlockedType类派生 的CInterlockedString类。可以使用 CInterlockedString类以线程安全的方式对字符串进行操作。 然后可以用CInterlockedString类派生一个 CWhenCertainString类,当字符串变成某个值或某几 244计计第二部分 编程的具体方法 个值时,它就能通知一个事件内核对象。这种可能性是无限的。 清单10-2 InterlockedType示例应用程序 下载 下载 245 第 10章 线程同步工具包计计 246计计第二部分 编程的具体方法 下载 下载 247 第 10章 线程同步工具包计计 248计计第二部分 编程的具体方法 下载 下载 249 第 10章 线程同步工具包计计 250计计第二部分 编程的具体方法 下载 下载 251 第 10章 线程同步工具包计计 10.3 单个写入程序 /多个阅读程序的保护 许多应用程序存在一个基本的同步问题,这个问题称为单个写入程序 /多个阅读程序环境。 该问题涉及到试图访问共享资源的任意数量的线程。这些线程中,有些线程(写入程序)需要 修改数据的内容,而有些线程(阅读程序)则需要读取数据。由于下面 4个原则,它们之间的 同步是必要的: 1) 当一个线程正在写入数据时,其他任何线程不能写入数据。 2) 当一个线程正在写入数据时,其他任何线程不能读取数据。 3) 当一个线程正在读取数据时,其他任何线程不能写入数据。 4) 当一个线程正在读取数据时,其他线程也能够读取数据。 让我们观察一下数据库应用程序环境中的这个问题。比如说,有 5个最终用户,他们都要 访问同一个数据库。两个员工将一些记录输入该数据库, 3个员工则从该数据库中检索记录。 在这个环境中,必须使用原则 1,因为我们不能让员工 1和员工2同时更新同一个记录。如 果两个员工都试图修改同一个记录,那么员工 1的修改和员工2的修改就会在同一时间内进行, 该记录中的信息就会被破坏。 原则2用于在某个员工更新数据库中的记录时禁止另一个员工访问该记录。如果不能防止 这种情况的发生,那么员工 4就能够在员工 1修改一个记录时读取该记录的内容。当员工 4的计 算机显示该记录时,该记录将包含某些老的信息和某些更新过的信息,这当然是不行的。原则 3可以用来解决同样的问题。无论谁首先拥有对数据库记录的访问权,即无论是试图写入的员 工还是试图读取的员工,原则 2和原则3用词上的差别都可以防止出现上面所说的情况。 原则4用于解决性能上的问题。如果没有员工试图修改数据库中的记录,那么数据库中的 内容就不会变更,因此凡是只想从数据库中检索记录的员工都应该被允许进行检索。它的另一 个前提是阅读者多于写入者。 好了,你已经掌握了问题的要领。现在的问题是,如何来解决这个问题。 252计计第二部分 编程的具体方法 下载 注意 这里介绍的代码是新编写的。以前我提出的对这个问题的解决办法受到了一些 人的批评,原因有二。首先,我以前编写的代码运行速度太慢,因为它们的设计初衷 是要在许多环境中都能运行。例如,它们使用了较多的内核对象,因此不同进程中的 线程能够同步它们对数据库的访问。当然,我的实现代码仍然可以在单进程环境中运 行,但是,大量使用内核对象后,当所有线程都在单进程中运行时,就会增加很大的 开销。必须承认,单进程的情况是更加常见的一种情况。 第二个批评是说,我的实现代码有可能完全把写入线程锁在外面。从前面介绍的几个原则 来看,如果有许多阅读线程访问数据库,那么写入线程就永远无法访问该资源。 这里展示的实现代码解决了上面所说的两个问题。它尽可能避免使用内核对象,而是使用 关键代码段,以便实现大多数同步问题。 为了简化操作,我将解决方案封装在一个 C++类中,它称为CSWNRG,是“单个写入程序 /多个阅读程序的保护”的英文缩写。 SWMRG.h和SWMRG.cpp文件(参见后面的清单 10-3) 显示了实现代码。 CSWMRG的使用是再容易不过的了。只需要创建CSWMRG C++类的一个对象,然后按照应 用程序的指令调用相应的成员函数。C++类中只有下面的3个方法(不包括构造函数和析构函数): 在执行读取共享资源的代码之前,调用第一个方法 WaitToRead。在执行读取或写入共享资 源的代码之前,调用第二个方法 WaitToWrite。当代码不再访问共享资源时,调用第三个方法 Done。这不是很简单吗? 一般来说,C S W M R G对象包含许多成员变量,这些变量反映了线程访问共享资源时的状态。 表10-2描述了每个成员变量的作用,并且综合说明了它的整个工作情况。详细信息参见源代码。 表10-2 CSWMRG对象中成员变量的作用 成员 m_cs m_nActive m _ n Wa i t i n g R e a d e r s m _ n Wa i t i n g Wr i t e r s m _ h s e m Wr i t e r s m_hsemReaders 描述 用于保护所有的其他成员变量,这样,对它们的操作就能够以原子操作 方式来完成 用于反映共享资源的当前状态。如果该值是0,那么没有线程在访问资源。 如果该值大于 0,这个值用于表示当前读取该资源的线程的数量。如果这个 数量是负值,那么写入程序正在将数据写入该资源。唯一有效的负值是 -1 表示想要访问资源的阅读线程的数量。该值被初始化为 0,当m_nActive 是-1时,每当线程调用一次 WaitToRead,该值就递增1 表示想要访问资源的写入线程的数量。该值被初始化为 0,当m_nActive大 于0时,每当线程调用一次 WaitToWrite,该值就递增1 当线程调用 WaitToWrite,但是由于 m_nActive大于0而被拒绝访问时,所 有写入线程均等待该信标。当一个线程正在等待时,新阅读线程将被拒绝 访问该资源。这可以防止阅读线程垄断该资源。当最后一个拥有资源访问 权的阅读线程调用 Done时,该信标就被释放,其数量是 1,从而唤醒一个正 在等待的写入线程 当许多线程调用 WaitToRead,但是由于 m_nActive是-1而被拒绝访问时, 所有阅读线程均等待该信标。当最后一个正在等待的阅读线程调用 Done时, 该信标被释放,其数量是 m_nWaitingReaders,从而唤醒所有正在等待的阅 读线程 下载 253 第 10章 线程同步工具包计计 SWMRG示例应用程序 清单10-3列出的SWMRG应用程序(“10 SWMRG.exe”)用于测试CSWMRG C++类。该应 用程序的源代码和资源文件均存放在本书所附光盘上的 10 SWMRG目录下。我总是在调试程序 中运行该应用程序,这样,就能清楚地观察所有的类成员函数和变量的变化。 当运行该应用程序时,主线程就会产生若干个线程,它们全部 运行相同的线程函数。然后,主线程通过调用 WaitForMultiple Objects函数,等待所有这些线程终止运行,当所有的线程终止运 行后,它们的句柄被关闭,该进程退出。 每个辅助线程均显示一条类似图 10-1所示的消息。 如果想要该线程模拟阅读该资源,单击 Yes按钮。如果想要该 图10-1 辅助线程显示的消息 线程模拟写入该资源,单击 No。这些操作只是使线程分别调用 SWMRG对象的WaitToRead或 Wa i t To Wr i t e函数。 当调用两个函数中的一个之后,该线程就显示另一个消息框,类似图 10-2或图10-3所示的 消息框。 图10-2 调用SWMRG对象的WaitToRead 后显示的消息框 图10-3 调用SWMRG对象的WaitToWrite 后显示的消息框 该消息框将暂停线程的运行,暂停的时间相当于拥有资源访问权的线程对该资源进行操作 所用的时间。 当然,如果一个线程当前正在读取资源,而你又命令另一个线程写入该资源,那么写入线 程的消息框就不会出现,因为该线程正在它对 WaitToWrite的调用中等待。同样,如果命令一 个线程读取资源,而另一个线程的消息框已经显示,那么想要读取资源的线程就会在对 WaitToRead的调用中暂停运行,它的消息框将不会出现,直到任何一个或所有写入线程完成它 们对资源的模拟访问为止。 当单击 OK按钮,以退出任何一个消息框时,拥有对资源访问权的线程就调用 Done。而 SWMRG对象便终止任何正在等待的线程的运行。 清单10-3 SWMRG示例应用程序 254计计第二部分 编程的具体方法 下载 下载 255 第 10章 线程同步工具包计计 256计计第二部分 编程的具体方法 下载 下载 257 第 10章 线程同步工具包计计 258计计第二部分 编程的具体方法 下载 下载 259 第 10章 线程同步工具包计计 10.4 实现一个WaitForMultipleExpressions函数 不久以前,我正在编写一个应用程序,我必须解决复杂的线程同步问题。 WaitFor M u l t i p l e O b j e c t s函数虽然能够让线程等待单个对象或多个对象,但是它不能满足我的需要。我 需要的是一个能够表达更丰富的等待环境的函数。我有 3个内核对象,即一个进程,一个信标 和一个事件。我需要一种方法,使我的线程能够等待进程和信标都得到通知,或者进程和事件 都得到通知为止。 通过创造性地运用现有的一些 Windows函数,我创建了所需要的函数 WaitForMultiple Expressions。我建立了下面这个函数原型: 260计计第二部分 编程的具体方法 下载 若要调用该函数,首先必须指定一个 HANDLE数组,并对该数组的所有项目进行初始化。 nExpObjects参数用于指明phExpObjects参数指向的数组中的项目数量。该数组包含多组内核对 象句柄,每组句柄之间用一个 NULL句柄项分开, WaitForMultipleExpressions将单组句柄中的 对象视为用AND组合起来的对象组,而各个句柄组则是用 OR组合起来的句柄组。因此,调用 WaitForMultipleExpressions函数就可以暂停调用线程的运行,直到单组句柄中的所有对象均已 同时得到通知为止。 下面是个例子。假设使用表 10-3中的4个内核对象。 表10-3 内核对象的句柄值 对象 句柄值 线程 信标 事件 进程 0 x 1111 0x2222 0x3333 0x4444 像下面这样对句柄数组进行初始化,就可以命令 WaitForMultipleObjects函数暂停调用线程 的运行,直到线程与信标得到通知,或者信标与事件与进程得到通知,或者线程与进程得到通 知。如表 10-4所示。 表10-4 句柄数组 索引 句柄值 组 0 0 x 1111(线程) 0 1 0x2222(信标) 2 0x0000(OR) 3 0 x 2 2 2 2(信标) 1 4 0x3333(事件) 5 0 x 4 4 4 4(进程) 6 0x0000(OR) 7 0 x 1111(线程) 2 8 0x4444(进程) 也许你还记得,不能调用 Wa i t F o r M u l t i p l e O b j e c t s来传递超过 6 4个( M A X I M U M _ WAIT_OBJECTS)项目的句柄数组。运用 WaitForMultipleExpressions,该句柄数组的项目可以 大大超过 64。然而你不得拥有 64个以上的表达式,并且每个表达式可以包含 63个以上的句柄。 另外,如果将一个互斥对象的句柄传递给它,那么 WaitForMultipleExpressions将不能正确运 行。 表10-5显示了 WaitForMultipleExpressions可能的返回值。如果一个表达式真的实现了,那 么WaitForMultipleExpressions将返回基于 WAIT_OBJECT_0的该表达式的索引。使用该例子, 如果线程和进程对象变为已通知状态, Wa i t F o r M u l t i p l e E x p r e s s i o n s就返回索引 WA I T _ OBJECT_0+2。 下载 261 第 10章 线程同步工具包计计 表10-5 WaitForMultipleExpressions 的返回值 返回值 WAIT_OBJECT_0至(WAIT_OBJECT_0+ 表达式-1的号码) WA I T _ T I M E O U T WA I T _ FA I L E D 描述 用于指明哪个表达式被选定了 在指定的时间内没有选定表达式 产生一个错误。若要了解详细信息,调用 GetLastError。 如果产生的错误是 ERROR_TOO_MANY_SECRETS,那么 意味着设定的表达式超过了 6 4个。如果产生的错误是 ERROR_SECRET_TOO_LONG,则意味着至少有一个表达 式设定的对象超过了 63个。也可能返回别的错误代码(为 了我自己的目的,我不得不使用这两个错误代码) WaitForMultipleExpressions示例应用程序 清单10-4列出的WaitForMultipleExpressions应用程序(“10 WaitForMultExp.exe”)用于测 试WaitForMultipleExpressions函数。该应用程序的源代码和资源文件均存放在本书所附光盘上 10-WaitForMultExp目录下。当运行该应用程序时,就会出现图 10-4所示的对话框。 如果不改变任何设置,并且单击 Wait For Multiple Expressions按钮,就会出现图 10-5所示 对话框。 图10-4 WaitForMultiple Expressions 对话框 图10-5 单击WaitForMultipleExpressions 按钮后出现的对话框 在内部,该应用程序创建了 4个事件内核对象,开始时它们都处于未通知状态,同时,它 为每个内核对象将一个项目放入这个多列、多节列表框中。然后,该应用程序对表达式的各个 域进行分析,并创建句柄数组。我选择了与前面例子相吻合的 4个内核对象和一个表达式。 由于我设定的超时为 30000ms,因此有 30s时间可以用来对列表框中的项目进行打开和关闭 的切换,以便选定和取消有关的事件对象。如果选定了一个项目,则调用 SetEvent,给对象发 送通知,如果取消一个项目,则调用 ResetEvent,使该事件取消已通知状态。当切换了足够的 项目以便满足表达式中的某一个后, WaitForMultipleExpressions函数就返回,并在对话框的底 部显示哪个表达式的条件得到了满足。如果在 30s内没有表达式的条件得到满足,则出现 “Timeout”(超时)字样。 262计计第二部分 编程的具体方法 下载 下面要介绍如何来实现 WaitForMultipleExpressions函数。这是个不容易实现的函数,当使 用这个函数的时候,必须注意某些开销问题。如你所知, Windows提供了 WaitForMultiple Objects函数,它使得线程可以等待单个 AND表达式: 为了扩展该函数,使之包含使用 OR的表达式,必须生成多个线程:每个 OR表达式需要一 个线程。这些线程中的每一个都使用 WaitForMultipleObjectsEx来等待 AND表达式(我使用 WaitForMultipleObjectsEx,而不是使用更常用的 WaitForMultipleObjects函数,其原因将在下面 说明)。当其中的一个表达式被选定时,生成的线程中就有一个被唤醒并终止运行。 调用WaitForMultipleExpressions函数的线程(它与产生所有OR线程的线程相同)必须等待, 直到其中的一个 OR表达式得以实现。它是通过调用 WaitForMultipleObjectsEx来实现的。生成 的线程(OR表达式)的数量被传递给 dwObjects参数,phObjects参数则指向一个数组,该数组 包含生成的线程句柄的列表。对于 fWaitAll参数,则传递 FALSE ,这样,一旦任何一个表达式 得以实现,主线程就立即醒来。最后,传递给 WaitForMultipleExpressions的dwTimeout值被传 递给WaitForMultipleObjectsEx。 如果在规定的时间内没有任何表达式得以实现, WaitForMultipleObjectsEx就返回 WAIT_TIMEOUT,而WaitForMultipleExpressions也返回WAIT_TIMEOUT。如果有一个表达式 得以实现,那么 WaitForMultipleExpressions就返回一个索引值,指明哪个线程已经终止运行。 由于每个线程都是一个独立的表达式,因此该索引值也指明哪个表达式已经得以实现,并且 Wa i t F o r M u l t i p l e E x p r e s s i o n s返回了相同的索引值。 这就是WaitForMultipleExpressions函数如何运行的基本情况。但是还有 3个较小的问题需 要具体加以说明。首先,我们不希望多个 OR线程在调用WaitForMultipleExpressions的时候同时 醒来,因为成功地等待某个内核对象会导致该对象改变其状态。例如,等待一个信标会导致它 的数量递减 1。WaitForMultipleExpressions只等待一个表达式得以实现,因此必须防止对象多 次改变它的状态。 对这个问题的解决方案实际上很简单。在生成 OR线程之前,我创建了一个我自己的信标 对象,其初始数量是 1。然后,OR线程对WaitForMultipleObjectsEx的每次调用都包含该信标的 句柄和表达式中的其他句柄。这说明每组句柄可以设定的句柄不得超过 63个。为了使一个 OR 线程醒来,它等待的所有对象,包括我的私有信标对象,都必须得到通知。由于我给信标设定 的初始数量是1,因此唤醒的OR线程不能超过1个,同时其他对象也不会不小心改变其状态。 第二个需要说明的具体问题是如何强制正在等待的线程停止等待,以便正确地撤消。添加 信标可以确保醒来的线程不会超过 1个,但是一旦我知道哪个表达式等待实现,我就必须强制 剩余的线程醒来,这样,它们就能够明确地终止运行,释放它们的内存堆栈。应该始终避免调 用TerminateThread函数,因此需要另一个机制。在思考了一会儿后,我想到,当一个项目进入 异步过程调用( APC)队列时,如果等待线程处于待命状态,那么它们就会被强制唤醒。 我的WaitForMultipleExpressions实现代码使用 QueueUserAPC来强制等待线程醒来。当主 线程调用的 WaitForMultipleObjects返回时,我将一个 APC项放入每个还在等待的 OR线程的队 列: 下载 263 第 10章 线程同步工具包计计 回调函数 WFME_ExpressionAPC之所以采用这种形式,原因是我实际上并没有什么事情要 做,只是想要让线程停止等待。 第三个需要说明的具体问题是如何正确处理超时。如果线程在等待时没有任何表达式等待 实现,那么主线程调用的 WaitForMultipleObjects就返回一个 WAIT_TIMEOUT值。如果出现这 种情况,我就想要防止任何表达式得以实现,这可能导致对象改变它们的状态。下面这个代码 能够做到这一点: 我用等待信标的办法来防止其他表达式的实现。这将使信标的数量递减为 0,同时所有的 OR线程都不会醒来。但是,在主线程调用 WaitForMultipleObjects和它调用WaitForSingleObject 之后的某个位置上,一个表达式可能得以实现。这就是为什么要检查调用 WaitForSingleObject 的返回值的原因。如果它返回 WAIT_OBJECT_0,那么主线程得到了信标对象,并且没有一个 264计计第二部分 编程的具体方法 下载 表达式得以实现。但是,如果返回 WAIT_TIMEOUT,那么在主线程得到信标前,一个表达式 就会得以实现。若要确定哪个表达式得到了实现,主线程要再次调用超时为 INFINITE的 WaitForMultipleObjects,这是可行的,因为我知道一个 OR线程得到了信标,并且将要终止运 行。这时,必须强制 OR线程醒来,这样它们才能彻底退出。调用 QueueUserAPC的循环能够进 行这项操作。 由于WaitForMultipleExpressions是通过使用不同的线程来等待每组用 AND连接起来的对象 而在内部实现的,因此可以非常容易地了解为什么不能使用互斥对象。与其他内核对象不同, 互斥对象可以被线程所拥有。因此,如果 AND线程之一获得对互斥对象的所有权,那么当线 程终止运行时,它就会放弃该互斥对象。如果 Microsoft给Windows增加了一个函数,使得一个 线程能够将互斥对象的所有权转交给另一个线程,那么 WaitForMultipleExpressions函数就能够 很容易地调整以便相应地支持互斥对象。在出现这个 WaitForMultipleExpressions函数之前,一 直没有支持互斥对象的出色方法。 清单10-4 WaitForMultipleExpressions示例应用程序 下载 265 第 10章 线程同步工具包计计 266计计第二部分 编程的具体方法 下载 下载 267 第 10章 线程同步工具包计计 268计计第二部分 编程的具体方法 下载 下载 269 第 10章 线程同步工具包计计 270计计第二部分 编程的具体方法 下载 下载 271 第 10章 线程同步工具包计计 272计计第二部分 编程的具体方法 下载 下载 273 第 10章 线程同步工具包计计 下载 第11章 线程池的使用 第8章讲述了如何使用让线程保持用户方式的机制来实现线程同步的方法。用户方式的同 步机制的出色之处在于它的同步速度很快。如果关心线程的运行速度,那么应该了解一下用户 方式的同步机制是否适用。 到目前为止,已经知道创建多线程应用程序是非常困难的。需要会面临两个大问题。一个 是要对线程的创建和撤消进行管理,另一个是要对线程对资源的访问实施同步。为了对资源访 问实施同步, Windows提供了许多基本要素来帮助进行操作,如事件、信标、互斥对象和关键 代码段等。这些基本要素的使用都非常方便。为了使操作变得更加方便,唯一的方法是让系统 能够自动保护共享资源。不幸的是,在 Windows提供一种让人满意的保护方法之前,我们已经 有了一种这样的方法。 在如何对线程的创建和撤消进行管理的问题上,人人都有自己的好主意。近年来,我自己 创建了若干不同的线程池实现代码,每个实现代码都进行了很好的调整,以便适应特定环境的 需要。Microsoft公司的Windows 2000提供了一些新的线程池函数,使得线程的创建、撤消和基 本管理变得更加容易。这个新的通用线程池并不完全适合每一种环境,但是它常常可以适合你 的需要,并且能够节省大量的程序开发时间。 新的线程池函数使你能够执行下列操作: • 异步调用函数。 • 按照规定的时间间隔调用函数。 • 当单个内核对象变为已通知状态时调用函数。 • 当异步I/O请求完成时调用函数。 为了完成这些操作,线程池由 4个独立的部分组成。表 11-1显示了这些组件并描述了控制 其行为特性的规则。 表11-1 线程池的组件及其行为特性 组件 定时器 等待 I/O 非I / O 线程的初始数值 当创建一个线程时 总是1 当调用第一个线程 池函数时 当线程被撤消时 当进程终止运行时 线程如何等待 待命状态 0 每6 3个注册对象有 一个线程 当已经注册的等待 对象数量是 0时 Wa i t F o r M u l t i p l e O b jects 0 0 系统使用试探法,但是这里有一些因 素会影响线程的创建: • 自从添加线程后已经过去一定的时间(以 秒计算) • 使用WT_EXECUTELONGFUNCTION 标志 • 已经排队的工作项目的数量超过了某个阈 值 当线程没有未处 当线程空闲了 理的I/O请求并且已 一个阈值周期 ( 约 经空闲了一个阈值 1ms)时 周期(约1ms)时 待命状态 GetQueued-Comple tion-Status 下载 275 第 11章 线程池的使用计计 (续) 组件 定时器 等待 I/O 是什么唤醒了线程 等待定时器通知排 内核对象变为已通知 排队的用户APC和 队的用户APC 状态 已完成的I/O请求 非I / O 展示已完成的 状态和 I/O请示(完 成端口最多允许数 量为 2*的CPU线程 同时运行的数量) 当进程初始化时,它并不产生与这些组件相关联的任何开销。但是,一旦新线程池函数之 一被调用时,就为进程创建某些组件,并且其中有些组件将被保留,直到进程终止运行为止。 如你所见,使用线程池所产生的开销并不小。相当多的线程和内部数据结构变成了你的进程的 一个组成部分。因此必须认真考虑线程池能够为你做什么和不能做什么,不要盲目地使用这些 函数。 好了,上述说明已经足够了。下面让我们来看一看这些函数能够做些什么。 11.1 方案1:异步调用函数 假设有一个服务器进程,该进程有一个主线程,正在等待客户机的请求。当主线程收到该 请求时,它就产生一个专门的线程,以便处理该请求。这使得应用程序的主线程循环运行,并 等待另一个客户机的请求。这个方案是客户机 /服务器应用程序的典型实现方法。虽然它的实 现方法非常明确,但是也可以使用新线程池函数来实现它。 当服务器进程的主线程收到客户机的请求时,它可以调用下面这个函数: 该函数将一个“工作项目”排队放入线程池中的一个线程中并且立即返回。所谓工作项 目是指一个(用 pfnCallback参数标识的)函数,它被调用并传递单个参数 pvContext。最后, 线程池中的某个线程将处理该工作项目,导致函数被调用。所编的回调函数必须采用下面的 原型: 尽管必须使这个函数的原型返回 DWORD,但是它的返回值实际上被忽略了。 注意,你自己从来不调用 CreateThread。系统会自动为你的进程创建一个线程池,线程池 中的一个线程将调用你的函数。另外,当该线程处理完客户机的请求之后,该线程并不立即被 撤消。它要返回线程池,这样它就可以准备处理已经排队的任何其他工作项目。你的应用程序 的运行效率可能会变得更高,因为不必为每个客户机请求创建和撤消线程。另外,由于线程与 完成端口相关联,因此可以同时运行的线程数量限制为 CPU数量的两倍。这就减少了线程的上 下文转移的开销。 该函数的内部运行情况是, QueueUserWorkItem检查非I/O组件中的线程数量,然后根据负 荷量(已排队的工作项目的数量)将另一个线程添加给该组件。接着 QueueUserWorkItem执行 对PostQueuedCompletionStatus的等价调用,将工作项目的信息传递给 I/O完成端口。最后,在 完成端口上等待的线程取出信息(通过调用 GetQueuedCompletionStatus),并调用函数。当函 276计计第二部分 编程的具体方法 下载 数返回时,该线程再次调用 GetQueuedCompletionStatus,以便等待另一个工作项目。 线程池希望经常处理异步 I/O请求,即每当线程将一个 I/O请求排队放入设备驱动程序时, 便要处理异步I/O请求。当设备驱动程序执行该 I/O时,请求排队的线程并没有中断运行,而是 继续执行其他指令。异步 I/O是创建高性能可伸缩的应用程序的秘诀,因为它允许单个线程处 理来自不同客户机的请求。该线程不必顺序处理这些请求,也不必在等待 I/O请求运行结束时 中断运行。 但是,Windows对异步I/O请求规定了一个限制,即如果线程将一个异步 I/O请求发送给设 备驱动程序,然后终止运行,那么该 I/O请求就会丢失,并且在 I/O请求运行结束时,没有线程 得到这个通知。在设计良好的线程池中,线程的数量可以根据客户机的需要而增减。因此,如 果线程发出一个异步 I/O请求,然后因为线程池缩小而终止运行,那么该 I/O请求也会被撤消。 因为这种情况实际上并不是你想要的,所以你需要一个解决方案。 如果你想要给发出异步 I/O请求的工作项目排队,不能将该工作项目插入线程池的非 I/O组 件中。必须将该工作项目放入线程池的 I/O组件中进行排队。该 I/O组件由一组线程组成,如果 这组线程还有尚未处理的 I/O请求,那么它们决不能终止运行。因此你只能将它们用来运行发 出异步I/O请求的代码。 若要为I/O组件的工作项目进行排队,仍然必须调用 QueueUserWorkItem函数,但是可以为 dwFlags参数传递WT_EXECUTEINIOTHREAD。通常只需传递 WT_EXECUTEDEFAULT(定 义为0),这使得工作项目可以放入非 I/O组件的线程中。 Windows提供的函数(如 RegNotifyChangeKeyValue)能够异步执行与非 I/O相关的任务。 这些函数也要求调用线程不能终止运行。如果想使用永久线程池的线程来调用这些函数中的一 个,可以使用 WT_EXECUTEINPERSISTENTTHREAD标志,它使定时器组件的线程能够执行 已排队的工作项目回调函数。由于定时器组件的线程决不会终止运行,因此可以确保最终发生 异步操作。应该保证回调函数不会中断,并且保证它能迅速执行,这样,定时器组件的线程就 不会受到不利的影响。 设计良好的线程池也必须设法保证线程始终都能处理各个请求。如果线程池包含 4个线程, 并且有100个工作项目已经排队,每次只能处理 4个工作项目。如果一个工作项目只需要几个毫 秒来运行,那么这是不成问题的。但是,如果工作项目需要运行长得多的时间,那么将无法及 时处理这些请求。 当然,系统无法很好地预料工作项目函数将要进行什么操作,但是,如果知道工作项目需 要 花 费 很 长 的 时 间 来 运 行 , 那么可以调用 Q u e u e U s e r Wo r k I t e m 函数,为它传递 W T _ E X E C U T E L O N G F U N C T I O N标志。该标志能够帮助线程池决定是否要将新线程添加给线 程池。如果线程池中的所有线程都处于繁忙状态,它就会强制线程池创建一个新线程。因此, 如果同时对10 000个工作项目进行了排队(使用 WT_EXECUTELONGFUNCTION标志),那么 这 10 000 个线程就被添加给该线程池。如果不想创建 10 000个线程,必须分开调用 QueueUserWorkItem函数,这样某些工作项目就有机会完成运行。 线程池不能对线程池中的线程数量规定一个上限,否则就会发生渴求或死锁现象。假如有10 000个排队的工作项目,当第10 001个项目通知一个事件时,这些工作项目将全部中断运行。如 果你已经设置的最大数量为10 000个线程,第10 001个工作项目没有被执行,那么所有的10 000 个线程将永远被中断运行。 当使用线程池函数时,应该查找潜在的死锁条件。当然,如果工作项目函数在关键代码段、 信标和互斥对象上中断运行,那么必须十分小心,因为这更有可能产生死锁现象。始终都应该 下载 277 第 11章 线程池的使用计计 了解哪个组件( I/O、非I/O、等待或定时器等)的线程正在运行你的代码。另外,如果工作项 目函数位于可能被动态卸载的 DLL中,也要小心。调用已卸载的 DLL中的函数的线程将会产生 违规访问。若要确保不卸载带有已经排队的工作项目的 DLL,必须对已排队工作项目进行引用 计数,在调用 QueueUserWorkItem函数之前递增计数器的值,当工作项目函数完成运行时则递 减该计数器的值。只有当引用计数降为 0时,才能安全地卸载DLL。 11.2 方案2:按规定的时间间隔调用函数 有时应用程序需要在某些时间执行操作任务。 Windows提供了一个等待定时器内核对象, 因此可以方便地获得基于时间的通知。许多程序员为应用程序执行的每个基于时间的操作任务 创建了一个等待定时器对象,但是这是不必要的,会浪费系统资源。相反,可以创建一个等待 定时器,将它设置为下一个预定运行的时间,然后为下一个时间重置定时器,如此类推。然而, 要编写这样的代码非常困难,不过可以让新线程池函数对此进行管理。 若要调度在某个时间运行的工作项目,首先要调用下面的函数,创建一个定时器队列: 定时器队列对一组定时器进行组织安排。例如,有一个可执行文件控制着若干个服务程序。 每个服务程序需要触发定时器,以帮助保持它的状态,比如客户机何时不再作出响应,何时收 集和更新某些统计信息等。让每个服务程序占用一个等待定时器和专用线程,这是不经济的。 相反,每个服务程序可以拥有它自己的定时器队列(这是个轻便的资源),并且共享定时器组 件的线程和等待定时器对象。当一个服务程序终止运行时,它只需要删除它的定时器队列即可, 因为这会删除该队列创建的所有定时器。 一旦拥有一个定时器队列,就可以在该队列中创建下面的定时器: 对于第二个参数,可以传递想要在其中创建定时器的定时器队列的句柄。如果只是创建少 数几个定时器,只需要为 hTimerQueue参数传递NULL,并且完全避免调用CreateTimerQueue函 数。传递NULL,会告诉该函数使用默认的定时器队列,并且简化了你的代码。 pfnCallback和 pvContext参数用于指明应该调用什么函数以及到了规定的时间应该将什么传递给该函数。 dwDueTime参数用于指明应该经过多少毫秒才能第一次调用该函数(如果这个值是 0,那么只 要可能,就调用该函数,使得 CreateTimerQueueTimer函数类似 QueueUserWorkItem)。 dwPeriod参数用于指明应该经过多少毫秒才能在将来调用该函数。如果为 dwPeriod传递0,那 么就使它成为一个单步定时器,使工作项目只能进行一次排队。新定时器的句柄通过函数的 p h N e w Ti m e r参数返回。 工作回调函数必须采用下面的原型: 278计计第二部分 编程的具体方法 下载 当该函数被调用时,fTimerOrWaitFired参数总是TRUE,表示该定时器已经触发。 下面介绍CreateTimerQueueTimer的dwFlags参数。该参数负责告诉函数,当到了规定的时 间时,如何给工作项目进行排队。如果想要让非 I/O组件的线程来处理工作项目,可以使用 W T _ E X E C U T E D E FA U LT 。如果想要在某个时间发出一个异步 I / O 请求,可以使用 WT_EXECUTEINIOTHREAD。如果想要让一个决不会终止运行的线程来处理该工作项目,可 以使用 WT_EXECUTEPERSISTENTTHREAD。如果认为工作项目需要很长的时间来运行,可 以使用WT_EXECUTELONGFUNCTION。 也可以使用另一个标志,即 WT_EXECUTEINTIMERTHREAD,下面将介绍它。在表 11-1 中,能够看到线程池有一个定时器组件。该组件能够创建单个定时器内核对象,并且能够管理 它的到期时间。该组件总是由单个线程组成。当调用 CreateTimerQueueTimer函数时,可以使 定时器组件的线程醒来,将你的定时器添加给一个定时器队列,并重置等待定时器内核对象。 然后该定时器组件的线程便进入待命睡眠状态,等待该等待定时器将一个 APC放入它的队列。 当等待定时器将该 APC放入队列后,线程就醒来,更新定时器队列,重置等待定时器,然后决 定对现在应该运行的工作项目执行什么操作。 接着,该线程要检查下面这些标志:WT_EXECUTEDEFAULT、WT_EXECUTEINIOTHREAD、 WT_EXECUTEINPERSISTENTTHREAD、WT_EXECUTELONGFUNCTION和WT_ EXECUTEINTIMERTHREAD。不过现在可以清楚地看到 WT_EXECUTEDINTIMERTHREAD 标志执行的是什么操作:它使定时器组件的线程能够执行该工作项目。虽然这使工作项目的运 行效率更高,但是这非常危险。如果工作项目函数长时间中断运行,那么等待定时器的线程就 无法执行任何其他操作。虽然等待定时器可能仍然将 APC项目排队放入该线程,但是在当前运 行的函数返回之前,这些工作项目不会得到处理。如果打算使用定时器线程来执行代码,那么 该代码应该迅速执行,不应该中断。 W T _ E X E C U T E I N I O T H R E A D、W T _ E X E C U T E I N P E R S I S T E N T T H R E A D和 WT_EXECUTEINTIMERTHREAD等标志是互斥的。如果不传递这些标志中的任何一个(或者使 用WT_EXECUTEDEFAULT标志),那么工作项目就排队放入I/O组件的线程中。另外,如果设定 了WT_EXECUTEINTIMERTHREAD标志,那么WT_EXECUTELONGFUNCTION将被忽略。 当不再想要触发定时器时,必须通过调用下面的函数将它删除: 即使对于已经触发的单步定时器,也必须调用该函数。 hTimerQueue参数指明定时器位于 哪个队列中。hTimer参数指明要删除的定时器,句柄通过较早时调用 CreateTimerQueueTimer来 返回。 最后一个参数 hCompletionEvent告诉你,由于该定时器,什么时候将不再存在没有处理的 已排队的工作项目。如果为该参数传递 INVALID_HANDLE_VALUE,那么在该定时器的所有 已排队工作项目完成运行之前, DeleteTimerQueueTimer函数不会返回。请想一想这将意味着 什么。如果在定时器处理自己的工作项目期间对定时器进行一次中断删除,就会造成一个死锁 条件。虽然你正在等待工作项目完成处理操作,但是你在等待它完成操作时却中断了它的处理。 只有当线程不是处理定时器的工作项目的线程时,该线程才能进行对定时器的中断删除。 另外,如果你正在使用定时器组件的线程,不应该试图对任何定时器进行中断删除,否则 下载 279 第 11章 线程池的使用计计 就会产生死锁。如果试图删除一个定时器,就会将一个 APC通知放入该定时器组件的线程队列 中。如果该线程正在等待一个定时器被删除,而它不能删除该定时器,那么就会发生死锁。 如果不为hCompletionEvent参数传递INVALID_HANDLE_VALUE,可以传递NULL。这将 告诉该函数,你想尽快删除定时器。在这种情况下, DeleteTimerQueueTimer将立即返回,但 是你不知道该定时器的所有工作项目何时完成处理。最后,你可以传递一个事件内核对象的句 柄作为hCompletionEvent的参数。当这样操作时, DeleteTimerQueueTimer将立即返回,同时, 当定时器的所有已经排队的工作项目完成运行之后,定时器组件的线程将设置该事件。在调用 D e l e t e Ti m e r Q u e u e Ti m e r之前,千万不要给该事件发送通知,否则你的代码将认为排队的工作 项目已经完成运行,但是实际上它们并没有完成。 一旦创建了一个定时器,可以调用下面这个函数来改变它的到期时间和到期周期: 这里传递了定时器队列的句柄和想要修改的现有定时器的句柄。可以修改定时器的 dwDueTime和dwPeriod。注意,试图修改已经触发的单步定时器是不起作用的。另外,你可以 随意调用该函数,而不必担心死锁。 当不再需要一组定时器时,可以调用下面这个函数,删除定时器队列: 该函数取出一个现有的定时器队列的句柄,并删除它里面的所有定时器,这样就不必为删 除每个定时器而显式调用 DeleteTimerQueueTimer。hCompletionEvent参数在这里的语义与它在 D e l e t e Ti m e r Q u e u e Ti m e r函数中的语义是相同的。这意味着它存在同样的死锁可能性,因此必 须小心。 在开始介绍另一个方案之前,让我们说明两个其他的项目。首先,线程池的定时器组件创 建等待定时器,这样,它就可以给 APC项目排队,而不是给对象发送通知。这意味着操作系统 能够连续给APC项目排队,并且定时器事件从来不会丢失。因此,设置一个定期定时器能够保 证每个间隔时间都能为你的工作项目排队。如果创建一个定期定时器,每隔 10s触发一次,那 么每隔10s就调用你的回调函数。必须注意这在使用多线程时也会发生必须对工作项目函数的 各个部分实施同步。 如果不喜欢这种行为特性,而希望你的工作项目在每个项目执行之后的 10s进行排队,那 么应该在工作项目函数的结尾处创建单步定时器。或者可以创建一个带有高超时值的单个定时 器,并在工作项目函数的结尾处调用 ChangeTimerQueueTimer. TimedMsgBox示例应用程序 清单11-1列出的TimedMsgBox应用程序(“11 TimedMsgBox.exe”)显示了如何使用线程池 的定时器函数来实现一个用户在规定时间内不作出响应时能自动关闭的消息框。该应用程序的 源代码和资源文件位于本书所附光盘上的 11-TimedMsgBox目录下。 当启动该程序时,它将全局变量 g_nSecLeft设置为10。这表示用户必须在规定时间内对消 息框作出响应的秒数。然后调用 CreateTimerQueueTimer函数,指令线程池每秒钟调用一次 280计计第二部分 编程的具体方法 下载 MsgBoxTimeout函数。一旦一切都已初始化,便调用 MessageBox,并向用户显示图11-1所示的 消息框。 在等待用户作出响应的时候,线程池中的一个线程便调用 MsgBoxTimeout函数。该函数寻 找消息框的窗口句柄,对全局变量 g_nSecLeft进行递减,并更新消息框中的字符串。当 MsgBoxTimeout第一次被调用后,消息框就类似下面的样子 (见图11-2)。 图11-1 调用 Message Box 时出现的消息框 图11-2 调用MsgBox Timeout 后出现的消息框 当MsgBoxTimeout第10次被调用时, g_nSecLeft变量变为 0,同时 MsgBoxTimeout调用 EndDialog函数来撤消该消息框。主线程调用的 MessageBox返回,DeleteTimerQueueTimer被调 用,以告诉线程池停止调用 MsgBoxTimeout函数,这时出现图 11-3所示的消息框,告诉用户他 没有在分配给他的时间内对图 11-1所示的消息框作出响应。 如果用户没有在时间到期之前作出响应,便出现图 11-4所示的消息框。 图11-3 对图11-1所示的消息框不作出 响应时出现的消息框 图11-4 在超时前不作出响应时 出现的消息框 清单11-1 TimedMsgBox示例应用程序 下载 281 第 11章 线程池的使用计计 282计计第二部分 编程的具体方法 下载 下载 283 第 11章 线程池的使用计计 11.3 方案3:当单个内核对象变为已通知状态时调用函数 Microsoft发现,许多应用程序产生的线程只是为了等待内核对象变为已通知状态。一旦对 象得到通知,该线程就将某种通知移植到另一个线程,然后返回,等待该对象再次被通知。有 些编程人员甚至编写了代码,在这种代码中,若干个线程各自等待一个对象。这对系统资源是 个很大的浪费。当然,与创建进程相比,创建线程需要的的开销要小得多,但是线程是需要资 源的。每个线程有一个堆栈,并且需要大量的 CPU指令来创建和撤消线程。始终都应该尽量减 少它使用的资源。 如果想在内核对象得到通知时注册一个要执行的工作项目,可以使用另一个新的线程池函 数: 该函数负责将参数传送给线程池的等待组件。你告诉该组件,当内核对象(用 hObject进 行标识)得到通知时,你想要对工作项目进行排队。也可以传递一个超时值,这样,即使内核 对象没有变为已通知状态,也可以在规定的某个时间内对工作项目进行排队。超时值 0和 284计计第二部分 编程的具体方法 下载 INFINITE是合法的。一般来说,该函数的运行情况与 WaitForSingleObject函数(第 9章已经介 绍)相似。当注册了一个等待组件后,该函数返回一个句柄(通过 phNewWaitObject参数)以 标识该等待组件。 在内部,等待组件使用 WaitForMultipleObjects函数来等待已经注册的对象,并且要受到该 函数已经存在的任何限制的约束。限制之一是它不能多次等待单个句柄。因此,如果想要多次 注册单个对象,必须调用 DuplicateHandle函数,并对原始句柄和复制的句柄分开进行注册。当 然,WaitForMultipleObjects能够等待已通知的对象中的任何一个,而不是所有的对象。如果熟 悉 Wa i t F o r M u l t i p l e O b j e c t s 函数,那么一定知道它一次最多能够等待 6 4( M A X I M U M _ WAIT_OBJECTS)个对象。如果用 RegisterWaitForSingleObject函数注册的对象超过 64个,那 么将会出现什么情况呢?这时等待组件就会添加另一个也调用 WaitForMultipleObjects函数的线 程。实际上,每隔 63个对象后,就要将另一个线程添加给该组件,因为这些线程也必须等待负 责控制超时的等待定时器对象。 当工作项目准备执行时,它被默认排队放入非 I/O组件的线程中。这些线程之一最终将会 醒来,并且调用你的函数,该函数的原型必须是下面的形式: 如果等待超时了, fTimerOrWaitFired参数的值是 TRUE。如果等待时对象变为已通知状态, 则该参数是 FALSE。 对于 RegisterWaitForSingleObject函数的 dwFlags参数,可以传递 WT_EXECUTEIN- WA I T T H R E A D,它使等待组件的线程之一运行工作项目函数本身。它的运行速率更高,因为 工作项目不必排队放入 I/O组件中。但是这样做有一定的危险性,因为正在执行工作项目的等 待组件函数的线程无法等待其他对象得到通知。只有当工作项目函数运行得很快时,才应该使 用该标志。 如果工作项目将要发出异步 I/O请求,或者使用从不终止运行的线程来执行某些操作,那 么也可以传递 WT_EXECUTEINIOTHREAD或者 WT_EXECUTEINPERSISTENTTHREAD。也 可以使用 WT_EXECUTELONGFUNCTION标志来告诉线程池,你的函数可能要花费较长的时 间来运行,而且它应该考虑将一个新线程添加给线程池。只有当工作项目正在被移植到非 I/O 组件或I/O组件中时,才能使用该标志,如果使用等待组件的线程,不应该运行长函数。 应该了解的最后一个标志是 WT_EXECUTEONLYONCE。假如你注册一个等待进程内核对 象的组件,一旦该进程对象变为已通知状态,它就停留在这个状态中。这会导致等待组件连续 地给工作项目排队。对于进程对象来说,可能不需要这个行为特性。如果使用 WT_ EXECUTEONLYONCE标志,就可以防止出现这种情况,该标志将告诉等待组件在工作项目执 行了一次后就停止等待该对象。 现在,如果正在等待一个自动重置的事件内核对象。一旦该对象变为已通知状态,该对象 就重置为它的未通知状态,并且它的工作项目将被放入队列。这时,该对象仍然处于注册状态, 同时,等待组件再次等待该对象被通知,或者等待超时(它已经重置)结束。当不再想让该等 待组件等待你的注册对象时,必须取消它的注册状态。即使是使用 WT_EXECUTEONLYONCE 标志注册的并且已经拥有队列的工作项目的等待组件,情况也是如此。调用下面这个函数,可 以取消等待组件的注册状态: 下载 285 第 11章 线程池的使用计计 第一个参数指明一个注册的等待(由 RegisterWaitForSingleObject返回),第二个参数指明 当已注册的、正在等待的所有已排队的工作项目已经执行时,你希望如何通知你。与 D e l e t e Ti m e r Q u e u e Ti m e r 函数一样,可以传递 N U L L ( 如 果 不 要 通 知 的 话 ),或者传递 INVALID_HANDLE_VALUE(中断对函数的调用,直到所有排队的工作项目都已执行),也可 以传递一个事件对象的句柄(当排队的工作项目已经执行时,它就会得到通知)。对于无中断 的函数调用,如果没有排队的工作项目,那么UnregisterWaitEx返回TRUE,否则它返回FALSE, 而GetLastError返回STATUS_PENDING。 同样,当你将 INVALID_HANDLE_VALUE传递给UnregisterWaitEx时,必须小心避免死锁 状态。在试图取消等待组件的注册状态,从而导致工作项目运行时,该工作项目函数不应该中 断自己的运行。这好像是说:暂停我的运行,直到我完成运行为止一样——这会导致死锁。然 而,如果等待组件的线程运行一个工作项目,而该工作项目取消了导致工作项目运行的等待组 件的注册状态, UnregisterWaitEx是可以用来避免死锁的。还有一点需要说明,在取消等待组 件的注册状态之前,不要关闭内核对象的句柄。这会使句柄无效,同时,等待组件的线程会在 内部调用WaitForMultipleObjects函数,传递一个无效句柄。 WaitForMultipleObjects的运行总是 会立即失败,整个等待组件将无法正常工作。 最后,不应该调用 PulseEvent函数来通知注册的事件对象。如果这样做了,等待组件的线 程就可能忙于执行某些别的操作,从而错过了事件的触发。这不应该是个新问题了。 PulseEvent几乎能够避免所有的线程结构产生这个问题。 11.4 方案4:当异步I/O请求完成运行时调用函数 最后一个方案是个常用的方案,即服务器应用程序发出某些异步 I/O请求,当这些请求完 成时,需要让一个线程池准备好来处理已完成的 I/O请求。这个结构是 I/O完成端口原先设计时 所针对的一种结构。如果要管理自己的线程池,就要创建一个 I/O完成端口,并创建一个等待 该端口的线程池。还需要打开多个 I/O设备,将它们的句柄与完成端口关联起来。当异步 I/O请 求完成时,设备驱动程序就将“工作项目”排队列入该完成端口。 这是一种非常出色的结构,它使少数线程能够有效地处理若干个工作项目,同时它又是一 种很特殊的结构,因为线程池函数内置了这个结构,使你可以节省大量的设计和精力。若要利 用这个结构,只需要打开设备,将它与线程池的非 I/O组件关联起来。记住, I/O组件的线程全 部在一个 I/O组件端口上等待。若要将一个设备与该组件关联起来,可以调用下面的函数: 该函数在内部调用 CreateIoCompletionPort,传递 hDevice和内部完成端口的句柄。调用 BindIoCompletionCallback也可以保证至少有一个线程始终在非 I/O组件中。与该设备相关联的 完成关键字是重叠完成例程的地址。这样,当该设备的 I/O运行完成时,非 I/O组件就知道要调 用哪个函数,以便它能够处理已完成的 I/O请求。该完成例程必须采用下面的原型: 286计计第二部分 编程的具体方法 下载 你将会注意到没有将一个 OVERLAPPED结构传递给 BindIoCompletionCallback。 OVERLAPPED结构被传递给ReadFile和WriteFile之类的函数。系统在内部始终保持对这个带有 待处理 I/O请求的重叠结构进行跟踪。当该请求完成时,系统将该结构的地址放入完成端口, 从而使它能够被传递给你的 OverlappedCompletionRoutine函数。另外,由于该完成例程的地址 是完成的关键,因此,如果要将更多的上下文信息放入 OverlappedCompletionRoutine函数,应 该使用将上下文信息放入 OVERLAPPED结构的结尾处的传统方法。 还应该知道,关闭设备会导致它的所有待处理的 I/O请求立即完成,并产生一个错误代码。 要作好准备,在你的回调函数中处理这种情况。如果关闭设备后你想确保没有运行任何回调函 数,那么必须引用应用程序中的计数特性。换句话说,每次发出一个 I/O请求时,必须使计数 器的计数递增,每次完成一个 I/O请求,则递减计数器的计数。 目前没有特殊的标志可以传递给 BindIoCompletionCallback函数的dwFlags参数,因此必须 传递0。相信你能够传递的标志是 WT_EXECUTEINIOTHREAD。如果一个 I/O请求已经完成, 它将被排队放入一个非 I/O组件线程。在OverlappedCompletionRoutine函数中,可以发出另一个 异步I/O请求。但是记住,如果发出 I/O请求的线程终止运行,该 I/O请求也会被撤消。另外,非 I/O组件中的线程是根据工作量来创建或撤消的。如果工作量很小,该组件中的线程就会终止 运行,其 I/O 请求仍然处于未处理状态。如果 BindIoCompletionCallback函数支持 WT_EXECUTEINIOTHREAD标志,那么在完成端口上等待的线程就会醒来,并将结果移植到 一个I/O组件的线程中。由于在 I/O请求处于未处理状态下时这些线程决不会终止运行,因此可 以发出I/O请求而不必担心它们被撤消。 虽然WT_EXECUTEINIOTHREAD标志的作用不错,但是可以很容易模仿刚才介绍的行为 特性。在 OverlappedCompletionRoutine函数中,只需要调用 QueueUserWorkItem,传递 WT_EXECUTEINIOTHREAD标志和想要的任何数据(至少是重叠结构)。这就是线程池函数 能够为你执行的全部功能。 下载 第12章 纤 程 Microsoft公司给Windows添加了一种纤程,以便能够非常容易地将现有的 UNIX服务器应 用程序移植到Windows中。UNIX服务器应用程序属于单线程应用程序(由 Windows定义),但 是它能够为多个客户程序提供服务。换句话说, UNIX应用程序的开发人员已经创建了他们自 己的线程结构库,他们能够使用这种线程结构库来仿真纯线程。该线程包能够创建多个堆栈, 保存某些 CPU寄存器,并且在它们之间进行切换,以便为客户机请求提供服务。 显然,若要取得最佳的性能,这些 UNIX应用程序必须重新设计,仿真的线程库应该用 Windows提供的纯线程来替代。然而,这种重新设计需要花费数月甚至更长的时间才能完成, 因此许多公司首先将它们现有的 UNIX代码移植到 Windows中,这样就能够将某些应用软件推 向Windows市场。 当你将UNIX代码移植到 Windows中时,一些问题就会因此而产生。尤其是 Windows管理 线程的内存栈的方法要比简单地分配内存复杂得多。 Windows内存栈开始时的物理存储器的容 量比较小,然后根据需要逐步扩大。这个过程在第 16章“线程的堆栈”中详细介绍。由于结构 化异常处理机制的原因,代码的移植就更加复杂了。 为了能够更快和更正确地将它们的代码移植到 Windows中,Microsoft公司在操作系统中添 加了纤程。本章将要介绍纤程的概念、负责操作纤程的函数以及如何利用纤程的特性。要记住, 如果有设计得更好的使用 Windows自身线程的应用程序,那么应该避免使用纤程。 12.1 纤程的操作 首先要注意的一个问题是,实现线程的是 Windows内核。操作系统清楚地知道线程的情况, 并且根据 Microsoft定义的算法对线程进行调度。纤程是以用户方式代码来实现的,内核并不知 道纤程,并且它们是根据用户定义的算法来调度的。由于你定义了纤程的调度算法,因此,就 内核而言,纤程采用非抢占式调度方式。 需要了解的下一个问题是,单线程可以包含一个或多个纤程。就内核而言,线程是抢占调 度的,是正在执行的代码。然而,线程每次执行一个纤程的代码—你决定究竟执行哪个纤程 (随着我们讲解的深入,这些概念将会越来越清楚)。 当使用纤程时,你必须执行的第一步操作是将现有的线程转换成一个纤程。可以通过调用 ConvertThreadToFiber函数来执行这项操作: 该函数为纤程的执行环境分配相应的内存(约为 200字节)。该执行环境由下列元素组成: • 一个用户定义的值,它被初始化为传递给 ConvertThreadToFiber的pvParam参数的值。 • 结构化异常处理链的头。 • 纤程内存栈的最高和最低地址(当将线程转换成纤程时,这也是线程的内存栈)。 • CPU寄存器,包括堆栈指针、指令指针和其他。 当对纤程的执行环境进行分配和初始化后,就可以将执行环境的地址与线程关联起来。该 线程被转换成一个纤程,而纤程则在该线程上运行。 ConvertThreadToFiber函数实际上返回纤 程的执行环境的内存地址。虽然必须在晚些时候使用该地址,但是决不应该自己对该执行环境 288计计第二部分 编程的具体方法 下载 数据进行读写操作,因为必要时纤程函数会为你对该结构的内容进行操作。现在,如果你的纤 程(线程)返回或调用 ExitThread函数,那么纤程和线程都会终止运行。 除非打算创建更多的纤程以便在同一个线程上运行,否则没有理由将线程转换成纤程。若 要创建另一个纤程,该线程(当前正在运行纤程的线程)可以调用 CreateFiber函数: CreateFiber首先设法创建一个新内存栈,它的大小由 dwStackSize参数来指明。通常传递的 参数是0,按照默认设置,它创建一个内存栈,其大小可以扩展为 1MB,不过开始时有两个存 储器页面用于该内存栈。如果设定一个非 0值,那么就用设定的大小来保存和使用内存栈。 接着, CreateFiber函数分配一个新的纤程执行环境结构,并对它进行初始化。该用户定义 的值被设置为传递给 CreateFiber的pvParam参数的值,新内存栈的最高和最低地址被保存,同 时,纤程函数的内存地址(作为 pfnStartAddress参数来传递)也被保存。 PfnStartAddress参数用于设定必须实现的纤程例程的地址,它必须采用下面的原型: 当纤程被初次调度时,该函数就开始运行,并且将原先传递给 CreateFiber的pvParam的值 传递给它。可以在这个纤程函数中执行想执行的任何操作。但是该函数的原型规定返回值是 VOID,这并不是因为返回值没有任何意义,而是因为该函数根本不应该返回。如果纤程确实 返回了,那么线程和该线程创建的所有纤程将立即被撤消。 与ConvertThreadToFiber函数一样,CreateFiber函数也返回纤程运行环境的内存地址。但是, 与ConvertThreadToFiber不同的是,这个新纤程并不执行,因为当前运行的纤程仍然在执行。 在单个线程上,每次只能运行一个纤程。若要使新纤程能够运行,可以调用 Switch To Fiber函 数: Switch To Fiber函数只有一个参数,即 pvFiberExecutionContext,它是上次调用 ConvertThreadToFiber或CreateFiber函数时返回的纤程的执行环境的内存地址。该内存地址告 诉该函数要对哪个纤程进行调度。 SwitchToFiber函数在内部执行下列操作步骤: 1) 它负责将某些当前的CPU寄存器保存在当前运行的纤程执行环境中,包括指令指针寄存 器和堆栈指针寄存器。 2) 它将上一次保存在即将运行的纤程的执行环境中的寄存器装入 CPU寄存器。这些寄存器 包括堆栈指针寄存器。这样,当线程继续执行时,就可以使用该纤程的内存栈。 3) 它将纤程的执行环境与线程关联起来,线程运行特定的纤程。 4) 它将线程的指令指针设置为已保存的指令指针。线程(纤程)从该纤程上次执行的地方 开始继续执行。 SwitchToFiber函数是纤程获得 CPU 时间的唯一途径。由于你的代码必须在相应的时间显式 调用SwitchToFiber函数,因此你对纤程的调度可以实施全面的控制。记住,纤程的调度与线程 调度毫不相干。纤程运行所依赖的线程始终都可以由操作系统终止其运行。当线程被调度时, 当前选定的纤程开始运行,而其他纤程则不能运行,除非显式调用 SwitchToFiber函数。若要撤 消纤程,可以调用 DeleteFiber函数: 下载 289 第 12章 纤 程计计 该函数用于删除 pvFiberExecutionContext参数指明的纤程,当然这是纤程的执行环境的地 址。该函数能够释放纤程栈使用的内存,然后撤消纤程的执行环境。但是,如果传递了当前与 线程相关联的纤程地址,那么该函数就在内部调用 ExitThread函数,该线程及其创建的所有纤 程全部被撤消。 DeleteFiber函数通常由一个纤程调用,以便删除另一个纤程。已经删除的纤程的内存栈将 被撤消,纤程的执行环境被释放。注意,纤程与线程之间的差别在于,线程通常通过调用 ExitThread函数将自己撤消。实际上,用一个线程调用 TerminateThread函数来终止另一个线程 的运行,是一种不好的方法。如果你确实调用了 TerminateThread函数,系统并不撤消已经终止 运行的线程的内存栈。可以利用纤程的这种能力来删除另一个纤程,后面介绍示例应用程序时 将说明这是如何实现的。 为了使操作更加方便,还可以使用另外两个纤程函数。一个线程每次可以执行一个纤程, 操作系统始终都知道当前哪个纤程与该线程相关联。如果想要获得当前运行的纤程的执行环境 的地址,可以调用 GetCurrentFiber函数: 另一个使用非常方便的函数是 GetFiberData: 前面讲过,每个纤程的执行环境包含一个用户定义的值。这个值使用作为 ConvertThread ToFiber或CreateFiber的pvParam参数而传递的值进行初始化。该值也可以作为纤程函数的参数 来传递。 GetFiberData只是查看当前执行的纤程的执行环境,并返回保存的值。 无论 GetCurrentFiber还是 GetFiberData,运行速度都很快,并且通常是作为内蕴函数 (infrinsic funcfion)来实现的,这意味着编译器能够为这些函数生成内联代码。 12.2 Counter示例应用程序 后面清单12-1中的Counter应用程序(“12 Counter.exe”)使用纤程来实现后台处理。当运 行该应用程序时,便出现图 12-1所示的对话框(建议通 过运行该应用程序来了解阅读下面的内容时将会出现什 么情况并观察它的行为特性)。 可以将该应用程序视为包含两个单元格的超小型电 子表格。第一个单元格是个可写入的单元格,它是作为 编辑控件(标注为 Count To)来实现的。第二个单元格 图12-1 Counter 对话框 是个只读单元格,它是作为一个静态控件(标注为 Answer)来实现的。当改变编辑控件中的数 字时,Answer单元格就会自动重新计算。对于这个简单的应用程序来说,重新计算是由计数器 来进行的,它的起始数字是 0,然后慢慢递增,直到 Answer单元格中的值与输入的数字相同为 止。为了演示的需要,对话框底部的静态控件中的数字不断更新,以显示当前执行的是哪个纤 程。该纤程既可以是用户界面纤程,也可以是重新计算的纤程。 为了测试该应用程序的运行情况,可以将 5键入编辑控件。Currently Running Fiber(当前 运行的纤程)域改为 Recalculation(重新计算),Answer域中的数字慢慢地从 0递增为5。当计数 完成时, Currently Running Fiber域重新改为User Interface(用户界面),线程转入睡眠状态。 这时,在编辑框中,在 5后面键入0(使数字变为 50),然后观察计数从0 开始,逐步递增为50。 不过这次在 Answer域中的数字递增的同时,你移动屏幕上的窗口。你将会发现,重新计算的纤 程暂停运行,而用户界面纤程重新被调度,使应用程序的用户界面保持对用户的响应状态。当 290计计第二部分 编程的具体方法 下载 你停止移动窗口时,重新计算的纤程又被重新调度,而 Answer域则从上次暂停的地方继续开始 计数。 最后一个测试项是,当重新计算纤程正在计数时,改变编辑框中的数字。同样,你会看到 用户界面对你的输入作出响应,但是你也会看到当你停止键入时,重新计算纤程开始从头计数。 这就是你在功能完善的电子表格应用程序中需要的那种行为特性。 记住,这个应用程序中没有使用任何关键代码段或其他线程同步对象,一切都是使用由两 个纤程组成的单线程来完成的。 下面让我们来说明这个应用程序是如何实现的。当进程的主线程通过执行 _tWinMain(在 程序清单的结尾处)开始运行时, ConvertThreadToFiber函数被调用,以便将线程转换成纤程, 并且允许我们在以后创建另一个纤程。然后,创建一个无模式对话框,它是应用程序的主对话 框,接着,一个状态变量被初始化,指明后台处理的状态( BPS)。该状态变量是全局变量 g_FiberInfo中包含的 bps成员。如表 12-1所示,共有 3个状态。 表12-1 全局变量g_Fiberlnfo 中DPS成员的状态 状态 描述 BPS_DONE BPS_STARTOVER BPS_CONTINUE 重新计算即将完成,用户没有修改需要重新计算的任何东西 用户修改了某些东西,因此需要从头开始重新计算 重新计算已经开始,但是尚未完成。另外,用户没有修改需要从 头开始重新计算的任何东西 后台处理的状态变量是在线程的消息循环中进行观察的,该消息循环比普通的消息循环更 加复杂。下面是消息循环的作用: • 如果存在一个窗口消息(用户界面处于活动状态)。那么它就负责处理该消息。保持用户 界面的响应特性的优先级总是高于重新计算的优先级。 • 如果用户界面无事可做,它可以查看是否需要进行任何重新计算操作(后台处理状态是 BPS_STARTOVER或BPS_CONTINUE)。 • 如果没有任何重新计算操作需要执行( BPS_DONE),它就通过调用 WaitMessage函数暂 停线程的运行;只有用户界面事件能够导致需要进行的重新计算。 如果用户界面纤程无事可做,同时,用户刚刚修改了编辑框中的值,那么需要从头开始重 新计算( BPS_STARTOVER)。首先必须了解,我们可能已经有一个重新计算纤程正在运行。 如果是这种情况,必须删除该纤程,并且创建一个从头开始计数的新纤程。用户界面纤程调用 DeleteFiber函数来撤消现有的重新计算纤程。这正是纤程(比线程更加)便于使用的一个表现。 删除重新计算的纤程是完全没有问题的,该纤程的内存栈和执行环境将被完全彻底地释放。如 果使用线程而不是纤程,那么用户界面线程将不会完全彻底地删除重新计算的线程,必须使用 某种形式的线程之间的通信手段,并且等待重新计算的线程自行终止运行。一旦知道不再存在 重新计算的纤程,就可以创建新的重新计算纤程,并将后台处理状态设置为 BPS_CONTINUE。 当用户界面空闲而重新计算的纤程有事可做时,可以通过调用 SwitchToFiber函数为它调度 所需的时间。SwitchToFiber函数要等到重新计算的纤程再次调用 SwitchToFiber函数并传递用户 界面纤程的执行环境的地址时才会返回。 FiberFunc函数包含了重新计算纤程执行的代码。该纤程函数将得到全局结构 g_FiberInfo的 地址,因此它知道对话框窗口的句柄、用户界面纤程的执行环境的地址,以及当前后台处理的 状态。该结构的地址不需要传递,因为它是位于一个全局变量之中,但是我想要展示如何将参 数传递给纤程函数。此外,传递该地址可以较少地依赖代码,这总是一种好的做法。 下载 291 第 12章 纤 程计计 纤程函数首先更新对话框中的状态控件,以便指明重新计算纤程正在运行。然后它要获取 编辑框中的数字,并进入一个循环,从 0 开始计数,直到计算到编辑框中的这个数字。每当递 增到接近这个数字的时候,便调用 GetQueueStatus函数,以了解线程的消息队列中是否显示有 任何消息(单个线程上运行的所有纤程均共享线程的消息队列)。当显示一条消息时,用户界 面线程就有事可做了。由于我们希望它拥有高于重新计算的优先级,因此立即调用 SwitchToFiber函数,使用户界面纤程能够处理该消息。当消息处理完毕后,用户界面纤程便重 新调度该重新计算的纤程(如前面介绍的那样),同时,后台处理继续进行。 当没有消息需要处理时,重新计算纤程便更新对话框中的 Answer域的数字,然后睡眠 200ms。在实际的代码中,应该删除对 Sleep的调用,我在这里加上了对 Sleep的调用,是为了 强调展示进行重新计算所需要的时间。 当重新计算纤程完成 Answer域数字的计算时,后台处理状态变量被设置为 BPS_DONE, 同时,对 SwitchToFiber函数的调用将对用户界面纤程进行重新调度。这时,如果用户界面纤程 无事可做,它将调用 WaitMessage,暂停线程的运行,使得 CPU时间不会浪费。 清单12-1 Counter示例应用程序 292计计第二部分 编程的具体方法 下载 下载 293 第 12章 纤 程计计 294计计第二部分 编程的具体方法 下载 下载 295 第 12章 纤 程计计 296计计第二部分 编程的具体方法 下载 下载 297 第 12章 纤 程计计 下载 第三部分 内 存 管 理 第13章 Windows的内存结构 操作系统使用的内存结构是理解操作系统如何运行的最重要的关键。当开始对一个新的操 作系统进行操作时,你会想到一系列的问题。比如,“如何在两个应用程序之间共享数据呢?” “系统将要查找的信息存放在什么地方呢?”“如何使我的程序能够更加有效地运行呢?”等 等。 很好地理解系统如何管理内存,可以帮助你更快和更准确地地回答这些问题。本章将要介 绍Microsoft公司的Windows操作系统使用的内存结构。 13.1 进程的虚拟地址空间 每个进程都被赋予它自己的虚拟地址空间。对于 32位进程来说,这个地址空间是 4GB,因 为32位指针可以拥有从 0x00000000至0xFFFFFFFF之间的任何一个值。这使得一个指针能够拥 有4 294 967 296个值中的一个值,它覆盖了一个进程的 4GB虚拟空间的范围。对于64位进程来 说,这个地址空间是 16EB(101 8字节),因为 64位指针可以拥有从 0x0000000000000000至 0xFFFFFFFFFFFFFFFF之间的任何值。这使得一个指针可以拥有 18 446 744 073 709 551 616个 值中的一个值,它覆盖了一个进程的 16EB虚拟空间的范围。这是相当大的一个范围。 由于每个进程可以接收它自己的私有的地址空间,因此当进程中的一个线程正在运行时, 该线程可以访问只属于它的进程的内存。属于所有其他进程的内存则隐藏着,并且不能被正在 运行的线程访问。 注意 在Windows 2000中,属于操作系统本身的内存也是隐藏的,正在运行的线程无 法访问。这意味着线程常常不能访问操作系统的数据。 Windows 98中,属于操作系统 的内存是不隐藏的,正在运行的线程可以访问。因此,正在运行的线程常常可以访问 操 作 系 统 的 数 据 , 也 可 以 破 坏 操 作 系 统 ( 从 而 有 可 能 导 致 操 作 系 统 崩 溃 )。在 Windows 98中,一个进程的线程不可能访问属于另一个进程的内存。 前面说过,每个进程有它自己的私有地址空间。进程 A可能有一个存放在它的地址空间中 的数据结构,地址是 0x12345678,而进程B则有一个完全不同的数据结构存放在它的地址空间 中,地址是0x12345678。当进程A中运行的线程访问地址为 0x12345678的内存时,这些线程访 问的是进程A的数据结构。当进程 B中运行的线程访问地址为 0x12345678的内存时,这些线程 访问的是进程B的数据结构。进程 A中运行的线程不能访问进程 B的地址空间中的数据结构。反 之亦然。 当你因为拥有如此大的地址空间可以用于应用程序而兴高采烈之前,记住,这是个虚拟地 址空间,不是物理地址空间。该地址空间只是内存地址的一个范围。在你能够成功地访问数据 而不会出现违规访问之前,必须赋予物理存储器,或者将物理存储器映射到各个部分的地址空 间。本章后面将要具体介绍这是如何操作的。 300计计第三部分 内 存 管 理 下载 13.2 虚拟地址空间如何分区 每个进程的虚拟地址空间都要划分成各个分区。地址空间的分区是根据操作系统的基本实 现方法来进行的。不同的 Windows内核,其分区也略有不同。表 13-1显示了每种平台是如何对 进程的地址空间进行分区的。 表13-1 进程的地址空间如何分区 分区 3 2位Wi n d o w s 2000(x86和 A l p h a处理器) 32位Windows 2000(x86 w/3 G B用户方式) 64位Windows 2000(Alpha和 I A - 6 4处理器) Windows 98 NULL指针分 配的分区 D O S / 1 6位 Windows应用程 序兼容分区 用户方式 64-KB 禁止进入 共享内存映射 文件(MMF) 内 核方式 0x00000000 0x0000FFFF 无 0x00010000 0x7FFEFFFF 0x7FFF0000 0x7FFFFFFF 无 0x800000000 0xFFFFFFFF 0x00000000 0x0000FFFF 无 0x00010000 0xBFFEFFFFF 0xBFFF0000 0xBFFFFFFF 无 0xC0000000 0xFFFFFFFF 0x00000000 00000000 0x00000000 0000FFFF 无 0X00000000 0x00000FFF 0x000001000 0x003FFFFF 0x00000000 00010000 0x000003FF FFFEFFFF 0x000003FFFFFF0000 0x000003FFFFFFFFFF 无 0x00000400 00000000 0xFFFFFFFFF FFFFFFF 0x00400000 0x7FFFFFFF 无 0x80000000 0xBFFFFFFF 0xC0000000 0xFFFFFFFF 如你所见, 32位Windows 2000的内核与 64位Windows 2000的内核拥有大体相同的分区, 差别在于分区的大小和位置有所不同。另一方面,可以看到 Windows 98下的分区有着很大的不 同。下面让我们看一下系统是如何使用每一个分区的。 注意 Microsoft公司正在积极开发 64位Windows 2000。但是当我撰写本书时,该系统 仍在开发之中。应该使用本书中关于 64位Windows 2000的信息,将它们用于你的当前 项目的设计和实现中。不过应该知道,等到 64位Windows 2000上市时,本章中介绍的 一些详细信息很可能已经发生了变化。至于 IA-64(64位Intel结构)的内存管理,分 区和系统页面大小的特定虚拟地址范围也有可能变更。 13.2.1 NULL指针分配的分区—适用于Windows 2000和Windows 98 进程地址空间的这个分区的设置是为了帮助程序员掌握 NULL指针的分配情况。如果你的 进程中的线程试图读取该分区的地址空间的数据,或者将数据写入该分区的地址空间,那么 CPU就会引发一个访问违规。保护这个分区是极其有用的,它可以帮助你发现 NULL指针的分 配情况。 C / C + +程序中常常不进行严格的错误检查。例如,下面这个代码就没有进行任何错误检 查: 下载 301 第 13章 Windows的内存结构计计 如果malloc不能找到足够的内存来满足需要,它就返回 NULL。但是,该代码并不检查这 种可能性,它认为地址的分配已经取得成功,并且开始访问 0x00000000地址的内存。由于这个 分区的地址空间是禁止进入的,因此就会发生内存访问违规现象,同时该进程将终止运行。这 个特性有助于编程员发现应用程序中的错误。 13.2.2 MS-DOS/16位Windows应用程序兼容分区—仅适用于Windows 98 进程地址空间的这个4MB分区是Windows 98需要的,目的是维护MS-DOS应用程序与16位 应用程序之间的兼容性。不应该试图从 32位应用程序来读取该分区的数据,或者将数据写入该 分区。在理想的情况下,如果进程中的线程访问该内存, CPU应该产生一个访问违规,但是由 于技术上的原因, Microsoft无法保护这个4MB的地址空间。 在Windows 2000中,16位MS-DOS与16位Windows应用程序是在它们自己的地址空间中运 行的,32位应用程序不会对它们产生任何影响。 13.2.3 用户方式分区 —适用于Windows 2000和Windows 98 这个分区是进程的私有(非共享)地址空间所在的地方。一个进程不能读取、写入、或者 以任何方式访问驻留在该分区中的另一个进程的数据。对于所有应用程序来说,该分区是维护 进程的大部分数据的地方。由于每个进程可以得到它自己的私有的、非共享分区,以便存放它 的数据,因此,应用程序不太可能被其他应用程序所破坏,这使得整个系统更加健壮。 Windows 2000 在Windows 2000中,所有的.exe和DLL模块均加载这个分区。每个进 程可以将这些 DLL加载到该分区的不同地址中(不过这种可能性很小)。系统还可以 在这个分区中映射该进程可以访问的所有内存映射文件。 Windows 98 在Windows 98中,主要的Win32系统DLL(Kernel32.dll,AdvAPI32.dll, User32.dll和GDI32.dll)均加载共享内存映射文件分区中。 .exe和所有其他DLL模块则 加载到这个用户方式分区中。所有进程的共享 DLL均位于相同的虚拟地址中,但是其 他DLL可以将这些 DLL加载到用户方式分区的不同地址中(不过这种可能性不大)。 另外,在Windows 98 中,用户方式分区中决不会出现内存映射文件。 当我最初观察32位进程的地址空间的时候,我惊奇地发现可以使用的地址空间还不到我的 进程的全部地址空间的一半。难道内核方式分区真的需要上面的一半地址空间吗?实际上回答 是肯定的。系统需要这个地址空间,供内核代码、设备驱动程序代码、设备 I/O高速缓存、非 页面内存池的分配和进程页面表等使用。实际上 Microsoft将内核压缩到这个2GB空间之中。在 64位Windows 2000 中,内核终于得到了它真正需要的空间。 1. 在x86 的Windows2000中获得3GB用户方式分区 多年来,编程人员一直强烈要求扩大用户方式的地址空间。为了满足这个需要, Microsoft 允许x86的Windows 2000 Advanced Server版本和Windows 2000 Data Center版本将用户方式分 区扩大为 3GB。若要使所有进程都能够使用 3GB用户方式分区和 1GB内核方式分区,必须将 /3GB开关附加到系统的 BOOT.INI文件的有关项目中。表 13-1中的“32位Windows 2000(x86 w/3GB用户方式)”这一列显示了使用3GB开关时它的地址空间是个什么样子。 在M i c r o s o f t添加 / 3 G B 开关之前,应用程序无法看到设置了高位的内存指针。一些有创意 的编程员自己将这个高位用作一个标志,这个标志只对他们的应用程序具有意义。这时,当应 用程序访问内存地址时,运行的代码将在内存地址被使用之前清除该指针的高位。可以想象, 302计计第三部分 内 存 管 理 下载 当应用程序在3GB的用户方式环境中运行时,该应用程序转眼之间就会运行失败。 Microsoft不得不提出一个解决方案,以便使该应用程序能够在3GB环境中运行。当系统准备 运行一个应用程序时,它要查看该应用程序是否与/LARGEADDRESSAWARE链接程序开关相链接。 如果是链接的,那么应用程序就声称它并没有对内存地址执行什么特殊的操作,并且完全准备充 分利用3GB 用户方式地址空间。另一方面,如果该应用程序没有与/LARGEADDRESSAWARE开 关相链接,那么操作系统将保留 0x80000000至0xBFFFFFFF之间的1GB区域。这可以防止在已 经设置了高位的内存地址上进行内存分配。 注意,内核已经被紧紧地压缩到了一个 2GB的分区中。当使用 3GB的开关时,内核勉强地 被放入一个 1 GB的分区中。使用 /3GB的开关,可以减少系统能够创建的线程、堆栈和其他资 源的数量。此外,系统最多只能使用 16GB的RAM,而通常情况下最多可以使用 64GB的RAM, 因为内核方式中没有足够的虚拟地址空间可以用来管理更多的 RAM。 注意 当操作系统创建进程的地址空间时,需要检查一个可执行的 LARGEADDRE SSAWARE标志。对于 DLL,系统则忽略该标志。在编写 DLL时,必须使之能够在 3 GB用户方式分区中正确地运行,否则它们的行为特性是无法确定的。 2. 在64位Windows 2000中获得2 GB用户方式分区 Microsoft发现许多编程人员需要尽可能迅速而方便地将现有的 32位应用程序移植到64位环 境中去。但是,在许多源代码中,指针被视为 32位值。如果简单地重新编写应用程序,就会造 成指针被截断的错误和不正确的内存访问。 然而,如果系统能够确保不对 0x000000007FFFFFFF以上的内存地址进行分配,那么应用 程序就能很好地运行。当较高的 33位是0时,将64位地址截断为32位地址,不会产生任何问题。 通过在地址空间范围内运行应用程序,而这个地址空间范围将进程的可用地址空间限制为最低 的GB,那么系统就能够确保这一点。 默认情况下,当启动一个 64位应用程序时,系统将保留从 0x000000080000000开始的所有 用户地址空间。这可以确保在底部的 2GB 64位地址空间中进行所有的内存分配。这就是地址 空间的范围。对于大多数应用程序来说,这个地址空间足够了。若要使 64位应用程序能够访问 它的全部4 TB(terabyte)用户方式分区,该应用程序必须使用 /LARGEADDRESSAWARE 链接开 关来创建。 注 意 当操作系统创建进程的 64位地址空间时,要检查一个可执行文件的 LARGEADDRESSAWARE标志。如果是DLL,那么系统将忽略该标志。编写DLL时,必 须使之能够在整个4 TB用户方式分区中正确地运行,否则它们的行为特性将无法确定。 13.2.4 64 KB禁止进入的分区 —仅适用于Windows 2000 这个位于用户方式分区上面的64 KB分区是禁止进入的,访问该分区中的内存的任何企图均 将导致访问违规。Microsoft之所以保留该分区,是因为这样做将使得Microsoft能够更加容易地实 现操作系统。当将内存块的地址和它的长度传递给Windows函数时,该函数将在执行它的操作之 前使内存块生效。可以很容易创建类似下面这个代码(在32位Windows 2000系统上运行): 下载 303 第 13章 Windows的内存结构计计 对于WriteProcessMemory这样的函数来说,写入的内存区是由内核方式代码来使之生效的, 该代码能够成功地访问内核方式分区中的内存( 32位系统上 0x80000000以上的地址)。如果在 0x80000000地址上存在内存,上面的函数调用就能成功地将数据写入只应该由内核方式代码访 问的内存。为了防止出现这种情况,并使这个内存区迅速生效, Microsoft选择的办法是使该分 区始终保持禁止进入的状态。只要试图读取或写入该分区中的内存,就一定会导致访问违规。 13.2.5 共享的MMF分区—仅适用于Windows 98 这个1GB分区是系统用来存放所有 32位进程共享数据的地方。例如,系统的动态链接库 Kernel32.dll、AdvAPI32.dll、User32.dll和GDI32.dll等,全部存放在这个地址空间分区中,因 此,所有 32位进程都能很容易同时访问它们。系统还为每个进程将 DLL加载相同的内存地址。 此外,系统将所有内存映射文件映射到这个分区中。内存映射文件将在第 17章中详细介绍。 13.2.6 内核方式分区—适用于Windows 2000和Windows 98 这个分区是存放操作系统代码的地方。用于线程调度、内存管理、文件系统支持、网络支 持和所有设备驱动程序的代码全部在这个分区加载。驻留在这个分区中的一切均可被所有进程 共享。在Windows 2000 中,这些组件是完全受到保护的。如果你试图访问该分区中的内存地址, 你的线程将会产生访问违规,导致系统向用户显示一个消息框,并关闭你的应用程序。关于访 问违规和如何处理这些违规的详细说明,请参见第 23、24和25章的内容。 Windows 2000 在64位Windows 2000中,4 TB用户方式分区看上去与 16, 777, 212 TB 的内核方式分区非常不成比例。并不是内核方式分区需要使用该虚拟地址空间的 全部空间,它只是说明 64位地址空间是非常大的,而该地址空间的大部分是不用的。 系统允许应用程序使用4 TB分区,并且允许内核使用它需要的东西,而内核方式分区 的大部分是不用的。幸好系统并不需要任何内部数据结构来维护内核方式分区的不用 部分。 Windows 98 不幸的是,在 Windows 98中该分区中的数据是不受保护的。任何应用 程序都可以从该分区读取数据,也可以写入数据,因此有可能破坏操作系统。 13.3 地址空间中的区域 当进程被创建并被赋予它的地址空间时,该可用地址空间的主体是空闲的,即未分配的。 若要使用该地址空间的各个部分,必须通过调用 VirtualAlloc函数(第15章介绍)来分配它里边 的各个区域。对一个地址空间的区域进行分配的操作称为保留 (reserving)。 每当你保留地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始。对于 不同的 CPU平台来说,分配粒度是各不相同的。但是,截止到撰写本书时,所有的 CPU平台 (x86、32位Alpha、64位Alpha和IA-64)都使用64KB这个相同的分配粒度。 当你保留地址空间的一个区域时,系统还要确保该区域的大小是系统的页面大小的倍数。 页面是系统在管理内存时使用的一个内存单位。与分配粒度一样,不同的 CPU,其页面大小也 是不同的。 x86使用的页面大小是 4 KB,而Alpha(当既能运行32位Windows 2000也能运行64 位Windows 2000时)使用的页面大小则是 8 KB。在撰写本书时, Microsoft预计IA-64也使用8 KB的页面。但是,如果测试显示使用更大的页面能够提高系统的总体性能,那么 Microsoft可 304计计第三部分 内 存 管 理 下载 以切换到更大的页面( 16KB或更大)。 注意 有时系统能够代表你的进程来保留地址空间的区域。例如,系统可以分配一个 地址空间区域,以便存放进程环境块( FEB)。FEB是由系统创建、操作和撤消的一个 小型数据结构。当创建一个进程时,系统就为 FEB分配一个地址空间区域。 系统也需要创建一个线程环境块( TEB),以便管理进程中当前存在的所有线程。 用于这些TEB的区域将根据进程中的线程被创建和撤消等情况而保留和释放。 虽然系统规定,要求保留的地址空间区域均从分配粒度边界(目前所有平台上均 为64KB)开始,但是系统本身并不受这个规定的限制。为你的进程的 PEB和TEB保留 的地址空间区域很可能不是从 64 KB这个边界开始的。不过这些保留区域仍然必须是 C P U的页面大小的倍数。 如果想保留一个 10 KB的地址空间区域,系统将自动对你的请求进行四舍五入,使保留的 地址空间区域的大小是页面大小的倍数。这意味着,在 x86平台上,系统将保留一个 12KB的区 域,在 Alpha平台上,系统将保留一个 16KB的区域。 当你的程序算法不再需要访问已经保留的地址空间区域时,该区域应该被释放。这个过程 称为释放地址空间的区域,它是通过调用 VirtualFree函数来完成的。 13.4 提交地址空间区域中的物理存储器 若要使用已保留的地址空间区域,必须分配物理存储器,然后将该物理存储器映射到已保 留的地址空间区域。这个过程称为提交物理存 储器。物理存储器总是以页面的形式来提交的。 若要将物理存储器提交给一个已保留的地址空 间区域,也要调用 VirtualAlloc函数。 页面11至 页面16 页面6至 页面8 当将物理存储器提交给地址空间区域时, 不必将物理存储器提交给整个区域。例如,可 24576字节 24576字节 以保留一个64KB的区域,然后将物理存储器提 交给该区域中的第二和第四个页面。图 13-1显 示了进程的地址空间是个什么样子。注意,根 据运行的 CPU平台的不同,地址空间是各有差 别的。左边的地址空间显示了 x86计算机(它 的页面大小是4 KB)上的情况,而右边的地址 空间则显示了 Alpha计算机(它的页面大小是 8 KB)上发生的情况。 当你的程序算法不再需要访问保留的地址 空间区域中已提交的物理存储器时,该物理存 储器应该被释放。这个过程称为回收物理存储 器,它是通过VirtualFree函数来完成的。 13.5 物理存储器与页文件 在较老的操作系统中,物理存储器被视为 页面10 4096字节 页面9 4096字节 页面8 4096字节 页面7 4096字节 页面6 4096字节 页面5 4096字节 页面4 4096字节 页面3 4096字节 页面2 4096字节 页面1 4096字节 页面5 8192字节 页面4 8192字节 页面3 8192字节 地址空 间中的 64KB 区域 页面2 8192字节 页面1 8192字节 计算机拥有的 RAM的容量。换句话说,如果计 图13-1 不同的CPU使用的示例进程地址空间 下载 305 第 13章 Windows的内存结构计计 算机拥有16MB的RAM,那么加载和运行的应用程序最多可以使用 16MB的RAM。今天的操作 系统能够使得磁盘空间看上去就像内存一样。磁盘上的文件通常称为页文件,它包含了可供所 有进程使用的虚拟内存。 当然,若要使虚拟内存能够运行,需要得到 CPU本身的大量帮助。当一个线程试图访问一 个字节的内存时, CPU必须知道这个字节是在RAM中还是在磁盘上。 从应用程序的角度来看,页文件透明地增加了应用程序能够使用的 RAM(即内存)的数 量。如果计算机拥有 64MB的RAM,同时在硬盘上有一个 100 MB的页文件,那么运行的应用 程序就认为计算机总共拥有 164MB的RAM。 当然,实际上并不拥有 164MB的RAM。相反,操作系统与 CPU相协调,共同将 RAM的各 个部分保存到页文件中,当运行的应用程序需要时,再将页文件的各个部分重新加载到 RAM。 由于页文件增加了应用程序可以使用的 RAM的容量,因此页文件的使用是视情况而定的。如 果没有页文件,那么系统就认为只有较少的 RAM可供应用程序使用。但是,我们鼓励用户使 用页文件,这样他们就能够运行更多的应用程序,并且这些应用程序能够对更大的数据集进行 操作。最好将物理存储器视为存储在磁盘驱动器(通常是硬盘驱动器)上的页文件中的数据。 这样,当一个应用程序通过调用Vi r t u a l A l l o c函数,将物理存储器提交给地址空间的一个区域时, 地址空间实际上是从硬盘上的一个文件中进行分配的。系统的页文件的大小是确定有多少物理 存储器可供应用程序使用时应该考虑的最重要的因素, RAM的容量则影响非常小。 现在,当你的进程中的一个线程试图访问进程的地址空间中的一个数据块时,将会发生两 种情况之一,参见图 13-2中的流程图。 在第一种情况中,线程试图访问的数据是在 RAM中。在这种情况下, CPU将数据的虚拟 内存地址映射到内存的物理地址中,然后执行需要的访问。 在第二种情况中,线程试图访问的数据不在 RAM中,而是存放在页文件中的某个地方。 这时,试图访问就称为页面失效, CPU将把试图进行的访问通知操作系统。这时操作系统就 寻找 RAM 中的一个内存空页。如果找不到空页,系统必须释放一个空页。如果一个页面尚未 被修改,系统就可以释放该页面。但是,如果系统需要释放一个已经修改的页面,那么它必 须首先将该页面从 RAM拷贝到页交换文件中,然后系统进入该页文件,找出需要访问的数据 块,并将数据加载到空闲的内存页面。然后,操作系统更新它的用于指明数据的虚拟内存地 址现在已经映射到 RAM中的相应的物理存储器地址中的表。这时 CPU重新运行生成初始页面 失效的指令,但是这次 CPU能够将虚拟内存地址映射到一个物理 RAM地址,并访问该数据块。 系统需要将内存页面拷贝到页文件并反过来将页文件拷贝到内存页面的次数越多,你的硬 盘倒腾的次数就越多,系统运行得越慢(倒腾意味着操作系统要花费更多的时间将页面从内存 中转出转进,而不是将时间用于程序的运行)。因此,通过给你的计算机增加更多的 RAM,就 可以减少运行应用程序所需的倒腾次数,这就必然可以大大提高系统的运行速度。所以必须遵 循一条基本原则,那就是要让你的计算机运行得更块,增加更多的 RAM。实际上,在大多数 情况下,若要提高系统的运行性能,增加 RAM比提高CPU的速度所产生的效果更好。 不在页文件中维护的物理存储器 当阅读了上一节后,你必定会认为,如果同时运行许多文件的话,页文件就可能变得非常 大,而且你会认为,每当你运行一个程序时,系统必须为进程的代码和数据保留地址空间的一 些区域,将物理存储器提交给这些区域,然后将代码和数据从硬盘上的程序文件拷贝到页文件 中已提交的物理存储器中。 306计计第三部分 内 存 管 理 下载 实际上系统并不进行上面所说的这些操作。如果它进行这些操作的话,就要花费很长的时 间来加载程序并启动它运行。相反,当启动一个应用程序的时候,系统将打开该应用程序 的.exe文件,确定该应用程序的代码和数据的大小。然后系统要保留一个地址空间的区域,并 指明与该区域相关联的物理存储器是在 .exe文件本身中。即系统并不是从页文件中分配地址空 间,而是将 .exe文件的实际内容即映像用作程序的保留地址空间区域。当然,这使应用程序的 加载非常迅速,并使页文件能够保持得非常小。 线程访问 一个数据块 数据在RAM中吗? 否 数据在页文件中吗? 否 出现错误 是 引发访问违规 是 RAM中存在空 闲页面吗? 否 在RAM中找到 一个要释放的页面 是 CPU将进程的虚拟地 址映射到物理地址 数据从页文件加载到 否 RAM中的空闲页面 页面的数据 是否已作废? 数据被访问 是 将该页面写入 页文件中 图13-2 将虚拟地址转换成物理存储器地址的流程图 当硬盘上的一个程序的文件映像(这是个 .exe文件或DLL文件)用作地址空间的区域的物 理存储器时,它称为内存映射文件。当一个 .exe文件或DLL文件被加载时,系统将自动保留一 个地址空间的区域,并将该文件映像映射到该区域中。但是,系统也提供了一组函数,使你能 够将数据文件映射到一个地址空间的区域中。关于内存映射文件的详细说明,将在第 17章中介 绍。 Windows 2000 Windows 2000能够使用多个页文件。如果多个页文件存在于不同的 物理硬盘驱动器上,系统的运行将能得快得多,因为它能够将数据同时写入多个驱动 器。打开 System Properties Control Panel (系统属性控制面板)小程序,再选择 Advanced选项卡,单击 Performance Options(性能选项)按钮,就能够添加或删除页 文件。图13-3显示了该对话框的形式。 下载 307 第 13章 Windows的内存结构计计 图13-3 Virtual Memory 对话框 注意 当.exe或DLL文件从软盘加载时, Windows 98和Windows 2000都能将整个文件 从软盘拷贝到系统的 RAM中。此外,系统将从页文件中分配足够的内存,以便存放 该文件的映像。如果系统选择对当前包含该文件的一部分映像的 RAM页面进行裁剪, 那么该内存属于只能写入的内存。如果系统 RAM上的负载比较小,那么文件始终都 可以直接从 RAM来运行。 Microsoft不得不通过软盘来运行的映射文件,这样,安装应用程序才能正确运行。 安装程序常常从一个软盘开始,然后用户将软盘从驱动器中取出来,再插入另一个软 盘。如果系统需要回到第一个软盘,以便加载 .exe或DLL文件的某些代码,当然该代 码已经不再在软盘驱动器中了。然而,由于系统将文件拷贝到 RAM(并且受页文件 的支持),要访问安装程序是不会有任何问题的。 系统并不将 RAM映射文件拷贝在其他可换式介质上,如光盘或网络驱动器,除非 映射文件是用 /SWAPRUN:CD或/SWAPRUN:NET开关链接的。注意, Windows 98 不支持/SWAPRUN映像标志。 13.6 保护属性 已经分配的物理存储器的各个页面可以被赋予不同的保护属性。表 13-2显示了这些保护属 性。 x86和Alpha CPU不支持“执行”保护属性,不过操作系统软件却支持这个属性。这些 CPU 将读访问视为执行访问。这意味着如果将 PAGE_EXECUTE保护属性赋予内存,那么该内存也 将拥有读优先权。当然,不应该依赖这个行为特性,因为在其他 CPU上的Windows实现代码很 308计计第三部分 内 存 管 理 下载 表13-2 页面的保护属性 保 护属 性 描述 PA G E _ N O A C C E S S PA G E _ R E A D O N LY PA G E _ R E A D W R I T E PA G E _ E X E C U T E PA G E _ E X E C U T E _ R E A D PA G E _ E X E C U T E _ R E A D W R I T E PA G E _ W R I T E C O P Y PA G E _ E X E C U T E _ W R I T E C O P Y 如果试图在该页面上读取、写入或执行代码,就会引发访问违规 如果试图在该页面上写入或执行代码,就会引发访问违规 如果试图在该页面上执行代码,就会引发访问违规 如果试图在该页面上对内存进行读取或写入操作,就会引发访问违规 如果试图在该页面上对内存进行写入操作,就会引发访问违规 对于该页面不管执行什么操作,都不会引发访问违规 如果试图在该页面上执行代码,就会引发访问违规。如果试图在该页面 上写入内存,就会导致系统将它自己的私有页面(受页文件的支持)拷 贝赋予该进程 对于该地址空间的区域,不管执行什么操作,都不会引发访问违规。如 果试图在该页面上的内存中进行写入操作,就会将它自己的私有页面 (受页文件的支持)拷贝赋予该进程 可能将“执行”保护视为“仅为执行”保护。 Windows 98 Windows 98只支持PAGE_NOACCESS、PAGE_READONLY和PAGE_ READWRITE等保护属性。 13.6.1 Copy-On-Write 访问 表13-2列出的保护属性都是非常容易理解的,不过最后两个属性需要作一些说明。一个是 PAGE_WRITECOPY,另一个是PAGE_EXECUTE_WRITECOPY。这两个属性的作用是为了节 省RAM的使用量和页文件的空间。 Windows支持一种机制,使得两个或多个进程能够共享单个 内存块。因此,如果 10个Notepad实例正在运行,那么所有实例可以共享应用程序的代码和数 据页面。让所有实例共享同样的内存页面将能够大大提高系统的性能,但是这要求所有实例都 将该内存视为只读或只执行的内存。如果一个实例中的线程将数据写入内存修改它,那么其他 实例看到的这个内存也将被修改,从而造成一片混乱。 为了防止出现这种混乱,操作系统给共享内存块赋予了 Copy-On-Write保护属性。当一 个.exe或DLL模块被映射到一个内存地址时,系统将计算有多少页面是可以写入的(通常包含 代码的页面标为 PAGE_EXECUTE_READ,而包含数据的页面则标为 PAGE_READWRITE)。 然后,系统从页文件中分配内存,以适应这些可写入的页面的需要。除非该模块的可写入页面 是实际的写入模块,否则这些页文件内存是不使用的。 当一个进程中的线程试图将数据写入一个共享内存块时,系统就会进行干预,并执行下列 操作步骤: 1) 系统查找 RAM中的一个空闲内存页面。注意,当该模块初次被映射到进程的地址空间 时,该空闲页面将被页文件中已分配的页面之一所映射。当该模块初次被映射时,由于系统要 分配所有可能需要的页文件,因此这一步不可能运行失败。 2) 系统将试图被修改的页面内容拷贝到第一步中找到的页面。该空闲页面将被赋予 PAGE_READWRITE或PAGE_EXECUTE_READWRITE保护属性。原始页面的保护属性和数据 不发生任何变化。 3) 然后系统更新进程的页面表,使得被访问的虚拟地址被转换成新的 RAM页面。 当系统执行了这 3个操作步骤之后,该进程就可以访问它自己的内存页面的私有实例。第 下载 309 第 13章 Windows的内存结构计计 17章还要详细地介绍共享内存和 Copy-On-Write保护属性。 此外,当使用 VirtualAlloc函数来保留地址空间或者提交物理存储器时,不应该传递 PAGE_WRITECOPY或PAGE_EXECUTE_WRITECOPY。如果传递的话,将会导致 VirtualAlloc 调用的失败。对 GetLastError的调用将返回 ERROR_INVALID_PARAMETER。当操作系统映 射.exe或DLL文件映像时,这两个属性将被操作系统使用。 Windows 98 Windows 98 不支持 Copy-On-Write保护。当 Windows 98 发现需要 Copy_On_Write保护时,它就立即进行数据的拷贝,而不是等待试图对内存进行写入 操作。 13.6.2 特殊的访问保护属性的标志 除了上面介绍的保护属性外,还有 3个保护属性标志,即 PAGE_NOCACHE,PAGE_ WRITECOMBINE和PAGE_GUARD。可以用 OR逐位将它们连接,以便将这 3个标志用于任何 一个保护属性(PAGE_NOCACHE除外)。 第一个保护属性标志 PAGE_NOCACHE用于停用已提交页面的高速缓存。一般情况下最好 不要使用该标志,因为它主要是供需要处理内存缓冲区的硬件设备驱动程序的开发人员使用 的。 第二个保护属性 PAGE_WRITECOMBINE也是供设备驱动程序开发人员使用的。它允许把 单个设备的多次写入合并在一起,以便提高运行性能。 最后一个保护属性标志 PAGE_GUARD可以在页面上写入一个字节时使应用程序收到一个 通知(通过一个异常条件)。该标志有一些非常巧妙的用法。 Windows 2000在创建线程堆栈时 使用该标志。关于该标志的详细说明,参见第 16章。 Windows 98 Windows 98将忽略 PAGE_NOCACHE、PAGE_WRITECOMBINE和 PAGE_GUARD这3个保护属性标志。 13.7 综合使用所有的元素 本节要将地址空间、分区、区域、内存块和页面等元素综合起来加以使用。为了更好地说 明问题,我们首先来看一看虚拟内存表,它可以显示单个进程中的所有地址空间的区域。该进 程正好是第 14章中介绍的 VMMap示例应用程序。为了全面了解进程的地址空间,首先要介绍 一下当在32位x86计算机上的Windows 2000下运行VMMap时,地址空间是个什么样子。表 13-3 显示了一个示例地址空间表。后面将要介绍 Windows 2000与Windows 98的地址空间之间的差 别。 表13-3中的地址空间表显示了进程的地址空间中的各个不同区域。每行显示一个区域,每 行包含6列。 第一列,即最左边的一列显示了区域的基地址。你会发现我们是从地址为 0x00000000的区 域开始观察进程的地址空间的,并在可用地址空间的最后一个区域结束,该区域的起始地址是 0x7FFE0000。所有区域都是相邻的。你也会注意到,非空闲区域的所有基地址几乎都是从 6 4 K B的倍数上开始的。这是由系统采用的保留地址空间的分配粒度所决定的。不是从分配粒 度边界开始的区域,表示该区域是由操作系统代码代表你的进程来分配的。 310计计第三部分 内 存 管 理 下载 基地址 00000000 00010000 0 0 0 11 0 0 0 00020000 00021000 00030000 00130000 00230000 00240000 00256000 00260000 00293000 002A0000 002E1000 002F0000 002F4000 00300000 003C8000 00400000 0041A000 00420000 00463000 00470000 00770000 00771000 00780000 00781000 00790000 007A0000 007A2000 699D0000 699DB000 77D50000 77DBE000 77DC0000 77E14000 77E20000 77E82000 77E90000 77F40000 77F7B000 表13-3 显示了32位x86计算机上运行的 Windows 2000 下的地址空间区域的示例地址空间表 类型 大小 块 空闲 私有 空闲 私有 空闲 私有 私有 映射 映射 65 536 4096 1 61 440 4096 1 61 440 1 048 576 3 1 048 576 2 65 536 2 90 112 1 空闲 映射 40 960 208 896 1 空闲 映射 53248 266 240 1 空闲 映射 61 400 16 384 1 空闲 映射 空闲 映像 空闲 映像 空闲 映像 私有 空闲 私有 空闲 私有 映射 49 152 819 200 4 229 376 106 496 5 24 576 274 432 1 53 248 3145 728 2 4096 1 61 440 4096 1 61 440 65 536 2 8192 1 空闲 1 763 893 248 映像 45 056 4 空闲 238 505 984 映像 450 560 4 空闲 8192 映像 344 064 5 空闲 49152 映像 401 408 4 空闲 57 344 映像 720 896 5 映像 241 664 4 空闲 20 480 保护属性 描述 - RW- RW- RW- RW- RW- RW- -R-- -R-- -R-- ER-ERWC -R-ER-- RW- RW- RW-R-- ERWC ERWC ERWC ERWC ERWC ERWC 线程堆栈 \ D e v i c e \ H a r d d i s k Vo l u m e 1 \ W I N N T \ system32\unicode.nls \ D e v i c e \ H a r d d i s k Vo l u m e 1 \ W I N N T \ system32\locale.nls \ D e v i c e \ H a r d d i s k Vo l u m e 1 \ W I N N T \ s y s t e m 3 2 \ s o r t k e y. n l s \ D e v i c e \ H a r d d i s k Vo l u m e 1 \ W I N N T \ system32\sorttbls.nls C:\CD\x86\Debug\14\VMMap.exe \ D e v i c e \ H a r d d i s k Vo l u m e 1 \ W I N N T \ system32\ctype.nls C:\ W I N N T \ S y s t e m 3 2 \ P S A P I . d l l C:\ W I N N T \ S y s t e m 3 2 \ R P C RT 4 . d l l C:\WINNT\System32\ADVAPI32.dll C:\ W I N N T \ S y s t e m 3 2 \ U S E R 3 2 . d l l C:\WINNT\System32\KERNEL32.dll C:\WINNT\System32\GDI32.dll 下载 311 第 13章 Windows的内存结构计计 基地址 类型 大小 块 保护属性 (续) 描述 77F80000 映像 483 328 5 77FF6000 空闲 40 960 78000000 映像 290 816 6 78047000 空闲 124 424 192 7F6F0000 映射 1 048 576 2 7F7F0000 空闲 8 126 464 7FFB0000 映射 147 456 1 7FFD4000 空闲 40 960 7FFDE000 私有 4096 1 7FFDF000 私有 4096 1 7FFE0000 私有 65 536 2 ERWC ERWC ER-- -R-- E RWE RW-R-- C:\WINNT\System32\ntdl1.dll C:\ W I N N T \ S y s t e m 3 2 \ M S V C RT. d l l 第二列显示了区域的类型。区域类型共有 4个值,即空闲,私有,映像或映射。表 13-4对 它们进行了介绍。 表13-4 区域类型说明 类型 说明 空闲 私有 映像 映射 该区域的虚拟地址不受任何内存的支持。该地址空间没有被保留。应用程序既可 以将一个区域保留在显示的基地址上,也可以保留在空闲区域中的任何位置上 该区域的虚拟地址将受系统的页文件的支持。 该区域的虚拟地址原先受内存映射的映像文件(如 .exe或DLL文件)的支持,但 也许不再受映像文件的支持。例如,当写入模块映像中的全局变量时,“写入时拷 贝”的机制将由页文件来支持特定的页面,而不是受原始映像文件的支持 该区域的虚拟地址原先是受内存映射的数据文件的支持,但也许不再受数据文件 的支持。例如,数据文件可以使用“写入时拷贝”的保护属性来映射。对文件的 任何写入操作都将导致页文件而不是原始数据支持特定的页面 我的VMMap应用程序计算这一列的方法可能产生错误的结果。当地址空间区域不空闲时, VNNap示例应用程序就要猜测剩余的 3个值中哪一个可以使用。没有一个函数可以调用,以便 确定该区域的准确用途。这一列的值的方法是,对区域中的所有内存块进行扫描,然后进行合 乎逻辑的推测。可以参考第 14章中的代码,以便更好地了解计算方法。 第三列显示了为该区域保留的字节数量。例如,系统将 User.DLL的映像映射到内存地址 0x77E20000。当系统为该映像保留地址空间时,它必须保留 401 408个字节。第三列中的数字 总是CPU的页面大小的倍数(x86CPU为4096字节)。 第四列显示了保留区域中的块的数量。所谓块是指一组相邻的页面,它们拥有相同的保护 属性,并且都是受相同类型的物理存储器支持的。下一节将要详细介绍这个问题。对于空闲区 域来说,这个值始终都是 0,因为在空闲区域中不能提交任何内存(在第四列中空闲区域不显 示任何信息)。对于非空闲区域来说,这个值可以是 1到区域大小 /页面大小的最大数字之间的 任何值。例如,从内存地址 0x77E20000开始的区域,它的区域大小是 401 408个字节。由于该 进程是在x86 CPU上运行的(x86 CPU的页面大小是4096个字节),因此提交的各种不同的块的 最大数量是98(401 408/4096)。表中显示该区域中的块的数量是 4。 第五列显示了区域的保护属性。各个字母所代表的含义是: E=执行,R=读取,W=写入, C=写入时拷贝。如果一个区域没有显示任何保护属性,那么该区域就没有访问保护。空闲区 域没有显示任何保护属性,因为未保留区域不拥有与其相关联的保护属性。这里决不会出现 312计计第三部分 内 存 管 理 下载 GUARD保护属性标志或 NO-CACHE保护属性标志。只有当这些标志与物理存储器相关联,而 不是与保留的地址空间相关联时,它们才具有意义。给一个区域赋予保护属性的目的只是为了 提高效率,并且总是会被赋予物理存储器的保护属性所取代。 第六列即最后一列显示了区域中的信息的文字描述。如果是空闲区域,那么这一列总是空 的。如果是私有区域,它通常也是空的,因为 VMMap没有办法知道应用程序为什么要保留这 个私有地址空间区域。但是, VMMap能够识别包含线程堆栈的私有区域。 VMMap通常能够发 现线程堆栈,因为它们通常拥有一个具有 GUARD保护属性的物理存储器块。不过,当线程堆 栈满了的时候,它就不再拥有一个保护属性为 GUARD的物理存储器块,同时 VMMap将无法发 现它。 对于映像区域来说, VMMap将显示映射到该区域中的文件的全路径名。使用 ToolHelp函 数, VMMap就可以获得该信息。在 Windows 2000 中,通过调用 GetMappedFileName函数 (Windows 98 中没有这个函数),VMMap就能够显示受数据文件支持的区域。 13.7.1 区域的内部情况 我们还可以将区域划分得比表 13-3显示的情况更细一些。表 13-5显示的地址空间与表 13-3 所示的地址空间相同,但是它也显示了每个区域中包含的内存块。 表13-5 显示了32位x86计算机上的Windows 2000下的 内存区域和块的示例地址空间表 基地址 类型 大小 块 00000000 空闲 65 536 00010000 私有 4096 1 00010000 私有 4096 0 0 0 11 0 0 0 空闲 61 440 00020000 私有 4096 1 00020000 私有 4096 00021000 空闲 61 440 00030000 私有 1 048 576 3 00030000 保留 905 216 0010D000 私有 4096 0010E000 私有 139 264 00130000 私有 1048576 2 00130000 私有 36 864 00139000 保留 1 011 712 00230000 映射 65 536 2 00230000 映射 4096 00231000 保留 61 440 00240000 映射 90 112 1 00240000 00256000 00260000 映射 空闲 映射 90 112 40 960 208 896 1 00260000 00293000 映射 空闲 208 896 53248 保护属性 描述 - RW-RW- --- - RW-RW- --- - RW-RW- ---RW- G--RW- --- RW-RW- ---RW- --- RW-RW- ---RW- ---R-- -R-- --- 线程堆栈 \ D e v i c e \ H a r d d i s k Vo l u m e l \ W I N N T \ system32\unicode.nls -R--R-- --- \ D e v i c e \ H a r d d i s k Vo l u m e l \ W I N N T \ system32\locale.nls 下载 基地址 002A0000 类型 映射 大小 266240 002A0000 002E1000 002F0000 映射 空闲 映射 266240 61 440 16 384 002F0000 002F4000 00300000 00300000 00304000 003C0000 003C2000 003C8000 00400000 00400000 00401000 00415000 00416000 00418000 0041A000 00420000 00420000 00463000 00470000 00470000 004B3000 00770000 00770000 00771000 00780000 00780000 00781000 00790000 00790000 00795000 007A0000 映射 空闲 映射 映射 保留 映射 保留 空闲 映像 映像 映像 映像 映像 映像 空闲 映射 映射 空闲 映射 映射 保留 私有 私有 空闲 私有 私有 空闲 私有 私有 保留 映射 16 384 49 152 819 200 16 384 770 048 8192 24 576 229 376 106 496 4096 81 920 4096 8192 8192 24 576 274 432 274 432 53 248 3 145 728 274 432 2 871 296 4096 4096 61 440 4096 4096 61 440 65 536 20 480 45 056 8192 007A0000 007A2000 007B0000 007B0000 007B1000 00830000 699D0000 699D0000 699D1000 映射 空闲 私有 私有 保留 空闲 映像 映像 映像 8192 57 344 524 288 4096 520 192 1 763 311 616 45 056 4096 16 384 313 第 13章 Windows的内存结构计计 (续) 块 保护属性 描述 1 -R-- \ D e v i c e \ H a r d d i s k Vo l u m e l \ W I N N T \ s y s t e m 3 2 \ s o r t k e y. n l s -R-- --- 1 -R-- \ D e v i c e \ H a r d d i s k Vo l u m e l \ W I N N T system32\sorttbls.nls -R-- --- 4 ER-- ER-- --- ER-- --- ER-- --- ER-- --- 5 ERWC C:\CD\x86\Debug\14 VMMap.exe -R-- --- ER-- --- -R-- --- -RW- --- -R-- --- 1 -R-- -R-- --- 2 ER-- ER-- --- ER-- --- 1 - RW- -RW- --- 1 - RW- -RW- --- 2 - RW- -RW- --- -RW- --- 1 -R-- \ D e v i c e \ H a r d d i s k Vo l u m e l \ W I N N T \ system32\ctype.nls -R-- --- 2 - RW- -RW- --- -RW- --- 4 ERWC C:\WINNT\System32\PSAPI.dll -R-- --- ER-- --- 314计计第三部分 内 存 管 理 基地址 类型 大小 块 699D5000 映像 16 384 699D9000 映像 8192 699DB000 空闲 238 505 984 77D50000 映像 450 560 4 77D50000 映像 4096 77D51000 映像 421 888 77DB8000 映像 4096 77DB9000 映像 20 480 77DBE000 空闲 8192 77DC0000 映像 344 064 5 77DC0000 映像 4096 77DC1000 映像 307 200 77E0C000 映像 4096 77E0D000 映像 4096 77E0E000 映像 24 576 77E14000 空闲 49 152 77E20000 映像 401 408 4 77E20000 映像 4096 77E21000 映像 348 160 77E76000 映像 4096 77E77000 映像 45 056 77E82000 空闲 57 344 77E90000 映像 720 896 5 77E90000 映像 4096 77E91000 映像 368 640 77EEB000 映像 8192 77EED000 映像 4096 77EEE000 映像 335 872 77F40000 映像 241 664 4 77F40000 映像 4096 77F41000 映像 221 184 77F77000 映像 4096 77F78000 映像 12 288 77F7B000 空闲 20 480 77F80000 映像 483 328 5 77F80000 映像 4096 77F81000 映像 299 008 77FCA000 映像 8192 77FCC000 映像 4096 77FCD000 映像 167 936 77FF6000 空闲 40 960 78000000 映像 290 816 6 78000000 映像 4096 78001000 映像 208 896 78034000 映像 32 768 7803C000 映像 12 288 7803F000 映像 16 384 下载 保护属性 -RWC ---R-- --- (续) 描述 ERWC -R-- --ER-- ---RW- ---R-- --- C : \ W I N N T \ s y s t e m 3 2 \ R P C RT 4 . D L L ERWC -R-- --ER-- ---RW- ---RWC ---R-- --- C:\WINNT\system32\ ADVAPI32.dll ERWC -R-- --ER-- ---RW- ---R-- --- C:\WINNT\system32\USER32.dll ERWC -R-- --ER-- ---RW- ---RWC ---R-- --ERWC -R-- --ER-- ---RW- ----R-- --- C:\WINNT\system32\KERNEL32.dll C:\WINNT\system32\GDI32.DLL ERWC -R-- --ER-- ---RW- ---RWC ---R-- --- C:\WINT\system32\ntdll.dll ERWC -R-- --ER-- ---R-- ---RW- --RWC- --- C : \ W I N N T \ s y s t e m 3 2 \ M S V C RT. d l l 下载 315 第 13章 Windows的内存结构计计 (续) 基地址 类型 大小 块 保护属性 描述 78043000 映像 16 384 78047000 空闲 124 424 192 7F6F0000 映射 1 048 576 2 7F6F0000 映射 28 672 7F6F7000 保留 1 019 904 7F7F0000 空闲 8 126 464 7FFB0000 映射 147 456 1 7FFB0000 映射 147 456 7FFD4000 空闲 40 960 7FFDE000 私有 4096 1 7FFDE000 私有 4096 7FFDF000 私有 4096 1 7FFDF000 私有 4096 7FFE0000 私有 65 536 2 7FFE0000 私有 4096 7FFE1000 保留 61 440 -R-- --- ER-ER-- --ER-- --- -R--R-- --- E RWERW- --E RWERW- ---R--R-- ---R-- --- 当然,空闲区域根本不会扩展,因为它们里面没有已经提交的内存页面。每个内存块的行 显示4列,下面介绍它们的具体情况。 第一列显示一组页面的地址,这些页面具有相同的状态和保护属性。例如,具有只读保护属 性的内存的单个页面(4096字节)被提交的地址是0x77E20000。在地址0x77E21000上,有一个85 页(348 160字节)的已提交内存块,它具有执行和读保护特性。如果这两个内存块具有相同的 保护属性,那么它们就被组合起来,在内存表中显示为一个86个页面(352 256字节)的项目。 第二列显示的是何种类型的物理存储器支持保留区域中的内存块。这一列中可以出现 5个 值中的一个。这 5个值是空闲,私有,映射,映像和保留。如果这个值是私有、映射或映像, 则表示内存块是分别受页文件、数据文件或加载的 .exe或DLL文件中的物理存储器支持的。如 果这个值是空闲或保留,那么该内存块根本没有任何物理存储器的支持。 大多数情况下,相同类型的物理存储器支持单个区域中的所有提交的内存块。但是单个区 域中不同的已提交内存块可以受不同类型的物理存储器的支持。例如,内存映射的文件映像可 以受 . e x e 或 D L L 文件的支持。如果要在该区域中写入拥有 PA G E _ W R I T E C O P Y 或 PAGE_EXECUTE_WRITECOPY保护属性的单个页面,那么系统就会使你的进程成为一个由页 文件而不是文件映像支持的私有页面拷贝。这个新页面拥有的属性将与没有 copy_on_write保护 属性的原始页面相同。 第三列显示了地址空间块的大小。一个区域中的所有地址空间块都是相邻的,块与块之间 没有任何空隙。 第四列显示保留区域中的块的数量。 第五列显示块的保护属性和保护属性标志。块的保护属性优先于包含该块的区域的保护属 性。块使用的保护属性与区域的保护属性相同,但是,与区域不关联的保护属性标志 PAGE_GUARD、PAGE_NOCACHE和PAGE_WRITECOMBINE可以与块相关联。 13.7.2 与Windows 98地址空间的差别 表13-6显示了在 Windows 98下运行相同的 VMMAP程序时的地址空间表。为了节省篇幅, 316计计第三部分 内 存 管 理 下载 我们没有显示0x80018000至0x85620000之间的虚拟地址。 表13-6 显示Windows 98 下地址空间区域内块的示例地址空间表 基地址 类型 大小 块 00000000 空闲 4 194 304 00400000 私有 131 072 6 00400000 私有 8192 00402000 私有 8192 00404000 私有 73 728 00416000 私有 8192 00418000 私有 8192 0041A000 保留 24 576 00420000 私有 1 114 112 4 00420000 私有 20 480 00425000 保留 1 028 096 00520000 私有 4096 00521000 保留 61440 00530000 私有 65 536 2 00530000 私有 4096 00531000 保留 61 440 00540000 私有 1 179 648 6 00540000 保留 942 080 00626000 私有 4096 00627000 保留 24576 0062D000 私有 4096 0062E000 私有 139 264 00650000 保留 65 536 00660000 私有 1 114 112 4 00660000 私有 20 480 00665000 保留 1 028 096 00760000 私有 4096 00761000 保留 61 440 00770000 私有 1 048 576 2 00770000 私有 32 768 00778000 保留 1 015 808 00870000 空闲 2 004 418 560 7800000 私有 2 6 2 11 4 3 78000000 私有 188 416 7802E000 私有 57 344 7803C000 私有 16 384 78040000 空闲 133 955 584 80000000 私有 4096 1 80000000 保留 4096 80001000 私有 4096 1 80001000 私有 4096 80002000 私有 4096 1 80002000 私有 4096 80003000 私有 4096 1 80003000 私有 4096 80004000 私有 65 536 2 保护属性 描述 ----R-- ---RW- ---R-- ---RW- ---R-- ------ ------RW- ------ ---RW- ------ --- RW-RW- ---RW- --------- ---RW- ------ ------ ---RW- ------ ------RW- ------ ---RW- ------ --- RW-RW- ---RW- --- C:\CD\X86\DEBUG\14 VMMAP.EXE 线程堆栈 ----R-- ---RW- ---R-- --- C:\WINDOWS\SYSTEM\MSVCRT.DLL ------- ------RW- ------RW- ------RW- ------ 下载 基地址 类型 大小 80004000 8000C000 80014000 80014000 80015000 80015000 80016000 80016000 80017000 80017000 85620000 85F72000 85F72000 85F97000 85F97000 85FE7000 874EF000 874EF000 878EF000 B00B0000 B00B0000 B00E9000 B00EE000 B0187000 BAAA0000 BAAA0000 BAAA1000 BAAA2000 BAADD000 BAADE000 BAADF000 BAAE7000 BAAED000 BFDE0000 BFDE0000 BFDE5000 BFDF0000 BFDF0000 B F D FA 0 0 0 BFDFB000 BFE00000 BFE20000 BFE20000 BFE22000 BFE23000 BFE24000 BFE60000 私有 保留 私有 私有 私有 私有 私有 私有 私有 私有 空闲 私有 私有 私有 私有 空闲 私有 保留 空闲 私有 私有 私有 私有 空闲 私有 私有 私有 私有 私有 私有 私有 私有 空闲 私有 私有 空闲 私有 私有 私有 私有 空闲 私有 私有 私有 私有 空闲 私有 32 768 32 768 4096 4096 4096 4096 4096 4096 4096 4096 9 773 056 151 552 151 552 327 680 327 680 22 052 864 4194304 4 194 304 679 219 200 880 640 233 472 20 480 626 288 177 311 744 315 392 4096 4096 241 664 4096 4096 32 768 24 576 86 978 560 20 480 20 480 45 056 65 536 40 960 4096 20 480 131 072 16 384 8192 4096 4096 245 760 24 576 317 第 13章 Windows的内存结构计计 块 保护属性 -RW- --- ---- --- 1 ---- -RW- --- 1 ---- -RW- --- 1 ---- -RW- --- 1 ---- -RW- ---- 1 ---- -R-- --- 1 ---- -R-- --- 1 ---- ---- --- 3 ---- -R-- --- -RW- --- -R-- --- 7 ---- -R-- --- -RW- --- -R-- --- -RW- --- -R-- --- -RW- --- -R-- --- 1 ---- -R-- --- 3 ---- -R-- --- -RW- --- -R-- --- 3 ---- -R-- --- -RW- --- -R-- --- 3 ---- (续) 描述 318计计第三部分 内 存 管 理 下载 基地址 类型 大小 块 BFE60000 BFE62000 BFE63000 BFE66000 BFE70000 BFE70000 BFE72000 BFE73000 BFE76000 BFE80000 BFE80000 BFE8C000 BFE8D000 BFE90000 BFE90000 BFEF8000 BFEF9000 BFF1C000 BFF20000 BFF20000 BFF3F000 BFF41000 BFF42000 BFF43000 BFF46000 BFF50000 BFF50000 BFF5D000 BFF5E000 BFF61000 BFF70000 BFF70000 BFFC6000 BFFC9000 BFFCD000 BFFE3000 BFFFF000 私有 私有 私有 空闲 私有 私有 私有 私有 空闲 私有 私有 私有 私有 私有 私有 私有 私有 空闲 私有 私有 私有 私有 私有 私有 空闲 私有 私有 私有 私有 空闲 私有 私有 保留 私有 私有 保留 空闲 8192 4096 12 288 40 960 24 576 3 8192 4096 12 288 40 960 65 536 3 49 152 4096 12 288 573 440 3 425 984 4096 143 360 16 384 155 648 5 126 976 8192 4096 4096 12 288 40 960 69 632 3 53 248 4096 12 288 61 440 585 728 5 352 256 12 288 16 384 90 112 114 688 4096 保护属性 -R-- ---RW- ---R-- --- (续) 描述 ----R-- ---RW- ---R-- --- ----R-- ---RW- ---R-- ------R-- ---RW- ---R-- --- C:\WINDOWS\SYSTEM\ADVAPI32.DLL ----R-- ---RW- ---R-- ---RW- ---R-- --- C:\WINDOWS\SYSTEM\GDI32.DLL ----R-- ---RW- ---R-- --- C:\WINDOWS\SYSTEM\USER32.DLL ----R-- ------ ---RW- ---R-- ------ --- C:\ W I N D O W S \ S Y S T E M \ K E R N E L 3 2 . D L L 两个地址空间表的最大不同是在 Windows 98下缺少了某些的信息。例如,每个区域和块能 反映出地址空间的区域是空闲、保留还是私有的。你决不会看到映射或者映像之类的字样,因 为Windows 98没有提供更多的信息来指明支持该区域的物理存储器的是个内存映射文件还是包 含在.exe或DLL中的文件映像。 你会发现大多数地址空间区域的大小是分配粒度( 64KB)的倍数。如果包含在地址空间 区域中的块的大小不是分配粒度的倍数,那么在地址空间区域的结尾处常常有一个保留的地址 空间块。这个地址空间块的大小必须使得地址空间区域能够符合分配粒度边界( 64KB)倍数 的要求。例如,从地址 0x00530000开始的地址空间区域包含两个地址块,一个是 4 KB的已提 下载 319 第 13章 Windows的内存结构计计 交内存块,另一个是占用 60 KB内存地址范围的已保留的地址块。 最后,保护标志从来不反映执行或 copy-on-write访问权,因为Windows 98不支持这些标志。 它也不支持 3个保护属性标志,即 PAGE_NOCACHE 、PAGE_WRITECOMBINE和 PAGE_GUARD。由于不支持PAGE_GUARD标志,因此 VMMap使用更加复杂的技术来确定是 否已经为线程的堆栈保留了地址空间区域。 你将注意到,与Windows 2000不同,在Windows 98中,0x80000000至0xBFFFFFFF之间的 地址空间区域是可以查看的。这个分区包含了所有 32位应用程序共享的地址空间。如你所见, 有4个系统DLL被加载了这个地址空间区域,可以供所有进程使用。 13.8 数据对齐的重要性 本节不再讨论进程的虚拟地址空间问题,而是要介绍数据对齐的重要性。数据对齐并不是 操作系统的内存结构的一部分,而是 CPU结构的一部分。 当CPU访问正确对齐的数据时,它的运行效率最高。当数据大小的数据模数的内存地址是 0时,数据是对齐的。例如, WORD值应该总是从被2除尽的地址开始,而 DWORD值应该总是 从被4除尽的地址开始,如此等等。当 CPU试图读取的数据值没有正确对齐时, CPU可以执行 两种操作之一。即它可以产生一个异常条件,也可以执行多次对齐的内存访问,以便读取完整 的未对齐数据值。 下面是访问未对齐数据的某个代码: 显然,如果CPU执行多次内存访问,应用程序的运行速度就会放慢。在最好的情况下,系 统访问未对齐的数据所需要的时间将是访问对齐数据的时间的两倍,不过在有些情况下,访问 时间可能更长。为了使应用程序获得最佳的运行性能,编写的代码必须使数据正确地对齐。 下面让我们更加深入地说明 x86 CPU是如何进行数据对齐的。 X86 CPU的EFLAGS寄存器 中包含一个特殊的位标志,称为 AC(对齐检查的英文缩写)标志。按照默认设置,当 CPU首 次加电时,该标志被设置为 0。当该标志是 0时,CPU能够自动执行它应该执行的操作,以便成 功地访问未对齐的数据值。然而,如果该标志被设置为 1,每当系统试图访问未对齐的数据时, CPU就会发出一个 INT 17H中断。x86的Windows 2000和Windows 98版本从来不改变这个 CPU 标志位。因此,当应用程序在 x86处理器上运行时,你根本看不到应用程序中出现数据未对齐 的异常条件。 现在让我们来看一看 Alpha CPU的情况。Alpha CPU不能自动处理对未对齐数据的访问。 320计计第三部分 内 存 管 理 下载 当未对齐的数据访问发生时, CPU就会将这一情况通知操作系统。这时, Windows 2000将会确 定它是否应该引发一个数据未对齐异常条件。它也可以执行一些辅助指令,对问题默默地加以 纠正,并让你的代码继续运行。按照默认设置,当在 Alpha计算机上安装Windows 2000时,操 作系统会对未对齐数据的访问默默地进行纠正。然而,可以改变这个行为特性。当引导 Windows 2000时,系统就会在注册表中查找的这个关键字: 在这个关键字中,可能存在一个值,称为 EnableAlignmentFaultExceptions。如果这个值不 存在(这是通常的情况),Windows 2000 会默默地处理对未对齐数据的访问。如果存在这个值, 系统就能获取它的相关数据值。如果数据值是 0,系统会默默地进行访问的处理。如果数据值 是1,系统将不执行默默的处理,而是引发一个未对齐异常条件。几乎从来都不需要修改该注 册表值的数据值,因为如果修改有些应用程序能够引发数据未对齐的异常条件并终止运行。 为了更加容易地修改该注册表项。 Alpha处理器上运行的 Microsoft Visual C++版本包含了 一个小型实用程序 AXPAlign.exe。AXPAlign的用法如下面所示: 该实用程序只是修改注册表值的状态,或者显示值的当前状态。当用该实用程序修改数据 值后,必须重新引导操作系统,使所做的修改生效。 如果不使用 AXPAlign实用程序,仍然可以让系统为进程中的所有线程默默地纠正对未对 齐数据的访问,方法是让进程的线程调用 SetErrorMode函数: 就我们的讨论来说,需要说明的标志是 SEM_NOALIGNMENTFAULTEXCEPT标志。当该 标志设定后,系统会将自动纠正对未对齐数据的访问。当该标志重新设置时,系统将不纠正对 未对齐数据的访问,而是引发数据未对齐异常条件。注意,修改该标志将会影响拥有调用该函 下载 321 第 13章 Windows的内存结构计计 数的线程的进程中包含的所有线程。换句话说,改变该标志不会影响其他进程中的任何线程。 还要注意,进程的错误方式标志是由所有的子进程继承的。因此,在调用 CreateProcess函数之 前,必须临时重置该标志(不过通常不必这样做)。 当然,无论在哪个 CPU平台上运行,都可以调用 SetErrorMode函数,传递 SEM_ NOALIGNMENTFAULTEXCEPT标志。但是,结果并不总是相同。如果是 x86系统,该标志总 是打开的,并且不能被关闭。如果是 Alpha系统,那么只有当EnableAlignmentFault Exceptions 注册表值被设置为 1时,才能关闭该标志。 可以使用Windows 2000的MMC Performance Monitor来查看每秒钟系统执行多少次数据对 齐的调整修改。图 13-4显示了在将该计数器添加给图表之前, Add Counter(添加计数器)对话 框是个什么样子。 图13-4 Add Counters 对话框 该计数器显示的是每秒钟 CPU通知操作系统的未对齐数据访问的次数。如果监视 x86计算 机上的这个计数器,你会看到它总是报告每秒钟为 0次数据对齐的调整。这是因为 x86 CPU本 身正在进行调整,因此没有通知操作系统。由于是 x86 CPU而不是操作系统来进行这种调整, 因此访问x86计算机上的未对齐数据对性能产生的影响并不像需要用软件( Windows 2000操作 系统代码)来进行数据对齐调整的 CPU那样大。 可以看到,只需要调用 SetErrorMode函数便足以使你的应用程序能够正确运行。但是这个 解决方案肯定不是效率最高的方案。实际上, Digital 出版社出版的《 Alpha Architecture Reference Manual》手册上讲到,系统默默纠正未对齐数据访问的仿真代码运行时所花费的时 间相当于普通情况下的 100倍。这是个相当大的开销。不过有一种更加有效的解决方案可以解 决你的问题。 Microsoft用于Alpha CPU的C/C++编译器支持一个特殊的关键字,称为 __unaligned。可以 像使用const或volatile修改符那样使用 __unaligned修改符,差别在于 __unaligned修改符只有在 用于指针变量时才起作用。当通过未对齐指针来访问数据时,编译器就会生成一个代码,该代 码假设数据没有正确对齐,因此添加一些访问数据时必须使用的辅助 CPU指令。下面显示的代 码是前面已经讲过的代码的修改版。这个新版本利用了关键字 __unaligned。 322计计第三部分 内 存 管 理 下载 当我对Alpha计算机上运行的下面这行代码进行编译时,生成了 7个CPU指令: 然而,如果我从这行代码中删除 __unaligned关键字并进行编译,那么只生成 3个CPU指令。 可以看到,在Alpha CPU上使用__unaligned关键字,生成的CPU指令要多两倍以上。编译器添 加的指令比CPU跟踪未对齐数据的访问并让操作系统来纠正未对齐数据的效率要高得多。实际 上,如果监控Alignment Fixup/sec计数器,你将发现通过未对齐指针进行的访问对图表上显示 的数据没有什么影响。 最后要说明的是, x86 CPU上运行的Visual C/C++编译器不支持__unaligned关键字。我想 Microsoft公司也许认为这没有必要,因为 CPU本身进行未对齐数据的纠正速度很快。但是,这 也意味着 x86编译器在遇到 __unaligned关键字时就会产生错误。因此,如果打算为应用程序创 建单个基本源代码,就必须使用 UNALIGNED宏,而不是__unaligned关键字。在WinNT.h文件 中,UNALIGNED宏定义为下面的形式: 下载 第14章 虚 拟 内 存 上一章介绍了系统如何管理虚拟内存,每个进程如何获得它自己的私有地址空间,进程的 地址空间是个什么样子等内容。这一章不再介绍抽象的概念,而要具体介绍几个 Windows函数, 这些函数能够提供关于系统内存管理以及进程中的虚拟地址空间等信息。 14.1 系统信息 许多操作系统的值是根据主机而定的,比如页面的大小,分配粒度的大小等。这些值决不 应该用硬编码的形式放入你的源代码。相反,你始终都应该在进程初始化的时候检索这些值, 并在你的源代码中使用检索到的值。 GetSystemInfo函数将用于检索与主机相关的值: 必须传递 SYSTEM_INFO结构的地址给这个函数。这个函数将初始化所有的结构成员然后 返回。下面是SYSTEM_INFO数据结构的样子。 当系统引导时,它要确定这些成员的值是什么。对于任何既定的系统来说,这些值总是相 同的,因此决不需要为任何既定的进程多次调用该函数。由于有了 GetSystemInfo函数,因此 应用程序能够在运行的时候查询这些值。在该结构的所有成员中,只有 4个成员与内存有关。 表14-1对这4个成员作了描述。 表14-1 与内存有关的成员函数 成员名 dwPageSize lpMinimumApplicationAddress 描述 用于显示CPU的页面大小。在x86 CPU上,这个值是4096字节。在 Alpha CPU 上,这个值是8192字节。在IA-64上,这个值是8192字节 用于给出每个进程的可用地址空间的最小内存地址。在 Windows 98 上,这个值是 4 194 304,或0x00400000,因为每个进程的地址空间 中下面的 4MB是不能使用的。在 Windows 2000上,这个值是 65 536 或0x00010000,因为每个进程的地址空间中开头的 64KB总是空闲的 324计计第三部分 内 存 管 理 下载 成员名 lpMaximumApplicationAddress dwAllocationGranularity (续) 描述 用于给出每个进程的可用地址空间的最大内存地址。在 Windows 98 上,这 个地址是2 147 483 647或0x7FFFFFFF,因为共享内存映射文件区域和共享 操作系统代码包含在上面的 2 GB 分区中。在 Windows 2000上,这个地址是 内核方式内存开始的地址,它不足 64KB 显示保留的地址空间区域的分配粒度。截止到撰写本书时,在所有 Windows平台上,这个值都是 65 536 该结构的其他成员与内存管理毫无关系,为了完整起见,下面也对它们进行了介绍(见表 14-2)。 表14-2 与内存无关的成员函数 成员名 dwOemId WRederved dwNumberOfProcessors dwActiveProcessorMask d w P r o c e s s o r Ty p e wProcessorArchitecture wProcessorLevel wProcessorRevision 描述 已作废,不引用 保留供将来使用,不引用 用于指明计算机中的 CPU数目 一个位屏蔽,用于指明哪个 CPU是活动的(允许运行线程) 只用于Windows 98,不用于 Windows 2000,用于指明处理器的类型,如 Intel 386、486或Pentium 只用于Windows 2000 ,不用于Windows 98,用于指明处理的结构,如Intel、 Alpha、Intel 64 位或Alpha 64 位 只用于Windows 2000 ,不用于Windows 98 ,用于进一步细分处理器的结构, 如用于设定Intel Pentium Pro或Pentium II 只用于Windows 2000 ,不用于Windows 98,用于进一步细分处理器的级别 系统信息示例应用程序 清单14-1显示的SysInfo应用程序(“14 SysInfo.exe”)是一个简单的调用 GetSystemInfo函 图14-1 在x86 CPU上运行Windows 98 时返回的结果 图14-2 在x86 CPU上运行32位Windows 2000 时返回的结果 图14-3 在Alpha CPU上运行32位Windows 2000 时返回的结果 图14-4 在Alpha CPU上运行64位Windows时 返回的结果 下载 325 第 14章 虚 拟 内 存计计 数的程序,它显示 SYSTEM_INFO结构中返回的信息。该应用程序的源代码和资源文件均位于 本书所附光盘上的14-SysInfo目录下。图14-1、图14-2、图14-3和图14-4所示的两个对话框显示 了在若干不同平台上运行的 SysInfo应用程序返回的结果。 清单14-1 SysInfo应用程序 326计计第三部分 内 存 管 理 下载 下载 327 第 14章 虚 拟 内 存计计 328计计第三部分 内 存 管 理 下载 下载 329 第 14章 虚 拟 内 存计计 330计计第三部分 内 存 管 理 下载 14.2 虚拟内存的状态 Windows函数GlobalMemoryStatus可用于检索关于当前内存状态的动态信息: 我认为这个函数的名字起的并不好,因为 GlobalMemoryStatus表示这个函数多少与 16位 下载 331 第 14章 虚 拟 内 存计计 Windows中的全局堆栈有些相关。我认为 GlobalMemoryStatus应该改为VirtualMemoryStatus比 较好些。 当调用GlobalMemoryStatus时,必须传递一个 MEMORYSTATUS结构的地址。下面显示了 M O M O RY S TAT U S的数据结构。 在调用GlobalMemoryStatus之前,必须将dwLength成员初始化为用字节表示的结构的大小,即 一个MEMORYSTATUS结构的大小。这个初始化操作使得 Microsoft能够将成员添加给将来的 Windows版本中的这个结构,而不会破坏现有的应用程序。当调用GlobalMemoryStatus时,它将对该 结构的其余成员进行初始化并返回。下一节中的VMStat示例应用程序将要描述各个成员及其含义。 如果希望应用程序在内存大于 4GB的计算机上运行,或者合计交换文件的大小大于 4GB, 那么可以使用新的 GlobalMemoryStatusEx函数: 必须给该函数传递新的 MEMORYSTATUSEX结构的地址: 这个结构与原先的 MEMORYSTATUS结构基本相同,差别在于新结构的所有成员的大小都 是64位宽,因此它的值可以大于 4GB。最后一个成员是 ullAvailExtendedVirtual,用于指明在调 用进程的虚拟地址空间的极大内存( VLM)部分中未保留内存的大小。该 VLM部分只适用于 某些配置中的某些 CPU结构。 虚拟内存状态示例应用程序 清单14-2列出的VMStat应用程序显示了一个简单的对话框,用于列出调用 GlobalMemory S t a t u s函数的结果。对话框中的信息每秒钟更新一次,因此,你可以在对系统中的其他进程进 行操作时,使应用程序继续运行。该应用程序的源代码和资源文件均位于本书所附光盘上的 14-VMStat目录下。图14-5显示了在内存为 128 MB的Intel Pentium II计算机上的Windows 2000 下运行该程序的结果。 dwMemoryLoad成员(图 14-5中显示为 Memory Load)给出了内存管理系统的大致繁忙程 332计计第三部分 内 存 管 理 下载 度。该数字的范围是 0至100。计算这个值时使用的具体算法 在Windows 98与Windows 2000上是不同的。根据将来的操作 系统版本的情况,该算法还会有所变化。实际上,该成员变 量报告的值是没有什么用处的。 dwTotalPhys成员(图14-5中显示为TotalPhys)用于指明 存在的物理存储器( EAM)的总字节数。在 128 MB 的 Pentium II计算机上,这个值是 133 677 056,它只比128 MB 图14-5 VMStat 运行结果显示 少540 672 个字节。GlobalMemoryStatus之所以不报告全部的128MB,原因是在引导进程中,系 统将一些内存保留为非页面内存池。这些内存甚至不能被内核使用。 dwAvailPhys成员(图145中显示为AvailPyhs)用于指明可供分配的物理存储器的总字节数。 dwTotalPageFile成员(图 14-5中显示为 TotalPageFile)用于指明你的硬盘上的页文件中包 含的最大字节数。虽然 VMStat报告的页文件当前是 318 574 592字节,但是系统可以根据需要 对页交换文件进行扩大和压缩。 dwAvailPageFile成员(图14-5中显示为AvailPageFile)用于指 明页文件中有233 046 016字节尚未提交给任何进程,因此,如果一个进程决定提交任何私有内 存的话,目前就可以使用这些字节。 dwTotalVirtual成员(图14-5中显示为TotalVitual)用于指明每个进程的地址空间中私有的 总字节数。它的值是2 147 352 576,比准确的2 GB少128 KB。从0x00000000至0x0000FFFF以 及从0x7FFF0000至0x7FFFFFFF的两个分区是不能访问的地址空间,这正好等于 128 KB这个差 额。如果在 Windows 98下运行VMStat,你将看到dwTotalVirtual返回的值是 2 143 289 344,这 比准确的2 GB只少4 MB。之所以差4 MB,原因是系统决不允许应用程序访问从 0x00000000至 0x003FFFFF之间的这个4 MB分区。 最后一个成员 dwAvailVirtual(图14-5中显示为 AvailVirtual)是该结构中专门用于调用 GlobalMemoryStatus的进程的唯一成员,所有其他成员在系统中的使用情况都是一样的,而不 管是哪个进程调用 GlobalMemoryStatus。若要计算这个值, GlobalMemoryStatus将调用进程的 地址空间中的所有空闲区域相加。 dwAvailVirtual的值2 136 846 336表示可供VMStat随意使用 的空闲地址空间的数量。如果将 dwTotalVirtual的值减去dwAvailVirtual的值,你会看到 VMStat 在它的虚拟地址空间中保留了 10 506 240个字节。 没有一个成员能够指明进程当前使用的物理存储器的数量。 清单14-2 VMStat应用程序 下载 333 第 14章 虚 拟 内 存计计 334计计第三部分 内 存 管 理 下载 下载 335 第 14章 虚 拟 内 存计计 336计计第三部分 内 存 管 理 下载 14.3 确定地址空间的状态 Windows提供了一个函数,可以用来查询地址空间中内存地址的某些信息(如大小,存储 器类型和保护属性等)。实际上本章后面显示的 VMMap示例应用程序就使用这个函数来生成第 13章所附的虚拟内存表交换信息。这个函数称为 VirtualQuery: Windows还提供了另一个函数,它使一个进程能够查询另一个进程的内存信息: 这两个函数基本相同,差别在于使用 VirtualQueryEx时,可以传递你想要查询的地址空间 信息的进程的句柄。调试程序和其他实用程序使用这个函数最多,几乎所有的应用程序都只需 要调用VirtualQuery函数。当调用 VirtualQuery(Ex)函数时,pvAddress参数必须包含你想要 查询其信息的虚拟内存地址。 Pmbi参数是你必须分配的 MEMORY_BASIC_INFORMATION结 构的地址。该结构在 WinNT.h 文件中定义为下面的形式: 最后一个参数是 dwLength,用于设定 MEMORY_BASIC_INFORMATION结构的大小。 VirtualQuery(Ex)函数返回拷贝到缓存中的字节的数量。 根据在pvAddress参数中传递的地址, VirtualQuery(Ex)函数将关于共享相同状态、保护 属性和类型的相邻页面的范围信息填入 MEMORY_BASIC_INFORMATION结构中。表 14-3描 述了该结构的成员。 下载 337 第 14章 虚 拟 内 存计计 表14-3 MEMORY_BASIC_INFORMATION结构的成员函数 成员名 BaseAddress AllocationBase AllocationProtect RegionSize State Protect Ty p e 描述 与pvAddress参数的值相同,但是四舍五入为页面的边界值 用于指明包含在 pvAddress参数中设定的地址区域的基地址 用于指明一个地址空间区域被初次保留时赋予该区域的保护属性 用于指明从基地址开始的所有页面的大小(以字节为计量单位) 这些页面与含有用 pvSddress参数设定的地址的页面拥有相同的保护属性、 状态和类型 用于指明所有相邻页面的状态( MEM_FREE、 MEM_RESERVE或 MEM_COMMIT)。这些页面与含有用 pvAddress参数设定的地址的页面拥有 相同的保护属性、状态和类型 如果它的状态是空闲,那么 AllocationBase、AllocationProtect、Protect和 Ty p e等成员均未定义 如果状态是 MEM_RESERVE,则Protect成员未定义 用于指明所有相邻页面的保护属性( PAGE_*)。这些页面与含有用 p v A d d r e s s参数设定的地址的页面拥有相同的保属性、状态和类型 用于指明支持所有相邻页面的物理存储器的类型( MEM_IMAGE, MEM_MAPPED或MEM_PRIVATE)。这些相邻页面与含有用 pvAddress参数 设定的地址的页面拥有相同的保护属性、状态和类型。如果是 Windows 98, 那么这个成员将总是 MEM_PRIVATE 14.3.1 VMQuery函数 当我刚刚开始学习 Windows的内存结构的时候,我将 VirtualQuery函数用作我的学习指导。 实际上,如果观察本书的第 1版(原英文版第1版),你会发现 VMMap示例应用程序比本书下一 节中介绍的程序简单得多。在老版本中,我使用了一个简单的循环,它反复调用 VirtualQuery 函数,而每次调用时,我只是创建一个简单的代码行,它包含了 M E M O RY _ B A S E _ INFORMATION结构的各个成员。我研究了它的交换方式,并且在参考 SDK文档(当时这个文 档很不完善)时将内存管理结构组合在一起。从此我学到了许多知识。虽然 VirtualQuery和 MEMORY_BASIC_ INFORMATION结构使你能够深入了解许多关于内存的情况,但是现在它 们无法为你提供使你真正了解全部情况的足够信息。 问题是MEMORY_BASIC_INFORMATION结构无法返回系统已经存放在内部的所有信息。 如果你有一个内存地址,想获得关于该地址的某些简单的信息,那么使用 VirtualQuery函数的 效果是相当不错的。如果只是想知道是否给一个地址提交了物理存储器,或者是否可以向一个 内存地址读取或写入信息,那么 VirtualQuery函数的作用也很好。但是,如果想知道已保留的 地址空间区域的合计大小,或者想要知道一个区域中的地址空间块的数量,或者想知道一个区 域是否包含线程堆栈,那么仅仅一次调用 VirtualQuery将无法为你提供你想知道的信息。 为了获得完整的内存信息,我创建了一个函数,即 VMQuery: 该函数与VirtualQueryEx有些类似,它拥有一个进程句柄(在 hProcess中),一个内存地址 (在 p v A d d r e s s 中)和一个指向将被填充的结构的指针(由 p V M Q 设定)。该结构是个 VMQUERY结构,下面是结构的定义: 338计计第三部分 内 存 管 理 下载 只要简单地看一眼就会发现,我的 VMQUERY结构比 Windows的MEMORY_BASIC_ INFORMATION结构包含了多得多的信息。我的结构分成两个不同的部分:一个是区域信息, 另一个是块信息。区域信息部分用于描述关于区域的信息,块信息部分用于描述关于包含用 pvAddress参数设定的地址块的信息。表 14-4对所有的成员进行了说明。 表14-4 VMQUERY 函数的成员变量 成员名 描述 pvRgnBaseAddress dwRgnProtection RgnSize dwRgnStorage dwRgnBlocks dwRgnGuardBlks fRgnIsAStack pvBlkBaseAddress dwBlkProtection BlkSize dwBlkStorage 指明虚拟地址空间区域的基地址,该区域包含了用 pvAddress参数设定的地址 指明地址空间区域刚刚被保留时赋予该区域的保护属性( PAGE_*) 指明已经保留的地址空间区域的大小(以字节为计量单位) 指明区域中的地址空间块主体使用的物理存储器的类型。该值可以是下列几 个值之一: MEM_FREE、MEM_IMAGE、MEM_MAPPED或MEM_PRIVATE。 Windows 98 不区分不同的内存类型,因此在 Windows 98 下本成员将总是 MEM_FREE或MEM_PRIVATE 指明地址空间区域中包含的地址块的数量 指明PAGE_GUARD保护属性标志已经打开的地址块的数量。该值通常既可 以是0,也可以是 1。如果是 1,则表示该区域已经被保留,以包含一个线程堆 栈。在Windows 98下,这个成员的值总是 0 指明一个区域是否包含线程堆栈。该值是通过“优化推测”来确定,因为不 可能有百分之百的把握知道一个区域是否包含线程堆栈 指明包含用pvAddress参数设定的地址块的基地址 指明包含用pvAddress参数设定的地址块的保护属性 指明包含用pvAddress参数设定的地址块的大小(以字节为计量单位) 指明包含用 pvAddress参数设定的地址块的内容。这个值可以是下列值之一: M E M _ F R E E 、 M E M _ R E S E RV E 、 M E M _ I M A G E、 M E M _ M A P P E D 或 MEM_PRIVATE。在 Windows 98 下,这个值决不能是 MEM_IMAGE或 MEM_MAPPED 毫无疑问, VMQuery函数必须执行相当数量的处理操作,包括多次调用 VirtualQueryEx, 以便获取所有的信息,这意味着它的运行速度要大大低于 VirtualQueryEx函数。由于这个原因, 在决定调用这两个函数中的哪一个时,应该三思而后行。如果不需要通过 VMQuery获得的额外 信息,那么应该调用 VirtualQuery或VirtualQueryEx函数。 清单14-3列出的VMQuery.cpp文件显示了我如何获得和管理设置 VMQUERY结构的成员时 需要的全部信息。 VMQuery.cpp和VMQuery.h两个文件均在本书所附光盘上的 14-VMMap目录 下载 339 第 14章 虚 拟 内 存计计 下。清单 14-3中的代码注释说明了处理数据的方法。 清单14-3 VMQuery的程序清单 340计计第三部分 内 存 管 理 下载 下载 341 第 14章 虚 拟 内 存计计 342计计第三部分 内 存 管 理 下载 下载 343 第 14章 虚 拟 内 存计计 14.3.2 虚拟内存表示例应用程序 清单14-4列出的VMMap应用程序( 14 VMMap.exe)列出了一个进程的地址空间,并且显 示了各个地址空间区域和区域中的地址块。该应用程序的源代码和资源文件均位于本书所附光 盘上的14-VMMap目录下。当启动该应用程序时,就会出现图 14-6所示窗口。 我使用该应用程序的列表框的内容来生成第 13章中的表 13-3、表13-5和表13-6中显示的虚 拟内存表交换信息。该列表框中的每一项都显示了调用 VMQuery函数所获得的信息的结果。 Refresh函数中的主要循环类似下面的样子: 344计计第三部分 内 存 管 理 下载 图14-6 启动VMMap时出现的窗口 下载 345 第 14章 虚 拟 内 存计计 该循环从虚拟地址 NULL开始运行,当VMQuery函数返回FALSE时结束,表示它不再能够 通过该进程的地址空间。该循环的每个重复操作,均有一个对 ConstructRgnInfoLine函数的调 用,该函数将关于区域的信息填入一个字符缓存,然后这些信息被附加给列表框。 在这个主循环中,有一个嵌套的循环,它重复通过该地址空间区域中的每个地址块,该循 环的每次重复操作均调用 ConstructRgnInfoLine,以便用关于该区域的块的信息填入字符缓存, 然后这些信息被附加给列表框。使用 VMQuery函数,可以比较容易地通过进程的地址空间。 清单14-4 VMMap应用程序的程序清单 346计计第三部分 内 存 管 理 下载 下载 347 第 14章 虚 拟 内 存计计 348计计第三部分 内 存 管 理 下载 下载 349 第 14章 虚 拟 内 存计计 350计计第三部分 内 存 管 理 下载 下载 351 第 14章 虚 拟 内 存计计 352计计第三部分 内 存 管 理 下载 下载 353 第 14章 虚 拟 内 存计计 下载 第15章 在应用程序中使用虚拟内存 Windows提供了3种进行内存管理的方法,它们是: • 虚拟内存,最适合用来管理大型对象或结构数组。 • 内存映射文件,最适合用来管理大型数据流(通常来自文件)以及在单个计算机上运行 的多个进程之间共享数据。 • 内存堆栈,最适合用来管理大量的小对象。 本章将要介绍第一种方法,即虚拟内存。内存映射文件和堆栈分别在第 17章和第 18章介 绍。 用于管理虚拟内存的函数可以用来直接保留一个地址空间区域,将物理存储器(来自页文 件)提交给该区域,并且可以设置你自己的保护属性。 15.1 在地址空间中保留一个区域 通过调用VirtualAlloc函数,可以在进程的地址空间中保留一个区域: 第一个参数pvAddress包含一个内存地址,用于设定想让系统将地址空间保留在什么地方。 在大多数情况下,你为该参数传递 MULL。它告诉VirtualAlloc,保存着一个空闲地址区域的记 录的系统应该将区域保留在它认为合适的任何地方。系统可以从进程的地址空间的任何位置来 保留一个区域,因为不能保证系统可以从地址空间的底部向上或者从上面向底部来分配各个区 域。可以使用MEM_TOP_DOWN标志来说明该分配方式。这个标志将在本章的后面加以介绍。 对大多数程序员来说,能够选择一个特定的内存地址,并在该地址保留一个区域,这是个 非同寻常的想法。当你在过去分配内存时,操作系统只是寻找一个其大小足以满足需要的内存 块,并分配该内存块,然后返回它的地址。但是,由于每个进程有它自己的地址空间,因此可 以设定一个基本内存地址,在这个地址上让操作系统保留地址空间区域。 例如,你想将一个从50 MB开始的区域保留在进程的地址空间中。这时,可以传递52 428 800 (50×1024×1024)作为pvAddress参数。如果该内存地址有一个足够大的空闲区域满足你的要 求,那么系统就保留这个区域并返回。如果在特定的地址上不存在空闲区域,或者如果空闲区 域不够大,那么系统就不能满足你的要求, VirtualAlloc函数返回NULL。注意,为pvAddress参 数传递的任何地址必须始终位于进程的用户方式分区中,否则对 VirtualAlloc函数的调用就会失 败,导致它返回 NULL。 第13章讲过,地址空间区域总是按照分配粒度的边界来保留的(迄今为止在所有的Windows环 境下均是64KB)。因此,如果试图在进程地址空间中保留一个从19 668 992(300×65 536 + 8192) 这个地址开始的区域,系统就会将这个地址圆整为 64KB的倍数,然后保留从 19 660 800(300 ×65 536)这个地址开始的区域。 如果VirtualAlloc函数能够满足你的要求,那么它就返回一个值,指明保留区域的基地址。 下载 355 第 15章 在应用程序中使用虚拟内存计计 如果传递一个特定的地址作为 VirtualAlloc的pvAddress参数,那么该返回值与传递给 VirtualAlloc的值相同,并被圆整为(如果需要的话) 64KB边界值。 VirtualAlloc函数的第二个参数是 dwSize,用于设定想保留的区域的大小(以字节为计量单 位)。由于系统保留的区域始终必须是 CPU页面大小的倍数,因此,如果试图保留一个跨越 62KB的区域,结果就会在使用 4 KB、8 KB或16 KB 页面的计算机上产生一个跨越 64KB的区 域。 VirtualAlloc函数的第三个参数是 fdwAllocationType,它能够告诉系统你想保留一个区域还 是提交物理存储器(这样的区分是必要的,因为 VirtualAlloc函数也可以用来提交物理存储器)。 若要保留一个地址空间区域,必须传递 MEM_RESERVE标识符作为FdwAllocationType参数的 值。 如果保留的区域预计在很长时间内不会被释放,那么可以在尽可能高的内存地址上保留该 区域。这样,该区域就不会从进程地址空间的中间位置上进行保留。因为在这个位置上它可能 导致区域分成碎片。如果想让系统在最高内存地址上保留一个区域,必须为 pvAddress参数和 f d w A l l o c a t i o n Ty p e 参数传递 N U L L ,还必须逐位使用 O R 将M E M _ TO P _ D O W N标志和 MEM_RESERVE标志连接起来。 注意 在Windows 98下,MEM_TOP_DOWN标志将被忽略。 最后一个参数是 fdwProtect,用于指明应该赋予该地址空间区域的保护属性。与该区域相 关联的保护属性对映射到该区域的已提交内存没有影响。无论赋予区域的保护属性是什么,如 果没有提交任何物理存储器,那么访问该范围中的内存地址的任何企图都将导致该线程引发一 个访问违规。 当保留一个区域时,应该为该区域赋予一个已提交内存最常用的保护属性。例如,如果打 算提交的物理存储器的保护属性是 PAGE_READWRITE(这是最常用的保护属性),那么应该 用PAGE_READWRITE保护属性来保留该区域。当区域的保护属性与已提交内存的保护属性相 匹配时,系统保存的内部记录的运行效率最高。 可以使用下列保护属性中的任何一个: PAGE_NOACCESS、PAGE_READWRITE、 PA G E _ R E A D O N LY 、 PA G E _ E X E C U T E 、 PA G E _ E X E C U T E _ R E A D 或 PA G E _ E X E C U T E _ READWRITE。但是,既不能设定 PAGE_WRITECOPY属性,也不能设定 PAGE_EXECUTE_ WRITECOPY属性。如果设定了这些属性,VirtualAlloc函数将不保留该区域,并且返回 NULL。 另外,当保留地址空间区域时,不能使用保护属性标志 PAGE_GUARD,PAGE_NOCACHE或 PAGE_WRITECOMBINE,这些标志只能用于已提交的内存。 注意 Windows 98只支持PAGE_NOACCESS、PAGE_READONLY和PAGE_READWRITE 保护属性。如果试图保留使用 PAGE_EXECUTE或PAGE_EXECUTE_READ两个保护 属性的区域,将会产生一个带有 PAGE_READONLY保护属性的区域。同样,如果保 留一个使用 PAGE_EXECUTE_READWRITE保护属性的区域,就会产生一个带有 PA G E _ R E A D W R I T E保护属性的区域。 15.2 在保留区域中的提交存储器 当保留一个区域后,必须将物理存储器提交给该区域,然后才能访问该区域中包含的内存 地址。系统从它的页文件中将已提交的物理存储器分配给一个区域。物理存储器总是按页面边 界和页面大小的块来提交的。 356计计第三部分 内 存 管 理 下载 若要提交物理存储器,必须再次调用 VirtualAlloc函数。不过这次为fdwAllocationType参数 传递的是 MEM_COMMIT标志,而不是 MEM_RESERVE标志。传递的页面保护属性通常与调 用VirtualAlloc来保留区域时使用的保护属性相同(大多数情况下是 PAGE_READWRITE),不 过也可以设定一个不同的保护属性。 在已保留的区域中,你必须告诉 VirtualAlloc函数,你想将物理存储器提交到何处,以及要 提交多少物理存储器。为了做到这一点,可以在 pvAddress参数中设定你需要的内存地址,并 在dwSize参数中设定物理存储器的数量(以字节为计量单位)。注意,不必立即将物理存储器 提交给整个区域。 下面让我们来看一个如何提交物理存储器。比如说,你的应用程序是在 x86 CPU 上运行的, 该应用程序保留了一个从地址 5 242 880开始的512 KB的区域。你想让应用程序将物理存储器 提交给已保留区域的 6 KB部分,从2 KB 的地方开始,直到已保留区域的地址空间。为此,可 以调用带有 MEM_COMMIT标志的 VirtualAlloc函数,如下所示: 在这个例子中,系统必须提交8 KB 的物理存储器,地址范围从5 242 880到5 251 071 (5 242 880 + 8 KB -1字节)。这两个提交的页面都拥有 PAGE_READWRITE保护属性。保护属性只以 整个页面为单位来赋予。同一个内存页面的不同部分不能使用不同的保护属性。然而,区域中 的一个页面可以使用一种保护属性(比如 PAGE_READWRITE),而同一个区域中的另一个页 面可以使用不同的保护属性(比如 PAGE_READONLY)。 15.3 同时进行区域的保留和内存的提交 有时你可能想要在保留区域的同时,将物理存储器提交给它。只需要一次调用 VirtualAlloc 函数就能进行这样的操作,如下所示: 这个函数调用请求保留一个 99 KB的区域,并且将 99 KB的物理存储器提交给它。当系统 处理这个函数调用时,它首先要搜索你的进程的地址空间,找出未保留的地址空间中一个地址 连续的区域,它必须足够大,能够存放 100 KB(在4 KB页面的计算机上)或104 KB(在8 KB 页面的计算机上)。 系统之所以要搜索地址空间,原因是已将 pvAddress参数设定为NULL。如果为 pvAddress 设定了内存地址,系统就要查看在该内存地址上是否存在足够大的未保留地址空间。如果系统 找不到足够大的未保留地址空间, VirtualAlloc将返回NULL, 如果能够保留一个合适的区域,系统就将物理存储器提交给整个区域。无论是该区域还是 提交的内存,都将被赋予 PAGE_READWRITE保护属性。 最后需要说明的是, VirtualAlloc将返回保留区域和提交区域的虚拟地址,然后该虚拟地址 被保存在 pvMem变量中。如果系统无法找到足够大的地址空间,或者不能提交该物理存储器, VirtualAlloc将返回NULL。 当用这种方式来保留一个区域和提交物理存储器时,将特定的地址作为 pvAddress参数传 递给 V i r t u a l A l l o c 当然是可能的。否则就必须用 O R 将 M E M _ TO P _ D O W N 标志与 fdwAllocationType参数连接起来,并为 pvAddress参数传递NULL,让系统在进程的地址空间的 顶部选定一个适当的区域。 下载 357 第 15章 在应用程序中使用虚拟内存计计 15.4 何时提交物理存储器 假设想实现一个电子表格应用程序,这个电子表格为 200行 x 256列。对于每一个单元格, 都需要一个 CELLDATA结构来描述单元格的内容。若要处理这种二维单元格矩阵,最容易的方 法是在应用程序中声明下面的变量: 如果CELLDATA结构的大小是128字节,那么这个二维矩阵将需要 6 553 600(200 x 256 x 128)个字节的物理存储器。对于电子表格来说,如果直接用页文件来分配物理存储器,那么 这是个不小的数目了,尤其是考虑到大多数用户只是将信息放入少数的单元格中,而大部分单 元格却空闲不用,因此显得有些浪费。内存的利用率非常低。 传统上,电子表格一直是用其他数据结构技术来实现的,比如链接表等。使用链接表,只 需要为电子表格中实际包含数据的单元格创建 CELLDATA结构。由于电子表格中的大多数单元 格都是不用的,因此这种方法可以节省大量的内存。但是这种方法使得你很难获得单元格的内 容。如果想知道第 5行第10列的单元格的内容,必须遍历链接表,才能找到需要的单元格,因 此使用链接表方法比明确声明的矩阵方法速度要慢。 虚拟内存为我们提供了一种兼顾预先声明二维矩阵和实现链接表的两全其美的方法。运用 虚拟内存,既可以使用已声明的矩阵技术进行快速而方便的访问,又可以利用链接表技术大大 节省内存的使用量。 如果想利用虚拟内存技术的优点,你的程序必须按照下列步骤来编写: 1) 保留一个足够大的地址空间区域,用来存放 CELLDATA结构的整个数组。保留一个根 本不使用任何物理存储器的区域。 2) 当用户将数据输入一个单元格时,找出 CELLDATA结构应该进入的保留区域中的内存 地址。当然,这时尚未有任何物理存储器被映射到该地址,因此,访问该地址的内存的任何企 图都会引发访问违规。 3) 就CELLDATA结构来说,只将足够的物理存储器提交给第二步中找到的内存地址(你 可以告诉系统将物理存储器提交给保留区域的特定部分,这个区域既可以包含映射到物理存储 器的各个部分,也可以包含没有映射到物理存储器的各个部分)。 4) 设置新的CELLDATA结构的成员。 现在物理存储器已经映射到相应的位置,你的程序能够访问内存,而不会引发访问违规。 这个虚拟内存技术非常出色,因为只有在用户将数据输入电子表格的单元格时,才会提交物理 存储器。由于电子表格中的大多数单元格是空的,因此大部分保留区域没有提交给它的物理存 储器。 虚拟内存技术存在的一个问题是,必须确定物理存储器在何时提交。如果用户将数据输入 一个单元格,然后只是编辑或修改该数据,那么就没有必要提交物理存储器,因为该单元格的 CELLDATA结构的内存在数据初次输入时就已经提交了。 另外,系统总是按页面的分配粒度来提交物理存储器的。因此,当试图为单个 CELLDATA 结构提交物理存储器时(像上面的第二步那样),系统实际上提交的是内存的一个完整的页面。 这并不像它听起来那样十分浪费:为单个 CELLDATA结构提交物理存储器的结果是,也要为附 近的其他 CELLDATA结构提交内存。如果这时用户将数据输入邻近的单元格(这是经常出现的 情况),就不需要提交更多的物理存储器。 有4种方法可以用来确定是否要将物理存储器提交给区域的一个部分: 358计计第三部分 内 存 管 理 下载 • 始终设法进行物理存储器的提交。每次调用 VirtualAlloc函数的时候,不要查看物理存储 器是否已经映射到地址空间区域的一个部分,而是让你的程序设法进行内存的提交。系 统首先查看内存是否已经被提交,如果已经提交,那么就不要提交更多的物理存储器。 这种方法最容易操作,但是它的缺点是每次改变 CELLDATA结构时要多进行一次函数的 调用,这会使程序运行得比较慢。 • (使用VirtualQuery函数)确定物理存储器是否已经提交给包含 CELLDATA结构的地址空 间。如果已经提交了,那么就不要进行任何别的操作。如果尚未提交,则可以调用 Vi r t u a l A l l o c函数以便提交内存。这种方法实际上比第一种方法差,它既会增加代码的长 度,又会降低程序运行的速度(因为增加了对 VirtualAlloc函数的调用)。 • 保留一个关于哪些页面已经提交和哪些页面尚未提交的记录。这样做可以使你的应用程 序运行得更快,因为不必调用 VirtualAlloc函数,你的代码能够比系统更快地确定内存是 否已经被提交。它的缺点是,必须不断跟踪页面提交的信息,这可能非常简单,也可能 非常困难,要根据你的情况而定。 • 使用结构化异常处理( SEH)方法,这是最好的方法。 SEH是一个操作系统特性,它使 系统能够在发生某种情况时将此情况通知你的应用程序。实际上可以创建一个带有异常 处理程序的应用程序,然后,每当试图访问未提交的内存时,系统就将这个问题通知应 用程序。然后你的应用程序便进行内存的提交,并告诉系统重新运行导致异常条件的指 令。这时对内存的访问就能成功地进行了,程序将继续运行,仿佛从未发生过问题一样。 这种方法是优点最多的方法,因为需要做的工作最少(也就是说要你编写的代码比较少), 同时,你的程序可以全速运行。关于 SEH的全面介绍,请参见第 23、24和25章。第25章 中的电子表格示例应用程序说明了如何按照上面介绍的方法来使用虚拟内存。 15.5 回收虚拟内存和释放地址空间区域 若要回收映射到一个区域的物理存储器,或者释放这个地址空间区域,可调用 VirtualFree 函数: 首先让我们观察一下调用 VirtualFree函数来释放一个已保留区域的简单例子。当你的进程 不再访问区域中的物理存储器时,就可以释放整个保留的区域和所有提交给该区域的物理存储 器,方法是一次调用 VirtualFree函数。 就这个函数的调用来说, pvAddress参数必须是该区域的基地址。此地址与该区域被保留 时VirtualAlloc函数返回的地址相同。系统知道在特定内存地址上的该区域的大小,因此可以为 dwSize参数传递0。实际上,必须为 dwSize参数传递0,否则对VirtualFree的调用就会失败。对 于第三个参数fdwFreeType,必须传递 MEM_RELEASE,以告诉系统将所有映射的物理存储器 提交给该区域并释放该区域。当释放一个区域时,必须释放该区域保留的所有地址空间。例如 不能保留一个128 KB的区域,然后决定只释放它的 64 KB。必须释放所有的128 KB。 当想要从一个区域回收某些物理存储器,但是却不释放该区域时,也可以调用 VirtualFree 函数,若要回收某些物理存储器,必须在 VirtualFree函数的pvAddress参数中传递用于标识要回 收的第一个页面的内存地址,还必须在 dwSize参数中设定要释放的字节数,并在 fdwFreeType 下载 359 第 15章 在应用程序中使用虚拟内存计计 参数中传递 MEM_DECOMMIT标志。 与提交物理存储器的情况一样,回收时也必须按照页面的分配粒度来进行。这就是说,设 定页面中间的一个内存地址就可以回收整个页面。当然,如果 pvAddress + dwSize的值位于一 个页面的中间,那么包含该地址的整个页面将被回收。因此位于 pvAddress 至pvAddress + dwSize范围内的所有页面均被回收。 如果dwSize是0,pvSddress是已分配区域的基地址,那么 VirtualFree将回收全部范围内的 已分配页面。当物理存储器的页面已经回收之后,已释放的物理存储器就可以供系统中的所有 其他进程使用,如果试图访问未回收的内存,将会造成访问违规。 15.5.1 何时回收物理存储器 在实践中,知道何时回收内存是非常困难的。让我们再以电子表格为例。如果你的应用程 序是在x86计算机上运行,每个内存页面是 4 KB ,它可以存放32个(4096/128)CELLDATA结 构。如果用户删除了单元格 CellData[0][1]的内容,那么只要单元格 CellData[0][0]至 CellData[0][31]也不被使用,就可以回收它的内存页面。那么怎么能够知道这个情况呢?可以 用下面3种方法来解决这个问题。 • 毫无疑问,最容易的方法是设计一个 CELLDATA结构,它的大小只有一个页面。这时, 由于始终都是每个页面使用一个结构,因此当不再需要该结构中的数据时,就可以回收 该页面的物理存储器。即使你的数据结构是 x86 CPU 上的8 KB或12 KB页面的倍数(通常 这是非常大的数据结构),回收内存仍然是非常容易的。当然,如果要使用这种方法,必 须定义你的数据结构,使之符合你针对的 CPU的页面大小而不是我们通常编写程序所用 的结构。 • 更为实用的方法是保留一个正在使用的结构的记录。为了节省内存,可以使用一个位图。 这样,如果有一个 100个结构的数组,你也可以维护一个 100位的数组。开始时,所有的 位均设置为0,表示这些结构都没有使用。当使用这些结构时,可以将对应的位设置为 1。 然后,每当不需要某个结构,并将它的位重新改为 0时,你可以检查属于同一个内存页面 的相邻结构的位。如果没有相邻的结构正在使用,就可以回收该页面。 • 最后一个方法是实现一个无用单元收集函数。这个方案依赖于这样一种情况,即当物理 存储器初次提交时,系统将一个页面中的所有字节设置为 0。若要使用该方案,首先必须 在你的结构中设置一个 BOOL(也许称为 fInUse)。然后,每次你将一个结构放入已提交 的内存中,必须确保该 fI nUse被置于TRUE。 当你的应用程序运行时,必须定期调用无用单元收集函数。该函数应该遍历所有潜在的数 据结构。对于每个数据结构,该函数首先要确定是否已经为该结构提交内存。如果已经提交, 该函数将检查fInUse成员,以确定它是否是 0。如果该值是 0,则表示该结构没有被使用。如果 该值是 TRUE,则表示该结构正在使用。当无用单元函数检查了属于既定页面的所有结构后, 如果所有结构都没有被使用,它将调用 VirtualFree函数,回收该内存。 当一个结构不再被视为“在用”(In Use)后,就可以立即调用无用单元收集函数,不过这 项操作需要的时间比你想像的要长,因为该函数要循环通过所有可能的结构。实现该函数的一 个出色方法是让它作为低优先级线程的一部分来运行。这样,就不必占用执行主应用程序的线 程的时间。每当主应用程序运行空闲时,或者主应用程序的线程执行文件的 I/O操作时,系统 就可以给无用单元收集函数安排运行时间。 在上面列出的所有方法中,前面的两种方法是我个人喜欢使用的方法。不过,如果你的结 360计计第三部分 内 存 管 理 下载 构比较小(小于一个页面),那么建议你使用最后一种方法。 15.5.2 虚拟内存分配的示例应用程序 清单15-1中列出的VMAlloc应用程序(“15 VMAlloc.exe”)显示了如何使用虚拟内存技术 来处理一个结构数组。该应用程序的源代码和 资源文件均位于本书所附光盘上的 15-VMAlloc 目录下。当启动该应用程序时,将出现图 15-1 所示的窗口。 开始时,没有为该结构数组保留任何地址 空间的区域,准备为它保留的所有地址空间都 是空闲的,如内存表所示。当你点击 Reserve Region(50,2 KB结构)按钮时,VMAlloc便调 用VirtualAlloc函数,保留该区域,同时内存表 图15-1 运时VMAlloc 程序时出现的窗口 被更新,以反映该区域已经被保留。当 VirtualAlloc函数保留该区域后,其余按钮变均为活动按 钮。 现在可以将一个索引键入编辑控件,以便选定一个索引,然后单击 Use按钮。它的作用是将 物理存储器提交给用于放置数组元素的内存地址。当一个内存页面被提交时,内存表被刷新,以 反映整个数组的保留区域的状态。因此,如果该区域被保留后,你用Use按钮将数组元素7和46标 记为“在用”,那么该窗口就显示出图15-2所示的样子(当在4 KB计算机上运行该程序时)。 单击 C l e a r 按 钮 , 清 除 带 有 “ 在 用 ” 标 记 的 任 何 元 素 。 但 是 这 样 做 并 不 回 收 映 射 到 数 组 元 素的物理存储器,因为每个页面都包含多个结构的空间,清除一个元素并不意味着其他元素也 被清除。如果内存被回收,那么其他结构中的数据就会丢失。由于单击 Clear并不影响区域的 物理存储器,因此当数组元素被清除时,内存表不会被更新。 但是,当一个结构被清除时,它的 fInUse成员将被设置为 FALSE。这样的设置是必要的, 因为这使得无用单元收集函数能够运行通过所有的结构,并回收不再使用的内存。如果你现在 尚未想到这一点,那么 Garbage Collect按钮会告诉 VMAlloc执行它的无用单元收集例程。为了 简化操作,我没有在各个线程上实现无用单元收集例程。 若要展示无用单元收集函数,清除索引 46上的数组元素。注意,内存表并没有改变。现在 单击Garbage Collect按钮。该程序回收包含元素 46的内存页面,同时内存表被更新,以反映这 个变化,如图 15-3所示。注意, GarbageCollect函数可以很容易地用于你自己的应用程序。我 将它用于对任意大小的数据结构数组的操作,这些结构不必完全等于一个页面的大小。唯一的 要求是,结构的第一个成员的值必须是 BOOL,这表示该结构是否处于在用状态。 图15-2 标记数组元素后显示的窗口 图15-3 内存表更新后显示的窗口 下载 361 第 15章 在应用程序中使用虚拟内存计计 最后要说明的是,尽管没有直观的图形为你提供必要的信息,但是,当窗口关闭时,所有 已提交的内存均被回收,保留的区域被释放。 该程序还包含另一个元素尚未加以说明。该程序必须在 3个位置上确定区域的地址空间中 的内存状态: • 当改变索引后,该程序必须激活 Use按钮,并停用 Clear按钮,或者激活 Use按钮,停用 Clear按钮。 • 在无用单元收集函数中,在实际查看是否已经设置 fInUse标志之前,该程序必须查看内 存是否已经提交。 • 当更新内存表时,该程序必须知道哪个页面是空闲的,哪个页面已经被保留,哪个页面 已经提交。 VMAlloc通过调用 VirtualQuery函数来执行所有这些测试。 清单15-1 VMAlloc示例应用程序 362计计第三部分 内 存 管 理 下载 下载 363 第 15章 在应用程序中使用虚拟内存计计 364计计第三部分 内 存 管 理 下载 下载 365 第 15章 在应用程序中使用虚拟内存计计 366计计第三部分 内 存 管 理 下载 下载 367 第 15章 在应用程序中使用虚拟内存计计 368计计第三部分 内 存 管 理 下载 15.6 改变保护属性 虽然实践中很少这样做,但是可以改变已经提交的物理存储器的一个或多个页面的保护属 性。例如,你编写了一个用于管理链接表的代码,将它的节点存放在一个保留区域中。可以设 计一些函数,以便处理该链接表,这样,它们就可以在每个函数开始运行时将已提交内存的保 护属性改为 PAGE_READWRITE ,然后在每个函数终止运行时将保护属性重新改为 PA G E _ N O A C C E S S。 通过这样的设置,就能够使链接表数据不受隐藏在程序中的其他错误的影响。如果进程中 的任何其他代码存在一个迷失指针,试图访问你的链接表数据,那么就会引发访问违规。当试 图寻找应用程序中难以发现的错误时,利用保护属性是极其有用的。 若要改变内存页面的保护属性,可以调用 VirtualProtect函数: 这里的pvAddress参数指向内存的基地址(它必须位于进程的用户方式分区中),dwSize参 数用于指明你想要改变保护属性的字节数,而 flNewProtect参数则代表PAGE_*保护属性标志中 的任何一个标志,但 PAGE_WRITECOPY和PAGE_EXECUTE_WRITECOPY这两个标志除外。 最后一个参数 pflOldProtect是DWORD的地址,VirtualProtect将用原先与pvAddress位置上 的字节相关的保护属性填入该 DWORD。尽管许多应用程序并不需要该信息,但是必须为该参 数传递一个有效地址,否则该函数的运行将会失败。 当然,保护属性是与内存的整个页面相关联的,而不是赋予内存的各个字节的。因此,如 果要使用下面的代码来调用 4 KB 页面的计算机上的 VirtualProtect函数,其结果是把 PA G E _ N O A C C E S S保护属性赋予内存的两个页面: 下载 369 第 15章 在应用程序中使用虚拟内存计计 Windows 98 Windows 98只支持PAGE_READONLY和PAGE_READWRITE两个保护 属性。如果试图将页面的保护属性改为 PAGE_EXECUTE或PAGE_EXECUTE_READ, 该页面可得到 PAGE_READONLY保护属性。同样,如果试图将页面的保护属性改为 PAGE_EXECUTE_READWRITE,那么该页面将得到 PAGE_READWRITE保护属性。 Vi r t u a l P r o t e c t函数不能用于改变跨越不同保留区域的页面的保护属性。如果拥有相邻的保 留区域并想改变这些区域中的一些页面的保护属性,那么必须多次调用 VirtualProtect函数。 15.7 清除物理存储器的内容 Windows 98 Windows 98 不支持物理存储器内容的清除。 当你修改物理存储器的各个页面的内容时,系统将尽量设法将修改的内容保存在 RAM中。 但是,当应用程序运行时,从 .exe文件、DLL文件和/或页文件加载页面就可能需要占用系统的 RAM。由于系统要查找 RAM的页面,以满足当前加载页面的需求,因此系统必须将 RAM的已 修改页面转到系统的页文件中。 Windows 2000提供了一个特性,使得应用程序能够提高它的性能,这个特性就是对物理存 储器内容进行清除。清除存储器意味着你告诉系统,内存的一个或多个页面上的数据并没有被 修改。如果系统正在搜索 RAM的一个页面,并且选择一个已修改的页面,系统必须将 RAM的 这个页面写入页文件。这个操作的速度很慢,而且会影响系统的运行性能。对于大多数应用程 序来说,可以让系统将你修改了的页面保留在系统的页文件中。 然而,有些应用程序使用内存的时间很短,然后就不再要求保留该内存的内容。为了提高 性能,应用程序可以告诉系统不要将内存的某些页面保存在系统的页文件中。这是应用程序告 诉系统数据页面尚未修改的一个基本方法。因此,如果系统选择将 RAM的页面用于别的目的, 那么该页面的内容就不必保存在页文件中,从而可以提高应用程序的运行性能。若要清除内存 的内容,应用程序可以调用VirtualAlloc函数,在第三个参数中传递 MEM_RESET标志。 如果在调用 VirtualAlloc函数时引用的页面位于页文件中,系统将删除这些页面。下次应用 程序访问内存时,便使用最初被初始化为 0的RAM页面。如果清除了当前 RAM中的页面内容, 那么它们将被标上未修改的标记,这样它们将永远不会被写入页文件。注意,虽然 RAM页面 的内容没有被置 0,但是不应该继续从内存的该页面读取数据。如果系统不需要 RAM的这个页 面,它将包含其原始内容。但是如果系统需要 RAM的这个页面,系统就可以提取该页面。然 后当你试图访问该页面的内容时,系统将给你一个已经删除内容的新页面。由于你无法控制这 个行为特性,因此,当清除页面的内容后,你必须假定该页面的内容是无用信息。 当清除内存的内容时,有两件事情必须记住。首先,当调用 VirtualAlloc函数时,基地址通 常圆整为一个页面边界的值,而字节数则圆整为一个页面的整数。当清除页面的内容时,用这 种办法圆整基地址和字节数是非常危险的,因此,当传递 MEM_RESET标志时,VirtualAlloc将 按反方向对这些值进行圆整。例如,有下面这个代码: 370计计第三部分 内 存 管 理 下载 该代码提交了一个内存页面,然后指明前 4个字节( sizeof(int))不再需要,可以被清除。 但是,与所有内存操作一样,一切操作都必须在页面边界上并按页面增量来进行。结果,对上 面这个内存清除函数的调用会失败( VirtualAlloc函数返回 NULL)。为什么呢?因为当将 MEM_RESET传递给VirtualAlloc时,传递给函数的基地址被圆整为页面边界的值,字节数圆整 为一个页面的整数。这样做是为了确保重要的数据不会丢失。在上面的例子中,字节数圆整后 得出的是 0,而清除 0字节是不合法的。 要记住的第二件事情是, MEM_RESET标志始终必须自己单独使用,不能用 OR将它与任 何其他标志连接起来使用。下面这个函数调用总是失败的,它返回的是 NULL。 将MEM_RESET标志与任何其他标志连接起来确实没有任何意义。 最后请注意,带有MEM_RESET标志的VirtualAlloc函数要求传递一个有效的页面保护属性, 即使该函数不使用这个值,也必须传递该值。 M e m R e s e t示例应用程序 清单15-2列出的MemReset应用程序(“15 MemReset.exe”)显示了MEM_RESET标志是如 何运行的。该应用程序的源代码和资源文件位于本书所附光盘上的 15-MemReset目录下。 M e m R e s e t . c p p代码执行的第一项操作是保留和提交一个物理存储器的区域。由于传递给 VirtualAlloc函数的页面大小是 1024,因此系统将自动把这个值圆整为系统的页面大小。这时, 使用lstrcpy函数将一个字符串拷贝到该缓存,导致页面的内容被修改。如果系统后来决定它需 要我们的数据页面占用的 RAM页面,那么系统将首先将我们页面中的数据写入系统的页文件。 当我们的应用程序后来试图访问该数据时,系统将自动把该页面从页文件重新加载到 RAM的 另一个页面,这样我们就能够成功地访问该数据。 当该字符串被写入内存页面后,该代码便向用户显示一个消息框,询问是否需要在晚些时 候访问这些数据。如果用户选定 No按钮,那么该代码就迫使操作系统认为该页面中的数据没 有通过调用 VirtualAlloc函数并传递 MEM_RESET标志而被修改。 为了说明内存的内容已经被清除,我们必须对系统的 RAM提出大量的使用需求。若要进 行这项操作,可以分 3步来进行: 1) 调用GlobalMemoryStatus函数,获取计算机中RAM的总容量。 2) 调用VirtualAlloc函数,提交该数量的内存。这项操作的运行速度非常快,因为在进程 试图访问页面之前,系统实际上并不为该内存分配 RAM。 3) 调用ZeroMemory函数,使新提交的页面可以被访问。这将给系统的 RAM带来沉重的负 担,导致当前正在 RAM中的某些页面被写入页文件。 如果用户指明该数据将在以后被访问,那么该数据将不被清除,并且在以后访问该数据时 将数据转入 RAM。但是,如果用户指明以后将不再访问该数据,那么数据将被清除,并且系 统不把数据写入页文件,这样就可以提高应用程序的运行性能。 当ZeroMemory函数返回时,代码将对数据页面的内容与原先写入页面的字符串进行比较。 如果数据没有被清除,那么可以保证它们的内容是相同的。如果数据页面已经被清除,那么它 们的内容可能相同,也可能不同。在 MemReset程序中,它们的内容决不可能相同,因为 RAM 中的所有页面均被强制写入页文件中。但是,如果这个伪区域小于计算机中的 RAM总容量, 那么原始内容有可能仍然在 RAM中。如前所述,操作时务必小心。 下载 371 第 15章 在应用程序中使用虚拟内存计计 清单15-2 MemReset示例应用程序 372计计第三部分 内 存 管 理 下载 15.8 地址窗口扩展 —适用于Windows 2000 随着时间的推移,应用程序需要的内存越来越多。对于服务器应用程序来说,情况更是如 此。由于越来越多的客户机对服务器提出访问请求,服务器的运行性能就会降低。为了提高运 行性能,服务器应用程序必须在 RAM中保存更多的数据,并且缩小磁盘的页面。其他类别的 应用程序,比如数据库、工程设计和科学应用程序,也需要具备处理大块内存的能力。对于所 有这些应用程序来说, 32位地址空间是不够使用的。 为了满足这些应用程序的需要, Windows 2000提供了一个新特性。称为地址窗口扩展 (AWE)。Microsoft创建AWE是出于下面两个目的: • 允许应用程序对从来不在操作系统与磁盘之间交换的 RAM进行分配。 • 允许应用程序访问的RAM大于进程的地址空间。 AWE基本上为应用程序提供了一种分配一个或多个 RAM块的手段。当分配 RAM块时,在 进程的地址空间中是看不见这些 RAM块的。后来,应用程序(使用 VirtualAlloc函数)保留一 个地址空间区域,这个区域就成为地址窗口。这时应用程序调用一个函数,每次将一个 RAM 块赋予该地址窗口。将一个 RAM块赋予地址窗口的速度是非常快的(通常只需要几个毫秒)。 显然,通过单个地址窗口,每次只能访问一个 RAM块。这使得你的代码很难实现,因为, 当需要时,必须在你的代码中显式调用函数,才能将不同的 RAM块赋予地址窗口。 下面的代码显示了如何使用 AWE的方法: 下载 373 第 15章 在应用程序中使用虚拟内存计计 如你所见, AWE的使用非常简单。现在我们要说明该代码的一些有意思的情况。 对VirtualAlloc函数的调用保留了一个 1 MB的地址窗口。通常该地址窗口要大得多。你必 须选定一个适合于应用程序需要的 RAM块大小的窗口大小。当然,你创建的最大窗口取决于 你的地址空间中可用的最大相邻空闲地址块。 MEM_RESERVE标志用于指明我正在保留一个 地址区域。 MEM_PHYSICAL标志用于指明这个区域最终将受 RAM物理存储器的支持。 AWE 的局限性是,映射到地址窗口的所有内存必须是可读的和可写入的,因此 PAGE_READWRITE 是可以传递VirtualAlloc函数的唯一有效的保护属性。此外,不能使用 VirtualProtect函数来修改 这个保护属性。 RAM物理存储器的分配是非常简单的,只需要调用 AllocateUserPhysicalPages: 该函数负责分配 pulRAMPages参数指明的值设定的 RAM页面的数量,并且将这些页面赋 予hProcess参数标识的进程。 374计计第三部分 内 存 管 理 下载 每个RAM页面由操作系统赋予一个页框号。当系统选择供分配用的 RAM页面时,它就将 每个RAM页面的页框号填入 aRAMPages参数指向的数组。页框号本身对应用程序没有任何用 处,不应该查看该数组的内容,并且肯定不应该修改该数组中的任何一个值。注意,你不知道 哪些RAM页面已经被分配给该内存块,也不应该去关注这个情况。当地址窗口显示 RAM块中 的页面时,它们显示为一个相邻的内存块。这使得 RAM非常便于使用,并且使你可以不必了 解系统内部的运行情况。 该函数返回时,pulRAMPages参数中的值用于指明该函数成功地分配的页面数量。这个数 量通常与传递给函数的值是相同的,但是它也可能是个较小的值。 只有拥有页面的进程才能使用已经分配的 RAM页面,AWE不允许RAM页面被映射到另一 个进程的地址空间。因此不能在进程之间共享 RAM块。 注意 当然,物理RAM是一种非常宝贵的资源,并且应用程序只能分配尚未指定用途 的RAM。应该非常节省地使用 AWE,否则你的进程和其他进程将会过分地在内存与 磁盘之间进行页面的交换,从而严重影响系统的运行性能。此外,如果可用 RAM的 数量比较少,也会对系统创建新进程、线程和其他资源的能力产生不利的影响。应用 程序可以使用 GlobalMemoryStatusEx函数来监控物理存储器的使用情况。 为了保护RAM的分配,AllocateUserPhysicalPages函数要求调用者拥有 Lock Pages in Memory(锁定内存中的页面)的用户权限,并且已经激活该权限,否则该函数的 运行将会失败。按照默认设置,该权限不被赋予任何用户或用户组。该权限被赋予 Local System (本地系统)帐户,它通常用于服务程序。如果想要运行一个调用 AllocateUserPhysicalPages函数的交互式应用程序,那么管理员必须在你登录和运行应 用程序之前为你赋予该权限。 Windows 2000 在Windows 2000中,可以执行下列步骤,打开 Lock Pages in Memory 用户权限: 1) 单击Start按钮,选定Run菜单项,打开Computer Management MMC控制台。在 Run框中,键入“Compmgmt.msc/a”,再单击OK按钮。 2) 如果在左边的窗格中没有显示 Local Computer Policy(本地计算机政策)项, 那么在控制台菜单中选定 Add/Remove Snap-ins(添加 /删除咬接项( snap-in))。在 Standalone选项卡上,从Snap-ins Added To(咬接项添加到)组合框中选定 Computer Management(local)。现在单击Add按钮,显示Add Standalone Snap-in(添加独立咬接 项)对话框。从Available Standalone Snap-ins(可用独立咬接项)中选定Group Policy (组政策)。并单击Add按钮。在Select Group Policy Object(选定组政策对象)对话框 中,保留默认值,并单击 Finish按钮。单击Add Standalone Snap-in对话框上的Close按 钮,再单击Add/Remove Snap-in对话框上的OK按钮。这时,在 Computer Management 控制台的左窗格中就可以看到Local Computer Policy项。 3) 在控制台的左窗格中,双击下列项目,将它们展开: Local Computer Policy(本 地计算机政策)、Computer Configuration(计算机配置)、Windows Settings(窗口设置)、 Security Settings (安全性设置)和 Local Policy(本地政策)。然后选定 User Rights Assignment(用户权限赋值)项。 4) 在右窗格中,选定 Lock Pages in Memory属性。 5) 从Action(操作)菜单中选定Security,显示Lock Pages in Memory对话框,单 下载 375 第 15章 在应用程序中使用虚拟内存计计 击Add按钮。使用 Select Users or Group对话框,添加你想为其赋予 Lock Pages in Memory用户权限的用户和 /或用户组。单击OK按钮,退出每个对话框。 当用户登录时,他将被赋予相应的用户权限。如果你只是将 Lock Pages in M e m o r y权限赋予你自己,那么必须在该权限生效前退出并重新登录。 现在我们已经创建了地址窗口并且分配了一个 RAM块,可以通过调用 MapUserPhysical Pages函数将该RAM块赋予该地址窗口: 第一个参数 p v A d d r e s s Wi n d o w 用于指明地址窗口的虚拟地址,第二和第三个参数 ulRAMPages和aRAMPages用于指明该地址窗口中可以看到多少个 RAM页面以及哪些页面可以 看到。如果窗口小于你试图映射的页面数量,那么函数运行就会失败。 Microsoft设置这个函数 的主要目的是使它运行起来非常快。一般来说, MapUserPhysicalPages函数能够在几个微秒内 映射该RAM块。 注意 也可以调用MapUserPhysicalPages函数来取消对当前RAM块的分配,方法是为 aRAMPages参数传递 NULL。下面是它的一个例子: 一旦 R A M块被分配给地址窗口,只需要引用相对于地址窗口的基地址(在我的示例代码 中是pvWindow)的虚拟地址,就可以很容易地访问该 RAM内存。 当不再需要RAM块时,应该调用FreeUserPhysicalPages函数将它释放: 第一个参数hProcess用于指明哪个进程拥有你试图释放的 RAM页面。第二和第三个参数用 于指明你要释放多少个页面和这些页面的页框号。如果该 RAM块目前已经被映射到该地址窗 口,那么它将被取消映射并被释放。 最后,为了彻底清除页面,我仅仅调用了 VirtualFree函数,传递窗口的虚拟基地址,为区 域大小传递 0,再传递 MEM_RELEASE,将地址窗口释放掉。 我的简单的示例代码创建了单个地址窗口和单个 RAM块。这使得我的应用程序能够访问 没有与磁盘进行数据交换的 RAM。但是,应用程序也能够创建若干个地址窗口,并且可以分 配若干个RAM块。虽然这些 RAM块可以分配给任何一个地址窗口,但是系统不允许单个 RAM 块同时出现在两个地址窗口中。 64位Windows 2000全面支持 AWE。对使用AWE的32位应用程序进行移植是非常容易和简 单的。不过对于 64位应用程序来说, AWE的用处比较小,因为进程的地址空间太大了。但是 AWE仍然是有用的,因为它使得应用程序能够分配不与磁盘进行数据交换的物理 RAM。 AW E示例应用程序 清单15-3列出的AWE应用程序(“15-AWE.exe”)显示了如何创建多个地址窗口和如何将 376计计第三部分 内 存 管 理 下载 不同的内存块分配给这些窗口。该应用程序的源代码和资源文件位于本书所附光盘上的 15AWE目录下。当启动该程序时,它在内部创建两个地址窗口区域,并且分配两个 RAM块。 首先,第一个RAM块用字符串“Text in Storage 0”填入,第二个 RAM块用字符串“Text in Storage 1”填入。然后,第一个 RAM块被赋予第一个地址窗口,第二个 RAM块被赋予第二 个地址窗口。该应用程序的窗口反映了这个情况(见图 15-4)。 图15-4 AWE示例应用程序的窗口 使用该窗口,可以进行一些试验。首先,可以使用每个地址窗口的组合框将 RAM块赋予 各个地址窗口。该组合框也提供了一个 No Storage选项,用于从地址窗口中撤消对任何内存的 映射。其次,编辑窗口中的文本将会更新地址窗口中当前选定的 RAM块。 如果试图将一个 RAM块同时赋予两个地址窗口,就会出现图 15-5所示的消息,因为 AWE 不支持这种操作。 图15-5 消息显示 该示例应用程序的源代码是非常清楚的。为了使 AWE的操作更加容易些,我创建了 3个 C++类,它们包含在 AddrWindows.h文件中。第一个类是CSystemInfo,它是GetSystemInfo函数 中的一个非常简单的包装类,另外两个类均用于创建 CSystemInfo类的实例。 第二个C++类是CAddrWindow,它用于封装一个地址窗口。 Create方法基本上用于保留一 个地址窗口,Destroy方法则用于撤消一个地址窗口, UnmapStorage方法用于取消当前赋予地址 窗口的任何RAM块的映像,而PVOID类型转换操作符方法只是用于返回地址窗口的虚拟地址。 第三个 C++类是CAddrWindowStorage,它用于封装一个可以赋予 CAddrWindow对象的 RAM块。Allocate方法用于激活 Lock Pages in Memory用户权限,并设法分配 RAM块,然后停 用该用户权限。 Free方法用于释放RAM块。HowManyPagesAllocated方法返回已经成功地分配 的页面数量。 MapStorage和UnmapStorage方法则用于与CAddrWindow对象之间进行RAM块的 映射和取消映射。 使用这些C++类,可以使示例应用程序的实现变得容易得多。该示例应用程序创建了两个 CAddrWindow对象和两个CAddrWindowStorage对象。代码的其余部分只是用于在正确的时间 为正确的对象调用正确的方法而已。 清单15-3 A WE示例应用程序 下载 377 第 15章 在应用程序中使用虚拟内存计计 378计计第三部分 内 存 管 理 下载 下载 379 第 15章 在应用程序中使用虚拟内存计计 380计计第三部分 内 存 管 理 下载 下载 381 第 15章 在应用程序中使用虚拟内存计计 382计计第三部分 内 存 管 理 下载 下载 383 第 15章 在应用程序中使用虚拟内存计计 384计计第三部分 内 存 管 理 下载 下载 第16章 线程的堆栈 有时系统会在你自己进程的地址空间中保留一些区域。第 3章讲过,对于进程和线程环境 块来说,就会出现这种情况。另外,系统也可以在你自己进程的地址空间中为线程的堆栈保留 一些区域。 每当创建一个线程时,系统就会为线程的堆栈(每个线程有它自己的堆栈)保留一个堆栈 空间区域,并将一些物理存储器提交给这个已保留的区域。按照默认设置,系统保留 1 MB的 地址空间并提交两个页面的内存。但是,这些默认值是可以修改的,方法是在你链接应用程序 时设定Microsoft的链接程序的/STACK选项: 当创建一个线程的堆栈时,系统将会保留一个链接程序的 /STACK开关指明的地址空间区 域。但是,当调用 CreateThread或_beginthreadex函数时,可以重载原先提交的内存数量。这两 个函数都有一个参数,可以用来重载原先提交给堆栈的地址空间的内存数量。如果设定这个参 数为 0,那么系统将使用 /STACK开关指明的已提交的堆栈大小值。后面将假定我们使用默认的 堆栈大小值,即1 MB的保留区域,每次提交一个页面的内存。 图16-1显示了在页面大小为 4 KB 的计算机上的一个堆栈区域的样子 (保留的起始地址是 0 x 0 8 0 0 0 0 0 0 ) 。该堆栈区域和提交给它的所有物理存储器均拥有页面保护属性 PA G E _ READWRITE。 内存地址 页面状态 0x080FF000 堆栈顶部:已提示的页面 0x080FD000 带有保护属性标志的已提交页面 0x080FD000 保留页面 0x08003000 0x08002000 0x08001000 0x08000000 保留页面 保留页面 保留页面 堆栈底部:保留页面 图16-1 线程的堆栈区域刚刚创建时的样子 386计计第三部分 内 存 管 理 下载 当保留了这个区域后,系统将物理存储器提交给区域的顶部的两个页面。在允许线程启动运 行之前,系统将线程的堆栈指针寄存器设置为指向堆栈区域的最高页面的结尾处(一个非常接近 0x08100000的地址)。这个页面就是线程开始使用它的堆栈的位置。从顶部向下的第二个页面称 为保护页面。当线程调用更多的函数来扩展它的调用树状结构时,线程将需要更多的堆栈空间。 每当线程试图访问保护页面中的存储器时,系统就会得到关于这个情况的通知。作为响应, 系统将提交紧靠保护页面下面的另一个存储器页面。然后,系统从当前保护页面中删除保护页 面的保护标志,并将它赋予新提交的存储器页面。这种方法使得堆栈存储器只有在线程需要时 才会增加。最终,如果线程的调用树继续扩展,堆栈区域就会变成图 16-2所示的样子。 如图 1 6 - 2 所示,假定线程的调用树非常深,堆栈指针 C P U 寄存器指向堆栈内存地址 0x08003004。这时,当线程调用另一个函数时,系统必须提交更多的物理存储器。但是,当系 统将物理存储器提交给 0x08001000地址上的页面时,系统执行的操作与它给堆栈的其他内存区 域提交物理存储器时的操作并不完全一样。图 16-3显示了堆栈的保留内存区域的样子。 如你预计的那样,从地址 0x08002000开始的页面的保护属性已经被删除,物理存储器被提 交给从0x08001000地址开始的页面。它们的差别是,系统并不将保护属性应用于新的物理存储 器页面( 0x08001000)。这意味着该堆栈已保留的地址空间区域包含了它能够包含的全部物理 存储器。最底下的页面总是被保留的,从来不会被提交。下面将要说明它的原因。 当系统将物理存储器提交给 0x08001000地址上的页面时,它必须再执行一个操作,即它要 引发一个 E X C E P T I O N _ S TA C K _ O V E R F L O W 异常处理(在 Wi n N T. h 文件中定义为 0xC00000FD)。通过使用结构化异常处理( SEH),你的程序将能得到关于这个异常处理条件 的通知,并且能够实现适度恢复。关于 SEH的详细说明,请参见第23、24和25章的内容。本章 结尾处的 Summation示例应用程序将展示如何对堆栈溢出进行适度恢复。 内存地址 0x080FF000 0x080FD000 0x080FD000 页面状态 堆栈顶部:已提交的页面 已提交的页面 已提交的页面 0x08003000 0x08002000 0x08001000 0x08000000 已提交的页面 带有保护属性标志的已提交页面 保留页面 堆栈底部:保留页面 图16-2 几乎完整的线程堆栈区域 下载 内存地址 0x080FF000 0x080FE000 0x080FD000 387 第 16章 线程的堆栈计计 页面状态 堆栈顶部:已提交的页面 已提交的页面 已提交的页面 0x08003000 0x08002000 0x08001000 0x08000000 已提交的页面 已提交的页面 已提交的页面 堆栈底部:保留页面 图16-3 完整的线程堆栈区域 如果在出现堆栈溢出异常条件之后,线程继续使用该堆栈,那么在 0x08001000地址上的页 面中的全部内存均将被使用,同时,该线程将试图访问从 0x08000000开始的页面中的内存。当 该线程试图访问这个保留的(未提交的)内存时,系统就会引发一个访问违规异常条件。如果 在线程试图访问该堆栈时引发了这个访问违规异常条件,线程就会陷入很大的麻烦之中。这时, 系统就会接管控制权,并终止进程的运行 —不仅终止线程的运行,而切终止整个进程的运行。 系统甚至不向用户显示一个消息框,整个进程都消失了! 下面要说明为什么堆栈区域的最后一个页面始终被保留着。这样做的目的是为了防止不小 心改写进程使用的其他数据。可以看到,在 0x07FF000这个地址上( 0x08000000下面的一个页 面),另一个地址空间区域已经提交了物理存储器。如果 0x08000000地址上的页面包含物理存 储器,系统将无法抓住线程访问已保留堆栈区域的尝试。如果堆栈深入到已保留堆栈区域的下 面,那么线程中的代码就会改写进程的地址空间中的其他数据,这是个非常难以抓住的错误。 16.1 Windows 98下的线程堆栈 在Windows 98下,堆栈的行为特性与 Windows 2000下的堆栈非常相似。但是它们之间存 在某些重大的差别。 图16-4显示了Windows 98下1 MB 的堆栈的各个区域的样子(从 0x00530000地址上开始保 留)。 首先请注意,尽管我们想要创建的堆栈大小最大只有 1 MB,但是堆栈区域的大小实际上 是1 MB 加128 KB 。在Windows 98中,每当为一个堆栈保留一个区域时,系统保留的区域实际 上比要求的尺寸要大128 KB。该堆栈位于该区域的中间,堆栈的前面有一个 64 KB的块,堆栈 的后面是另一个64 KB 的块。 388计计第三部分 内 存 管 理 下载 内存地址 0x00640000 0x0063F000 0x0063E000 0x00638000 0x00637000 0x006540000 0x00530000 大小 页面状态 16页面 (65 536字节) 堆栈顶部:保留供堆栈下溢时使用 1页面 (4096字节) 1页面 (4096字节) 6页面 (24 576字节) 1页 (4096字节) 带有PAGE_READWRITE保护属性的已 提交页面,堆栈在用 仿真PAGE_GUARD标志的 PAGE_NO_ ACCESS页面 保留供堆栈溢出时使用的页面 带有PAGE_READ_ WRITE保护属性的已 提交页面,用于与16位组件相兼容 247页面 (1 011 712字节) 保留页面,供堆栈扩展时使用 16页面 (65 536字节) 堆栈底部:保留供堆栈溢出时使用 图16-4 Windows 98下线程的堆栈区域刚刚创建时的样子 堆栈开始处的64 KB用于抓取堆栈的溢出条件,而堆栈后面的 64 KB则用于抓取堆栈的下 溢条件。若要了解为什么需要检测堆栈下溢条件,请看下面这个代码段: 当该函数的赋值语句执行时,便尝试访问线程堆栈结尾处之外的内存。当然,编译器和链接 程序不会抓住上面代码中的错误,但是,如果应用程序是在Windows 98下运行,那么当该语句执行 时,就会引发访问违规。这是Windows 98的一个出色特性,而Windows 2000是没有的。在Windows 2000中,可以在紧跟线程堆栈的后面建立另一个区域。如果出现这种情况,并且你试图访问你的堆 栈外面的内存,那么你将会破坏与进程的另一个部分相关的内存,而系统将不会发现这个情况。 需要指出的第二个重要差别是,没有一个页面具有 PAGE_GUARD保护属性标志。由于 Windows 98不支持这个标志,所以它使用一个不同的方法来扩展线程的堆栈。 Windows 98将 紧靠堆栈下面的已提交页面标记为 PAGE_NOACCESS保护属性(图16-4中的地址0x0063E000)。 然后,当线程接触读 /写页面下面的页面时,将会发生访问违规。系统抓住这个访问违规,将 不能访问的页面改为读写页面,并提交前一个保护页面下面的一个新保护页面。 第三个应该注意的差别是图 16-4中的0x00637000地址上的单个 PAGE_READWRITE内存页 面。这个页面是为了实现与 16位Windows相兼容而存在的。虽然 Microsoft从未将它纳入文档, 但是开发人员发现 16位应用程序的堆栈段(SS)开始处的16个字节包含了关于16位应用程序的 堆栈、本地堆栈和本地原子表的信息。由于在 Windows 98上运行的Win32应用程序常常调用16 位DLL组件,有些16位组件认为这些信息可以在堆栈段的开始处得到,因此 Microsoft不得不在 Windows 98中仿真这些字节的设置。当 32位代码转换为 16位代码时,Windows 98 将把一个16 下载 389 第 16章 线程的堆栈计计 位CPU选择器映射到32位堆栈,并且将堆栈段寄存器设置为指向 0x00637000地址上的页面。这 时该16位代码就可以访问堆栈段的开始处的 16个字节,并且可以继续运行而不会出任何问题。 现在,当 Windows 98扩大它的线程堆栈时,它将继续扩大 0x0063F000地址上的内存块。 它也会不断地将保护页面下移,直到 1 MB的堆栈内存被提交为止。然后保护页面消失,就像 在Windows 2000 下运行的情况一样。系统还继续为了 16位Windows组件的兼容性而将页面下移, 最后该页面将进入堆栈区域开始处的 64 KB的内存块中。因此, Windows 98中一个完全提交的 堆栈将类似图16-5所示的样子。 内存地址 0x00640000 0x00540000 0x00539000 0x00538000 0x00538000 大小 16页面 (65 536字节) 256页面 (1MB) 7页面 (28 672字节) 1页面 (4096字节) 8页面 (32 768字节) 页面状态 堆栈顶部:保留供堆栈下溢时使用 带有PAGE_READWRITE保护属性的已 提交页面,堆栈在用 供堆栈溢出时使用的保留页面 带有PAGE_READWRITE保护属性的已 提交页面,用于与16位组件相兼容 堆栈底部:保留供堆栈溢出时使用 图16-5 Windows 98下的一个完整的线程堆栈区域 16.2 C/C++运行期库的堆栈检查函数 C/C++运行期库包含一个堆栈检查函数。当编译源代码时,编译器将在必要时自动生成对 该函数的调用。堆栈检查函数的作用是确保页面被适当地提交给线程的堆栈。下面让我们来看 一个例子。 这是一个小型函数,它需要相当多的内存用于它的局部变量: 该函数至少需要16 000个字节(4000 x sizeof(int),每个整数是4个字节)的堆栈空间,以便 放置整数数组。通常情况下,编译器生成的用于分配该堆栈空间的代码只是将 CPU的堆栈指针 递减16 000个字节。但是,在程序试图访问内存地址之前,系统并不将物理存储器分配给堆栈 区域的这个较低区域。 在使用4 KB或8 KB 页面的系统上,这个局限性可能导致一个问题出现。如果初次访问堆 栈是在低于保护页面的一个地址上进行的(如上面这个代码中的赋值行所示),那么线程将访 问已经保留的内存并且引发访问违规。为了确保能够成功地编写上面所示的函数,编译器将插 入对C运行期库的堆栈检查函数的调用。 当编译程序时,编译器知道你针对的 CPU系统的页面大小。 x86编译器知道页面大小是 4 KB,Alpha编译器知道页面大小是8 KB。当编译器遇到程序中的每个函数时,它能确定该函数 390计计第三部分 内 存 管 理 下载 需要的堆栈空间的数量。如果该函数需要的堆栈空间大于目标系统的页面大小,编译器将自动 插入对堆栈检查函数的调用。 下面这个伪代码显示了堆栈检查函数执行什么操作。之所以称它是伪代码,是因为这个函 数通常是由编译器供应商用汇编语言来实现的: Microsoft 的Visual C++确实提供了一个编译器开关,使你能够控制一个页面大小的阈值, 这个阈值可供编译器用来确定何时添加对 StackCheck函数的自动调用。只有当确切地知道究竟 在进行什么操作并且有着特殊需要时,才能使用这个编译器开关。对于绝大多数应用程序和 DLL来说,都不应该使用这个开关。 16.3 Summation示例应用程序 本章后面清单16-1中的Summation(“16 Summation.exe”)示例应用程序展示了如何使用异 常过滤器和异常处理程序以便对堆栈溢出进行适度恢复的方法。该应用程序的源代码和资源文 件均位于本书所附光盘上的 16-Summation目录下。若要全面了解该应用程序是如何运行的,可 以参见关于 SEH 的有关章节。 Summation应用程序用于计算从 0到x的全部数字的总和,其中 x是用户输入的一个数字。 当然,进行这项操作的最简单的方法是创建一个称为 Sum的函数,它只是进行下面的计算: 然而对于这个例子来说,我将 Sum编写为一个递归函数,这样,如果输入较大的数字,它 将使用大量的堆栈空间。 当程序启动运行时,它将显示图 16-6所示的对话框。 下载 391 第 16章 线程的堆栈计计 在这个对话框中,你将一个数字输入编辑控件,然后单击 Calculate按钮。这使程序创建一 个新线程,该线程的唯一作用是将 0到x的全部数字进行相 加。当这个新线程运行时,程序的主线程通过调用 Wa i t F o r S i n g l e O b j e c t函数并传递新线程的句柄,等待线程 运行的结果。当新线程运行终止时,系统就唤醒主线程。 主线程取出合计的总数,方法是调用 GetExitCodeThread函 图16-6 Summation 对话框 数来获得新线程的退出代码。最后,最重要的一点是,主线程要关闭新线程的句柄,这样,系 统就能完全撤消线程对象,并且使应用程序不会出现资源的泄漏。 这时,主线程要查看合计线程的退出代码。退出代码 UNIT_MAX指明出现了一个错误, 即合计线程在计算数字的总数时产生了堆栈溢出,为此,主线程显示一个消息框,以说明这个 情况。如果退出代码不是 UNIX_MAX,那么合计线程将成功地结束其运行,而退出代码则是 合计。在这种情况下,主线程只是将合计的结果放入对话框。 下面让我们转入合计线程的介绍。该线程的线程函数称为 SumThreadFunc。当主线程创建 该线程时,它将应该合计的各个整数的数量作为唯一的参数来传递,这个参数就是 pvParam。 然后,函数将 uSum变量初始化为 UNIX_MAX ,这意味着该函数认为它将不会成功地完成运行。 接着, SumThreadFunc建立 SEH,这样,它就能够抓住线程运行时出现的任何异常条件。然后 调用递归函数Sum来计算总数。 如果成功地计算出总数, SumThreadFunc函数返回uSum变量的值,这是线程的退出代码。 但是,如果在sum函数运行时引发了一个异常条件,系统将立即对 SEH过滤器表达式进行计算。 换句话说,系统将调用 FilterFunc函数,并为它传递用于标识引发的异常条件的代码。如果是 堆栈溢出异常,那么该代码是 EXCEPTION_STACK_OVERFLOW。如果想要观察程序适度处 理堆栈溢出的异常条件,那么请告诉程序计算前面的 44 000个数字的总数。 我的 FilterFunc函数非常简单。它查看是否出现了堆栈溢出异常条件。如果没有出现,它 返回 EXCEPTION_CONTINUE_SEARCH。否则,该过滤器返回 EXCEPTION_EXECUTE_ HANDLER。它向系统指明过滤器预计到了这个异常条件,同时, Except块中包含的代码应该 执行。对于这个示例应用程序来说,异常处理程序没有什么特殊的操作需要执行,而是让线程 恰当地退出并返回代码 UNIT_MAX(这是 uSumNum中的值)。父线程将会看到这个特殊的返回 值,并向用户显示一条警告消息。 要说明的最后一点是,为什么要在 Sum函数自己的线程中运行 Sum函数,而不是在主线程 中建立一个 SEH块,并从 try块中调用 Sum函数。创建这个独立线程的理由有三: 首先,每次创建一个线程时,它会得到它自己的 1 MB堆栈区域。如果我从主线程中调用 Sum函数,那么有些堆栈空间就已经在使用了,因此Sum函数将无法使用完整的1 MB堆栈空间。 然而,我的示例应用程序是个简单的程序,也许它不需要使用那么多完整的堆栈空间,不过其 他程序可能要复杂得多。我能够非常容易地想像到这样一种情况,即 Sum函数能够成功地计算 出从1到1000的所有整数的总和。然后,当 Sum在以后被再次调用时,堆栈可能变得更深,从 而在Sum试图只计算从 0到750之间的整数的总和时,导致堆栈溢出的发生。因此,为了使 Sum 函数的运行具备更好的一致性,我设法使它拥有一个尚未被其他代码使用过的完整的堆栈。 使用独立线程的第二个原因是,关于堆栈溢出的异常条件,线程只能得到一次通知。如果我 调用主线程中的Sum函数,并且发生了堆栈溢出,那么就可以跟踪和恰当地处理该异常条件。但是, 这时已经向堆栈的所有已保留地址空间提交了物理存储器,并且没有更多的带有已打开的保护属性 标志的页面。如果用户执行另一个总数的计算,Sum函数就会使堆栈溢出,但是却不会引发堆栈溢 392计计第三部分 内 存 管 理 下载 出异常条件。相反,一个访问违规异常将会发生,而这时恰当地处理这个异常条件就太晚了。 使用独立的线程的最后一个原因是,该堆栈的物理存储器可以释放。请看下面这个例子。 用户要求Sum函数计算从 0到30 000之间的整数的总和。这需要将相当数量的物理存储器提交 给堆栈区域。然后,用户要进行若干个合计操作,其中最高的数字是 5000。在这种情况下, 大量的内存被提交给堆栈区域,但是却不再被使用。该物理存储器是从页文件那里分配来的。 不应该使该物理存储器保持提交状态,最好是释放该内存,重新将它交给系统和其他进程。 通过使 SumThreadFunc 的线程终止运行,系统将自动收回已经提交给堆栈区域的物理存储器。 清单16-1 Summation示例应用程序 下载 393 第 16章 线程的堆栈计计 394计计第三部分 内 存 管 理 下载 下载 395 第 16章 线程的堆栈计计 396计计第三部分 内 存 管 理 下载 下载 第17章 内存映射文件 对文件进行操作几乎是所有应用程序都必须进行的,并且这常常是人们争论的一个问题。应用 程序究竟是应该打开文件,读取文件并关闭文件,还是打开文件,然后使用一种缓冲算法,从文件 的各个不同部分进行读取和写入呢?Microsoft提供了一种两全其美的方法,那就是内存映射文件。 与虚拟内存一样,内存映射文件可以用来保留一个地址空间的区域,并将物理存储器提交 给该区域。它们之间的差别是,物理存储器来自一个已经位于磁盘上的文件,而不是系统的页 文件。一旦该文件被映射,就可以访问它,就像整个文件已经加载内存一样。 内存映射文件可以用于 3个不同的目的: • 系统使用内存映射文件,以便加载和执行 .exe和DLL文件。这可以大大节省页文件空间 和应用程序启动运行所需的时间。 • 可以使用内存映射文件来访问磁盘上的数据文件。这使你可以不必对文件执行 I/O操作, 并且可以不必对文件内容进行缓存。 • 可以使用内存映射文件,使同一台计算机上运行的多个进程能够相互之间共享数据。 Wi n d o w s确实提供了其他一些方法,以便在进程之间进行数据通信,但是这些方法都是 使用内存映射文件来实现的,这使得内存映射文件成为单个计算机上的多个进程互相进 行通信的最有效的方法。 本章将要介绍内存映射文件的各种使用方法。 17.1 内存映射的可执行文件和 DLL文件 当线程调用 CreateProcess时,系统将执行下列操作步骤: 1) 系统找出在调用 CreateProcess时设定的 .exe文件。如果找不到这个 .exe文件,进程将无 法创建, CreateProcess将返回 FALSE。 2) 系统创建一个新进程内核对象。 3) 系统为这个新进程创建一个私有地址空间。 4) 系统保留一个足够大的地址空间区域,用于存放该 .exe文件。该区域需要的位置在 .exe 文件本身中设定。按照默认设置, .exe文件的基地址是 0x00400000(这个地址可能不同于在 64 位Windows 2000上运行的 64位应用程序的地址),但是,可以在创建应用程序的 .exe文件时重 载这个地址,方法是在链接应用程序时使用链接程序的 /BASE选项。 5) 系统注意到支持已保留区域的物理存储器是在磁盘上的 .exe文件中,而不是在系统的页 文件中。 当.exe文件被映射到进程的地址空间中之后,系统将访问 .exe文件的一个部分,该部分列 出了包含 .exe文件中的代码要调用的函数的 DLL文件。然后,系统为每个 DLL文件调用 LoadLibrary函数,如果任何一个 DLL需要更多的DLL,那么系统将调用 LoadLibrary函数,以 便加载这些DLL。每当调用LoadLibrary来加载一个DLL时,系统将执行下列操作步骤,它们均 类似上面的第4和第5个步骤: 1) 系统保留一个足够大的地址空间区域,用于存放该 DLL文件。该区域需要的位置在 DLL文件本身中设定。按照默认设置, Microsoft的Visual C++ 建立的 DLL文件基地址是 398计计第三部分 内 存 管 理 下载 0x10000000(这个地址可能不同于在 64位Windows 2000上运行的 64位DLL的地址)但是,你 可以在创建 DLL文件时重载这个地址,方法是使用链接程序的 /BASE选项。Windows提供的所 有标准系统 DLL都拥有不同的基地址,这样,如果加载到单个地址空间,它们就不会重叠。 2) 如果系统无法在该DLL的首选基地址上保留一个区域,其原因可能是该区域已经被另一个 DLL或.exe占用,也可能是因为该区域不够大,此时系统将设法寻找另一个地址空间的区域来保 留该DLL。如果一个DLL无法加载到它的首选基地址,这将是非常不利的,原因有二。首先,如 果系统没有再定位信息,它就无法加载该DLL(可以在DLL创建时,使用链接程序的/FIXED开关, 从DLL中删除再定位信息,这能够使DLL变得比较小,但是这也意味着该DLL必须加载到它的首 选地址中,否则它就根本无法加载)。第二,系统必须在 DLL中执行某些再定位操作。在 Windows 98中,系统可以在页面被转入RAM时执行再定位操作。在Windows 2000中,这些再定 位操作需要由系统的页文件提供更多的存储器,它们也增加了加载DLL所需要的时间量。 3) 系统会注意到支持已保留区域的物理存储器位于磁盘上的 DLL文件中,而不是在系统的 页文件中。如果由DLL无法加载到它的首选基地址,Windows 2000必须执行再定位操作,那么 系统也将注意到 DLL的某些物理存储器已经被映射到页文件中。 如果由于某个原因系统无法映射 .exe和所有必要的DLL文件,那么系统就会向用户显示一 个消息框,并且释放进程的地址空间和进程对象。 CreateProcess函数将向调用者返回 FALSE, 调用者可以调用 GetLastError函数,以便更好地了解为什么无法创建该进程。 当所有的 .exe和DLL文件都被映射到进程的地址空间之后,系统就可以开始执行 .exe文件 的启动代码。当 .exe文件被映射后,系统将负责所有的分页、缓冲和高速缓存的处理。例如, 如果 .exe文件中的代码使它跳到一个尚未加载到内存的指令地址,那么就会出现一个错误。系 统能够发现这个错误,并且自动将这页代码从该文件的映像加载到一个 RAM页面。然后,系 统将这个 RAM页面映射到进程的地址空间中的相应位置,并且让线程继续运行,就像这页代 码已经加载了一样。当然,这一切是应用程序看不见的。当进程中的线程每次试图访问尚未加 载到RAM的代码或数据时,该进程就会重复执行。 17.1.1 可执行文件或DLL的多个实例不能共享静态数据 当为正在运行的应用程序创建新进程时,系统将打开用于标识可执行文件映像的文件映射 对象的另一个内存映射视图,并创建一个新进程对象和(为主线程创建)一个新线程对象。系 统还要将新的进程 ID和线程 ID赋予这些对象。通过使用内存映射文件,同一个应用程序的多个 正在运行的实例就能够共享 RAM中的相同代码和数据。 这里有一个小问题需要注意。进程使用的是一个平面地址空间。当编译和链接你的程序时, 所有的代码和数据都被合并在一起,组成一个很大的结构。数据与代码被分开,但仅限于跟 在.exe文件中的代码后面的数据而已 。图17-1简单说明了应用程序的代码和数据究竟是如何 加载到虚拟内存中,然后又被映射到应用程序的地址空间中的。 作为一个例子,假设应用程序的第二个实例正在运行。系统只是将包含文件的代码和数据 的虚拟内存页面映射到第二个应用程序的地址空间,如图 17-2所示。 如果应用程序的一个实例改变了驻留在数据页面中的某些全局变量,那么该应用程序的所 有实例的内存内容都将改变。这种类型的改变可能带来灾难性的后果,因此是决不允许的。 实际上,文件的内容被分割为不同的节。代码放在一个节中,全局变量放在另一个节中。各个节按照页面 边界来对齐。通过调用Get SystemInfo 函数,应用程序可以确定正在使用的页面的大小。在 .exe或DLL文件中,代码节 通常位于数据数据节的前面。 下载 399 第 17章 内存映射文件计计 系统运用内存管理系统的 copy-on-write(写入时拷贝)特性来防止进行这种改变。每当应 用程序尝试将数据写入它的内存映射文件时,系统就会抓住这种尝试,为包含应用程序尝试写 入数据的内存页面分配一个新内存块,再拷贝该页面的内容,并允许该应用程序将数据写入这 个新分配的内存块。结果,同一个应用程序的所有其他实例的运行都不会受到影响。图 17-3显 示了当应用程序的第一个实例尝试改变数据页面 2时出现的情况。 磁盘上的可执行文件 虚拟内存 应用程序的地址空间 代码节包含3个 代码页面 数据节包含2个 数据页面 代码页面 2 代码页面 1 数据页面 2 代码页面 3 数据页面 1 代码页面 1 代码页面 2 代码页面 3 数据页面 1 数据页面 2 图17-1 应用程序的代码和数据加载及映射示意图 第二个实例的地址空间 虚拟内存 第一个实例的地址空间 代码页面 1 代码页面 2 代码页面 1 代码页面 2 代码页面 1 代码页面 2 代码页面 3 数据页面 1 数据页面 2 代码页面 3 代码页面 3 数据页面 1 数据页面 2 数据页面 1 数据页面 2 图17-2 应用程序与虚拟内存地址空间之间的关系示意图 第二个实例的地址空间 代码页面 1 代码页面 2 代码页面 3 数据页面 1 数据页面 2 虚拟内存 代码页面 2 代码页面 1 数据页面 2 代码页面 3 数据页面 1 新页面 第一个实例的地址空间 代码页面 1 代码页面 2 代码页面 3 数据页面 1 数据页面 2 图17-3 应用程序的第一个实例尝试改变数据页面 2时的情况 系统分配一个新的虚拟内存页面,并且将数据页面 2的内容拷贝到新页面中。第一个实例的 地址空间发生了变更,这样,新数据页面就被映射到与原始地址页面相同位置上的地址空间中。 这时系统就可以让进程修改全局变量,而不必担心改变同一个应用程序的另一个实例的数据。 当应用程序被调试时,将会发生类似的事件。比如说,你正在运行一个应用程序的多个实 例,并且只想调试其中的一个实例。你访问调试程序,在一行源代码中设置一个断点。调试程 序修改了你的代码,将你的一个汇编语言指令改为能使调试程序自行激活的指令。因此你再次 400计计第三部分 内 存 管 理 下载 遇到了同样的问题。当调试程序修改代码时,它将导致应用程序的所有实例在修改后的汇编语 言指令运行时激活该调试程序。为了解决这个问题,系统再次使用 copy-on-write内存。当系统 发现调试程序试图修改代码时,它就分配一个新内存块,将包含该指令的页面拷贝到新的内存 页面中,并且允许调试程序修改页面拷贝中的代码。 Windows 98 当一个进程被加载时,系统要查看文件映像的所有页面。系统立即为通 常用copy-on-write属性保护的那些页面提交页文件中的存储器。这些页面只是被提交 而已,它们并不被访问。当文件映像中的页面被访问时,系统就加载相应的页面。如 果该页面从来没有被修改,它就可以从内存中删除,并在必要时重新加载。但是,如 果文件的页面被修改了,系统就将修改过的页面转到页文件中以前被提交的页面之一。 Windows 2000与Windows 98之间的行为特性的唯一差别,是在你加载一个模块的 两个拷贝并且可写入的数据尚未被修改的时候显示出来的。在这种情况下,在 Windows 2000下运行的进程能够共享数据,而在 Windows 98下,每个进程都可以得到 它自己的数据拷贝。如果只加载模块的一个拷贝,或者可写入的数据已经被修改(这 是通常的情况),那么Windows 2000与Windows 98 的行为特性是完全相同的。 17.1.2 在可执行文件或 DLL的多个实例之间共享静态数据 全局数据和静态数据不能被同一个 .exe或DLL文件的多个映像共享,这是个安全的默认设 置。但是,在某些情况下,让一个 .exe文件的多个映像共享一个变量的实例是非常有用和方便 的。例如, Windows没有提供任何简便的方法来确定用户是否在运行应用程序的多个实例。但 是,如果能够让所有实例共享单个全局变量,那么这个全局变量就能够反映正在运行的实例的 数量。当用户启动应用程序的一个实例时,新实例的线程能够简单地查看全局变量的值(它已 经被另一个实例更新);如果这个数量大于 1,那么第二个实例就能够通知用户,该应用程序 只有一个实例可以运行,而第二个实例将终止运行。 本节将介绍一种方法,它允许你共享 .exe或DLL文件的所有实例的变量。不过在介绍这个 方法之前,首先让我们介绍一些背景知识。 每个.exe或DLL文件的映像都由许多节组成。按照规定,每个标准节的名字均以圆点开头。 例如,当编译你的程序时,编译器会将所有代码放入一个名叫 .text的节中。该编译器还将所有 未经初始化的数据放入一个 .bss节,而已经初始化的所有数据则放入 .data节中。 每一节都拥有与其相关的一组属性,这些属性如表 17-1所示。 表17-1 .exe或DLL文件各节的属性 属性 含义 READ WRITE EXECUTE SHARED 该节中的字节可以读取 该节中的字节可以写入 该节中的字节可以执行 该节中的字节可以被多个实例共享(本属性能够有效地关闭 copy-on-write机制) 使用Microsoft的Visual Studio的DumpBin实用程序(带有 /Headers开关),可以查看 .exe或 DLL映射文件中各个节的列表。下面选录的代码是在一个可执行文件上运行 DumpBin程序而生 成的: 下载 401 第 17章 内存映射文件计计 402计计第三部分 内 存 管 理 下载 表17-2显示了比较常见的一些节的名字,并且说明了每一节的作用。 除了编译器和链接程序创建的标准节外,也可以在使用下面的命令进行编译时创建自己的节: 表17-2 常见的节名及作用 节名 .bss . C RT .data .debug .didata .edata .idata .rdata .reloc .rsrc .text .tls .xdata 作用 未经初始化的数据 C运行期只读数据 已经初始化的数据 调试信息 延迟输入文件名表 输出文件名表 输入文件名表 运行期只读数据 重定位表信息 资源 .exe或DLL文件的代码 线程的本地存储器 异常处理表 下载 403 第 17章 内存映射文件计计 我可以创建一个称为“ Shared”的节,它包含单个 LONG值,如下所示: 当编译器对这个代码进行编译时,它创建一个新节,称为 Shared,并将它在编译指示后面 看到的所有已经初始化( initialized)的数据变量放入这个新节中。在上面这个例子中,变量放 入Shared节中。该变量后面的 #pragma dataseg()一行告诉编译器停止将已经初始化的变量放入 Shared节,并且开始将它们放回到默认数据节中。需要记住的是,编译器只将已经初始化的变 量放入新节中。例如,如果我从前面的代码段中删除初始化变量(如下面的代码所示),那么 编译器将把该变量放入 Shared节以外的节中。 Microsoft 的Visual C++编译器提供了一个 Allocate说明符,使你可以将未经初始化的数据 放入你希望的任何节中。请看下面的代码: 上面的注释清楚地指明了指定的变量将被放入哪一节。若要使 Allocate声明的规则正确地 起作用,那么首先必须创建节。如果删除前面这个代码中的第一行 #pragma data_seg,上面的 代码将不进行编译。 之所以将变量放入它们自己的节中,最常见的原因也许是要在 .exe或DLL文件的多个映像 之间共享这些变量。按照默认设置, .exe或DLL文件的每个映像都有它自己的一组变量。然而, 可以将你想在该模块的所有映像之间共享的任何变量组合到它自己的节中去。当给变量分组时, 系统并不为 .exe或DLL文件的每个映像创建新实例。 仅仅告诉编译器将某些变量放入它们自己的节中,是不足以实现对这些变量的共享的。还 404计计第三部分 内 存 管 理 下载 必须告诉链接程序,某个节中的变量是需要加以共享的。若要进行这项操作,可以使用链接程 序的命令行上的 /SECTION开关: 在冒号的后面,放入你想要改变其属性的节的名字。在我们的例子中,我们想要改变 Shared节的属性。因此应该创建下面的链接程序开关: 在逗号的后面,我们设定了需要的属性。用 R 代表 R E A D , W 代表 W E I T E , E 代表 EXECUTE,S代表SHARED。上面的开关用于指明位于 Shared节中的数据是可以读取、写入和 共享的数据。如果想要改变多个节的属性,必须多次设定 /SECTION开关,也就是为你要改变 属性的每个节设定一个 /SECTION开关。 也可以使用下面的句法将链接程序开关嵌入你的源代码中: 这一行代码告诉编译器将上面的字符串嵌入名字为“ .drectve”的节。当链接程序将所有 的.obj模块组合在一起时,链接程序就要查看每个 .obj模块的“ .drectve”节,并且规定所有的 字符串均作为命令行参数传递给该链接程序。我一直使用这种方法,因为它非常方便。如果将 源代码文件移植到一个新项目中,不必记住在 Visual C++的Project Settings(项目设置)对话框 中设置链接程序开关。 虽然可以创建共享节,但是,由于两个原因, Microsoft并不鼓励你使用共享节。第一,用 这种方法共享内存有可能破坏系统的安全。第二,共享变量意味着一个应用程序中的错误可能 影响另一个应用程序的运行,因为它没有办法防止某个应用程序将数据随机写入一个数据块。 假设你编写了两个应用程序,每个应用程序都要求用户输入一个口令。然而你又决定给应 用程序添加一些特性,使用户操作起来更加方便些:如果在第二个应用程序启动运行时,用户 正在运行其中的一个应用程序,那么第二个应用程序就可以查看共享内存的内容,以便获得用 户的口令。这样,如果程序中的某一个已经被使用,那么用户就不必重新输入他的口令。 这听起来没有什么问题。毕竟没有别的应用程序而只有你自己的应用程序加载了 DLL,并 且知道到什么地方去查找包含在共享节中的口令。但是,黑客正在窥视着你的行动,如果他们 想要得到你的口令,只需要编写一段很短的程序,加载到你的公司的 DLL文件中,然后监控共 享内存块。当用户输入口令时,黑客的程序就能知道该用户的口令。 黑客精心编制的程序也可能试图反复猜测用户的口令并将它们写入共享内存。一旦该程序 猜测到正确的口令,它就能够将各种命令发送给两个应用程序中的一个。如果有一种办法只为 某些应用程序赋予访问权,以便加载一个特定的 DLL,那么这个问题也许是可以解决的。但是 目前还不行,因为任何程序都能够调用 LoadLibrary函数来显式加载DLL。 17.1.3 AppInst示例应用程序 清单17-1列出的AppInst示例应用程序(“17 AppInst.exe”)显示了应用程序如何能够知道 每次有多少个应用程序的实例正在运行。该应用程序的源代 码和资源文件位于本书所附光盘上的 17-AppInst目录下。当运 行AppInst程序时,就会出现它的对话框(见图 17-4),指明该 应用程序的一个实例正在运行。 如果运行该应用程序的第二个实例,那么第一和第二个 图17-4 运行AppInst 时 出现的对话框 实例的对话框都会发生变化,以反映目前两个实例都在运行(见图 17-5,图17-6)。 下载 405 第 17章 内存映射文件计计 图17-5 运行 AppInst 的第二个实例时, 第一个实例对话框的变化 图17-6 运行 AppInst 的第二个实例时, 第二个实例对话框的变化 可以根据你的喜好,运行和撤消任意多个实例,实例的数量始终都能正确地反映在仍然保 留的实例中。 在靠近AppInst.cpp应用程序的顶部,可以看到下面的代码行: 这些代码行用于创建一个称为 Shared的节,该节拥有读取、写入和共享保护属性。在这个 节中,有一个变量是g_lApplicationInstances。该应用程序的所有实例均可以共享该变量。注意, 该变量是个易失性变量,因此优化程序对我们不起多大的作用。 当每个实例的 _tWinMain函数执行时, g_lApplicationInstances变量就递增1。在_tWinMain 退出之前,该变量将递减 1。我使用InterlockedExchangeAdd来改变这个变量,因为多个线程将 要访问该共享资源。 当每个实例的对话框出现时, Dlg_OnInitDialog函数就被调用。该函数将一个注册窗口消 息广播发送到所有的高层窗口(该消息的 ID包含在g_aMsgAppInstCountUpdate变量中): 系统中的所有窗口将忽略这个注册窗口消息,但 AppInst的各个窗口例外。当我们的各个 窗口中的一个接收到该消息时, Dlg_Proc中的代码将更新该对话框中的实例数量,以反映当前 的实例数量(该数量在 g_lApplicationInstances共享变量中进行维护)。 清单17-1 AppInst示例应用程序 406计计第三部分 内 存 管 理 下载 下载 407 第 17章 内存映射文件计计 408计计第三部分 内 存 管 理 下载 下载 409 第 17章 内存映射文件计计 17.2 内存映射数据文件 操作系统使得内存能够将一个数据文件映射到进程的地址空间中。因此,对大量的数据进 行操作是非常方便的。 为了理解用这种方法来使用内存映射文件的功能,让我们看一看如何用 4种方法来实现一 个程序,以便将文件中的所有字节的顺序进行倒序。 17.2.1 方法1:一个文件,一个缓存 第一种方法也是理论上最简单的方法,它需要分配足够大的内存块来存放整个文件。该文 件被打开,它的内容被读入内存块,然后该文件被关闭。文件内容进入内存后,我们就可以对 所有字节的顺序进行倒序,方法是将第一个字节倒腾为最后一个字节,第二个字节倒腾为倒数 第二个字节,依次类推。这个倒腾操作将一直进行下去直到文件的中间位置。当所有的字节都 已经倒腾之后,就可以重新打开该文件,并用内存块的内容来改写它的内容。 这种方法实现起来非常容易,但是它有两个缺点。首先,必须分配一个与文件大小相同的 内存块。如果文件比较小,那么这没有什么问题。但是如果文件非常大,比如说有 2GB大,那 该怎么办呢?一个 32位的系统不允许应用程序提交那么大的物理内存块。因此大文件需要使用 不同的方法。 第二,如果进程在运行过程的中间被中断,也就是说当倒序后的字节被重新写入该文件时 进程被中断,那么文件的内容就会遭到破坏。防止出现这种情况的最简单的方法是在对它的内 容进行倒序之前先制作一个原始文件的拷贝。如果整个进程运行成功,那么可以删除该文件的 拷贝。这种方法需要更多的磁盘空间。 17.2.2 方法2:两个文件,一个缓存 在第二种方法中,你打开现有的文件,并且在磁盘上创建一个长度为 0的新文件。然后分 410计计第三部分 内 存 管 理 下载 配一个比较小的内部缓存,比如说 8 KB。你找到离原始文件结尾还有 8 KB的位置,将这最后 的8 KB读入缓存,将字节倒序,再将缓存中的内容写入新创建的文件。这个寻找、读入、倒 序和写入的操作过程要反复进行,直到到达原始文件的开头。如果文件的长度不是 8 KB的倍 数,那么必须进行某些特殊的处理。当原始文件完全处理完毕之后,将原始文件和新文件关闭, 并删除原始文件。 这种方法实现起来比第一种方法要复杂一些。它对内存的使用效率要高得多,因为它只需 要分配一个 8 KB的缓存块,但是它存在两个大问题。首先,它的处理速度比第一种方法要慢, 原因是在每个循环操作过程中,在执行读入操作之前,必须对原始文件进行寻找操作。第二, 这种方法可能要使用大量的硬盘空间。如果原始文件是 400 MB,那么随着进程的不断运行, 新文件就会增大为 400 MB。在原始文件被删除之前,两个文件总共需要占用 800 MB的磁盘空 间。这比应该需要的空间大 400 MB。由于存在这个缺点,因此引来了下一个方法。 17.2.3 方法3:一个文件,两个缓存 如果使用这个方法,那么我们假设程序初始化时分配了两个独立的 8 KB缓存。程序将文件 的第一个8 KB读入一个缓存,再将文件的第二个 8 KB 读入另一个缓存。然后进程将两个缓存 的内容进行倒序,并将第一个缓存的内容写回文件的结尾处,将第二个缓存的内容写回同一个 文件的开始处。每个迭代操作不断进行(以 8 KB为单位,从文件的开始和结尾处移动文件块)。 如果文件的长度不是16 KB的倍数,并且有两个8 KB的文件块相重叠,那么就需要进行一些特 殊的处理。这种特殊处理比上一种方法中的特殊处理更加复杂,不过这难不倒经验丰富的编程 员。 与前面的两种方法相比,这种方法在节省硬盘空间方面有它的优点。由于所有内容都是从 同一个文件读取并写入同一个文件,因此不需要增加额外的磁盘空间,至于内存的使用,这种 方法也不错,它只需要使用 16 KB的内存。当然,这种方法也许是最难实现的方法。与第一种 方法一样,如果进程被中断,本方法会导致数据文件被破坏。 下面让我们来看一看如何使用内存映射文件来完成这个过程。 17.2.4 方法4:一个文件,零缓存 当使用内存映射文件对文件内容进行倒序时,你打开该文件,然后告诉系统将虚拟地址空 间的一个区域进行倒序。你告诉系统将文件的第一个字节映射到该保留区域的第一个字节。然 后可以访问该虚拟内存的区域,就像它包含了这个文件一样。实际上,如果在文件的结尾处有 一个单个 0字节,那么只需要调用 C 运行期函数 _strrev,就可以对文件中的数据进行倒序操作。 这种方法的最大优点是,系统能够为你管理所有的文件缓存操作。不必分配任何内存,或 者将文件数据加载到内存,也不必将数据重新写入该文件,或者释放任何内存块。但是,内存 映射文件仍然可能出现因为电源故障之类的进程中断而造成数据被破坏的问题。 17.3 使用内存映射文件 若要使用内存映射文件,必须执行下列操作步骤: 1) 创建或打开一个文件内核对象,该对象用于标识磁盘上你想用作内存映射文件的文件。 2) 创建一个文件映射内核对象,告诉系统该文件的大小和你打算如何访问该文件。 3) 让系统将文件映射对象的全部或一部分映射到你的进程地址空间中。 当完成对内存映射文件的使用时,必须执行下面这些步骤将它清除: 下载 411 第 17章 内存映射文件计计 1) 告诉系统从你的进程的地址空间中撤消文件映射内核对象的映像。 2) 关闭文件映射内核对象。 3) 关闭文件内核对象。 下面将详细介绍这些操作步骤。 17.3.1 步骤1:创建或打开文件内核对象 若要创建或打开一个文件内核对象,总是要调用 CreateFile函数: CreateFile函数拥有好几个参数。这里只重点介绍前 3个参数,即pszFileName,dwDesired Access和dwShareMode。 你可能会猜到,第一个参数 pszFileName用于指明要创建或打开的文件的名字(包括一个 选项路径)。第二个参数dwDesiredAccess用于设定如何访问该文件的内容。可以设定表 17-3所 列的4个值中的一个。 表17-3 dwDesiredAccess的值 值 含义 0 GENERIC_READ GENERIC_WRITE GENERIC_READ |GENERIC_WRITE 不能读取或写入文件的内容。当只想获得文件的属性时,请设定 0 可以从文件中读取数据 可以将数据写入文件 可以从文件中读取数据,也可以将数据写入文件 当创建或打开一个文件,将它作为一个内存映射文件来使用时,请选定最有意义的一个或 多个访问标志,以说明你打算如何访问文件的数据。对内存映射文件来说,必须打开用于只读 访问或读写访问的文件,因此,可以分别设定 GENERIC_READ或GENERIC_READ | GENERIC_WRITE。 第三个参数 dwShareMode告诉系统你想如何共享该文件。可以为 dwShareMode设定表17-4 所列的4个值之一。 表17-4 dwShareMode 的值 值 含义 0 FILE_SHARE_READ FILE_SHARE_WRITE FILE_SHARE_READ FILE_SHARE_WRITE| 打开文件的任何尝试均将失败 使用GENERIC_WRITE打开文件的其他尝试将会失败 使用GENERIC_READ打开文件的其他尝试将会失败 打开文件的其他尝试将会取得成功 如果 CreateFile函数成功地创建或打开指定的文件,便返回一个文件内核对象的句柄,否 则返回INVALID_HANDLE_VALUE。 412计计第三部分 内 存 管 理 下载 注意 能够返回句柄的大多数Windows函数如果运行失败,那么就会返回NULL。但是, CreateFile函数将返回INVALID_HANDLE_VALUE,它定义为((HANDLE)-1)。 17.3.2 步骤2:创建一个文件映射内核对象 调用CreateFile函数,就可以将文件映像的物理存储器的位置告诉操作系统。你传递的路径 名用于指明支持文件映像的物理存储器在磁盘(或网络或光盘)上的确切位置。这时,必须告 诉系统,文件映射对象需要多少物理存储器。若要进行这项操作,可以调用 CreateFileMapping 函数: 第一个参数 hFile用于标识你想要映射到进程地址空间中的文件句柄。该句柄由前面调用的 CreateFile函数返回。psa参数是指向文件映射内核对象的 SECURITY_ATTRIBUTES结构的指针, 通常传递的值是 NULL(它提供默认的安全特性,返回的句柄是不能继承的)。 本章开头讲过,创建内存映射文件就像保留一个地址空间区域然后将物理存储器提交给该 区域一样。因为内存映射文件的物理存储器来自磁盘上的一个文件,而不是来自从系统的页文 件中分配的空间。当创建一个文件映射对象时,系统并不为它保留地址空间区域,也不将文件 的存储器映射到该区域(下一节将介绍如何进行这项操作)。但是,当系统将存储器映射到进 程的地址空间中去时,系统必须知道应该将什么保护属性赋予物理存储器的页面。 CreateFileMapping函数的fdwProtect参数使你能够设定这些保护属性。大多数情况下,可以设 定表 17-5中列出的 3个保护属性之一。 表17-5 使用fdwProtect 参数设定的部分保护属性 保护属性 PA G E _ R E A D O N LY PA G E _ R E A D W R I T E PA G E _ W R I T E C O P Y 含义 当文件映射对象被映射时,可以读取文件的数据。必须已 经将GENERIC_READ传递给CreateFile函数 当文件映射对象被映射时,可以读取和写入文件的数据。必 须已经将GENERIC_READ | GENERIC_WRITE传递给CreateFile 当文件映射对象被映射时,可以读取和写入文件的数据。 如果写入数据,会导致页面的私有拷贝得以创建。必须已经 将GENERIC_READ或GENERIC_WRITE传递给CreateFile Windows 98 在Windows 98下,可以将 PAGE_WRITECOPY标志传递给 CreateFile Mapping,这将告诉系统从页文件中提交存储器。该页文件存储器是为数据文件的数 据拷贝保留的,只有修改过的页面才被写入页文件。你对该文件的数据所作的任何修 改都不会重新填入原始数据文件。其最终结果是, PAGE_WRITECOPY标志的作用在 Windows 2000 和Windows 98 上是相同的。 除了上面的页面保护属性外,还有 4个节保护属性,你可以用 OR将它们连接起来放入 CreateFileMapping函数的fdwProtect参数中。节只是用于内存映射的另一个术语。 下载 413 第 17章 内存映射文件计计 节的第一个保护属性是 SEC_NOCACHE,它告诉系统,没有将文件的任何内存映射页面 放入高速缓存。因此,当将数据写入该文件时,系统将更加经常地更新磁盘上的文件数据。这 个标志与PAGE_NOCACHE保护属性标志一样,是供设备驱动程序开发人员使用的,应用程序 通常不使用。 Windows 98 Windows 98将忽略SEC_NOCACHE标志。 节的第二个保护属性是 SEC_IMAGE,它告诉系统,你映射的文件是个可移植的可执行 (PE)文件映像。当系统将该文件映射到你的进程的地址空间中时,系统要查看文件的内容, 以确定将哪些保护属性赋予文件映像的各个页面。例如, PE文件的代码节( .text)通常用 PA G E _ E X E C U T E _ R E A D 属 性进 行映 射, 而 P E 文 件的 数据 节 ( . d a t a ) 则通 常用 PAGE_READWRITE属性进行映射。如果设定的属性是 SEC_IMAGE,则告诉系统进行文件映 像的映射,并设置相应的页面保护属性。 Windows 98 Windows 98 将忽略SEC_IMAGE标志。 最后两个保护属性是 SEC_RESERVE和SEC_COMMIT,它们是两个互斥属性,当使用内 存映射数据文件时,它们不能使用。这两个标志将在本章后面介绍。当创建内存映射数据文件 时,不应该设定这些标志中的任何一个标志。 CreateFileMapping将忽略这些标志。 CreateFileMapping的另外两个参数是 dwMaximumSizeHigh和dwMaximumSizeLow,它们是 两个最重要的参数。 CreateFileMapping函数的主要作用是保证文件映射对象能够得到足够的物 理存储器。这两个参数将告诉系统该文件的最大字节数。它需要两个 32位的值,因为Windows 支持的文件大小可以用 64位的值来表示。 dwMaximumSizeHigh参数用于设定较高的 32位,而 dwMaximumSizeLow参数则用于设定较低的 32位值。对于 4 GB 或小于 4 GB的文件来说, dwMaximumSizeHigh的值将始终是 0。 使用64位的值,意味着 Windows能够处理最大为 16EB(1018字节)的文件。如果想要创建 一个文件映射对象,使它能够反映文件当前的大小,那么可以为上面两个参数传递 0。如果只 打算读取该文件或者访问文件而不改变它的大小,那么为这两个参数传递 0。如果打算将数据 附加给该文件,可以选择最大的文件大小,以便为你留出一些富裕的空间。如果当前磁盘上的 文件包含 0字节,那么可以给 CreateFileMapping函数的 dwMaximumSizeHigh和dwMaximum SizeLow传递两个0。这样做就可以告诉系统,你要的文件映射对象里面的存储器为 0字节。这 是个错误, CreateFileMapping将返回 NULL。 如果你对我们讲述的内容一直非常关注,你一定认为这里存在严重的问题。 Windows支持 最大为16EB的文件和文件映射对象,这当然很好,但是,怎样将这样大的文件映射到 32位进 程的地址空间( 32位地址空间是 4GB文件的上限)中去呢?下一节介绍解决这个问题的办法。 当然,64位进程拥有16 EB的地址空间,因此可以进行更大的文件的映射操作,但是,如果文 件是个超大规模的文件,仍然会遇到类似的问题。 若要真正理解 CreateFile和CreateFileMapping两个函数是如何运行的,建议你做一个下面的 实验。建立下面的代码,对它进行编译,然后在一个调试程序中运行它。当你一步步执行每个 语句时,你会跳到一个命令解释程序,并执行 C:\目录上的“dir”命令。当执行调试程序中的 每个语句时,请注意目录中出现的变化。 414计计第三部分 内 存 管 理 下载 如果调用CreateFileMapping函数,传递 PAGE_READWRITE标志,那么系统将设法确保磁 盘上的相关数据文件的大小至少与 dwMaximumSizeHigh和dwMaximumSizeLow参数中设定的 大小相同。如果该文件小于设定的大小, CreateFileMapping函数将扩展该文件的大小,使磁盘 上的文件变大。这种扩展是必要的,这样,当以后将该文件作为内存映射文件使用时,物理存 储器就已经存在了。如果正在用 PAGE_READONLY或PAGE_WRITECOPY标志创建该文件映 射对象,那么 CreateFileMapping特定的文件大小不得大于磁盘文件的物理大小。这是因为你无 法将任何数据附加给该文件。 CreateFileMapping函数的最后一个参数是 pszName。它是个以 0结尾的字符串,用于给该文 件映射对象赋予一个名字。该名字用于与其他进程共享文件映射对象(本章后面展示了它的一 个例子。第3章详细介绍了内核对象的共享操作)。内存映射数据文件通常并不需要被共享,因 此这个参数通常是NULL。 系统创建文件映射对象,并将用于标识该对象的句柄返回该调用线程。如果系统无法创建 文件映射对象,便返回一个NULL句柄值。记住,当CreateFile运行失败时,它将返回INVALID_ HANDLE_VALUE(定义为- 1),当CreateFileMapping运行失败时,它返回 NULL。请不要混淆 这些错误值。 17.3.3 步骤3:将文件数据映射到进程的地址空间 当创建了一个文件映射对象后,仍然必须让系统为文件的数据保留一个地址空间区域,并 将文件的数据作为映射到该区域的物理存储器进行提交。可以通过调用 MapViewOfFile函数来 进行这项操作: 下载 415 第 17章 内存映射文件计计 参数hFileMappingObject用于标识文件映射对象的句柄,该句柄是前面调用CreateFile Mapping 或OpenFileMapping(本章后面介绍)函数返回的。参数dwDesiredAccess用于标识如何访问该数 据。不错,必须再次设定如何访问文件的数据。可以设定表17-6所列的4个值中的一个。 表17-6 值及其含义 值 含义 FILE_MAP_WRITE FILE_MAP_READ FILE_MAP_ALL_ACCESS FILE_MAP_COPY 可以读取和写入文件数据。 CreateFileMapping函数必须通过传递 PA G E _ R E A D W R I T E标志来调用 可以读取文件数据。CreateFileMapping函数可以通过传递下列任何一个保护 属性来调用:PAGE_READONLY、PAGE_READWRITE或PAGE_WRITECOPY 与FILE_MAP_WRITE相同 可以读取和写入文件数据。如果写入文件数据,可以创建一个页面的私有 拷贝。在Windows 2000中,CreateFileMapping函数可以用PAGE_READONLY、 PAGE_READWRITE或PAGE_WRITECOPY等保护属性中的任何一个来调用。 在Windows 98中,CreateFileMapping必须用PAGE_WRITECOPY来调用 Windows要求所有这些保护属性一次又一次地重复设置,这当然有些奇怪和烦人。我认为 这样做可以使应用程序更多地对数据保护属性进行控制。 剩下的3个参数与保留地址空间区域及将物理存储器映射到该区域有关。当你将一个文件 映射到你的进程的地址空间中时,你不必一次性地映射整个文件。相反,可以只将文件的一小 部分映射到地址空间。被映射到进程的地址空间的这部分文件称为一个视图,这可以说明 MapViewOfFile是如何而得名的。 当将一个文件视图映射到进程的地址空间中时,必须规定两件事情。首先,必须告诉系统, 数据文件中的哪个字节应该作为视图中的第一个字节来映射。你可以使用 dwFileOffsetHigh和 dwFileOffsetLow参数来进行这项操作。由于 Windows支持的文件最大可达 16EB,因此必须用 一个 6 4位的值来设定这个字节的位移值。这个 6 4位值中,较高的 3 2位传递给参数 dwFileOffsetHigh,较低的32位传递给参数dwFileOffsetLow。注意,文件中的这个位移值必须 是系统的分配粒度的倍数(迄今为止, Windows的所有实现代码的分配粒度均为 64 KB)。第14 章介绍了如何获取某个系统的分配粒度。 第二,必须告诉系统 ,数据文件有多少字节要映射到地址空间。这与设定要保留多大的地 址空间区域的情况是相同的。可以使用 dwNumberOfBytesToMap参数来设定这个值。如果设定 的值是0,那么系统将设法把从文件中的指定位移开始到整个文件的结尾的视图映射到地址空 间。 Windows 98 在Windows 98中,如果MapViewOfFile无法找到足够大的区域来存放整 个文件映射对象,那么无论需要的视图是多大, MapViewOfFile均将返回NULL。 Windows 2000 在Windows 2000中,MapViewOfFile只需要为必要的视图找到足够大 的一个区域,而不管整个文件映射对象是多大。 如果在调用 MapViewOfFile函数时设定了 FILE_MAP_COPY标志,系统就会从系统的页文 件中提交物理存储器。提交的地址空间数量由 dwNumberOfBytesToMap参数决定。只要你不进 行其他操作,只是从文件的映像视图中读取数据,那么系统将决不会使用页文件中的这些提交 的页面。但是,如果进程中的任何线程将数据写入文件的映像视图中的任何内存地址,那么系 统将从页文件中抓取已提交页面中的一个页面,将原始数据页面拷贝到该页交换文件中,然后 416计计第三部分 内 存 管 理 下载 将该拷贝的页面映射到你的进程的地址空间。从这时起,你的进程中的线程就要访问数据的本 地拷贝,不能读取或修改原始数据。 当系统制作原始页面的拷贝时,系统将把页面的保护属性从 PAGE_WRITECOPY改为 PA G E _ R E A D W R I T E。下面这个代码段就说明了这个情况: Windows 98 前面讲过,Windows 98必须预先为内存映射文件提交页文件中的存储 器。然而,它只有在必要时才将修改后的页面写入页文件。 17.3.4 步骤4:从进程的地址空间中撤消文件数据的映像 当不再需要保留映射到你的进程地址空间区域中的文件数据时,可以通过调用下面的函数 下载 将它释放: 417 第 17章 内存映射文件计计 该函数的唯一的参数 pvBaseAddress用于设定返回区域的基地址。该值必须与调用 MapViewOfFile函数返回的值相同。必须记住要调用 UnmapViewOfFile函数。如果没有调用这 个函数,那么在你的进程终止运行前,保留的区域就不会被释放。每当你调用 MapViewOfFile 时,系统总是在你的进程地址空间中保留一个新区域,而以前保留的所有区域将不被释放。 为了提高速度,系统将文件的数据页面进行高速缓存,并且在对文件的映射视图进行操作 时不立即更新文件的磁盘映像。如果需要确保你的更新被写入磁盘,可以强制系统将修改过的 数据的一部分或全部重新写入磁盘映像中,方法是调用 FlushViewOfFile函数: 第一个参数是包含在内存映射文件中的视图的一个字节的地址。该函数将你在这里传递的 地址圆整为一个页面边界值。第二个参数用于指明你想要刷新的字节数。系统将把这个数字向 上圆整,使得字节总数是页面的整数。如果你调用 FlushViewOfFile函数并且不修改任何数据, 那么该函数只是返回,而不将任何信息写入磁盘。 对于存储器是在网络上的内存映射文件来说, FlushViewOfFile能够保证文件的数据已经从 工作站写入存储器。但是 FlushViewOfFile不能保证正在共享文件的服务器已经将数据写入远程 磁盘,因为服务器也许对文件的数据进行了高速缓存。若要保证服务器写入文件的数据,每当 你为文件创建一个文件映射对象并且映射该文件映射对象的视图时,应该将 FILE_FLAG_ WRITE_THROUGH标志传递给CreateFile函数。如果你使用该标志打开该文件,那么只有当文 件的全部数据已经存放在服务器的磁盘驱动器中的时候, FlushViewOfFile函数才返回。 记住UnmapViewOfFile函数的一个特殊的特性。如果原先使用 FILE_MAP_COPY标志来映 射视图,那么你对文件的数据所作的任何修改,实际上是对存放在系统的页文件中的文件数据 的拷贝所作的修改。在这种情况下,如果调用 UnmapViewOfFile函数,该函数在磁盘文件上就 没有什么可以更新,而只会释放页文件中的页面,从而导致数据丢失。 如果想保留修改后的数据,必须采用别的措施。例如,你可以用同一个文件创建另一个文 件映射对象(使用 PAGE_READWRITE),然后使用 FILE_MAP_WRITE标志将这个新文件映射 对象映射到进程的地址空间。之后,你可以扫描第一个视图,寻找带有 PAGE_READWRITE保 护属性的页面。每当你找到一个带有该属性的页面时,可以查看它的内容,并且确定是否将修 改了的数据写入该文件。如果不想用新数据更新该文件,那么继续对视图中的剩余页面进行扫 描,直到视图的结尾。但是,如果你确实想要保存修改了的数据页面,那么只需要调用 M o v e M e m o r y函数,将数据页面从第一个视图拷贝到第二个视图。由于第二个视图是用 PAGE_READWRITE保护属性映射的,因此 MoveMemory函数将更新磁盘上的实际文件内容。 可以使用这种方法来确定文件的变更并保存你的文件的数据。 Windows 98 Windows 98不支持copy-on-write(写入时拷贝)保护属性,因此,当扫 描内存映射文件的第一个视图时,无法测试用 PAGE_READWRITE标志做上标记的页 面。你必须设计一种方法来确定第一个视图中的哪些页面已经做了修改。 17.3.5 步骤5和步骤6:关闭文件映射对象和文件对象 不用说,你总是要关闭你打开了的内核对象。如果忘记关闭,在你的进程继续运行时会出 418计计第三部分 内 存 管 理 下载 现资源泄漏的问题。当然,当你的进程终止运行时,系统会自动关闭你的进程已经打开但是忘 记关闭的任何对象。但是如果你的进程暂时没有终止运行,你将会积累许多资源句柄。因此你 始终都应该编写清楚而又“正确的”代码,以便关闭你已经打开的任何对象。若要关闭文件映 射对象和文件对象,只需要两次调用 CloseHandle函数,每个句柄调用一次: 让我们更加仔细地观察一下这个进程。下面的伪代码显示了一个内存映射文件的例子: 上面的代码显示了对内存映射文件进行操作所用的“预期”方法。但是,它没有显示,当 你调用MapViewOfFile时系统对文件对象和文件映射对象的使用计数的递增情况。这个副作用 是很大的,因为它意味着我们可以将上面的代码段重新编写成下面的样子: 当对内存映射文件进行操作时,通常要打开文件,创建文件映射对象,然后使用文件映射 对象将文件的数据视图映射到进程的地址空间。由于系统递增了文件对象和文件映射对象的内 部使用计数,因此可以在你的代码开始运行时关闭这些对象,以消除资源泄漏的可能性。 如果用同一个文件来创建更多的文件映射对象,或者映射同一个文件映射对象的多个视图, 那么就不能较早地调用 CloseHandle函数——以后你可能还需要使用它们的句柄,以便分别对 CreateFileMapping和MapViewOfFile函数进行更多的调用。 17.3.6 文件倒序示例应用程序 清单 17-2 列出的 FileRev应用程序(“17 FileRev.exe”)显示了如何使用内存映射对象来 对ANSI或Unicode文本文件的内容进行倒序。 该应用程序的源代码和资源文件位于本书所附 光盘上的 17-FileRev目录下。当启动该程序时, 会出现图 17-7所示的窗口。 图17-7 运行FileRev 时出现的窗口 FileRev应用程序首先允许选定一个文件,然后,当单击 Reverse File Contents(对文件内 容进行倒序)按钮时,该函数就会将文件中使用的字符进行倒序。该程序只能对文本文件进行 正确的倒序,对二进制文件不能正确地进行倒序操作。 FileRev能够确定文本文件是 ANSI文件 还是Unicode文件,方法是调用 IsTextUnicode函数(第2章中介绍)。 Windows 98 在Windows 98中,IsTextUnicode函数没有可以使用的实现代码,它只是返 下载 419 第 17章 内存映射文件计计 回FALSE。如果调用GetLastError函数,则返回ERROR_CALL_NOT_ IMPLEMENTED。 这意味着 FileRev示例应用程序总是认为,当它在 Windows 98下运行时,它操作的是 A N S I文本文件。 当单击Reverse File Contents按钮时,FileRev便制作指定文件的一个拷贝,称为 FileRev.dat。 它制作该拷贝的目的是,原始文件不会因为内容被倒序而变得无法使用。接着, FileRev调用 FileReverse函数,该函数负责对文件进行倒序操作。 FileReverse则调用 CreateFile函数,打开 FileRev.dat,以便进行读取和写入。 前面说过,对文件内容进行倒序的最容易的方法是调用 C运行期函数_strrev。与所有C字符 串一样,字符串的最后一个字符必须是个 0结束符。由于文本文件不以 0为结束符,因此 FileRev必须给文件附加一个0。若要进行这样的附加操作,首先要调用 GetFileSize函数: 现在已经得到了文件的长度,你能够通过调用 CreateFileMapping函数创建文件映射对象。 创建的文件映射对象的长度是 dwFileSize加一个宽字符的大小(对于 0字符来说)。当文件映射 对象创建后,该对象的视图就被映射到 FileRev的地址空间。变量 pvFile包含了MapViewOfFile 函数的返回值,并指向文本文件的第一个字节。 下一步是在文件的结尾处写一个 0,并对字符串进行倒序: 在文本文件中,每一行的结尾都是一个回车符(‘\r’)后随一个换行符(‘\n’)。但是,当 调用_strrev对文件进行倒序时,这些字符也会被倒序。如果将已经倒序的文本文件加载到文本 编辑器,那么出现的每一对“ \n\r”字符都必须重新改为它的原始顺序。这个倒序操作是由下 面的循环代码进行的: 当你观察这样一个简单的代码时,可能很容易忘记你实际上是在对磁盘驱动器上的文件内 容进行操作(这显示出内存映射文件的功能是多么大)。 在文件被倒序后, FileRev便进行清除操作,撤消文件映射对象的视图映象,关闭所有的 内核对象句柄。此外, FileRev必须删除附加给文件结尾处的 0字符(记住_strrev并不对结尾的0 字符进行倒序)。如果没有删除 0字符,那么倒序的文件将会多出一个字符,如果再次调用 FileRev函数,将无法使文件还原成它的原始样子。若要删除文件结尾处的 0字符,必须后退一 步,使用文件管理函数,而不是通过内存映射对文件进行操作。 如果要强制已经倒序的文件在某个位置上结束,就需要将文件指针定位在指定的位置(原 始文件的结尾处)并调用 SetEndOfFile函数: 注意 SetEndOfFile函数必须在撤消视图的映象并且关闭文件映射对象之后调用,否 则,该函数将返回 FALSE,GetLastError则返回 ERROR_USER_MAPPED_FILE。这个 错误表示不能在与文件映射对象相关联的文件上执行文件未尾的操作。 420计计第三部分 内 存 管 理 下载 FileRev函数做的最后一件事情是产生一个 Notepad实例,这样,就可以查看已经倒序的文 件。图 17-8显示了在 FileRev.cpp文件上运行 FileRev函数时产生的结果。 图17-8 FileRev 函数运行结果 清单17-2 FileRev示例应用程序 下载 421 第 17章 内存映射文件计计 422计计第三部分 内 存 管 理 下载 下载 423 第 17章 内存映射文件计计 424计计第三部分 内 存 管 理 下载 下载 425 第 17章 内存映射文件计计 426计计第三部分 内 存 管 理 下载 17.4 使用内存映射文件来处理大文件 上一节讲过我要告诉你如何将一个 16 EB的文件映射到一个较小的地址空间中。当然, 你是无法做到这一点的。你必须映射一个只包含一小部分文件数据的文件视图。首先映射一 个文件的开头的视图。当完成对文件的第一个视图的访问时,可以取消它的映像,然后映射 一个从文件中的一个更深的位移开始的新视图。必须重复这一操作,直到访问了整个文件。 这使得大型内存映射文件的处理不太方便,但是,幸好大多数文件都比较小,因此不会出现 这个问题。 让我们看一个例子,它使用一个 8 GB的文件和一个32位的地址空间。下面是一个例程,它 使用若干个步骤来计算一个二进制数据文件中的所有 0字节的数目: 下载 427 第 17章 内存映射文件计计 这个算法用于映射 64 KB (分配粒度的大小)或更小的视图。另外,要记住, MapView OfFile函数要求文件的位移是分配粒度大小的倍数。当每个视图被映射到地址空间时,对 0的 扫描不断进行。当每个 64 KB的文件块已经映射和扫描完毕时,就要通过关闭文件映射对象来 对每个文件块进行整理。 17.5 内存映射文件与数据视图的相关性 系统允许你映射一个文件的相同数据的多个视图。例如,你可以将文件开头的 10 KB映射 到一个视图,然后将同一个文件的头 4 KB映射到另一个视图。只要你是映射相同的文件映射 对象,系统就会确保映射的视图数据的相关性。例如,如果你的应用程序改变了一个视图中的 文件内容,那么所有其他视图均被更新以反映这个变化。这是因为尽管页面多次被映射到进程 的虚拟地址空间,但是系统只将数据放在单个 RAM页面上。如果多个进程映射单个数据文件 的视图,那么数据仍然是相关的,因为在数据文件中,每个 RAM页面只有一个实例——正是 428计计第三部分 内 存 管 理 下载 这个RAM页面被映射到多个进程的地址空间。 注意 Windows允许创建若干个由单个数据文件支持的文件映射对象。 Windows不能 保证这些不同的文件映射对象的视图具有相关性。它只能保证单个文件映射对象的多 个视图具有相关性。 然而,当对文件进行操作时,没有理由使另一个应用程序无法调用 CreateFile函数以打开 由另一个进程映射的同一个文件。这个新进程可以使用 ReadFile和WriteFile函数来读取该文件 的数据和将数据写入该文件。当然,每当一个进程调用这些函数时,它必须从内存缓冲区读取 文件数据或者将文件数据写入内存缓冲区。该内存缓冲区必须是进程自己创建的一个缓冲区, 而不是映射文件使用的内存缓冲区。当两个应用程序打开同一个文件时,问题就可能产生:一 个进程可以调用 ReadFile函数来读取文件的一个部分,并修改它的数据,然后使用 WriteFile函 数将数据重新写入文件,而第二个进程的文件映射对象却不知道第一个进程执行的这些操作。 由于这个原因,当你为将被内存映射的文件调用 CreateFile函数时,最好将 dwShareMode参数 的值设置为 0。这样就可以告诉系统,你想要单独访问这个文件,而其他进程都不能打开它。 只读文件不存在相关性问题,因此它们可以作为很好的内存映射文件。内存映射文件决不 应该用于共享网络上的可写入文件,因为系统无法保证数据视图的相关性。如果某个人的计算 机更新了文件的内容,其他内存中含有原始数据的计算机将不知道它的信息已经被修改。 17.6 设定内存映射文件的基地址 正如你可以使用 VirtualAlloc函数来确定对地址空间进行倒序所用的初始地址一样,你也可 以使用MapViewOfFileEx函数而不是使用 MapViewOfFile函数来确定一个文件被映射到某个特 定的地址。请看下面的代码: 该函数的所有参数和返回值均与 MapViewOfFile函数相同,唯一的差别是最后一个参数 pvBaseAddress有所不同。在这个参数中,你为要映射的文件设定一个目标地址。与 VirtualAlloc 一样,你设定的目标地址应该是分配粒度边界( 64 KB)的倍数,否则 MapViewOfFileEx将返 回NULL,表示出现了错误。 在Windows 2000下,如果设定的地址不是分配粒度的倍数,就会导致函数运行失败,同时 GetLastError将返回1132(ERROR_MAPPED_ALIGNMENT)。在Windows 98中,该地址将圆 整为分配粒度边界值。 如果系统无法将文件映射到该位置上(通常由于文件太大并且与另一个保留的地址空间相 重叠),那么该函数的运行就会失败并且返回 NULL。MapViewOfFileEx并不设法寻找另一个地 址空间来放置该文件。当然,你可以设定 NULL作为 pvBaseAddress参数的值,这时, MapViewOfFileEx函数的运行特性与 MapViewOfFile函数完全相同。 当你使用内存映射文件与其他进程共享数据时,你可以使用 MapViewOfFileEx函数。例如, 当两个或多个应用程序需要共享包含指向其他数据结构的一组数据结构时,可能需要在某个特 定地址上的内存映射文件。链接表是个极好的例子。在链接表中,每个节点或元素均包含列表 下载 429 第 17章 内存映射文件计计 中的另一个元素的内存地址。若要遍历该列表,必须知道第一个元素的地址,然后参考包含下 一个元素地址的元素成员。当使用内存映射文件时,这可能成为一个问题。 如果一个进程建立了内存映射文件中的链接表,然后与另一个进程共享该文件,那么另一 个进程就可能将文件映射到它的地址空间中的一个完全不同的位置上。当第二个进程视图遍历 该链接表时,它查看链接表的第一个元素,检索下一个元素的内存地址,然后设法引用下一个 元素。然而,第一个节点中的下一个元素的地址并不是第二个进程需要查找的地址。 可以用两种办法来解决这个问题。首先,当第二个进程将包含链接表的内存映射文件映射 到它自己的地址空间中去时,它只要调用 MapViewOfFileEx函数而不是调用 MapViewOfFile。 当然,这种方法要求第二个进程必须知道第一个进程原先在建立链接表时将文件映射到了什么 地方。当两个应用程序打算互相进行交互操作时(这是非常可能的),这就不会出现任何问题, 因为地址可以通过硬编码放入两个应用程序,或者一个进程可以通知另一个进程使用另一种进 程间通信的方式,比如将消息发送到窗口。 第二个方法是创建链接表的进程将下一个节点所在的地址中的位移存放在每个节点中。这 要求应用程序将该位移添加给内存映射文件的基地址,以便访问每个节点。这种方法并不高明, 因为它的运行速度可能比较慢,它会使程序变得更大(因为编译器要生成附加代码来执行所有 的计算操作),而且它很容易出错。但是,它仍然是个可行的方法, Microsoft的编译器为使用 __based关键字的基本指针提供了辅助程序。 Windows 98 当调用MapViewOfFileEx时,必须设定0x80000000与0xBFFFFFFF之间 的一个地址,否则MapViewOfFileEx将返回ULL。 Windows 20000 当调用MapViewOfFileEx时,必须设定在你的进程的用户方式分区 中的一个地址,否则 MapViewOfFileEx将返回NULL。 17.7 实现内存映射文件的具体方法 Windows 98和Windows 2000实现内存映射文件的方法是不同的。必须知道这些差别,因 为它们会影响你编写代码的方法,也会影响其他应用程序对你的数据进行不利的操作。 在Windows 98下,视图总是映射到0x80000000至0xBFFFFFFF范围内的地址空间分区中。因此, 对MapViewOfFile函数的成功调用都会返回这个范围内的一个地址。你也许还记得,所有进程都共 享该分区中的数据。这意味着如果进程映射了文件映射对象的视图,那么该文件映射对象的数据 实际上就可以被所有进程访问,而不管它们是否已经映射了该文件映射对象的视图。如果另一个 进程调用使用同一个文件映射对象的MapViewOfFile函数,Windows 98便将返回给第一个进程的同 一个内存地址返回给第二个进程。这两个进程访问相同的数据,并且它们的视图具有相关性。 在Windows 98中,一个进程可以调用MapViewOfFile函数 ,并且可以使用某种进程间的通 信方式将返回的内存地址传递给另一个进程的线程。一旦该线程收到这个内存地址,该线程就 可以成功地访问文件映射对象的同一个视图。但是,不应该这样做,原因有二。 • 你的应用程序将无法在Windows 2000 下运行,其原因将在下面说明。 • 如果第一个进程调用 UnmapViewOfFile函数,地址空间区域将恢复为空闲状态,这意味 着第二个进程的线程如果尝试访问视图曾经位于其中的内存,会引发一次访问违规。 如果第二个进程访问内存映射对象的视图,那么第二个进程中的线程应该调用 MapView OfFile函数。当第二个进程这样做的时候,系统将对内存映射视图的使用计数进行递增。因此, 如果第一个进程调用UnmapViewOfFile函数,那么在第二个进程也调用 UnmapViewOfFile之前, 430计计第三部分 内 存 管 理 下载 系统将不会释放视图占用的地址空间区域。 当第二个进程调用 MapViewOfFile函数时,返回的地址将与第一个进程返回的地址相同。 这样,第一个进程就没有必要使用进程间的通信方式将内存地址传送给第二个进程。 Windows 2000实现内存映射文件的方法要比 Windows 98好,因为Windows 2000要求在进 程的地址空间中的文件数据可供访问之前,该进程必须调用 MapViewOfFile函数。如果一个进 程调用MapViewOfFile函数,系统将为调用进程的地址空间中的视图进行地址空间区域的倒序 操作,这样,其他进程都将无法看到该视图。如果另一个进程想要访问同一个文件映射对象中 的数据,那么第二个进程中的线程就必须调用 MapViewOfFile,同时,系统将为第二个进程的 地址空间中的视图进行地址空间区域的倒序操作。 值得注意的是,第一个进程调用MapViewOfFile函数后返回的内存地址,很可能不同于第二个 进程调用MapViewOfFile函数后返回的内存地址。即使这两个进程映射了相同文件映射对象的视图, 它们返回的地址也可能不同。在Windows 98下,MapViewOfFile函数返回的内存地址是相同的,但 是,如果想让你的应用程序在Windows 2000下运行,那么绝对不应该指望它们也返回相同的地址。 让我们再来观察另一个实现代码的差别。下面是一个小程序,它映射了单个文件映射对象 的两个视图: 下载 431 第 17章 内存映射文件计计 在Windows 98中,当文件映射对象的视图被映射时,系统将为整个文件映射对象保留足够 的地址空间。即使调用 MapViewOfFile函数时它的参数指明你想要系统只映射文件映射对象的 一小部分,系统也会为它保留足够的地址空间。这意味着即使你规定只映射文件映射对象的一 个64 KB的部分,也不能将一个1 GB的文件映射对象映射到一个视图中。 每当进程调用MapViewOfFile时,该函数将返回一个为整个文件映射对象保留的地址空间区域 中的地址。因此,在上面的代码段中,第一次调用MapViewOfFile函数时返回包含整个映射文件的 区域的基地址,第二次调用MapViewOfFile函数时返回离同一个地址空间区域64 KB位置上的地址。 Windows 2000 的实现代码在这里同样存在很大的差别。在上面的代码段中,两次调用 MapViewOfFile函数将导致Windows 2000保留两个不同的地址空间区域。第一个区域的大小是 文件映射对象的大小,第二个区域的大小是文件映射对象的大小减去 64 KB。尽管存在两个不 同的区域,但是它们的数据能够保证其相关性,因为两个视图都是从相同的文件映射对象映射 而来的。在Windows 98 下,各个视图具有相关性,因为它们位于同一个内存中。 17.8 使用内存映射文件在进程之间共享数据 Windows总是出色地提供各种机制,使应用程序能够迅速而方便地共享数据和信息。这些 机制包括 RPC、COM、OLE、DDE、窗口消息(尤其是 WM_COPYDATA)、剪贴板、邮箱、 管道和套接字等。在 Windows中,在单个计算机上共享数据的最低层机制是内存映射文件。不 错,如果互相进行通信的所有进程都在同一台计算机上的话,上面提到的所有机制均使用内存 映射文件从事它们的烦琐工作。如果要求达到较高的性能和较小的开销,内存映射文件是举手 可得的最佳机制。 数据共享方法是通过让两个或多个进程映射同一个文件映射对象的视图来实现的,这意味 着它们将共享物理存储器的同一个页面。因此,当一个进程将数据写入一个共享文件映射对象 的视图时,其他进程可以立即看到它们视图中的数据变更情况。注意,如果多个进程共享单个 文件映射对象,那么所有进程必须使用相同的名字来表示该文件映射对象。 让我们观察一个例子,启动一个应用程序。当一个应用程序启动时,系统调用 CreateFile 函数,打开磁盘上的 .exe文件。然后系统调用 CreateFileMapping函数,创建一个文件映射对象。 最后,系统代表新创建的进程调用 MapViewOfFileEx函数(它带有 SEC_IMAGE标志),这 样, .exe文件就可以映射到进程的地址空间。这里调用的是 M a p Vi e w O f F i l e E x,而不是 MapViewOfFile,这样,文件的映像将被映射到存放在 .exe文件映像中的基地址中。系统创建 该进程的主线程,将该映射视图的可执行代码的第一个字节的地址放入线程的指令指针,然后 CPU启动该代码的运行。 如果用户运行同一个应用程序的第二个实例,系统就认为规定的 .exe文件已经存在一个文 件映射对象,因此不会创建新的文件对象或者文件映射对象。相反,系统将第二次映射该文件 的一个视图,这次是在新创建的进程的地址空间环境中映射的。系统所做的工作是将相同的文 件同时映射到两个地址空间。显然,这是对内存的更有效的使用,因为两个进程将共享包含正 在执行的这部分代码的物理存储器的同一个页面。 与所有内核对象一样,可以使用 3种方法与多个进程共享对象,这 3种方法是句柄继承性、 句柄命名和句柄复制。关于这 3种方法的详细说明,参见第 3章的内容。 17.9 页文件支持的内存映射文件 到现在为止,已经介绍了映射驻留在磁盘驱动器上的文件视图的方法。许多应用程序在运 432计计第三部分 内 存 管 理 下载 行时都要创建一些数据,并且需要将数据传送给其他进程,或者与其他进程共享。如果应用程 序必须在磁盘驱动器上创建数据文件,并且将数据存储在磁盘上以便对它进行共享,那么这将 是非常不方便的。 Microsoft公司认识到了这一点,并且增加了一些功能,以便创建由系统的页文件支持的内 存映射文件,而不是由专用硬盘文件支持的内存映射文件。这个方法与创建内存映射磁盘文件 所用的方法几乎相同,不同之处是它更加方便。一方面,它不必调用 CreateFile函数,因为你 不是要创建或打开一个指定的文件,你只需要像通常那样调用 CreateFileMapping函数,并且传 递INVALID_HANDLE_VALUE作为hFile参数。这将告诉系统,你不是创建其物理存储器驻留 在磁盘上的文件中的文件映射对象,相反,你想让系统从它的页文件中提交物理存储器。分配 的存储器的数量由 CreateFileMapping函数的dwMaximumSizeHigh和dwMaximumSizeLow两个 参数来决定。 当创建了文件映射对象并且将它的一个视图映射到进程的地址空间之后,就可以像使用任 何内存区域那样使用它。如果你想要与其他进程共享该数据,可调用 CreateFileMapping函数, 并传递一个以 0结尾的字符串作为 pszName参数。然后,想要访问该存储器的其他进程就可以 调用CreateFileMapping或OpenFileMapping函数,并传递相同的名字。 当进程不再想要访问文件映射对象时,该进程应该调用 CloseHandle函数。当所有句柄均 被关闭后,系统将从系统的页文件中收回已经提交的存储器。 注意 下面是一个非常有意思的问题,它使单纯的程序员大吃一惊。你能猜到下面这 个代码段错在哪里吗? 如果上面这个对 CreateFile函数的调用失败,它将返回 INVALID_HANDLE_ VA L U E。但是,编写这个代码的程序员没有测试一下,看文件是否已经创建成功。 当CreateFileMapping函数被调用时,在 hFile参数中传递了 INVALID_HANDLE_ VA L U E , 这 使 得 系 统 创 建 一 个 使 用 来 自 页 文 件 而 不 是 来 自 指 定 的 磁 盘 文 件 存 储 器 的 文件映像。使用内存映射文件的任何辅助代码都能够正确地运行。但是,当文件映射 对象被撤消时,写入文件映射存储器(页文件)的全部数据将被系统撤消。这时,程 序员就坐在那里绞尽脑汁,不知道问题究竟出在哪里。因此,必须始终检查 CreateFile 函数的返回值,以确定是否出现了错误,因为 CreateFile运行失败的原因太多了。 共享内存映射文件的示例应用程序 清单17-3列出的MMFShare应用程序(“17 MMFShare.exe”)显示了如何使用内存映射文件 在两个或多个独立的进程间传送数据。该应用程序的源代码和资源文件位于本书所附光盘上的 17-MMFShare目录下。 至少需要执行MMFShare程序的两个实例。每个实例创建它自己的对话框,如图 17-9所示。 若要将数据从MMFShare的一个实例传送到另一个实例,请将要传送的数据键入Data编辑框。 然后单击Create Mapping Of Data(创建数据映像)按钮。当进行这项操作时, MMFShare调用 CreateFileMapping函数,创建一个由系统的页文件支持的 4 KB内存映射文件对象,并将该对象 下载 433 第 17章 内存映射文件计计 命名为MMFSharedData。如果MMFShare发现已经存在 一个带有这个名字的文件映射对象,它就显示一个消 息框,告诉你它不能创建该对象。如果 MMFShare成功 地创建了该对象,那么它将进一步将文件的视图映射 到进程的地址空间,并将数据从编辑控件拷贝到内存 映射文件中。 图17-9 运行MMFShare 时出现的对话框 当数据被拷贝后,MMFShare就撤消文件的视图,使Create Mapping Of Data按钮不起作用, 并激活Close Mapping Of Data(关闭数据映像)按钮。这时,命名为 MMFSharedData的内存映 射文件仍然位于系统中的某个位置。没有任何进程映射了包含在文件中的数据视图。 如果这时转入 MMFShare的另一个实例,并且单击该实例的 Open Mapping And Get Data (打开映像并获取数据)按钮,那么 MMFShare将设法通过调用 OpenFileMapping函数,寻找一 个称为MMFSharedData的文件映射对象。如果无法找到带有该名字的对象, MMFShare就会显 示另一个消息框,将这个情况通知你。如果 MMFShare找到了这个对象,它将把该对象的视图 映射到它的进程的地址空间,将数据从内存映射文件拷贝到对话框的编辑控件中,然后撤消它 的映像,关闭文件映射对象。好极了,你已经成功地将数据从一个进程传送到另一个进程。 对话框中的Close Mapping Of Data(关闭数据映像)按钮用于关闭文件映射对象,它能够 释放页文件中的存储器。如果不存在任何文件映射对象,那么 MMFShare的其他实例将无法打 开文件映像并从中取出数据。另外,如果一个实例已经创建了一个内存映射文件,那么其他实 例均不得创建内存映射文件并改写文件中包含的数据。 清单17-3 MMFShare示例应用程序 434计计第三部分 内 存 管 理 下载 下载 435 第 17章 内存映射文件计计 436计计第三部分 内 存 管 理 下载 下载 437 第 17章 内存映射文件计计 438计计第三部分 内 存 管 理 下载 17.10 稀疏提交的内存映射文件 在迄今为止介绍的所有内存映射文件中,我们发现系统要求为内存映射文件提交的所有存 储器必须是在磁盘上的数据文件中或者是在页文件中。这意味着我们不能根据我们的喜好来有 效地使用存储器。让我们回到第 15章中介绍电子表格的内容上来,比如说,你想要与另一个进 程共享整个电子表格。如果我们使用内存映射文件,那么必须为整个电子表格提交物理存储 器: 如果CELLDATA结构的大小是 128字节,那么这个数组需要 6 553 600(200 x 256 x 128) 字节的物理存储器。第 15章讲过,如果用页文件为电子表格分配物理存储器,那么这是个不小 的数目了,尤其是考虑到大多数用户只是将信息放入少数的单元格中,而大多数单元格却空闲 不用时,这就显得有些浪费。 显然,我们宁愿将电子表格作为一个文件映射对象来共享,而不必预先提交所有的物理存 储器。 CreateFileMapping函数为这种操作提供了一种方法,即可以在 fdwProtect参数中设定 SEC_RESERVE或SEC_COMMIT标志。 只有当创建由系统的页文件支持的文件映射对象时,这些标志才有意义。 SEC_COMMIT 标志能使CreateFileMapping从系统的页文件中提交存储器。如果两个标志都不设定,其结果也 一样。 当调用CreateFileMapping函数并传递 SEC_RESERVE标志时,系统并不从它的页文件中提 交物理存储器,它只是返回文件映射对象的一个句柄。这时可以调用 M a p Vi e w O f F i l e或 MapViewOfFileEx函数,创建该文件映射对象的视图。 MapViewOfFile和MapViewOfFileEx将 保留一个地址空间区域,并且不提交支持该区域的任何物理存储器。对保留区域中的内存地址 进行访问的任何尝试均将导致线程引发访问违规。 现在我们得到的是一个保留的地址空间区域和用于标识该区域的文件映射对象的句柄。其 他进程可以使用相同的文件映射对象来映射同一个地址空间区域的视图。物理存储器仍然没有 被提交给该区域。如果其他进程中的线程试图访问它们区域中的视图的内存地址,这些线程将 会引发访问违规。 下面是令人感兴趣的一些事情。若要将物理存储器提交给共享区域,线程需要做的操作只 是调用VirtualAlloc函数: 第15章已经介绍了这个函数。调用VirtualAlloc函数将物理存储器提交给内存映射视图区域, 就像是调用 VirtualAlloc函数将存储器提交给开始时通过调用带有 MEM_RESERVE 标志的 VirtualAlloc函数而保留的区域一样。而且,就像你可以提交稀疏地存在于用 VirtualAlloc保留 的区域中的存储器一样,你也可以提交稀疏地存在于用 MapViewOfFile或MapViewOfFileEx保 留的区域中的存储器。但是,当你将存储器提交给用 MapViewOfFile或MapViewOfFileEx保留 的区域时,已经映射了相同文件映射对象视图的所有进程这时就能够成功地访问已经提交的页 面。 使用 SEC_RESERVE标志和 VirtualAlloc函数,就能够成功地与其他进程共享电子表格应用 下载 439 第 17章 内存映射文件计计 程序的CellData数组,并且能够非常有效地使用物理存储器。 Windows 98 通常情况下,当给VirtualAlloc函数传递的内存地址位于 0x00400000至 0x7FFFFFFF以外时, VirtualAlloc的运行就会失败。但是,当将物理存储器提交给使 用SEC_RESERVE标志创建的内存映射文件时,必须调用 VirtualAlloc函数,传递一个 位于0x80000000至0xBFFFFFFF之间的内存地址。Windows 98知道你正在把存储器提 交给一个保留的内存映射文件,并且让这个函数调用取得成功。 注意 在Windows 2000下,无法使用VirtualFree函数从使用 SEC_RESERVE标志保留 的内存映射文件中释放存储器。但是, Windows 98允许在这种情况下调用 VirtualFree 函数来释放存储器。 NT文件系统(NTFS 5)提供了对稀疏文件的支持。这是个非常出色的新特性。使用这个 新的稀疏文件特性,能够很容易地创建和使用稀疏内存映射文件,在这些稀疏内存映射文件中, 存储器包含在通常的磁盘文件中,而不是在系统的页文件中。 下面是如何使用稀疏文件特性的一个例子。比如,你想要创建一个 MMF文件,以便存放 记录的音频数据。当用户说话时,你想要将数字音频数据写入内存缓冲区,并且让该缓冲区得 到磁盘上的一个文件的支持。稀疏 MMF当然是在你的代码中实现这个要求的最容易和最有效 的方法。问题是你不知道用户在单击 Stop(停止)按钮之前讲了多长时间。你可能需要一个足 够大的文件来存放 5分钟或 5小时的数据,这两个时间长度的差别太大了。但是,当使用稀疏 MMF时,数据文件的大小确实无关紧要。 稀疏内存映射文件示例应用程序 清单 17-4列出的 MMFSparse 应用程序(“17 MMFS parse.exe”)显示了如何创建一个由 NTFS 5支持的内存映射 文件。该应用程序的源代码和资源文件位于本书所附光盘 上的 17-MMFSparse目录下。当启动该程序时,便会出现图 17-10所示的窗口。 当单击Create a 1MB(1024 KB)Sparse MMF(创建一 个1MB(1024 KB)稀疏MMF)按钮时,该程序将设法创建 一个称为“C:\MMFSparse”的稀疏文件。如果你的 C驱动 器不是个NTFS 5卷,那么它的运行将会失败,并且该进程将 终止运行。如果你的NTFS 5卷在另一个驱动器名上,你必须 修改源代码,并且将它重建,以了解应用程序运行的情况。 图17-10 MMF Sparse窗口 一旦稀疏文件创建完成,它就被映射到进程的地址空间中。底部的 Allocated Ranges(分 配的范围)编辑框显示了文件的哪些部分实际上是由磁盘存储器支持的。开始时,该文件中没 有任何存储器在里面,而编辑控件中则包含了“ No allocated ranges in the file”(文件中没有分 配范围)这一消息文本。 若要读取一个字节,只需将一个数字输入 Offset(位移)编辑框中,并单击 Read Byte(读 取字节)按钮。输入的数字与 1024(1 KB)相乘,在该位置上的字节被读取并放入 Byte编辑框 中。如果从没有支持存储器的任何部分中读取字节,将始终只能读取一个 0字节。如果从拥有 支持存储器的文件部分读取字节,将能够读取那里的任何字节。 若要写入一个字节,请将一个数字输入 Offset编辑框,并且将一个字节值( 0至255)输入 440计计第三部分 内 存 管 理 下载 Byte编辑框。然后,当单击 Write Byte(写入字节)按钮时,位移数字就与 1024相乘,同时, 该位置上的字节被修改以反映指定的字节值。这个写入操作可使系统为该部分文件提交支持的 存储器。当读取或写入操作执行完成后, Allocated Ranges编辑框总是会得到更新,以便向你 显示文件的哪些部分实际上得到存储器的支持。图 17-11显示了在1 024 000(1000 x 1024)这 个位移上仅仅写入一个字节后对话框是个什么样子。 注意图17-11中只存在一个分配范围,它从文件中的逻辑位移 983 040字节开始,并且支持 存储器的 65 536个字节已经被分配。也可以使用 Explorer来找出文件C:\MMFSparse并显示它 的属性页,如图 17-12所示。 图17-11 写入一个字节后的 MMF Sparse 对话框 图17-12 MMF Sparse Properties 对话框 注意,该属性页显示了文件的大小是 1 MB(这是文件的虚拟大小),但是该文件实际上只 占用64 KB磁盘空间。 最后一个按钮是 Free All Allocated Regions(释放所有分配的区域),该程序可以用它来释 放用于文件的所有存储器。这个特性能够释放磁盘空间,使文件中的所有字节均显示为 0。 下面让我们来介绍一下该程序是如何运行的。为了简便起见,我创建了一个 CSparseStream 的C++类(在SparseStream.h文件中实现)。这个类封装了可以用稀疏文件或数据流执行的任务。 然后,在MMFSparse.cpp文件中,我创建了另一个 C++类CMMFSparse,它是由CSparseStream 派生而来的。因此, CMMFSparse对象将拥有CSparseStream的所有特性,并且要加上将稀疏数 据流用作内存映射文件时特定的几个特性。该进程拥有 CMMFSparse对象的单个全局实例,称 为g_mmf。应用程序在它的整个代码中都要引用这个全局变量,以便对稀疏内存映射文件进行 操作。 当用户单击Create a 1 MB(1024 KB)Sparse MMF(创建一个1 MB(1024 KB)的稀疏 MMF)按钮时,CreateFile函数被调用,以便在 NTFS 5磁盘分区上创建一个新文件。这是个普 通的常规文件。但是,这时我使用 g_mmf对象并且调用它的 Initialize方法,传递该文件的句柄 和文件的最大长度( 1 MB)。在系统内部, Initialize方法调用CreateFileMapping函数,按照指 定的大小创建文件映射内核对象,然后调用 MapViewOfFile函数,使得该稀疏文件出现在进程 的地址空间中。 下载 441 第 17章 内存映射文件计计 当Initialize方法返回时, Dlg_ShowAllocatedRanges函数被调用。该函数在内部调用各个 Windows函数,以便枚举已经为之分配了存储器的稀疏文件的逻辑范围。每个分配范围的起始 位移和长度显示在对话框底部的编辑控件中。当 g_mmf对象首次被初始化时,已经为磁盘上的 文件分配的物理存储器实际上是 0,编辑控件将反映出这个情况。 这时,用户可以设法从稀疏内存映射文件中读取数据,或者将数据写入该文件。如果试图 写入数据,用户的位移和字节值可以从它们各自的编辑控件中获得,并将数据写入 g_mmf对象 中的内存地址。如果将数据写入 g_mmf,文件系统就会将存储器分配给文件的这个逻辑分区, 不过分配情况对应用程序来说是透明的。 如果用户试图从 g_mmf对象中读取一个字节,那么该读取操作将设法在已经分配了存储器 的文件中读取一个字节,否则该字节也许会标识一个尚未分配存储器的字节。如果该字节尚未 被分配存储器,那么读取该字节就会返回 0。同样,这对于应用程序来说是透明的。如果存在 供被读取的字节使用的存储器,当然就返回它的实际值。 该应用程序说明的最后一个问题是如何清除文件,使它的所有已分配的存储器范围被释放, 文件不再需要磁盘存储器。若要释放所有的已分配范围,用户可以单击 Free All Allocated Ranges按钮。 Windows无法为内存映射文件释放所有已分配的范围,因此应用程序要做的第一 件事情是调用g_mmf对象的ForceClose方法。ForceClose方法在内部调用UnmapViewOfFile函数, 然后调用 CloseHandle,传递文件映射内核对象的句柄。 接着DecommitPortionOfStream函数被调用,为文件中的0至1 MB的逻辑字节释放所有的存 储器。最后,再次为 g_mmf对象调用Initialize方法,将内存映射文件重新初始化到进程的地址 空间中。为了证明文件已经释放它的全部分配范围,需调用 Dlg_ShowAllocatedRanges函数, 它在编辑控件中显示“No allocated ranges in the file”(文件中没有已分配范围)字符串。 最后需要说明的是,如果在实际的应用程序中使用稀疏内存映射文件,当关闭文件时,可 能必须截断文件实是的逻辑长度。虽然将包含 0字节的稀疏文件的结尾截去不会对磁盘空间产 生任何影响,这确实是很好的事情—Explorer和命令外壳的 DIR命令能够向用户报告比较准 确的文件大小。若要为文件设置文件标记的结尾,可以在调用 ForceClose方法后,调用 SetFilePointer和SetEndOfFile函数。 清单17-4 MMFSparse示例应用程序 442计计第三部分 内 存 管 理 下载 下载 443 第 17章 内存映射文件计计 444计计第三部分 内 存 管 理 下载 下载 445 第 17章 内存映射文件计计 446计计第三部分 内 存 管 理 下载 下载 447 第 17章 内存映射文件计计 448计计第三部分 内 存 管 理 下载 下载 449 第 17章 内存映射文件计计 450计计第三部分 内 存 管 理 下载 下载 第18章 堆 栈 对内存进行操作的第三个机制是使用堆栈。堆栈可以用来分配许多较小的数据块。例如, 若要对链接表和链接树进行管理,最好的方法是使用堆栈,而不是第 15章介绍的虚拟内存操作 方法或第 17 章介绍的内存映射文件操作方法。堆栈的优点是,可以不考虑分配粒度和页面边界 之类的问题,集中精力处理手头的任务。堆栈的缺点是,分配和释放内存块的速度比其他机制 要慢,并且无法直接控制物理存储器的提交和回收。 从内部来讲,堆栈是保留的地址空间的一个区域。开始时,保留区域中的大多数页面没有 被提交物理存储器。当从堆栈中进行越来越多的内存分配时,堆栈管理器将把更多的物理存储 器提交给堆栈。物理存储器总是从系统的页文件中分配的,当释放堆栈中的内存块时,堆栈管 理器将收回这些物理存储器。 M i c r o s o f t并没有以文档的形式来规定堆栈释放和收回存储器时应该遵循的具体规则, Windows 98 与Windows 2000 的规则是不同的。可以这样说,Windows 98 更加注重内存的使用, 因此只要可能,它就收回堆栈。 Windows 2000更加注重速度,因此它往往较长时间占用物理存 储器,只有在一段时间后页面不再使用时,才将它返回给页文件。 Microsoft常常进行适应性测 试并运行各种不同的条件,以确定在大部分时间内最适合的规则。随着使用这些规则的应用程 序和硬件的变更,这些规则也会有所变化。如果了解这些规则对你的应用程序非常关键,那么 请不要使用堆栈。相反,可以使用虚拟内存函数(即 VirtualAlloc和VirtualFree),这样,就能 够控制这些规则。 18.1 进程的默认堆栈 当进程初始化时,系统在进程的地址空间中创建一个堆栈。该堆栈称为进程的默认堆栈。 按照默认设置,该堆栈的地址空间区域的大小是 1 MB。但是,系统可以扩大进程的默认堆栈, 使它大于其默认值。当创建应用程序时,可以使用 /HEAP链接开关,改变堆栈的 1MB默认区域 大小。由于 DLL没有与其相关的堆栈,所以当链接 DLL时,不应该使用 /HEAP链接开关。 /HEAP链接开关的句法如下: 许多 Windows函数要求进程使用其默认堆栈。例如, Windows 2000 的核心函数均使用 Unicode字符和字符串执行它们的全部操作。如果调用 Windows函数的ANSI版本,那么该ANSI 版本必须将 ANSI字符串转换成 Unicode字符串,然后调用同一个函数的 Unicode版本。为了进 行字符串的转换, ANSI函数必须分配一个内存块,以便放置 Unicode版本的字符串。该内存块 是从你的进程的默认堆栈中分配的。 Windows的其他许多函数需要使用一些临时内存块,这些 内存块是从进程的默认堆栈中分配的。另外,老的 16位Windows函数LocalAlloc和GlobalAlloc 也是从进程的默认堆栈中进行它们的内存分配的。 由于进程的默认堆栈可供许多 Windows函数使用,你的应用程序有许多线程同时调用各种 Windows函数,因此对默认堆栈的访问是顺序进行的。换句话说,系统必须保证在规定的时间 内,每次只有一个线程能够分配和释放默认堆栈中的内存块。如果两个线程试图同时分配默认 堆栈中的内存块,那么只有一个线程能够分配内存块,另一个线程必须等待第一个线程的内存 452计计第三部分 内 存 管 理 下载 块分配之后,才能分配它的内存块。一旦第一个线程的内存块分配完,堆栈函数将允许第二个 线程分配内存块。这种顺序访问方法对速度有一定的影响。如果你的应用程序只有一个线程, 并且你想要以最快的速度访问堆栈,那么应该创建你自己的独立的堆栈,不要使用进程的默认 堆栈。不幸的是,你无法告诉 Windows函数不要使用默认堆栈,因此,它们对堆栈的访问总是 顺序进行的。 单个进程可以同时拥有若干个堆栈。这些堆栈可以在进程的寿命期中创建和撤消。但是, 默认堆栈是在进程开始执行之前创建的,并且在进程终止运行时自动被撤消。不能撤消进程的 默认堆栈。每个堆栈均用它自己的堆栈句柄来标识,用于分配和释放堆栈中的内存块的所有堆 栈函数都需要这个堆栈句柄作为其参数。 可以通过调用 GetProcessHeap函数获取你的进程默认堆栈的句柄: 18.2 为什么要创建辅助堆栈 除了进程的默认堆栈外,可以在进程的地址空间中创建一些辅助堆栈。由于下列原因,你 可能想要在自己的应用程序中创建一些辅助堆栈: • 保护组件。 • 更加有效地进行内存管理。 • 进行本地访问。 • 减少线程同步的开销。 • 迅速释放。 下面让我们来详细说明每个原因。 18.2.1 保护组件 假如你的应用程序需要保护两个组件,一个是节点结构的链接表,一个是 BRANCH结构的 二进制树。你有两个源代码文件,一个是 LnkLst.cpp,它包含负责处理 NODE链接表的各个函数,另一个文件是 BinTree.cpp,它包含负责处理 节点 3 分支的二进制树的各个函数。 如果节点和分支一道存储在单个堆栈中,那么这个组合堆栈将类 似图18-1所示的样子。 现在假设链接表代码中有一个错误,它使节点 1后面的 8个字节不 小心被改写了,从而导致分支 3中的数据被破坏。当 BinTree.cpp文件中 的代码后来试图遍历二进制树时,它将无法进行这项操作,因为它的 分支 1 内存已经被破坏。当然,这使你认为二进制树代码中存在一个错误, 而实际上错误是在链接表代码中。由于不同类型的对象混合放在单个 堆栈中,因此跟踪和确定错误将变得非常困难。 节点 2 分支 2 通过创建两个独立的堆栈,一个堆栈用于存放节点,另一个堆栈 分支 3 用于存放分支,就能够确定你的问题。你的链接表代码中的一个小错 误不会破坏你的二进制树的完整性。反过来,二进制树中的小错误也 节点 1 不会影响链接表代码中的数据完整性。但是,你的代码中的错误仍然 可能导致对堆栈进行杂乱的内存写操作,不过出现这种情况的可能性 很小。 图18-1 将节点和 分支存放在一 起的单个堆栈 下载 453 第 18章 堆 栈计计 18.2.2 更有效的内存管理 节点 3 通过在堆栈中分配同样大小的对象,就可以更加有效地管理堆栈。 例如,假设每个节点结构需要 24字节,每个分支结构需要 32字节。所 有这些对象均从单个堆栈中分配。图 18-2显示了单个堆栈中已经分配 的若干个节点和分支对象占满了这个堆栈。如果节点 2和节点 4被释 放,堆栈中的内存将变成许多碎片。这时,如果试图分配分支结构, 那么尽管分支只需要 32个字节,而实际上可以使用的有 48个字节,但 是分配仍将失败。 如果每个堆栈只包含大小相同的对象,那么释放一个对象后,另 一个对象就可以恰好放入被释放的对象空间中。 节点 5 节点 4 节点 6 分支 5 分支 1 节点 2 分支 2 18.2.3 进行本地访问 分支 3 每当系统必须在 RAM与系统的页文件之间进行 RAM页面的交换 节点 1 时,系统的运行性能就会受到很大的影响。如果经常访问局限于一个 图18-2 变成碎片的单个 小范围地址的内存,那么系统就不太可能需要在 RAM与磁盘之间进 堆栈包含若干个 节点 行页面的交换。 和分支对象 所以,在设计应用程序的时候,如果有些数据将被同时访问,那么最好把它们分配在互相 靠近的位置上。让我们回到链接表和二进制树的例子上来,遍历链接表与遍历二进制树之间并 无什么关系。如果将所有的节点放在一起(放在一个堆栈中),就可以使这些节点位于相邻的 页面上。实际上,若干个节点很可能恰好放入单个物理内存页面上。遍历链接表将不需要 CPU 为了访问每个节点而引用若干不同的内存页面。 如果将节点和分支分配在单个页面上,那么节点就不一定会互相靠在一起。在最坏的情况 下,每个内存页面上可能只有一个节点,而其余的每个页面则由分支占用。在这种情况下,遍 历链接表将可能导致每个节点的页面出错,从而使进程运行得极慢。 18.2.4 减少线程同步的开销 正如下面就要介绍的那样,按照默认设置,堆栈是顺序运行的,这样,如果多个线程试图 同时访问堆栈,就不会使数据受到破坏。但是,堆栈函数必须执行额外的代码,以保证堆栈对 线程的安全性。如果要进行大量的堆栈分配操作,那么执行这些额外的代码会增加很大的负担, 从而降低你的应用程序的运行性能。当你创建一个新堆栈时,可以告诉系统,只有一个线程将 访问该堆栈,因此额外的代码将不执行。但是要注意,现在你要负责保证堆栈对线程的安全性。 系统将不对此负责。 18.2.5 迅速释放堆栈 最后要说明的是,将专用堆栈用于某些数据结构后,就可以释放整个堆栈,而不必显式释 放堆栈中的每个内存块。例如,当 Windows Explorer遍历硬盘驱动器的目录层次结构时,它必 须在内存中建立一个树状结构。如果你告诉 Windows Explorer刷新它的显示器,它只需要撤消 包含这个树状结构的堆栈并且重新运行即可(当然,假定它将专用堆栈用于存放目录树信息)。 对于许多应用程序来说,这是非常方便的,并且它们也能更快地运行。 454计计第三部分 内 存 管 理 18.3 如何创建辅助堆栈 你可以在进程中创建辅助堆栈,方法是让线程调用 HeapCreate函数: 下载 第一个参数fdwOptions用于修改如何在堆栈上执行各种操作。你可以设定 0、HEAP_NO_ SERIALIZE、HEAP_GENERATE_EXCEPTIONS或者是这两个标志的组合。 按照默认设置,堆栈将顺序访问它自己,这样,多个线程就能够分配和释放堆栈中的内存 块而不至于破坏堆栈。当试图从堆栈分配一个内存块时, HeapAlloc函数(下面将要介绍)必 须执行下列操作: 1) 遍历分配的和释放的内存块的链接表。 2) 寻找一个空闲内存块的地址。 3) 通过将空闲内存块标记为“已分配”分配新内存块。 4) 将新内存块添加给内存块链接表。 下面这个例子说明为什么应该避免使用 HEAP_NO_SERIALIZE标志。假定有两个线程试 图同时从同一个堆栈中分配内存块。线程 1执行上面的第一步和第二步,获得了空闲内存块的 地址。但是,在该线程可以执行第三步之前,它的运行被线程 2抢占,线程 2得到一个机会来 执行上面的第一步和第二步。由于线程 1尚未执行第三步,因此线程 2发现了同一个空闲内存块 的地址。 由于这两个线程都发现了堆栈中它们认为是空闲的内存块,因此线程 1更新了链接表,给 新内存块做上了“已分配”的标记。然后线程 2也更新了链接表,给同一个内存块做上了“已 分配”标记。到现在为止,两个线程都没有发现问题,但是两个线程得到的是完全相同的内存 块的地址。 这种类型的错误是很难跟踪的,因为它不会立即表现出来。相反,这个错误会在后台等待 着,直到很不适合的时候才显示出来。可能出现的问题是: • 内存块的链接表已经被破坏。在试图分配或释放内存块之前,这个问题不会被发现。 • 两个线程共享同一个内存块。线程 1和线程2会将信息写入同一个内存块。当线程 1查看该 内存块的内容时,它将无法识别线程 2提供的数据。 • 一个线程可能继续使用该内存块并且将它释放,导致另一个线程改写未分配的内存。这 将破坏该堆栈。 解决这个问题的办法是让单个线程独占对堆栈和它的链接表的访问权,直到该线程执行了 对堆栈的全部必要的操作。如果不使用 HEAP_NO_SERIALIZE标志,就能够达到这个目的。 只有当你的进程具备下面的一个或多个条件时,才能安全地使用 HEAP_NO_SERIALIZE标志: • 你的进程只使用一个线程。 • 你的进程使用多个线程,但是只有单个线程访问该堆栈。 • 你的进程使用多个线程,但是它设法使用其他形式的互斥机制,如关键代码段、互斥对 象和信标(第 8、9章中介绍),以便设法自己访问堆栈。 如果对是否可以使用 HEAP_NO_SERIALIZE标志没有把握,那么请不要使用它。如果不 使用该标志,每当调用堆栈函数时,线程的运行速度会受到一定的影响,但是不会破坏你的堆 栈及其数据。 下载 455 第 18章 堆 栈计计 另一个标志 HEAP_GENERATE_EXCEPTIONS,会在分配或重新分配堆栈中的内存块的尝 试失败时,导致系统引发一个异常条件。所谓异常条件,只不过是系统使用的另一种方法,以 便将已经出现错误的情况通知你的应用程序。有时在设计应用程序时让它查看异常条件比查看 返回值要更加容易些。异常条件将在第 23、24和25章中介绍。 HeapCreate的第二个参数 dwInitialSize用于指明最初提交给堆栈的字节数。如果必要的话, HeapCreate函数会将这个值圆整为 CPU页面大小的倍数。最后一个参数 dwMaximumSize用于指 明 堆 栈 能 够 扩 展 到 的 最 大 值 ( 即 系 统 能 够 为 堆 栈 保 留 的 地 址 空 间 的 最 大 数 量 )。如果 dwMaximumSize大于0,那么你创建的堆栈将具有最大值。如果尝试分配的内存块会导致堆栈 超过其最大值,那么这种尝试就会失败。 如果dwMaximumSize的值是0,那么可以创建一个能够扩展的堆栈,它没有内在的限制。 从堆栈中分配内存块只需要使堆栈不断扩展,直到物理存储器用完为止。如果堆栈创建成功, HeapCreate函数返回一个句柄以标识新堆栈。该句柄可以被其他堆栈函数使用。 18.3.1 从堆栈中分配内存块 若要从堆栈中分配内存块,只需要调用 HeapAlloc函数: 第一个参数hHeap用于标识分配的内存块来自的堆栈的句柄。 dwBytes参数用于设定从堆栈 中分配的内存块的字节数。参数 fdwFlags用于设定影响分配的各个标志。目前支持的标志只有 3 个,即 H E A P _ Z E R O _ M E M O RY 、H E A P _ G E N E R AT E _ E X C E P T I O N S 和H E A P _ N O _ SERIALIZE。 HEAP_ZERO_MEMORY标志的作用应该是非常清楚的。该标志使得 HeapAlloc在返回前用 0来填写内存块的内容。第二个标志 HEAP_GENERATE_EXCEPTIONS用于在堆栈中没有足够 的内存来满足需求时使 HeapAlloc函数引发一个软件异常条件。当用 HeapCreate函数创建堆栈 时,可以设定 HEAP_GENERATE_EXCEPTIONS标志,它告诉堆栈,当不能分配内存块时,就 应该引发一个异常条件。如果在调用 HeapCreate函数时设定了这个标志,那么当调用 HeapAlloc函数时,就不需要设定该标志。另外,你可能想要不使用该标志来创建堆栈。在这 种情况下,为HeapAlloc函数设定该标志只会影响对 HeapAlloc函数的一次调用,并不是每次调 用都会受到影响。 如果HeapAlloc运行失败,引发一个异常条件,那么这个异常条件将是表 18-1中的两个异 常条件之一。 表18-1 异常条件 标志 含义 S TAT U S _ N O _ M E M O RY S TAT U S _ A C C E S S _ V I O L AT I O N 由于内存不够,分配内存块的尝试失败 由于堆栈被破坏,或者函数的参数不正确,分配内存块的尝试失败 如果内存块已经成功地分配, HeapAlloc返回内存块的地址。如果内存不能分配并且没有 设定HEAP_GENERATE_EXCEPTIONS标志,那么 HeapAlloc函数返回 NULL。 最后一个标志HEAP_NO_SERIALIZE可以用来强制对 HeapAlloc函数的调用与访问同一个 堆栈的其他线程不按照顺序进行。在使用这个标志时应该格外小心,因为如果其他线程在同一 456计计第三部分 内 存 管 理 下载 时间使用该堆栈,那么堆栈就会被破坏。当从你的进程的默认堆栈中分配内存块时,决不要使 用这个标志,因为数据可能被破坏,你的进程中的其他线程可能在同一时间访问默认堆栈。 Windows 98 如果调用HeapAlloc函数并且要求分配大于256 MB的内存块,Windows 98 就将它看成是一个错误,函数的调用将失败。注意,在这种情况下,该函数总是 返回NULL,并且不会引发异常条件,即使你在创建堆栈或者试图分配内存块时使用 HEAP_GENERATE_ EXCEPTIONS标志,也不会引发异常条件。 注意 当你分配较大的内存块(大约 1 MB或者更大)时,最好使用 VirtualAlloc函数, 应该避免使用堆栈函数。 18.3.2 改变内存块的大小 常常需要改变内存块的大小。有些应用程序开始时分配的内存块比较大,然后,当所有数 据放入内存块后,再缩小内存块的大小。有些应用程序开始时分配的内存块比较小,后来需要 将更多的数据拷贝到内存块中去时,再设法扩大它的大小。如果要改变内存块的大小,可以调 用HeapReAlloc函数: 与其他情况一样, hHeap参数用于指明包含你要改变其大小的内存块的堆栈。 fdwFlags参 数用于设定改变内存块大小时 HeapReAlloc函数应该使用的标志。可以使用的标志只有下面4个, 即HEAP_GENERATE_EXCEPTIONS、HEAP_NO_SERIALIZE、HEAP_ZERO_MEMORY和 HEAP_REALLOC_IN_PLACE_ONLY。 前面两个标志在用于HeapAlloc时,其作用相同。 HEAP_ZERO_MEMORY标志只有在你扩 大内存块时才使用。在这种情况下,内存块中增加的字节将被置 0。如果内存块已经被缩小, 那么该标志不起作用。 HEAP_REALLOC_IN_PLACE_ONLY标志告诉HeapReAlloc函数,它不能移动堆栈中的内 存块。如果内存块在增大, HeapReAlloc函数可能试图移动内存块。如果 HeapReAlloc能够扩大 内存块而不移动它,那么它将会这样做并且返回内存块的原始地址。另外,如果 HeapReAlloc 必须移动内存块的内容,则返回新的较大内存块的地址。如果内存块被缩小, HeapReAlloc将 返回内存块的原始地址。如果内存块是链接表或二进制树的组成部分,那么可以设定 H E A P _ R E A L L O C _ I N _ P L A C E _ O N LY标志。在这种情况下,链接表或二进制树中的其他节点 可能拥有该节点的指针,改变堆栈中的节点位置会破坏链接表的完整性。 其余的两个参数 pvMem和dwBytes用于设定你要改变其大小的内存块的地址和内存块的新 的大小(以字节为计量单位)。HeapReAlloc既可以返回新的改变了大小的内存块的地址,也可 以在内存块不能改变大小时返回 NULL。 18.3.3 了解内存块的大小 当内存块分配后,可以调用 HeapSize函数来检索内存块的实际大小: 下载 457 第 18章 堆 栈计计 参数hHeap用于标识堆栈,参数 pvMem用于指明内存块的地址。参数 fdwFlags既可以是0, 也可以是 HEAP_NO_SERIALIZE。 18.3.4 释放内存块 当不再需要内存块时,可以调用 HeapFree函数将它释放: HeapFree函数用于释放内存块,如果它运行成功,便返回 TRUE。参数fdwFlags既可以是0, 也可以是 HEAP_NO_SERIALIZE。调用这个函数可使堆栈管理器收回某些物理存储器,但是 这没有保证。 18.3.5 撤消堆栈 如果应用程序不再需要它创建的堆栈,可以通过调用 HeapDestroy函数将它撤消: 调用HeapDestroy函数可以释放堆栈中包含的所有内存块,也可以将堆栈占用的物理存储 器和保留的地址空间区域重新返回给系统。如果该函数运行成功, HeapDestroy返回TRUE。如 果在进程终止运行之前没有显式撤消堆栈,那么系统将为你将它撤消。但是,只有当进程终止 运行时,堆栈才能被撤消。如果线程创建了一个堆栈,当线程终止运行时,该堆栈将不会被撤 消。 在进程完全终止运行之前,系统不允许进程的默认堆栈被撤消。如果将进程的默认堆栈的 句柄传递给 HeapDestroy函数,系统将忽略对该函数的调用。 18.3.6 用C++程序来使用堆栈 使用堆栈的最好方法之一是将堆栈纳入现有的 C++程序。在 C++中,调用 new操作符,而 不是调用通常的 C运行期例程malloc,就可以执行类对象的分配操作。然后,当我们不再需要 这个类对象时,调用 delete操作符,而不是调用通常的 C运行期例程 free将它释放。例如,我们 有一个称为 CSomeClass的类,我们想要分配这个类的一个实例,那么可以使用类似下面的句 法: 当C++编译器查看这一行代码时,它首先查看 CSomeClass类是否包含 new操作符的成员函 数。如果包含,那么编译器就生成调用该函数的代码。如果编译器没有找到重载 new操作符的 函数,那么编译器将生成调用标准 C++的new操作符函数的代码。 当完成对已分配对象的使用后,可以通过调用 delete操作符将它撤消: 通过为我们的 C++类重载new和delete操作符,就能够很容易地利用堆栈函数。为此,让我 们在头文件中将我们的 CSomeClass类定义为如下的形式: 458计计第三部分 内 存 管 理 下载 在这个代码段中,我声明了两个成员变量,即 s_hHeap和s_uNumAllocsInHeap作为静态变 量。由于它们是静态变量,因此 C++将使CSomeClass的所有实例共享相同的变量,也就是说, C++将不为已经创建的该类的每个实例分配独立的 s_hHeap和s_uNumAllocsInHeap变量。这个 情况对我们来说是非常重要的,因为我们的 CSomeClass类的所有实例都在相同的堆栈中分配。 变量s_hHeap将包含分配CSomeClass对象时所在堆栈的句柄。 s_uNumAllocsInHeap变量只 是一个计数器,用于计算堆栈中已经分配了多少个 CSomeClass对象。每次在堆栈中分配一个 新的CSomeClass对象时, s_uNumAllocsInHeap的数字就递减。当 s_uNumAllocsInHeap的数字 到达0时,堆栈就不再需要并被释放。用于对堆栈进行操作的代码应该包括在类似下面的 .cpp 文件中: 注意,我首先在代码的开始处定义了两个静态数字变量,即s _ h H e a p和s _ u N u m A l l o c s I n H e a p, 并且分别将它们初始化为 NULL和0。 C++的new操作符接受一个参数,即 Size。该参数用于指明存放 CSomeClass对象所需要的 字节数。 new操作符函数的第一个任务是创建一个堆栈,如果这样的堆栈尚未创建的话。这只 下载 459 第 18章 堆 栈计计 需要查看 s_hHeap变量来了解它的值是否是 NULL。如果是NULL,那么就调用 HeapCreate函数, 创建一个新堆栈,同时将 HeapCreate返回的句柄保存在 s_hHeap中,这样,下次调用new操作符 时,就不会创建另一个堆栈,而是使用我们刚刚创建的堆栈。 当调用上面的HeapCreate函数时,我使用了HEAP_NO_SERIALIZE标志,因为示例代码的 剩余部分不具备对多线程安全的特性。调用 HeapCreate函数时使用的另外两个参数分别用于指 明堆栈的初始大小和它的最大值。这里我为这两个值都选择了 0。第一个0表示该堆栈没有初始 大小的值。第二个 0表示该堆栈可以根据需要进行扩展。根据你的需要,可以改变这两个值中 的任何一个,也可以同时改变两个值。 你可能认为将new操作符函数的 size参数作为第二个参数传递给 HeapCreate函数是值得的。 如果这样的话,你可以对堆栈进行初始化,使它大得足以包含该类的一个实例。然后,当 H e a p A l l o c第一次被调用时,它将以更快的速度运行,因为堆栈不必改变它的大小以便存放类 的实例。但是,事情并不总是按照你的想像来进行的。由于堆栈中的每个已分配内存块都需要 与之相关的开销,因此调用 HeapAlloc时仍然必须改变堆栈的大小,这样它才能变得足够大, 以便放置一个类的实例和它相关的开销。 一旦堆栈创建完成,就可以使用 HeapAlloc函数从该堆栈中分配新的 CSomeClass对象。第 一个参数是堆栈的句柄,第二个参数是 CSomeClass对象的大小。HeapAlloc返回分配的内存块 的地址。 当这个分配操作成功地执行时,我对 s_uNumAllocsInHeap变量进行了递增,这样就可以知 道堆栈中已分配了一个内存块。 New操作符函数做的最后一项工作是返回新分配的 CSomeClass 对象的地址。 这就是创建新 CSomeClass对象的整个过程。下面要介绍的是,当应用程序不再需要 CSomeClass 时,如何将它撤消。这是 delete操作符函数的责任,它的代码如下: d e l e t e操作符函数只接受一个参数,即被删除的对象的地址。该函数进行的第一项操作是 调用 HeapFree,将堆栈的句柄和被释放的对象的地址传递给它。如果该对象被成功地释放了, s_uNumAllocsInHeap的值就被递减 1,表示堆栈中又少了一个 CSomeClass对象。接着该函数要 检查s_uNumAllocsInHeap的值是否是0。如果是0,那么该函数就调用 HeapDestroy,将堆栈的 句柄传递给它。如果堆栈被成功地撤消了, s_hHeap将被设置为 NULL。这一点非常重要,因 为我们的程序可能在将来的某个时候分配另一个 CSomeClass对象。当它进行这项操作时, new 460计计第三部分 内 存 管 理 下载 操作符将被调用,同时该操作符将查看 s_hHeap变量,以确定它是应该使用现有的堆栈还是创 建一个新堆栈。 这个例子显示了一种使用多个堆栈的简便方法。这个例子很容易建立,并且可以纳入若干 个类中。不过应该对继承性问题有所考虑。如果你用 CsomeClass类作为一个基类,派生一个新 类,那么这个新类就可以继承 CSomeClass的new和delete操作符。这个新类也可以继承 CSomeClass的堆栈,这意味着当 new操作符用于派生类时,该派生类对象的内存将从 CSomeClass使用的同一个堆栈中分配。根据具体情况,你也许希望这样,也许不希望这样。 如果对象的大小差别很大,建立的堆栈环境可能使你的堆栈变得支离破碎。正如本章前面部分 中的“组件保护”和“更加有效地进行内存管理”两节所说的那样,你可能更加难以跟踪代码 中的错误。 如果想将一个独立的堆栈用于各个派生类,只需要重复我在 CSomeClass类中所进行的操 作。也就是说,加上另一组 s_hHeap和s_uNumAllocsInHeap变量,为new和delete操作符拷贝该 代码。当进行编译时,编译器将发现你已经为该派生类重载了 new和delete操作符,并将调用这 些函数,而不是调用基类中的那些函数。 不为每个类创建新堆栈的唯一优点是,不必为每个堆栈分配开销和内存。但是,与这些堆 栈相关的开销和内存并不很大,并且与带来的好处相比,这样做也许是值得的。我们采取的折 中方案是,当你的应用程序已经经过很好的测试并且将要推向市场时,让每个类使用它自己的 堆栈,让派生类共享基类的堆栈。不过堆栈碎片问题仍然可能存在。 18.4 其他堆栈函数 除了上面介绍的堆栈函数外, Windows还提供了若干个别的函数。下面对它们作一个简单 的介绍。 ToolHelp的各个函数(第 4章后面部分讲过)可以用来枚举进程的各个堆栈和这些堆栈中分 配的内存块。关于这些函数的详细说明,请参见 Platform SDK文档中的下列函数:Heap32First、 Heap32Next、Heap32ListFirst和Heap32ListNext。ToolHelp函数的优点在于,在 Windows 98和 Windows 2000中都能够使用它们。 本节中介绍的其他堆栈函数只存在于 Windows 2000中。 由于进程的地址空间中可以存在多个堆栈,因此可以使用 GetProcessHeaps函数来获取现有 堆栈的句柄: 若要调用GetProcessHeaps函数,必须首先分配一个 HANDLE数组,然后调用下面的函数: 注意,当该函数返回时,你的进程的默认堆栈的句柄也包含在堆栈句柄的数组中。 下载 HeapValidate函数用于验证堆栈的完整性: 461 第 18章 堆 栈计计 调用该函数时,通常要传递一个堆栈句柄,一个值为 0的标志(唯一的另一个合法标志是 HEAP_NO_SERIALIZE),并且为 pvMem传递NULL。然后,该函数将遍历堆栈中的内存块以 确保所有内存块都完好无损。为了使该函数运行得更快,可以为参数 pvMem传递一个特定的内 存块的地址。这样做可使该函数只检查单个内存块的有效性。 若要合并地址中的空闲内存块并收回不包含已经分配的地址内存块的存储器页面,可以调 用下面的函数: 通常情况下,可以为参数fdwFlags传递0,但是也可以传递HEAP_NO_SERIALIZE。 下面两个函数 HeapLock和HeapUnlock是结合在一起使用的: 这些函数是用于线程同步的。当调用 HeapLock函数时,调用线程将成为特定堆栈的所有 者。如果其他任何线程调用堆栈函数(设定相同的堆栈句柄),系统将暂停调用线程的运行, 并且在堆栈被HeapUnlock函数解锁之前不允许它醒来。 HeapAlloc、HeapSize和HeapFree等函数在内部调用 HeapLock和HeapUnlock函数来确保对 堆栈的访问能够顺序进行。自己调用 HeapLock或HeapUnlock这种情况是不常见的。 最后一个堆栈函数是 HeapWalk: 该函数只用于调试目的。它使你能够遍历堆栈的内容。可以多次调用该函数。每次调用该 函数时,将传递必须分配和初始化的 PROCESS_HEAP_ENTRY结构的地址: 462计计第三部分 内 存 管 理 下载 当开始枚举堆栈中的内存块时,必须将成员 lpData设置为NULL。这将告诉 HeapWalk对该 结构中的成员进行初始化。当成功地调用 HeapWalk后,可以查看该结构的成员。若要进入堆 栈的下一个内存块,只需要再次调用 HeapWalk,传递相同的堆栈句柄和在上次调用该函数时 传递的PROCESS_HEAP_ENTRY结构的地址。当 HeapWalk返回FALSE时,堆栈中就没有更多 的内存块了。关于该结构中的成员的说明,请参见 Platform SDK文档。 在循环调用 HeapWalk的时候,必须使用 HeapLock和HeapUnlock函数,这样,当遍历堆栈 时,其他线程将无法分配和释放堆栈中的内存块。 下载 第四部分 动态链接库 第19章 DLL基础 自从Microsoft公司推出第一个版本的 Windows操作系统以来,动态链接库( DLL)一直是 这个操作系统的基础。 Windows API 中的所有函数都包含在 DLL中。 3个最重要的 DLL是 Kernel32.dll,它包含用于管理内存、进程和线程的各个函数; User32.dll,它包含用于执行用 户界面任务(如窗口的创建和消息的传送)的各个函数; GDI32.dll,它包含用于画图和显示 文本的各个函数。 Windows还配有若干别的 DLL,它们提供了用于执行一些特殊任务的函数。例如, AdvAPI32.dll包含用于实现对象安全性、注册表操作和事件记录的函数; ComDlg32.dll包含常 用对话框(如File Open 和File Save);ComCtl32.DLL则支持所有的常用窗口控件。 本章将要介绍如何为应用程序创建 DLL。下面是为什么要使用DLL的一些原因: • 它们扩展了应用程序的特性。由于 DLL能够动态地装入进程的地址空间,因此应用程序 能够在运行时确定需要执行什么操作,然后装入相应的代码,以便根据需要执行这些操 作。例如,当一家公司开发了一种产品,想要让其他公司改进或增强该产品的功能时, 那么就可以使用DLL。 • 它们可以用许多种编程语言来编写。可以选择手头拥有的最好的语言来编写 DLL。也许 你的应用程序的用户界面使用 Microsoft Visual Basic编写得最好,但是用 C++来处理它的 商用逻辑更好。系统允许 Visual Basic程序加载C++ DLL 、Cobol DLL和Fortran DLL等。 • 它们简化了软件项目的管理。如果在软件开发过程中不同的工作小组在不同的模块上工 作,那么这个项目管理起来比较容易。但是,应用程序在销售时附带的文件应该尽量少 一些。我知道有一家公司销售的产品附带了 100个DLL——每个程序员最多有 5个DLL。 这样,应用程序的初始化时间将会长得吓人,因为系统必须打开 100个磁盘文件之后,程 序才能执行它的操作。 • 它们有助于节省内存。如果两个或多个应用程序使用同一个 DLL,那么该 DLL的页面只 要放入RAM一次,所有的应用程序都可以共享它的各个页面。 C/C++运行期库就是个极 好的例子。许多应用程序都使用这个库。如果所有的应用程序都链接到这个静态库,那 么sprintf、strcpy和malloc等函数的代码就要多次存在于内存中。但是,如果所有这些应 用程序链接到DLL C/C++运行期库,那么这些函数的代码就只需要放入内存一次,这意 味着内存的使用将更加有效。 • 它们有助于资源的共享。 DLL可以包含对话框模板、字符串、图标和位图等资源。多个 应用程序能够使用 DLL来共享这些资源。 • 它们有助于应用程序的本地化。应用程序常常使用 DLL对自己进行本地化。例如,只包 含代码而不包含用户界面组件的应用程序可以加载包含本地化用户界面组件的 DLL。 • 它们有助于解决平台差异。不同版本的 Widnows配有不同的函数。开发人员常常想要调 用新的函数(如果它们存在于主机的 Windows版本上的话)。但是,如果你的源代码包含 464计计第四部分 动态链接库 下载 了对一个新函数的调用,而你的应用程序将要在不能提供该函数的 Windows版本上运行, 那么操作系统的加载程序将拒绝运行你的进程。即使你实际上从不调用该函数,情况也 是这样。如果将这些新函数保存在 DLL中,那么应用程序就能够将它们加载到 Windows 的老版本上。当然,你仍然可以成功地调用该函数。 • 它们可以用于一些特殊的目的。 Windows使得某些特性只能为 DLL所用。例如,只有当 DLL中包含某个挂钩通知函数的时候,才能安装某些挂钩(使用 SetWindowsHookEx和 SetWinEventHook来进行安装)。可以通过创建必须在 DLL中生存的 COM对象来扩展 Windows Explorer的外壳程序。对于可以由 Web浏览器加载的、用于创建内容丰富的 Web 页的ActiveX控件来说,情况也是一样. 19.1 DLL与进程的地址空间 创建DLL常常比创建应用程序更容易,因为 DLL往往包含一组应用程序可以使用的自主函 数。在DLL中通常没有用来处理消息循环或创建窗口的支持代码。 DLL只是一组源代码模块, 每个模块包含了应用程序(可执行文件)或另一个 DLL将要调用的一组函数。当所有源代码文 件编译后,它们就像应用程序的可执行文件那样被链接程序所链接。但是,对于一个 DLL来说, 你必须设定该连链程序的 /DLL开关。这个开关使得链接程序能够向产生的 DLL文件映像发出 稍有不同的信息,这样,操作系统加载程序就能将该文件映像视为一个 DLL而不是应用程序。 在应用程序(或另一个 DLL)能够调用 DLL中的函数之前, DLL文件映像必须被映射到调 用进程的地址空间中。若要进行这项操作,可以使用两种方法中的一种,即加载时的隐含链接 或运行期的显式链接。隐含链接将在本章的后面部分介绍,显式链接将在第 20章中介绍。 一旦DLL的文件映像被映射到调用进程的地址空间中, DLL的函数就可以供进程中运行的 所有线程使用。实际上, DLL几乎将失去它作为 DLL的全部特征。对于进程中的线程来说, DLL的代码和数据看上去就像恰巧是在进程的地址空间中的额外代码和数据一样。当一个线程 调用 DLL函数时,该 DLL函数要查看线程的堆栈,以便检索它传递的参数,并将线程的堆栈用 于它需要的任何局部变量。此外, DLL中函数的代码创建的任何对象均由调用线程所拥有,而 DLL本身从来不拥有任何东西。 例如,如果 VirtualAlloc函数被 DLL中的一个函数调用,那么将从调用线程的进程地址空间 中保留一个地址空间的区域,该地址空间区域将始终处于保留状态,因为系统并不跟踪 DLL中 的函数保留该区域的情况。保留区域由进程所拥有,只有在线程调用 VirtualFree函数或者进程 终止运行时才被释放。 如你所知,可执行文件的全局变量和静态变量不能被同一个可执行文件的多个运行实例共 享。Windows 98 能够确保这一点,方法是在可执行文件被映射到进程的地址空间时为可执行文 件的全局变量和静态变量分配相应的存储器。 Windows 2000确保这一点的方法是使用第 13章介 绍的写入时拷贝( copy-on-write)机制。DLL中的全局变量和静态变量的处理方法是完全相同 的。当一个进程将 DLL的映像文件映射到它的地址空间中去时,系统将同时创建全局数据变量 和静态数据变量的实例。 注意 必须注意的是,单个地址空间是由一个可执行模块和若干个 DLL模块组成的。 这些模块中,有些可以链接到静态版本的 C/C++运行期库,有些可以链接到一个 DLL 版本的 C/C++运行期库,而有些模块(如果不是用 C/C++编写的话)则根本不需要 C/C++运行期库。许多开发人员经常会犯一个常见的错误,因为他们忘记了若干个 C / C + +运行期库可以存在于单个地址空间中。请看下面的代码: 下载 465 第19章 DLL 基础计计 那么你是怎么看待这个问题的呢?上面这个代码能够正确运行吗? DLL函数分配 的内存块是由 EXE的函数释放的吗?答案是可能的。上面显示的代码并没有为你提供 足够的信息。如果EXE和DLL都链接到DLL的C/C++运行期库,那么上面的代码将能够 很好地运行。但是,如果两个模块中的一个或者两个都链接到静态C/C++运行期库,那 么对free函数的调用就会失败。我经常看到编程人员编写这样的代码,结果都失败了。 有一个很方便的方法可以解决这个问题。当一个模块提供一个用于分配内存块的 函数时,该模块也必须提供释放内存的函数。让我们将上面的代码改写成下面的样子: 这个代码是正确的,它始终都能正确地运行。当你编写一个模块时,不要忘记其 他模块中的函数也许没有使用 C/C++来编写,因此可能无法使用 malloc和free函数进行 内存的分配。应该注意不要在代码中使用这些假设条件。另外,在内部调用 malloc和 free函数时,这个原则对于 C++的new和delete操作符也是适用的。 19.2 DLL的总体运行情况 为了全面理解 DLL是如何运行的以及你和系统如何使用 DLL,让我们首先观察一下 DLL的 整个运行情况。图 19-1综合说明了它的所有组件一道配合运行的情况。 现在要重点介绍可执行模块和 DL模块之间是如何隐含地互相链接的。隐含链接是最常用 的链接类型。Windows也支持显式链接(第20章介绍这个问题)。 在图19-1中你可以看到,当一个模块(比如一个可执行文件)使用 DLL中的函数或变量时, 将有若干个文件和组件参与发挥作用。为了简单起见,我将“可执行模块”称为来自 DLL的输 466计计第四部分 动态链接库 下载 入函数和变量,将“ DLL模块”称为用于可执行模块的输出函数和变量。但是要记住, DLL模 块能够(并且确实常常)输入包含在其他 DLL模块中的函数和变量。 编译器 编译器 编译器 链接程序 编译器 编译器 编译器 链接程序 创造DLL: 1) 建立带有输出原型/结构/符号的头文件。 2) 建立实现输出函数/变量的C/C++源文件。 3) 编译器为每个C/C++源文件生成 .obj模块。 4) 链接程序将生成DLL的 .obj模块链接起来。 5) 如果至少输出一个函数/变量,那么链接程序也生成lib 文件。 创造EXE: 6) 建立带有输入原型/结构/符号的头文件。 7) 建立引用输入函数/变量的C/C++源文件。 8) 编译器为每个C/C++源文件生成 .obj源文件。 9) 链接程序将各个 .obj模块链接起来,产生一个 .exe文件(它包含了所需要DLL模块的名字和输入符号的列表)。 运行应用程序: 10) 加载程序为 .exe 创建地址空间。 11) 加载程序将需要的DLL加载到地址空间中进程的主线程开始执行;应用程序启动运行。 图19-1 应用程序如何创建和隐含链接 DLL的示意图 若要创建一个从DLL模块输入函数和变量的可执行模块,必须首先创建一个 DLL模块。然 后就可以创建可执行模块。 若要创建DLL模块,必须执行下列操作步骤: 1) 首先必须创建一个头文件,它包含你想要从 DLL输出的函数原型、结构和符号。 DLL的 所有源代码模块均包含该头文件,以帮助创建 DLL。后面将会看到,当创建需要使用 DLL中包 含的函数和变量的可执行模块(或多个模块)时,也需要这个头文件。 2) 要创建一个 C/C++源代码模块(或多个模块),用于实现你想要在 DLL模块中实现的函 数和变量。由于这些源代码模块在创建可执行模块时是不必要的,因此创建 DLL的公司能够保 下载 467 第19章 DLL 基础计计 护公司的秘密。 3) 创建DLL模块,将使编译器对每个源代码模块进行处理,产生一个 .obj模块(每个源代 码模块有一个.obj模块)。 4) 当所有的 .obj模块创建完成后,链接程序将所有 .obj模块的内容组合在一起,产生一个 DLL映象文件。该映像文件(即模块)包含了用于 DLL的所有二进制代码和全局 /静态数据变 量。为了执行这个可执行模块,该文件是必不可少的。 5) 如果链接程序发现 DLL的源代码模块至少输出了一个函数或变量,那么链接程序也生成 一个.lib文件。这个 .lib文件很小,因为它不包含任何函数或变量。它只是列出所有已输出函数 和变量的符号名。为了创建可执行模块,该文件是必不可少的。 一旦创建了 DLL模块,就可以创建可执行模块。其创建步骤是: 6) 在引用函数、变量、数据、结构或符号的所有源代码模块中,必须包含 DLL开发人员创 建的头文件。 7) 要创建一个C/C++源代码模块(或多个模块),用于实现你想要在可执行模块中实现的 函数和变量。当然该代码可以引用 DLL头文件中定义的函数和变量。 8) 创建可执行模块,将使编译器对每个源代码模块进行处理,生成一个 .obj模块(每个源 代码模块有一个.obj模块)。 9) 当所有.obj模块创建完成后,链接程序便将所有的.obj模块的内容组合起来,生成一个可 执行的映像文件。该映像文件(或模块)包含了可执行文件的所有二进制代码和全局/静态变量。 该可执行模块还包含一个输入节,列出可执行文件需要的所有 DLL模块名(关于各个节的详细 说明,参见第 17章)。此外,对于列出的每个 DLL名字,该节指明了可执行模块的二进制代码 引用了哪些函数和变量符号。下面你会看到操作系统的加载程序将对该输入节进行分析。 一旦DLL和可执行模块创建完成,一个进程就可以执行。当试图运行可执行模块时,操作 系统的加载程序将执行下面的操作步骤: 10) 加载程序为新进程创建一个虚拟地址空间。可执行模块被映射到新进程的地址空间。 加载程序对可执行模块的输入节进行分析。对于该节中列出的每个 DLL名字,加载程序要找出 用户系统上的DLL模块,再将该 DLL映射到进程的地址空间。注意,由于 DLL模块可以从另一 个DLL模块输入函数和变量,因此 DLL模块可以拥有它自己的输入节。若要对进程进行全面的 初始化,加载程序要分析每个模块的输入节,并将所有需要的DLL模块映射到进程的地址空间。 如你所见,对进程进行初始化是很费时间的。 一旦可执行模块和所有 DLL模块被映射到进程的地址空间中,进程的主线程就可以启动运 行,同时应用程序也可以启动运行。下面各节将更加详细地介绍这个进程的运行情况。 19.3 创建DLL模块 当创建DLL 时,要创建一组可执行模块(或其他DLL)可以调用的函数。DLL可以将变量、 函数或 C/C++类输出到其他模块。在实际工作环境中,应该避免输出变量,因为这会删除你的 代码中的一个抽象层,使它更加难以维护你的 DLL代码。此外,只有当使用同一个供应商提供 的编译器对输入 C++类的模块进行编译时,才能输出 C++类。由于这个原因,也应该避免输出 C++类,除非知道可执行模块的开发人员使用的工具与 DLL模块开发人员使用的工具相同。 当创建DLL模块时,首先应该建立一个头文件,该文件包含了你想要输出的变量(类型和 名字)和函数(原型和名字)。头文件还必须定义用于输出函数和变量的任何符号和数据结构。 你的DLL的所有源代码模块都应该包含这个头文件。另外,必须分配该头文件,以便它能够包 468计计第四部分 动态链接库 下载 含在可能输入这些函数或变量的任何源代码中。拥有单个头文件,供 DLL创建程序和可执行模 块的创建程序使用,就可以大大简化维护工作。 下面的代码说明了应该如何对单个头文件进行编码,以便同时包含可执行文件和 DLL的源 代码文件: 在你的每个 DLL源代码文件中,应该包含下面的头文件: 下载 469 第19章 DLL 基础计计 当上面的DLL源代码文件被编译时,在 MyLib.h头文件的前面使用 __declspec(dllexport)对 MYLIBAPI进行定义。当编译器看到负责修改变量、函数或 C++类的__declspec(dllexport)时,它 就知道该变量、函数或 C++类是从产生的DLL模块输出的。注意, MYLIBAPI标志被置于头文 件中要输出的变量的定义之前和要输出的函数之前。 另外,在源代码文件( MyLibFile1.cpp0)中, MYLIBAPI 标志并不出现在输出的变量和 函数之前。 MYLIBAPI标志在这里是不必要的,因为编译器在分析头文件时能够记住要输出哪 些变量或函数。 你会发现,MYLIBAPI标志包含了extern“C”修改符。只有当你编写 C++代码而不是直接 编写C代码时,才能使用这个修改符。通常来说, C++编译器可能会改变函数和变量的名字, 从而导致严重的链接程序问题。例如,假设你用 C++编写一个 DLL,并直接用C编写一个可执 行模块,当你创建 DLL时,函数名被改变,但是,当你创建可执行模块时,函数名没有改变。 当链接程序试图链接可执行模块时,它就会抱怨说,可执行模块引用的符号不存在。如果使用 extern“C”,就可以告诉编译器不要改变变量名或函数名,这样,变量和函数就可以供使用 C、 C++或任何其他编程语言编写的可执行模块来访问。 现在你已经知道 DLL源代码文件是如何使用这个头文件的。但是,可执行模块的源代码文 件情况又是如何呢?可执行模块的源代码文件不应该在这个头文件的前面定义 MYLIBAPI。由 于MYLIBAPI没有定义,因此头文件将 MYLIBAPI定义为__declspec(dllimport)。编译器看到可 执行模块的源代码文件从 DLL模块输入变量和函数。 如果观察 Microsoft的标准Windows头文件,如WinBase.h,你将会发现 Microsoft使用的方 法基本上与上面介绍的方法相同。 19.3.1 输出的真正含义是什么 上一节介绍的一个真正有意思的东西是 __declspec(dllexport)修改符。当 Microsoft的C/C++ 编译器看到变量、函数原型或 C++类之前的这个修改符的时候,它就将某些附加信息嵌入产生 的.obj文件中。当链接 DLL的所有.obj文件时,链接程序将对这些信息进行分析。 当DLL被链接时,链接程序要查找关于输出变量、函数或 C++类的信息,并自动生成一 个.lib文件。该.lib文件包含一个DLL输出的符号列表。当然,如果要链接引用该 DLL的输出符 号的任何可执行模块,该 .lib文件是必不可少的。除了创建 .lib文件外,链接程序还要将一个输 出符号表嵌入产生的 DLL文件。这个输出节包含一个输出变量、函数和类符号的列表(按字母 顺序排列)。该链接程序还将能够指明在何处找到每个符号的相对虚拟地址( RVA)放入DLL 模块。 使用Microsoft的Visual Studio的DumpBin.exe实用程序(带有 -exports开关),你能够看到 470计计第四部分 动态链接库 下载 DLL的输出节是个什么样子。下面是 Kernel32.dll的输出节的一个代码段(我已经删除了 DUMPBIN的某些输出,这样就不会占用本书的太多篇幅)。 如你所见,这些符号是按字母顺序排列的, RVA这一列下面的数字用于指明在 DLL文件映 像中的什么位置能够找到输出符号的位移量。序号列可以与 16位Windows源代码向后兼容,并 且它不应该用于现在的应用程序中。 hint(提示码)列可供系统用来改进代码的运行性能,在 此并不重要。 注意 许多开发人员常常通过为函数赋予一个序号值来输出 DLL函数。对于那些来自 16位Windows环境的函数来说,情况尤其是如此。但是, Microsoft并没有公布系统 DLL的序号值。当你的可执行模块或 DLL模块链接到任何一个 Windows函数时, 下载 471 第19章 DLL 基础计计 Microsoft要求你使用符号的名字进行链接。如果你按照序号进行链接,那么你的应用 程序有可能无法在其他Windows平台或将来的Windows平台上运行。 实际上,我就遇到过这样的情况。我曾经发布了一个示例应用程序,它使用 Microsoft System Journal中的序号。我的应用程序在 Windows NT 3.1上运行得很好, 但是当Windows NT 3.5推出时,我的应用程序就无法正确地运行。为了解决这个问题, 我不得不用函数名代替序号。现在该应用程序既能够在 Windows NT 3.1上运行,而且 能够在所有更新的版本上运行。 我问过Microsoft公司,为什么它不使用序号,我得到的回答是:“我们认为可移植 的可执行文件格式不仅具有序号的优点(查找迅速),而且提供了按名字输入的灵活性。 我们可以随时增加函数。在带有多个实现代码的大型程序项目中,序号很难管理。” 你可以将序号用于你创建的任何 DLL,并且按照序号将你的可执行文件链接到这 些DLL。Microsoft保证,即使在将来的操作系统版本中,这个方法也是可行的。但是 我在我的工作中总是避免使用序号,并且从现在起只按名字进行链接。 19.3.2 创建用于非Visual C++工具的DLL 如果使用Microsoft Visual C++来创建DLL和将要链接到该DLL的可执行模块,可以跳过本 节内容的学习。但是,如果使用 Visual C++创建DLL,而这个DLL要链接到使用任何供应商的 工具创建的可执行模块,那么必须做一些额外的工作。 前面讲过当进行 C和C++混合编程时使用 extern“C”修改符的问题。也讲过 C++类的问题 以及为什么因为名字改变的缘故你必须使用同一个编译器供应商的工具的问题。当你直接将 C 语言编程用于多个工具供应商时将会出现另一个问题。这个问题是,即使你根本不使用 C++, Microsoft的C编译器也会损害 C函数。当你的函数使用 __stdcall(WINAPI)调用规则时会出现这 种问题。这种调用规则是最流行的一种类型。当使用 __stdcall将C函数输出时, Microsoft的编 译器就会改变函数的名字,设置一个前导下划线,再加上一个 @符号的前缀,后随一个数字, 表示作为参数传递给函数的字节数。例如,下面的函数是作为 DLL的输出节中的 _MyFunc@8 输出的: 如果用另一个供应商的工具创建了一个可执行模块,它将设法链接到一个名叫 MyFunc的 函数,该函数在Microsoft编译器已有的DLL中并不存在,因此链接将失败。 若要使用与其他编译器供应商的工具链接的 Microsoft的工具创建一个可执行模块,必须告 诉Microsoft的编译器输出没有经过改变的函数名。可以用两种方法来进行这项操作。第一种方 法是为编程项目建立一个 .def文件,并在该.def文件中加上类似下面的EXPORTS节: 当Microsoft的链接程序分析这个 .def文件时,它发现 _MyFunc@8和MyFunc均被输出。由 于这两个函数名是互相匹配的(除了截断的尾部外),因此链接程序使用 MyFunc的.def文件名 来输出该函数,而根本不使用 _MyFunc@8的名字来输出函数。 现在你可能认为,如果使用 Microsoft的工具创建一个可执行模块,并且设法将它链接到包 含未截断名字的 DLL,那么链接程序的运行将会失败,因为它将试图链接到称为 _MyFunc@8 的函数。当然,你会高兴地了解到 Microsoft的链接程序进行了正确的操作,将可执行模块链接 到名字为 MyFunc的函数。 472计计第四部分 动态链接库 下载 如果想避免使用 .def文件,可以使用第二种方法输出未截断的函数版本。在 DLL的源代码 模块中,可以添加下面这行代码: 这行代码使得编译器发出一个链接程序指令,告诉链接程序,一个名叫 MyFunc的函数将 被输出,其进入点与称为 _MyFunc@8的函数的进入点相同。第二种方法没有第一种方法容易, 因为你必须自己截断函数名,以便创建该代码行。另外,当使用第二种方法时, DLL实际上 输出用于标识单个函数的两个符号,即 MyFunc和_MyFunc@8,而第一种方法只输出符号 MyFunc。第二种方法并没有给你带来更多的好处,它只是使你可以避免使用 .def的文件而 已。 19.4 创建可执行模块 下面的代码段显示了一个可执行的源代码文件,它输入了 DLL的输出符号,并且在代码中 引用了这些符号。 当创建可执行源代码文件时,必须加上 DLL的头文件。如果没有头文件,输入的符号将不 会被定义,而且编译器将会发出许多警告和错误消息。 可执行源代码文件不应该定义 DLL的头文件前面的MYLIBAPI。当上面显示的这个可执行 源代码文件被编译时, MYLIBAPI由MyLib.h头文件使用 __declspec(dllimport)进行定义。当编 译器看到修改变量、函数或 C++类的__declspec(dllimport)时,它知道这个符号是从某个 DLL模 块输入的。它不知道是从哪个 DLL模块输入的,并且它也不关心这个问题。编译器只想确保你 用正确的方法访问这些输入的符号。现在你在源代码中可以引用输入的符号,一切都将能够正 常工作。 下载 473 第19章 DLL 基础计计 接着,链接程序必须将所有 .obj模块组合起来,创建产生的可执行模块。该链接程序必须 确定哪些DLL包含代码引用的所有输入符号的 DLL。因此你必须将 DLL的.lib文件传递给链接 程序。如前所述, .lib文件只包含DLL模块输出的符号列表。链接程序只想知道是否存在引用 的符号和哪个 DLL模块包含该符号。如果连接程序转换了所有外部符号的引用,那么可执行模 块就因此而产生了。 输入的真正含义是什么 上一节介绍了修改符 --declspec(dllimport)。当输入一个符号时,不必使用关键字 -declspec(dllimport),只要使用标准的 C关键字 extern即可。但是,如果编译器预先知道你引用 的符号将从一个 DLL的.lib文件输入,那么编译器就能够生成运行效率稍高的代码。因此建议 你尽量将 --declspec(dllimport)关键字用于输入函数和数据符号。当你调用标准 Windows函数中 的任何一个时,Microsoft将为你进行这项设置。 当链接程序进行输入符号的转换时,它就将一个称为输入节的特殊的节嵌入产生的可执行 模块。输入节列出了该模块需要的 DLL模块以及由每个DLL模块引用的符号。 使用Visual Studio的DumpBin.exe实用程序(带有-imports开关),能够看到模块的输入节的 样子。下面是 Calc.exe文件的输入节的一个代码段(同样,我删除了 DUMPBIN的某些输出, 这样它就不会占用太多的篇幅)。 474计计第四部分 动态链接库 下载 如你所见,这一节为 Calc.exe需要的每个 DLL设置了一个项目,这些 DLL是Shell32.dll、 MSVCRt.dll、AdvAPI32.dll、Kernel32.dll、GDI32.dll和User32.dll。在每个 DLL的模块名下面, 有一个Calc.exe从该特定模块输入的符号列表。例如, Calc模块调用包含在 Kernel32.dll中的下 列函数: lstrcpyW、LocalAlloc、GetCommandLineW和GetProfileIntW等。 紧靠符号名左边的数字是符号的提示( hint)值,它与讨论无关。每个符号行最左边的数 字用于指明该符号在进程的地址空间中所在的内存地址。该内存地址只有在可执行模块相链接 时才出现。在DumpBin的输出的结尾处,可以看到更多的链接信息。 19.5 运行可执行模块 当一个可执行文件被启动时,操作系统加载程序将为该进程创建虚拟地址空间。然后,加 载程序将可执行模块映射到进程的地址空间中。加载程序查看可执行模块的输入节,并设法找 出任何需要的DLL,并将它们映射到进程的地址空间中。 下载 475 第19章 DLL 基础计计 由于该输入节只包含一个 DLL名而没有它的路径名。因此加载程序必须搜索用户的磁盘驱 动器,找出 DLL。下面是加载程序的搜索顺序: 1) 包含可执行映像文件的目录。 2) 进程的当前目录。 3) Windows系统目录。 4) Windows目录。 5) PATH环境变量中列出的各个目录。 应该知道其他的东西也会影响加载程序对一个 DLL的搜索(详细说明参见第 20章)。当 DLL模块映射到进程的地址空间中时,加载程序要检查每个 DLL的输入节。如果存在输入节 (通常它确实是存在的),那么加载程序便继续将其他必要的 DLL模块映射到进程的地址空间 中。加载程序将保持对 DLL模块的跟踪,使模块的加载和映射只进行一次(尽管多个模块需 要该模块)。 如果加载程序无法找到需要的 DLL模块,用户会看到图 19-2、图19-3所示的消息框中的一 个:如果是Windows 2000,那么将出现图 19-2所示的消息框,如果是 Windows 98,则出现图 19-3所示的消息框。 图19-2 Windows 2000下加载程序 搜索DLL时出现的消息框 图19-3 Windows 98下加载程序 搜索DLL时的消息框 当所有的 DLL模块都找到并且映射到进程的地址空间中之后,加载程序就会确定对输入的 符号的全部引用。为此,它要再次查看每个模块的输入节。对于列出的每个符号,加载程序都 要查看指定的 DLL的输出节,以确定该符号是否存在。如果该符号不存在(这种情况很少), 那么加载程序就显示图 19-4、图19-5所示的消息框之一:如果是 Windows 2000,那么出现图 19-4所示的消息框,如果是Windows 98 ,则出现图19-5所示的消息框。 图19-4 Windows 2000下加载程序查看 DLL 的输出节时出现的消息框 图19-5 Windows 98下加载程序 查看DLL时出现的消息框 如果Windows 2000版本的消息框指明漏掉的是哪个函数,而不是显示用户难以识别的错误 代码0xC000007B,那么这将是非常好的。也许下一个 Windows版本能够做到这一点。 如果这个符号不存在,那么加载程序将要检索该符号的 RVA,并添加DLL模块被加载到的 虚拟地址空间(符号在进程的地址空间中的位置)。然后它将该虚拟地址保存在可执行模块的 输入节中。这时,当代码引用一个输入符号时,它将查看调用模块的输入节,并且捕获输入符 号的地址,这样它就能够成功地访问输入变量、函数或 C++类的成员函数。好了,动态链接完 成,进程的主线程开始执行,应用程序终于也开始运行了! 476计计第四部分 动态链接库 下载 当然,这需要加载程序花费相当多的时间来加载这些 DLL模块,并用所有使用输入符号的 正确地址来调整每个模块的输入节。由于所有这些工作都是在进程初始化的时候进行的,因此 应用程序运行期的性能不会降低。不过,对于许多应用程序来说,初始化的速度太慢是不行的。 为了缩短应用程序的加载时间,应该调整你的可执行模块和 DLL模块的位置并且将它们连接起 来。真可惜很少有开发人员知道如何进行这项操作,因为这些技术是非常重要的。如果每个公 司都能够使用这些技术,系统将能运行的更好。实际上,我认为操作系统销售时应该配有一个 能够自动执行这些操作的实用程序。下一章将要介绍对模块调整位置和进行连接的方法。 下载 第20章 DLL的高级操作技术 上一章介绍了 DLL链接的基本方法,并且重点说明了隐含链接的技术,这是 DLL链接的最 常用的形式。虽然对于大多数应用程序来说,只要了解上一章介绍的知识就足够了,但是还可 以使用DLL进行更多的工作。本章将要介绍与 DLL相关的各种操作方法。大多数应用程序不一 定需要这些方法,但是它们是非常有用的,所以应该对它们有所了解。 20.1 DLL模块的显式加载和符号链接 如果线程需要调用 DLL模块中的函数,那么 DLL的文件映像必须映射到调用线程的进程地 编译器 编译器 编译器 链接程序 编译器 编译器 编译器 链接程序 创造DLL: 1) 建立带有输出原型/结构/符号的头文件。 2) 建立实现输出函数/变量的 C/C++源文件。 3) 编译器为每个 C/C++源文件生成 .obj模块。 4) 链接程序将生成DLL的 .obj模块链接起来。 5) 如果至少输出一个函数/变量,那么链接程序也生成 .lib 文件。 创造EXE: 6) 建立带有输入原型/结构/符号的头文件(视情况而定)。 7) 建立不引用输入函数/变量的 C/C++源文件。 8) 编译器为每个 C/C++源文件生成 .obj源文件。 9) 链接程序将各个 .obj模块链接起来,生成 .exe文件。 注: DLL的lib文件是不需要的,因为并不直接引用输出符号。.exe 文件不包含输入表。 运行应用程序: 10) 加载程序为 .exe 创建模块地址空进程的主线程开始执行;应用程序启动运行。 显式加载DLL: 11) 一个线程调用LoadLibrary (Ex)函数,将DLL加载到进程的地址空间这时线程可以调用GetProcAddress以便间接引用DLL的 输出符号。 图20-1 应用程序创建和显式链接DLL的示意图 478计计第四部分 动态链接库 下载 址空间中。可以用两种方法进行这项操作。第一种方法是让应用程序的源代码只引用 DLL中包 含的符号。这样,当应用程序启动运行时,加载程序就能够隐含加载(和链接)需要的 DLL。 第二种方法是在应用程序运行时让应用程序显式加载需要的 DLL并且显式链接到需要的输 出符号。换句话说,当应用程序运行时,它里面的线程能够决定它是否要调用 DLL中的函数。 该线程可以将DLL显式加载到进程的地址空间,获得 DLL中包含的函数的虚拟内存地址,然后 使用该内存地址调用该函数。这种方法的优点是一切操作都是在应用程序运行时进行的。 图20-1显示了一个应用程序是如何显式地加载 DLL并且链接到它里面的符号的。 20.1.1 显式加载DLL模块 无论何时,进程中的线程都可以决定将一个 DLL映射到进程的地址空间,方法是调用下面 两个函数中的一个: 这两个函数均用于找出用户系统上的文件映像(使用上一章中介绍的搜索算法),并设法 将DLL的文件映像映射到调用进程的地址空间中。两个函数返回的 HINSTANCE值用于标识文 件映像映射到的虚拟内存地址。如果 DLL不能被映射到进程的地址空间,则返回 NULL。若要 了解关于错误的详细信息,可以调用 GetLastError. 你会注意到, LoadLibraryEx函数配有两个辅助参数,即 hFile和dwFlags。参数hFile保留供 将来使用,现在必须是 NULL。对于参数 dwFlags ,必须将它设置为 0 ,或者设置为 DONT_RESOLVE_DLL_REFERENCES、LOAD_LIBRARY_AS_DATAFILE和LOAD_WITH_ A LT E R E D _ S E A R C H _ PAT H等标志的一个组合。 1. DON T_RESOLVE_DLL_REFERENCES DON T_RESOLVE_DLL_REFERENCES标志用于告诉系统将 DLL映射到调用进程的地址 空间中。通常情况下,当 DLL被映射到进程的地址空间中时,系统要调用 DLL中的一个特殊函 数,即 DllMain(本章后面介绍)。该函数用于对 DLL进行初始化。 DON T_RESOLVE_ DLL_REFERENCES标志使系统不必调用 DllMain函数就能映射文件映像。 此外,DLL能够输入另一个DLL中包含的函数。当系统将一个 DLL映射到进程的地址空间 中时,它也要查看该 D L L 是否需要其他的 D L L ,并且自动加载这些 D L L 。当 D O N T_RESOLVE_DLL_REFERENCES标志被设定时,系统并不自动将其他的 DLL加载到进程的地 址空间中。 2. LOAD_LIBRARY_AS_DATAFILE LOAD_LIBRARY_AS_DATAFILE标志与DON T_RESOLVE_DLL_REFERENCES标志相类 似,因为系统只是将 DLL映射到进程的地址空间中,就像它是数据文件一样。系统并不花费额 外的时间来准备执行文件中的任何代码。例如,当一个 DLL被映射到进程的地址空间中时,系 统要查看DLL中的某些信息,以确定应该将哪些页面保护属性赋予文件的不同的节。如果设定 了LOAD_LIBRARY_AS_DATAFILE标志,系统将以它要执行文件中的代码时的同样方式来设 置页面保护属性。 由于下面几个原因,该标志是非常有用的。首先,如果有一个 DLL(它只包含资源,但不 下载 479 第20章 DLL的高级操作技术计计 包含函数),那么可以设定这个标志,使 DLL的文件映像能够映射到进程的地址空间中。然后 可以在调用加载资源的函数时,使用 LoadLibraryEx函数返回的HINSTANCE值。通常情况下, 加载一个.exe文件,就能够启动一个新进程,但是也可以使用 LoadLibraryEx函数将.exe文件的 映像映射到进程的地址空间中。借助映射的 .exe文件的HINSTANCE值,就能够访问文件中的 资源。由于.exe文件没有DllMain函数,因此,当调用LoadLibraryEx来加载一个.exe文件时,必 须设定LOAD_LIBRARY_AS_DATAFILE标志。 3. LOAD_WITH_ALTERED_SEARCH_PATH LOAD_WITH_ALTERED_SEARCH_PATH标志用于改变 LoadLibraryEx用来查找特定的 DLL文件时使用的搜索算法。通常情况下, LoadLibraryEx按照第19章讲述的顺序进行文件的 搜索。但是,如果设定了 LOAD_WITH_ALTERED_SEARCH_PATH标志,那么 LoadLibraryEx 函数就按照下面的顺序来搜索文件: 1) pszDLLPathName参数中设定的目录。 2) 进程的当前目录。 3) Windows的系统目录。 4) Windows目录。 5) PATH环境变量中列出的目录。 20.1.2 显式卸载DLL模块 当进程中的线程不再需要 DLL中的引用符号时,可以从进程的地址空间中显式卸载 DLL, 方法是调用下面的函数: 必须传递 H I N S TA N C E 值,以便标识要卸载的 D L L 。该值是较早的时候调用 LoadLibrary(Ex)而返回的值。 也可以通过调用下面的函数从进程的地址空间中卸载 DLL: 该函数是在 Kernel32.dll中实现的,如下所示: 初看起来,这并不是个非常高明的代码,你可能不明白,为什么 Microsoft要创建 FreeLibraryAndExitThread这个函数。其原因与下面的情况有关:假定你要编写一个 DLL,当它 被初次映射到进程的地址空间中时,该 DLL就创建一个线程。当该线程完成它的操作时,它通 过调用 FreeLibrary函数,从进程的地址空间中卸载该 DLL,并且终止运行,然后立即调用 ExitThread。 但是,如果线程分开调用 FreeLibrary和ExitThread,就会出现一个严重的问题。这个问题 是调用FreeLibrary会立即从进程的地址空间中卸载 DLL。当调用的 FreeLibrary返回时,包含对 ExitThread调用的代码就不再可以使用,因此线程将无法执行任何代码。这将导致访问违规, 同时整个进程终止运行。 但是,如果线程调用FreeLibraryAndExitThread,该函数调用 FreeLibrary,使DLL立即被卸 480计计第四部分 动态链接库 下载 载。下一个执行的指令是在 Kernel32.dll中,而不是在刚刚被卸载的 DLL中。这意味着该线程能 够继续执行,并且可以调用 ExitThread。ExitThread使该线程终止运行并且不返回。 一般来说,并没有很大的必要去调用 FreeLibraryAndExitThread函数。我曾经使用过一次, 因为我执行了一个非常特殊的任务。另外,我为 Microsoft Windows 3.1编写了一个代码,它并 没有提供这个函数。因此我高兴地看到 Microsoft将这个函数增加到了较新的 Windows版本中。 在实际环境中, LoadLibrary和LoadLibraryEx这两个函数用于对与特定的库相关的进程使 用计数进行递增, FreeLibrary和FreeLibraryAndExitThread这两个函数则用于对库的每个进程的 使用计数进行递减。例如,当第一次调用 LoadLibrary函数来加载DLL时,系统将DLL的文件映 像映射到调用进程的地址空间中,并将 DLL的使用计数设置为 1。如果同一个进程中的线程后 来调用LoadLibrary来加载同一个DLL文件映像,系统并不第二次将 DLL映像文件映射到进程的 地址空间中,它只是将与该进程的 DLL相关的使用计数递增1。 为了从进程的地址空间中卸载 DLL文件映像,进程中的线程必须两次调用 FreeLibrary函数。 第一次调用只是将 DLL的使用计数递减为1,第二次调用则将 DLL的使用计数递减为0。当系统 发现 DLL的使用计数递减为 0时,它就从进程的地址空间中卸载 DLL的文件映像。试图调用 DLL中的函数的任何线程都会产生访问违规,因为特定地址上的代码不再被映射到进程的地址 空间中。 系统为每个进程维护了一个 DLL的使用计数,也就是说,如果进程 A中的一个线程调用下 面的函数,然后进程 B中的一个线程调用相同的函数,那么 MyLib.dll将被映射到两个进程的地 址空间中,这样,进程A和进程B的DLL使用计数都将是1。 如果进程 B中的线程后来调用下面的函数,那么进程 B的DLL使用计数将变成 0,并且该 DLL将从进程B的地址空间中卸载。但是,进程 A的地址空间中的 DLL映射不会受到影响,进 程A的DLL使用计数仍然是1。 如果调用GetModuleHandle函数,线程就能够确定DLL是否已经被映射到进程的地址空间中: 例如,只有当MyLib.dll尚未被映射到进程的地址空间中时,下面这个代码才能加载该文件: 如果只有 DLL的HINSTANCE 值,那么可以调用 GetModuleFileName函数,确定 DLL (或.exe)的全路径名: 第一个参数是 DLL(或.exe)的HINSTANCE。第二个参数pszPathName是该函数将文件映 像的全路径名放入的缓存的地址。第三参数cchPath用于设定缓存的大小(以字符为计量单位)。 20.1.3 显式链接到一个输出符号 一旦DLL模块被显式加载,线程就必须获取它要引用的符号的地址,方法是调用下面的函数: 下载 481 第20章 DLL的高级操作技术计计 参数hinstDll是调用 LoadLibrary(Ex)或GetModuleHandle函数而返回的,它用于设定包含符 号的DLL的句柄。参数 pszSymbolName可以采用两种形式。第一种形式是以 0结尾的字符串的 地址,它包含了你想要其地址的符号的名字: 注意,参数 pszSymbolName的原型是PCSTR,而不是 PCTSTR。这意味着 GetProcAddress 函数只接受ANSI字符串,决不能将Unicode字符串传递给该函数,因为编译器 /链接程序总是将 符号名作为ANSI字符串存储在DLL的输出节中。 参数pszSymbolName的第二种形式用于指明你想要其地址的符号的序号: 这种用法假设你知道你需要的符号名被 DLL创建程序赋予了序号值 2。同样,我要再次强 调,Microsoft非常反对使用序号,因此你不会经常看到 GetProcAddress的这个用法。 这两种方法都能够提供包含在 DLL中的必要符号的地址。如果 DLL模块的输出节中不存在 你需要的符号,GetProcAddress就返回NULL,表示运行失败。 应该知道,调用 GetProcAddress的第一种方法比第二种方法要慢,因为系统必须进行字符 串的比较,并且要搜索传递的符号名字符串。对于第二种方法来说,如果传递的序号尚未被分 配给任何输出的函数,那么 GetProcAddress就会返回一个非 NULL值。这个返回值将会使你的 应用程序错误地认为你已经拥有一个有效的地址,而实际上你并不拥有这样的地址。如果试图 调用该地址,肯定会导致线程引发一个访问违规。我在早期从事 Windows编程时,并不完全理 解这个行为特性,因此多次出现这样的错误。所以一定要小心(这个行为特性是应该避免使用 序号而使用符号名的另一个原因)。 20.2 DLL的进入点函数 一个DLL可以拥有单个进入点函数。系统在不同的时间调用这个进入点函数,这个问题将 在下面加以介绍。这些调用可以用来提供一些信息,通常用于供 DLL进行每个进程或线程的初 始化和清除操作。如果你的 DLL不需要这些通知信息,就不必在 DLL源代码中实现这个函数。 例如,如果你创建一个只包含资源的 DLL,就不必实现该函数。如果确实需要在 DLL中接受通 知信息,可以实现类似下面的进入点函数: 482计计第四部分 动态链接库 下载 注意 函数名 DllMain是区分大小写的。许多编程人员有时调用的函数是 DLLMain。 这是一个非常容易犯的错误,因为 DLL这个词常常使用大写来表示。如果调用的进入 点函数不是 DllMain,而是别的函数,你的代码将能够编译和链接,但是你的进入点 函数永远不会被调用,你的 DLL永远不会被初始化。 参数hinstDll包含了DLL的实例句柄。与(w)WinMain函数的hinstExe参数一样,这个值用于 标识DLL的文件映像被映射到进程的地址空间中的虚拟内存地址。通常应将这个参数保存在一 个全局变量中,这样就可以在调用加载资源的函数(如 DialogBox和LoadString)时使用它。最 后一个参数是 fImpLoad,如果DLL是隐含加载的,那么该参数将是个非 0值,如果 DLL是显式 加载的,那么它的值是 0。 参数fdwReason用于指明系统为什么调用该函数。该参数可以使用 4个值中的一个。这 4个 值是: DLL_PROCESS_ATTACH、DLL_PROCESS_DETACH、DLL_THREAD_ATTACH或 DLL_THREAD_DETACH。这些值将在下面介绍。 注意 必须记住,DLL使用DllMain函数来对它们进行初始化。当你的DllMain函数执行时, 同一个地址空间中的其他DLL可能尚未执行它们的DllMain函数。这意味着它们尚未初始 化,因此你应该避免调用从其他DLL中输入的函数。此外,你应该避免从DllMain内部调 用LoadLibrary(Ex)和FreeLibrary函数,因为这些函数会形式一个依赖性循环。 Platform SDK文档说,你的DllMain函数只应该进行一些简单的初始化,比如设置 本地存储器(第 21章介绍),创建内核对象和打开文件等。你还必须避免调用 User、 S h e l l 、 O D B C 、 C O M 、 R P C 和 套 接 字 函 数 ( 即 调 用 这 些 函 数 的 函 数 ),因为它们的 DLL也许尚未初始化 ,或者这些函数可能在内部调用 LoadLibrary(Ex)函数,这同样会 形成一个依赖性循环。 另外,如果创建全局性的或静态的 C++对象,那么应该注意可能存在同样的问题, 因为在你调用 DllMain函数的同时,这些对象的构造函数和析构函数也会被调用。 20.2.1 DLL_PROCESS_ATTACH通知 当DLL被初次映射到进程的地址空间中时,系统将调用该 DLL的DllMain函数,给它传递 参数 fdwReason的值 DLL_PROCESS_ATTACH。只有当 DLL的文件映像初次被映射时,才会出 现这种情况。如果线程在后来为已经映射到进程的地址空间中的 DLL调用LoadLibrary(Ex)函数, 那么操作系统只是递增 DLL的使用计数,它并不再次用 DLL_PROCESS_ATTACH的值来调用 DLL的DllMain函数。 当处理DLL_PROCESS_ATTACH时,DLL应该执行DLL中的函数要求的任何与进程相关的 初始化。例如, DLL可能包含需要使用它们自己的堆栈(在进程的地址空间中创建)的函数。 通过在处理DLL_PROCESS_ATTACH通知时调用 HeapCreate函数,该 DLL的DllMain函数就能 够创建这个堆栈。已经创建的堆栈的句柄可以保存在 DLL函数有权访问的一个全局变量中。 当DllMain处理一个DLL_PROCESS_ATTACH通知时,DllMain的返回值能够指明 DLL的初 下载 483 第20章 DLL的高级操作技术计计 始化是否已经取得成功。如果对 HeapCreate的调用取得了成功,DllMain应该返回TRUE。如果 堆栈不能创建,它应该返回 FA L S E。如果 f d w R e a s o n使用的是其他的值,即 D L L _ PROCESS_DETACH、DLL_THREAD_ATTACH和DLL_THREAD_DETACH,那么系统将忽略 DllMain返回的值。 当然,系统中的有些线程必须负责执行 DllMain函数中的代码。当一个新线程创建时,系 统将分配进程的地址空间,然后将 .exe文件映像和所有需要的 DLL文件映像映射到进程的地址 空间中。然后它创建进程的主线程,并使用该线程调用每个 DLL的带有 DLL_PROCESS_ ATTACH 值的DllMain函数。当已经映射的所有 DLL都对通知信息作出响应后,系统将使进程 的主线程开始执行可执行模块的 C/C++运行期启动代码,然后执行可执行模块的进入点函数 (main、wmain、WinMain或wWinMain)。如果DLL的任何一个 DllMain函数返回FALSE,指明 初始化没有取得成功,系统便终止整个进程的运行,从它的地址空间中删除所有文件映像,给 用户显示一个消息框,说明进程无法启动运行。 Windows 2000的这个消息框如 图20-2所示, 再下面是Windows 98的消息框(见图 20-3)。 图20-2 Windows 2000下显示的消息框 图20-3 Windows 98下显示的消息框 下面让我们来看一看 DLL被显式加载时的情况。当进程中的一个线程调用 LoadLibrary(Ex) 时,系统会找出特定的 DLL,并将它映射到进程的地址空间中。然后,系统使用调用 LoadLibrary(Ex)的线程,调用DLL的带有DLL_PROCESS_ATTACH 值的DllMain函数。当DLL 的DllMain函数处理了通知消息后,系统便允许调用的 LoadLibrary(Ex)函数返回,同时该线程 像平常一样继续进行处理。如果 DllMain函数返回 FALSE,指明初始化没有取得成功,那么系 统就自动从进程的地址空间中卸载 DLL的文件映像,而对 LoadLibrary(Ex)的调用则返回NULL。 20.2.2 DLL_PROCESS_DETACH通知 DLL从进程的地址空间中被卸载时,系统将调用 DLL的DllMain函数,给它传递fdwReason 的值 DLL_PROCESS_DETACH。当 DLL处理这个值时,它应该执行任何与进程相关的清除操 作。例如, DLL可以调用HeapDestroy函数来撤消它在 DLL_PROCESS_DETACH通知期间创建 的堆栈。注意,如果 DllMain函数接收到 DLL_PROCESS_DETACH通知时返回 FALSE,那么 DllMain就不是用DLL_PROCESS_DETACH通知调用的。如果因为进程终止运行而使 DLL被卸 载,那么调用 ExitProcess函数的线程将负责执行 DllMain函数的代码。在正常情况下,这是应 用程序的主线程。当你的进入点函数返回到 C/C++运行期库的启动代码时,该启动代码将显式 调用ExitProcess函数,终止进程的运行。 如果因为进程中的线程调用 FreeLibrary或FreeLibraryAndExitThread函数而将DLL卸载,那 么调用函数的线程将负责执行 DllMain函数的代码。如果使用 FreeLibrary,那么要等到 DllMain 函数完成对 DLL_PROCESS_DETACH通知的执行后,该线程才从对 FreeLibrary函数的调用中 返回。 注意,DLL能够阻止进程终止运行。例如,当 DllMain接收到DLL_PROCESS_DETACH通 知时,它就会进入一个无限循环。只有当每个 DLL都已完成对 DLL_PROCESS_DETACH通知 484计计第四部分 动态链接库 下载 的处理时,操作系统才会终止该进程的运行。 注意 如果因为系统中的某个线程调用了 TerminateProcess而使进程终止运行,那么系 统将不调用带有 DLL_PROCESS_DETACH值的 DLL的DllMain函数。这意味着映射到 进程的地址空间中的任何 DLL都没有机会在进程终止运行之前执行任何清除操作。这 可能导致数据的丢失。只有在迫不得已的情况下,才能使用 TerminateProcess函数。 图20-4显示了线程调用LoadLibrary时执行的操作步骤。图 20-5显示了线程调用 FreeLibrary 函数时执行的操作步骤。 线程调用Load Library 函数 DLL 已经映射 系统能够找 到进程的地址 否 到指定的DLL 否 空间中了吗? 文件吗? 是 递增DLL的 使用计数 是 将DLL映射 到进程的地 址空间中 使用计数 等于1吗? 是 调用该库带有DLL_ PROCESS_ATTACH值 的Dll Main函数 否 DllMain 函数 返回TRUE 了 否 吗? 是 返回该库的hinstDLL (加载地址) 递减DLL的使用计 数,从进程的地 址空间中撤消DLL 返回NULL 图20-4 线程调用LoadLibrary时系统执行的操作步骤 下载 485 第20章 DLL的高级操作技术计计 线程调用Free Library函数 hinstDll 参数 否 有效吗? 是 递减DLL 的 使用计数 返回FASE 使用计数等 否 于0吗? 是 调用该库带有DLL_ PROCESS_DETACH 值 的DllMain函数 返回TRUE 从进程的地址空 间中撤消该DLL 图20-5 线程调用FreeLibrary时系统执行的操作步骤 20.2.3 DLL_THREAD_ATTACH通知 当在一个进程中创建线程时,系统要查看当前映射到该进程的地址空间中的所有 DLL文件 映像,并调用每个文件映像的带有 DLL_THREAD_ATTACH值的DllMain函数。这可以告诉所 有的DLL执行每个线程的初始化操作。新创建的线程负责执行 DLL的所有DllMain函数中的代 码。只有当所有的 DLL都有机会处理该通知时,系统才允许新线程开始执行它的线程函数。 当一个新 DLL被映射到进程的地址空间中时,如果该进程内已经有若干个线程正在运行, 那么系统将不为现有的线程调用带有 DLL_THREAD_ATTACH值的DDL 的DllMain函数。只有 当新线程创建时DLL被映射到进程的地址空间中,它才调用带有 DLL_THREAD_ATTACH值的 DLL的DllMain函数。 另外要注意,系统并不为进程的主线程调用带有 DLL_THREAD_ATTACH值的任何 D l l M a i n 函数。进程初次启动时映射到进程的地址空间中的任何 D L L 均接收 D L L _ PROCESS_ATTACH通知,而不是 DLL_THREAD_ATTACH通知。 20.2.4 DLL_THREAD_DETACH通知 让线程终止运行的首选方法是使它的线程函数返回。这使得系统可以调用 ExitThread来撤 486计计第四部分 动态链接库 下载 消该线程。 ExitThread函数告诉系统,该线程想要终止运行,但是系统并不立即将它撤消。相 反,它要取出这个即将被撤消的线程,并让它调用已经映射的DLL的所有带有 DLL_THREAD_DETACH 值的DllMain函数。这个通知告诉所有的 DLL执行每个线程的清除操 作。例如, DLL版本的 C/C++运行期库能够释放它用于管理多线程应用程序的数据块。 注意, DLL能够防止线程终止运行。例如,当 DllMain函数接收到 DLL_THREAD_ DETACH通知时,它就能够进入一个无限循环。只有当每个 DLL已经完成对 DLL_THREAD_ DETACH通知的处理时,操作系统才会终止线程的运行。 注意 如果因为系统中的线程调用 TerminateThread函数而使该线程终止运行,那么系 统将不调用带有 DLL_THREAD_DETACH值的DLL的所有DllMain函数。这意味着映 射到进程的地址空间中的任何一个 DLL都没有机会在线程终止运行之前执行任何清除 操作。这可能导致数据的丢失。与 TerminateProcess一样,只有在迫不得已的时候,才 可以使用TerminateThread函数。 如果当 DLL被撤消时仍然有线程在运行,那么就不为任何线程调用带有 DLL_THREAD_ DETACH 值的 DllMain。可以在进行 DLL_THREAD_DETACH 的处理时查看这个情况,这样就 能够执行必要的清除操作。 上述规则可能导致发生下面这种情况。当进程中的一个线程调用 LoadLibrary来加载DLL时, 系统就会调用带有 DLL_PROCESS_ATTACH值的DLL的DllMain函数(注意,没有为该线程发 送DLL_THREAD_ATTACH通知)。接着,负责加载 DLL的线程退出,从而导致 DLL的DllMain 函数被再次调用,这次调用时带有 DLL_THREAD_DETACH值。注意,DLL得到通知说,该线 程将被撤消,尽管它从未收到 DLL_THREAD_ATTACH的这个通知,这个通知告诉该库说线程 已经附加。由于这个原因,当执行任何特定的线程清除操作时,必须非常小心。不过大多数程 序在编写时就规定调用LoadLibrary的线程与调用FreeLibrary的线程是同一个线程。 20.2.5 顺序调用DllMain 系统是顺序调用 DLL的DllMain函数的。为了理解这样做的意义,可以考虑下面这样一个 环境。假设一个进程有两个线程,线程 A和线程B。该进程还有一个 DLL,称为SomeDLL.dll, 它被映射到了它的地址空间中。两个线程都准备调用 CreateThread函数,以便再创建两个线程, 即线程C和线程D。 当线程 A调用CreateThread来创建线程 C时,系统调用带有 DLL_THREAD_ATTACH值的 SomeDLL.dll的DllMain函数。当线程C执行DllMain函数中的代码时,线程 B调用CreateThread 函数来创建线程 D。这时系统必须再次调用带有 DLL_THREAD_ATTACH值的DllMain函数,这 次是让线程 D 执行代码。但是,系统是顺序调用 DllMain函数的,因此系统会暂停线程 D的运 行,直到线程C完成对DllMain函数中的代码的处理并且返回为止。 当线程C完成DllMain的处理后,它就开始执行它的线程函数。这时系统唤醒线程 D,让它 处理DllMain中的代码。当它返回时,线程 D开始处理它的线程函数。 通常情况下,根本不会考虑到 DllMain的这个顺序操作特性。我曾经遇到过一个人,他的 代码中有一个DllMain顺序操作带来的错误。他创建的代码类似下面的样子: 下载 487 第20章 DLL的高级操作技术计计 我们花了好几个小时才发现这个代码中存在的问题。你能够看出这个问题吗?当 DllMain 收 到 D L L _ P R O C E S S _ AT TA C H 通 知 时 , 一 个 新 线 程 就 创 建 了 。 系 统 必 须 用 DLL_THREAD_ATTACH的值再次调用 DllMain函数。但是,新线程被暂停运行,因为导致 DLL_PROCESS_ATTACH 被发送给 DllMain函数的线程尚未完成处理操作。问题是调用 Wa i t F o r S i n g l e O b j e c t函数而产生的。这个函数使当前正在运行的线程暂停运行,直到新线程终 止运行。但是新线程从未得到机会运行,更不要说终止运行,因为它处于暂停状态,等待当前 线程退出 DllMain函数。这里我们得到的是个死锁条件。两个线程将永远处于暂停状态。 当我刚刚开始考虑如何解决这个问题的时候,我发现了 DisableThreadLibraryCalls函数: D i s a b l e T h r e a d L i b r a r y C a l l s告诉系统说,你不想将 D L L _ T H R E A D _ AT TA C H 和 DLL_THREAD_DETACH通知发送给特定的 DLL的DllMain函数。我认为这样做是有道理的, 如果我们告诉系统不要将 DLL通知发送给DLL,那么就不会发送死锁条件。但是当我测试解决 方案时,我很快发现它解决不了问题。请看下面的代码: 488计计第四部分 动态链接库 下载 通过进一步的研究,我终于发现了问题。当进程被创建时,系统也创建一个互斥对象。每 个进程都有它自己的互斥对象,也就是说多个进程并不共享互斥对象。当线程调用映射到进程 的地址空间中的 DLL的DllMain函数时,这个互斥对象负责对进程的所有线程实施同步。 当C r e a t e T h r e a d函数被调用时,系统首先创建线程的内核对象和线程的堆栈。然后它在内 部调用 WaitForSingleObject函数,传递进程的互斥对象的句柄。一旦新线程拥有该互斥对象, 系统就让新线程用 DLL_THREAD_ATTACH的值调用每个 DLL的DllMain函数。只有在这个时 候,系统才调用 ReleaseMutex,释放对进程的互斥对象的所有权。由于系统采用这种方式来运 行,因此添加对 DisableThreadLibraryCalls的调用,并不会防止线程被暂停运行。防止线程被暂 停运行的唯一办法是重新设计这部分源代码,使得 WaitForSingleObject不会在任何 DLL的 D l l M a i n函数中被调用。 20.2.6 DllMain与C/C++运行期库 在上面介绍的DllMain函数中,我假设你使用Microsoft的Visual C++编译器来创建你的 DLL。 当编写一个 DLL时,你需要得到 C/C++运行期库的某些初始帮助。例如,如果你创建的 DLL包 含一个全局变量,而这个全局变量是个 C++类的实例。在你顺利地在 DllMain函数中使用这个 全局变量之前,该变量必须调用它的构造函数。这是由 C/C++运行期库的 DLL启动代码来完成 的。 当你链接你的 DLL时,链接程序将DLL的进入点函数嵌入产生的 DLL文件映像。可以使用 链接程序的/ENTRY开关来设定该函数的地址。按照默认设置,当使用 Microsoft的链接程序并 下载 489 第20章 DLL的高级操作技术计计 且设定/DLL开关时,链接程序假设进入点函数称为 _DllMainCRTStartup。该函数包含在C/C++ 运行期的库文件中,并且在你链接 DLL时它被静态链接到你的 DLL文件的映像中(即使你使用 DLL版本的C/C++运行期库,该函数也是静态链接的)。 当你的 DLL文件映像被映射到进程的地址空间中时,系统实际上是调用 _DllMainCRT Startup函数,而不是调用DllMain函数。_DllMainCRTStartup函数负责对C/C++运行期库进行初 始化 ,并且确保在 _DllMainCRTStartup收到DLL_PROCESS_ATTACH通知时创建任何全局或 静态 C++对象。当执行任何 C/C++运行期初始化时, _DllMainCRTStartup函数将调用你的 DllMain函数。 当DLL收到DLL_PROCESS_DETACH通知时,系统再次调用 _DllMainCRTStartup函数。这 次该函数调用你的 DllMain函数,当DllMain返回时,_DllMainCRTStartup就为DLL中的任何全 局或静态 C++对象调用析构函数。当 _DllMainCRTStartup收到 DLL_THREAD_ATTACH通知时, _DllMainCRTStartup函数并不执行任何特殊的处理操作。但是对于 DLL_THREAD_DETACH来 说, C/C++运行期将释放线程的 tiddata内存块(如果存在这样的内存块的话)。但是,通常情况 下,这个 tiddata 内存块是不应该存在的,因为编写正确的线程函数将返回到内部调用 _endthreadex的C/C++运行期的 _threadstartex函数(第 6章已经介绍),它负责在线程试图调用 E x i t T h r e a d之前释放内存块。 然而,让我们看一看这样一种情况,即用 Pascal编写的应用程序调用 DLL中用C/C++编写 的函数。在这种情况下, Pascal应用程序创建了一个线程,并且不使用 _beginthreadex。因此线 程对C/C++运行期库的情况一无所知。这时线程调用 DLL中的一个函数,该函数又调用一个 C 运行期函数。当你再次调用该函数时, C运行期函数为该线程创建一个 tiddata内存块,并且在 创建过程中将它与线程关联起来。这意味着 Pascal应用程序能够创建成功地调用 C运行期函数 的线程。当用 Pascal编写的线程函数返回时, ExitThread被调用。 C/C++运行期库的 DLL收到 DLL_THREAD_DETACH通知,并释放 tiddata内存块,这样就不会出现任何内存泄漏。这确实 是个非常出色的思路。 前面讲过,不必在 DLL源代码中实现 DllMain函数。如果你并不拥有自己的 DllMain函数, 可以使用 C/C++运行期库的 DllMain函数的实现代码,它类似下面的形式(如果静态链接到 C/C++运行期库的话): 当链接程序链接DLL时,如果链接程序无法找到 DLL的.obj文件中的DllMain函数,那么它 就链接 C/C++运行期库的 DllMain函数的实现代码。如果你没有提供自己的 DllMain函数, C/C++运行期库就正确地假设你不在乎 DLL_THREAD_ATTACH和DLL_THREAD_DETACH通 知。为了提高创建和撤消线程的性能,则调用 DisableThreadLibraryCalls函数。 20.3 延迟加载DLL Microsoft Visual C++ 6.0提供了一个出色的新特性,它能够使 DLL的操作变得更加容 易。这个特性称为延迟加载 DLL。延迟加载的 DLL是个隐含链接的 DLL,它实际上要等到 你的代码试图引用 DLL中包含的一个符号时才进行加载。延迟加载的 DLL在下列情况下是 490计计第四部分 动态链接库 下载 非常有用的: • 如果你的应用程序使用若干个 DLL,那么它的初始化时间就比较长,因为加载程序要将 所有需要的 DLL映射到进程的地址空间中。解决这个问题的方法之一是在进程运行的时 候分开加载各个 DLL。延迟加载的DLL能够更容易地完成这样的加载。 • 如果调用代码中的一个新函数,然后试图在老版本的系统上运行你的应用程序,而该系 统中没有该函数,那么加载程序就会报告一个错误,并且不允许该应用程序运行。你需 要一种方法让你的应用程序运行,然后,如果(在运行时)发现该应用程序在老的系统 上运行,那么你将不调用遗漏的函数。例如,一个应用程序在 Windows 2000上运行时想 要使用PSAPI函数,而在Windows 98上运行想要使用ToolHelp函数(比如Process32Next)。 当该应用程序初始化时,它调用 GetVersionEx函数来确定主操作系统,并正确地调用相 应的其他函数。如果试图在 Windows 98上运行该应用程序,就会导致加载程序显示一条 错误消息,因为Windows 98上并不存在 PSAPI.dll模块。同样,延迟加载的 DLL能够使你 非常容易地解决这个问题。 我花费了相当多的时间来检验Visual C++ 6.0中的延迟加载DLL特性,必须承认,Microsoft在 实现这个特性方面做了非常出色的工作。它提供了许多特性,并且在Windows 98 和Windows 2000 上运行得都很好。 下面让我们从比较容易的操作开始介绍,也就是使延迟加载 DLL能够运行。首先,你象平 常那样创建一个 DLL。也要象平常那样创建一个可执行模块,但是必须修改两个链接程序开关, 并且重新链接可执行模块。下面是需要添加的两个链接程序开关: /Lib:DelayImp.lib /DelayLoad:MyDll.dll Lib开关告诉链接程序将一个特殊的函数 --delayLoadHelper嵌入你的可执行模块。第二个开 关将下列事情告诉链接程序: • 从可执行模块的输入节中删除 MyDll.dll,这样,当进程被初始化时,操作系统的加载程 序就不会显式加载DLL。 • 将新的Delay Import(延迟输入)节(称为 .didata)嵌入可执行模块,以指明哪些函数正 在从MyDll.dll输入。 • 通过转移到对--delayLoadHelper函数的调用,转换到对延迟加载函数的调用。 当应用程序运行时,对延迟加载函数的调用实际上是对 --delayLoadHelper函数的调用。该 函数引用特殊的 Delay Import节,并且知道调用 LoadLibrary之后再调用 GetProcAddress。一旦 获得延迟加载函数的地址, --delayLoadHelper就要安排好对该函数的调用,这样,将来的调用 就会直接转向对延迟加载函数的调用。注意,当第一次调用同一个 DLL中的其他函数时,必须 对它们做好安排。另外,可以多次设定 /delayLoad链接程序的开关,为想要延迟加载的每个 DLL设定一次开关。 好了,整个操作过程就这么简单。但是还应该考虑另外两个问题。通常情况下,当操作系 统的加载程序加载可执行模块时,它将设法加载必要的 DLL。如果一个 DLL无法加载,那么加 载程序就会显示一条错误消息。如果是延迟加载的 DLL,那么在进行初始化时将不检查是否存 在DLL。如果调用延迟加载函数时无法找到该 DLL,--delayLoadHelper函数就会引发一个软件 异常条件。可以使用结构化异常处理(SEH)方法来跟踪该异常条件。如果不跟踪该异常条件, 那么你的进程就会终止运行( SEH将在第23、24和25章中介绍)。 当--delayLoadHelper确实找到你的DLL,但是要调用的函数不在该 DLL中时,将会出现另 下载 491 第20章 DLL的高级操作技术计计 一个问题。比如,如果加载程序找到一个老的 DLL版本,就会发生这种情况。在这种情况 下,--delayLoadHelper也会引发一个软件异常条件,对这个软件异常条件的处理方法与上面相 同。下一节介绍的示例应用程序显示了如何正确地编写 SEH代码以便处理这些错误。 你会发现代码中有许多其他元素,这些元素与 SEH和错误处理毫无关系。但是这些元素与 你使用延迟加载的 DLL时可以使用的辅助特性有关。下面将要介绍这些特性。如果你不使用更 多的高级特性,可以删除这些额外的代码。 如你所见, Visual C++ 开发小组定义了两个软件异常条件代码,即 VcppException (ERROR_SEVERITY_ERROR、ERROR_MOD_NOT_FOUND)和VcppException(ERROR_ SEVERITY_ERROR、ERROR_PROC_NOT_FOUND)。这些代码分别用于指明 DLL模块没有 找到和函数没有找到。我的异常过滤函数 DelayLoadDllExceptionFilter用于查找这两个异常代 码。如果两个代码都没有找到,过滤函数将返回 EXCEPTION_CONTINUE_SEARCH,这与任 何出色的过滤函数返回的值是一样的(对于你不知道如何处理的异常代码,请不要随意删除)。 但是如果这两个代码中的一个已经找到,那么 --delayLoadHelper函数将提供一个指向包含某些 辅助信息的DelayLoadInfo结构的指针。在Visual C++的DelayImp.h文件中,DelayLoadInfo结构 定义为下面的形式: 这个数据结构是由 --delayLoadHelper函数来分配和初始化的。在该函数按步骤动态加载 DLL并且获得被调用函数的地址的过程中,它将填写该结构的各个成员。在 SEH结构的内部, 成员szDll指向你要加载的 DLL的名字,想要查看的函数则在成员 dlp中。由于可以按序号或名 字来查看各个函数,因此 dlp成员类似下面的样子: 如果DLL已经加载成功,但是它不包含必要的函数,也可以查看成员 hmodCur,以了解 DLL被加载到的内存地址。也可以查看成员 dwLastError,以了解是什么错误导致了异常条件的 引发。不过对于异常过滤函数来说,这是不必要的,因为异常代码能够告诉你究竟发生了什么 问题。成员 pfnCur包含了需要的函数的地址。在过滤函数中它总是置为 NULL,因为 -delayLoadHelper无法找到该函数的地址。 在其余的成员中, cb用于确定版本,pidd指向嵌入模块中包含延迟加载的 DLL和函数的节, ppfn是函数找到时,函数的地址应该放入的地址。最后两个成员供 --delayLoadHelper函数内部 使用。它们有着超高级的用途,现在还没有必要观察或者了解这两个成员。 492计计第四部分 动态链接库 下载 到现在为止,已经讲述了如何使用延迟加载的 DLL和正确解决错误条件的基本方法。但是 Microsoft的延迟加载DLL的实现代码超出了迄今为止我已讲述的内容范围。比如,你的应用程 序能够卸载延迟加载的 DLL。假如你的应用程序需要一个特殊的 DLL来打印一个文档,那么这 个DLL就非常适合作为一个延迟加载的 DLL,因为大部分时间它是不用的。不过,如果用户选 择了Print命令,你就可以调用该 DLL中的一个函数,然后它就能够自动进行 DLL的加载。这确 实很好,但是,当文档打印后,用户可能不会立即打印另一个文档,因此可以卸载这个 DLL, 释放系统的资源。如果用户决定打印另一个文档,那么 DLL就可以根据用户的要求再次加载。 若要卸载延迟加载的 DLL,必须执行两项操作。首先,当创建可执行文件时,必须设定另 一个链接程序开关( /delay:unload)。其次,必须修改源代码,并且在你想要卸载 DLL时调用-FUnloadDelayLoadedDLL函数: /Delay:unload链接程序开关告诉链接程序将另一个节放入文件中。该节包含了你清除已经 调用的函数时需要的信息,这样它们就可以再次调用 --delayLoadHelper函数。当调用 -FUnloadDelayLoadedDll时,你将想要卸载的延迟加载的 DLL的名字传递给它。该函数进入文 件中的未卸载节,并清除 DLL的所有函数地址,然后 --FUnloadDelayLoadedDll调用FreeLibrary, 以便卸载该 DLL。 下面要指出一些重要的问题。首先,千万不要自己调用 FreeLibrary来卸载DLL,否则函数 的地址将不会被清除,这样,当下次试图调用 DLL中的函数时,就会导致访问违规。第二,当 调用--FUnloadDelayLoadedDll时,传递的DLL名字不应该包含路径,名字中的字母必须与你将 DLL名字传递给 /DelayLoad链接程序开关时使用的字母大小写相同,否则, --FUnload DelayLoadedDll的调用将会失败。第三,如果永远不打算卸载延迟加载的 DLL,那么请不要设 定/Delay:unload链接程序开关,并且你的可执行文件的长度应该比较小。最后,如果你不从用 /Delay:unload开关创建的模块中调用 --FUnloadDelayLoadedDll,那么什么也不会发生, -FUnloadDelayLoadedDll什么操作也不执行,它将返回 FALSE。 延迟加载的DLL具备的另一个特性是,按照默认设置,调用的函数可以与一些内存地址相 链接,在这些内存地址上,系统认为函数将位于一个进程的地址中(本章后面将介绍链接的问 题)。由于创建可链接的延迟加载的 DLL节会使你的可执行文件变得比较大,因此链接程序也 支持一个/Delay:nobind开关。因为人们通常都喜欢进行链接,因此大多数应用程序不应该使用 这个链接开关。 延迟加载的DLL的最后一个特性是供高级用户使用的,它真正显示了 Microsoft的注意力之 所在。当 --delayLoadHelper函数执行时,它可以调用你提供的挂钩函数。这些函数将接收 -delayLoadHelper函数的进度通知和错误通知。此外,这些函数可以重载 DLL如何加载的方法以 及如何获取函数的虚拟内存地址的方法。 若要获得通知或重载的行为特性,必须对你的源代码做两件事情。首先必须编写类似清单 20-1所示的DliHook函数那样的挂钩函数。 DliHook框架函数并不影响 --delayLoadHelper函数的 运行。若要改变它的行为特性,可启动 DliHook函数,然后根据需要对它进行修改。接着将函 数的地址告诉--delayLoadHelper。 在DelayImp.lib静态链接库中,定义了两个全局变量,即 --pfnDliNotifyHook和-pfnDliFailureHook。这两个变量均属于 pfnDliHook类型: 下载 493 第20章 DLL的高级操作技术计计 如你所见,这是个数据类型的函数,与我的 DliHook函数的原型相匹配。在 DelayImp.lib文 件中,两个变量被初始化为 NULL,它告诉 --delayLoadHelper不要调用任何挂钩函数。若要使 你的函数被调用,必须将这两个函数中的一个设置为挂钩函数的地址。在我的代码中,我只是 将下面两行代码添加到全局作用域: 如你所见, --delayLoadHelper实际上是与两个回调函数一道运行的。它调用一个函数以便 报告通知,调用另一个函数来报告失败情况。由于这两个函数的原型是相同的,而第一个参数 dliNotify告诉为什么调用这个函数,因此我总是通过创建单个函数并将两个变量设置为指向我 的一个函数,使我的工作变得简单一些。 Visual C++ 6.0的延迟加载DLL的新特性非常出色,许多编程人员几年前就希望使用这个特 性。可以想像许多应用程序(尤其是 Microsoft的应用程序)都将充分利用这个特性。 DelayLoadApp示例应用程序 清单20-1中列出的 DelayLoadApp应用程序(“20 DelayLoadApp.exe”)显示了在充分利用 延迟加载DLL时应该做的所有工作。为了演示的需要,必须使用一个简单的的 DLL,它的代码 位于20-DelayLoadLib目录中。 由于该应用程序加载了“ 20 DelayLoadLib”模块,因此当运行该应用程序时,加载程序 不必将该模块映射到进程的地址空间中。在该应用程序中,我定期调用 IsModuleLoaded函数。 该函数只是用来显示一个消息框,通知是否有一个模块加载到了进程的地址空间中。当该应用 程序初次启动运行时,“20 DelayLoadLib”模块尚未加载,因此出现图 20-6所示的消息框。 然后该应用程序调用从 DLL输入的一个函数,这使得 __delayLoadHelper函数能够自动加载 该DLL。当该函数返回时,便出现图 20-7所示的消息框。 图20-6 DelayLoadApp显示“20 DelayLoadLib” 模块尚未加载 图20-7 DelayLoadApp显示“20 DelayLoadLib” 模块已经加载 当这个消息框关闭时, DLL中的另一个函数被调用。由于该函数是在同一个 DLL中,因此 该DLL不必再次加载到该地址空间中去,不过该新函数的地址被转换并调用。 这时,FUnloadDelayLoadedDLL函数被调用,它负责卸载“ 20 DelayLoadedLib”。同样, 如果调用IsModuleLoaded函数,就可以显示图 20-6所示的消息框。最后,一个输入函数再次被 调用,它由于重新加载“ 20 DelayLoadLib”模块,从而使最后一次对 IsModuleLoaded的调用能 够显示图 20-7所示的消息框。 如果一切顺利,程序将按描述的那样工作。然而,如果在运行程序之前删除“ 20 Delay LoadLib”模块或者如果模块不包含导入的一个函数,就会引发异常。示例代码显示了如何从 这种情况中恢复过来。 最后,该应用程序显示了如何正确地设置延迟加载的挂钩函数的方法。我的 DliHook框架 函数并没有执行什么新奇的功能。然而它能够捕获各个通知消息,显示收到这些通知时能够执 行的操作。 494计计第四部分 动态链接库 清单20-1 DelayLoadApp示例应用程序 下载 下载 495 第20章 DLL的高级操作技术计计 496计计第四部分 动态链接库 下载 下载 497 第20章 DLL的高级操作技术计计 498计计第四部分 动态链接库 下载 下载 499 第20章 DLL的高级操作技术计计 20.4 函数转发器 函数转发器是 DLL的输出节中的一个项目,用于将对一个函数的调用转至另一个 DLL中的 另一个函数。例如,如果在 Windows 2000 的Kernel32.dll上运行Visual C++的DumpBin实用程 序,那么将看到类似下面的一部分输出: 这个输出显示了4个转发函数。每当你的应用程序调用 HeapAlloc、HeapFree、HeapReAlloc 或HeapSize时,你的可执行模块就会自动与 Kernel32.dll相链接。当激活你的可执行模块时,加 载程序就加载 Kernel32.dll并看到转发的函数实际上包含在 NTDLL.dll中。然后它也加载 N T D L L . d l l 。当你的可执行模块调用 H e a p A l l o c时,它实际上调用的是 N T D L L . d l l中的 RtlAllocateHeap函数。系统中的任何地方都不存在 HeapAlloc函数。 如果调用下面的函数, GetProcAddress就会查看Kernel32的输出节,发现HeapAlloc是个转 发函数,然后按递归方式调用 GetProcAddress函数,查找 NTDLL.dll的输出节中的 RtlAllocateHeap。 也可以利用DLL模块中的函数转发器。最容易的方法是像下面这样使用一个 pragma指令: 这个 pragma告诉链接程序,被编译的 DLL应该输出一个名叫 SomeFunc的函数。但是 SomeFunc函数的实现实际上位于另一个名叫 SomeOtherFunc的函数中,该函数包含在称为 DllWork.dll的模块中。必须为你想要转发的每个函数创建一个单独的 pragma代码行。 20.5 已知的DLL 操作系统提供的某些 DLL得到了特殊的处理。这些 DLL称为已知的DLL。它们与其他DLL 基本相同,但是操作系统总是在同一个目录中查找它们,以便对它们进行加载操作。在注册表 中有下面的关键字: 在我的计算机上使用 RegEdit.exe实用程序时显示的是如图 20-8所示的对话框。 500计计第四部分 动态链接库 下载 图20-8 使用 RegEdit .exe实用程序时显示的子关键字对话框 如你所见,这个关键字包含一组值的名字,这些名字是某些 DLL的名字。每个值名字都有 一个数据值,该值恰好与带有 .dll文件扩展名的值名字相同(不过,情况并非完全如此,我将在 下面的例子中加以说明)。当LoadLibrary或LoadLibraryEx被调用时,这些函数首先查看是否传 递了包含.dll扩展名的DLL名字。如果没有传递,那么它们将使用通常的搜索规则来搜索 DLL。 如果确实设定了 .dll扩展名,那么这些函数将删除扩展名,然后搜索注册表关键字 KnownDLL,以便确定它是否包含匹配的值名字。如果没有找到匹配的名字,便使用通常的搜 索规则。但是,如果找到了匹配的值名字,系统将查找相关的值数据,并设法使用值数据来加 载DLL。系统也开始在注册表中的 DllDirectory值数据指明的目录中搜索 DLL。按照默认设置, Windows 2000上的DllDirectory值的数据是%SystemRoot%\System32。 为了更好地说明情况,假设将下面的值添加给注册表关键字 KnownDLL: 当调用下面的函数时,系统将使用通常的搜索规则来查找该文件: 但是,如果调用下面的函数,系统将会发现有一个匹配的值名字(记住,当系统检查注册 表的值名字时,它将删除扩展名 .dll)。 这时,系统设法加载称为 SomeOtherLib.dll的文件,而不是加载 SomeLib.dll。它首先 在%SystemRoot%\System32目录中查找 SomeOtherLib.dll。如果它在该目录中找到了文件,它 就加载该文件。如果文件不在该目录中, LoadLibrary(Ex)运行失败并返回 NULL,同时,对 GetLastError 的调用将返回 2(ERROR_FILE_NOT_FOUND)。 20.6 DLL转移 Windows 98 Windows 98 不支持DLL转移。 下载 501 第20章 DLL的高级操作技术计计 当Windows刚刚开发成功时,RAM和磁盘空间是非常宝贵的。因此 Windows在设计时总是 尽可能多地安排资源的共享,以节省宝贵的存储器资源。为了达到这个目的, Microsoft建议, 多个应用程序共享的任何模块,如 C/C++运行期库和 Microsoft基础类(MFC)DLL等,应该放 入Windows的系统目录中。这样,系统就能够方便地找到共享文件。 随着时间的推移,这变成一个非常严重的问题,因为安装程序会用旧文件或尚未完全实现 向后兼容的新文件来改写该目录中的文件。这将使用户的其他应用程序无法正确地运行。今天, 硬盘的容量已经非常大,并且很便宜, RAM的容量也相当富裕,价格也便宜了一些。因此, Microsoft改变了原先的开发策略,非常支持你将应用程序的所有文件放入它们自己的目录中, 而不要去碰 Windows的系统目录中的任何东西。这样,你的应用程序就不会损坏别的应用程序, 别的应用程序也不会损坏你的应用程序。 为了给你提供相应的帮助, Microsoft给Windows 2000增加了一个DLL转移特性。这个特性 能够强制操作系统的加载程序首先从你的应用程序目录中加载文件模块。只有当加载程序无法 在应用程序目录中找到该文件时,它才搜索其他目录。 为了强制加载程序总是首先查找应用程序的目录,要做的工作就是在应用程序的目录中放 入一个文件。该文件的内容可以忽略,但是该文件必须称为 AppName.local。 例如,如果有一个可执行文件的名字是 SuperApp.exe,那么转移文件必须称为 SuperApp.exe.local。 在系统内部, LoadLibrary(Ex)已经被修改,以便查看是否存在该文件。如果应用程序的目 录中存在该文件,该目录中的模块就已经被加载。如果应用程序的目录中不存在这个模块, L o a d L i b r a r y ( E x )将正常运行。 对于已经注册的 COM对象来说,这个特性是非常有用的。它使应用程序能够将它的 COM 对象DLL放入自己的目录,这样,注册了相同 COM对象的其他应用程序就无法干扰你的操作。 20.7 改变模块的位置 每个可执行模块和 DLL 模块都有一个首选的基地址,用于标识模块应该映射到的进程地址 空间中的理想内存地址。当创建一个可执行模块时,链接程序将该模块的首选基地址设置为 0x00400000。如果是DLL模块,链接程序设置的首选基地址是 0x10000000。使用Visual Studio 的DumpBin实用程序(带有 /Headers开关),可以看到一个映像的首选基地址。下面是使用 DumpBin来转储它自己的头文件信息的例子: 502计计第四部分 动态链接库 下载 当这个可执行模块被调用时,操作系统加载程序为新进程创建一个虚拟地址。然后该加载 程序将可执行模块映射到内存地址 0x00400000,并将DLL模块映射到0x10000000。为什么这个 首选基地址这么重要呢?让我们看一看下面的代码: 当编译器处理 Func函数时,该编译器和链接程序创建类似下面的机器代码: 换句话说,编译器和链接程序创建的机器代码实际上是在变量 g_x的地址0x00414540中硬 编码的代码。该地址位于机器代码中,用于标识变量在进程的地址空间中的绝对位置。但是, 当并且仅当可执行模块加载到它的首选基地址 0x00400000中时,这个内存地址才是正确的。 如果在一个 DLL模块中我们拥有与上面完全相同的代码,那将会如何呢?在这种情况下, 编译器和链接程序将生成类似下面的机器代码: 同样,注意DLL的变量g_z的虚拟内存地址是在磁盘驱动器上的 DLL文件映像中硬编码的代 码。而且,如果该DLL确实是在它的首选基地址上加载的,那么这个内存地址是绝对正确的。 下载 503 第20章 DLL的高级操作技术计计 现在假设你设计的应用程序需要两个 DLL。按照默认设置,链接程序将 .exe模块的首选基 地址设置为 0x00400000,同时,链接程序将两个 DLL模块的首选基地址均设置为 0x10000000。 如果想要运行 .exe模块,那么加载程序便创建该虚拟地址空间,并将 .exe模块映射到内存地址 0x00400000中。然后加载程序将第一个DLL映射到内存地址0x10000000中。但是,当加载程序 试图将第二个DLL映射到进程的地址空间中去时,它将无法把它映射到该模块的首选基地址中, 必须改变该 DLL模块的位置,将它放到别的什么地方。 改变可执行(或 DLL)模块的位置是个非常可怕的过程,应该采取措施避免这样的操作。 为什么要避免这样的操作呢?假设加载程序将第二个 DLL的地址改到0x20000000。这时,将变 量g_x的值改为5的代码应该是: 但是文件映像中的代码却类似下面的样子: 如果文件映像的代码被允许执行,那么第一个DLL模块中大约有4个字节的值将被值 5改写。 这是不能允许的。加载程序必须修改这个代码。当链接程序创建你的模块时,它将把一个移位 节嵌入产生的文件中。这一节包含一个字节位移的列表。每个字节位移用于标识一个机器代码 指令使用的内存地址。如果加载程序能够将一个模块映射到它的首选基地址中,那么系统将永 远不会访问模块的移位节。这当然是我们所希望的—你永远不希望使用移位节。 另一方面,如果该模块不能映射到它的首选基地址中,加载程序便打开模块的移位节,并 对所有项目重复执行该操作。对于找到的每个项目,加载程序将转至包含要修改机器代码指令 的存储器页面。然后它抓取机器指令当前正在使用的内存地址,并将模块的首选基地址与模块 实际映射到的地址之间的差与该地址相加。 因此,在上面的例子中,第二个 DLL被映射到 0x20000000,但是它的首选基地址是 0x10000000。它产生的差是 0x10000000,然后这个差与机器代码指令的地址相加,产生的结果 如下: 现在,第二个DLL中的代码将能够正确地引用它的变量 g_x。 当模块不能映射到它的首选基地址中去时,将会出现下面两个主要问题: • 加载程序必须重复经过移位节,并且要修改模块的许多代码。这会影响到系统的运行速 度,并且会增加应用程序的初始化时间。 • 当加载程序将数据写入模块的代码页面时,系统的写入时拷贝( copy-on-write)机制将 强制这些页面被系统的页文件拷贝。 上面的第二个问题确实会产生非常不良的作用。它意味着模块的代码页面不再能够从该磁 盘上的模块文件映像中删除和重新加载。相反,这些页面将在需要时从系统的页文件中来回倒 腾。这也会影响系统的运行速度。但是,还有更为糟糕的事情。由于页文件拷贝了模块的所有 代码页面,因此系统只有较少的存储器可供系统中的所有进程来运行。这就限制了用户的电子 表格、文字处理文档、 CAD图形和位图的大小。 另外,可以创建一个不包含移位节的可执行模块或 DLL模块。当创建该模块时,你可以将 / F I X E D开关传递给链接程序。使用这个开关,能够使模块变得比较小一些,但是这意味着模 块不能被改变位置。如果模块不能加载到它的首选基地址,那么它就根本无法加载。如果加载 程序必须改变模块的位置,但是却不存在用于该模块的移位节,那么加载程序就会撤消整个进 程,并且向用户显示一条“进程异常终止”的消息。 504计计第四部分 动态链接库 下载 对于只包含资源的DLL来说,这是个问题。只包含资源的 DLL中是没有代码的,因此,使 用/FIXED开关来链接该 DLL是很有意义的。但是,如果只有资源的 DLL不能加载到它的首选 基地址,那么该模块就根本不能加载。这很奇怪。为了解决这个问题,链接程序允许你用嵌入 头文件中的信息来创建一个模块,以指明该模块不包含移位信息,因为不需要这样的信息。 Windows 2000加载程序可以使用这个头文件信息,并且允许加载只包含资源的 DLL,而不会降 低系统的运行速度或者损害页文件的空间。 若要创建不需要进行任何移位的文件映像,请使用 /SUBSYSTEM:WINDOWS,5.0开关, 或者使用 /SUBSYSTEM:CONSOLE,5.0开关,但是不要设定 /FIXED开关。如果链接程序确 定模块中没有什么东西需要进行移位设置,那么它就从模块中删除移位节,并且关闭头文件中 的专用IMAGE_FILE_RELOCS_STRIPPED标志。当Windows 2000加载该模块时,它知道该模 块可以移位(因为 IMAGE_FILE_RELOCS_STRIPPED标志是关闭的),但是该模块没有移位 ( 因 为 不 存 在 移 位 节 )。请注意,这是 Windows 2000 加载程序的一个新特性,它说明了 /SUBSYSTEM开关的结尾处为什么需要带有 5.0。 现在你已经知道首选基地址的重要性了。所以,如果你有多个模块需要加载到单个地址空 间中,必须为每个模块设置不同的首选基地址。 Microsoft Visual Studio的Project Settings(项 目设置)对话框使得这项操作变得非常容易。你只需要选定 Link(链接)选项卡,再选定 Output(输出)类别。在 Base Address(基地址)域中(该域默认为空),可以输入一个数字。 在图20-9中,我将我的DLL模块的基地址设置为0x20000000。 图20-9 Project Settings 对话框 另外,始终都应该从高位内存地址开始加载 DLL,然后逐步向下加载到低位内存地址,以 减少地址空间中出现的碎片。 注意 首选基地址必须始终从分配粒度边界开始。在迄今为止的所有平台上,系统的 分配粒度是 64 KB。将来这个分配粒度可能发生变化。第 13章已经对分配粒度进行了 详细的介绍。 好了,现在已经对所有的内容做了介绍。但是,如果将许多模块加载到单个地址空间中, 情况将会如何呢?如果有一种非常容易的方法,可以为所有的模块设置很好的首选基地址,那 就好了。幸运的是,这种方法确实有。 Visual Studio配有一个实用程序,称为 Rebase.exe。如果运行不带任何命令行参数的 Rebase 下载 程序,会得到下面的使用信息: 505 第20章 DLL的高级操作技术计计 Platform SDK文档对Rebase实用程序进行了介绍,因此在这里就不再对它详细说明了。不 过要补充的一点是,这个实用程序并没有什么奇特之处。从内部来说,它只是分别为每个指定 的文件调用 ReBaseImage函数: 当你执行Rebase程序,为它传递一组映象文件名时,它将执行下列操作: 1) 它能够仿真创建一个进程的地址空间。 2) 它打开通常被加载到该地址空间中的所有模块。 506计计第四部分 动态链接库 下载 3) 它仿真改变各个模块在仿真地址空间中的位置,这样,各个模块就不会重叠。 4) 对于已经移位的模块,它会分析该模块的移位节,并修改磁盘上的模块文件中的代码。 5) 它会更新每个移位模块的头文件,以反映新的首选基地址。 Rebase是个非常出色的工具,建议尽可能使用这个工具。应该在接近你的应用程序模块创 建周期结束时运行这个实用程序,直到所有模块创建完成。另外,如果使用 Rebase实用程序, 可以忽略 Project Settings 对话框中的基地址的设置。链接程序将为 DLL提供一个基地址 0x10000000,但是Rebase会修改这个地址。 顺便要指出的是,决不应该改变操作系统配备的任何模块的地址。 Microsoft在销售 Windows操作系统之前,在操作系统提供的所有文件上运行了 Rebase程序,因此,如果将它们 映射到单个地址空间中,所有的操作系统模块都不会重叠。 我给第4章中介绍的ProcessInfo.exe应用程序添加了一个特殊的性质。这个工具显示了一个 列表,里面包括进程的地址空间中存在的所有模块。在 BaseAddr列的下面,会看到模块加载到 的虚拟内存地址。在 BaseAddr列的右边,是 ImagAddr列。通常这个列是空的,这表示模块加 载到了它的首选基地址中。你希望看到所有 模块都是这样。但是如果出现带有括号的另 一个地址,就表示该模块没有加载到它的首 选基地址中,这一列中的数字表示从模块的 磁盘文件的头文件信息中读取的该模块的首 选基地址。 下面是正在查看 Acrord32.exe进程的 ProcessInfo.exe工具。注意,有些模块确实 加载到了它们的首选基地址中,有些模块则 没有。你还会发现所有模块的首选基地址都 是0x10000000,表示它们是DLL模块,并且 这些模块的创建者并没有考虑改变地址的问 题(见图 20-10)。 图20-10 Process Information 窗口 20.8 绑定模块 模块的移位是非常重要的,它能够大大提高整个系统的运行性能。但是,还可以使用许多 别的办法来提高它的性能。比如说,可以改变应用程序的所有模块的位置。让我们回忆一下第 19章中关于加载程序如何查看所有输入符号的地址的情况。加载程序将符号的虚拟地址写入可 执行模块的输入节中。这样就可以参考输入的符号,以便到达正确的内存位置。 让我们进一步考虑一下这个问题。如果加载程序将输入符号的虚拟地址写入 .exe模块的输 入节,那么拷贝输入节的这些页面将被写入虚拟地址。由于这些页面是写入时拷贝的页面,因 此这些页面将被页文件拷贝。这样我们就遇到了一个与改变模块的位置相类似的问题,即映像 文件的各个部分将与系统的页文件之间来回倒腾,而不是在必要时将它们删除或者从文件的磁 盘映像中重新读取。另外,加载程序必须对(所有模块的)所有输入符号的地址进行转换,这 是很费时间的。 可以将模块绑定起来,使应用程序能够更快的进行初始化,并且使用较少的存储器。绑定 一个模块时,可以为该模块的输入节配备所有输入符号的虚拟地址。为了缩短初始化的时间和 使用较少的存储器,当然必须在加载模块之前进行这项操作。 下载 507 第20章 DLL的高级操作技术计计 Visual Studio配有另一个实用程序,名字是 Bind.exe,当运行这个不带任何命令行参数的程 序时,它将输出下面的信息: Bind(绑定)实用程序在 Platform SDK文档中作了描述,这里就不再详细介绍了。但是与 Rebase一样,这个实用程序也不是不可思议的程序。从内部来说,它为每个指定的文件重复调 用BindImageEx函数: 最后一个参数StatusRoutine是一个回调函数的地址, BindImageEx定期调用这个函数,这 样就能够监控连接进程。下面是该函数的原型: 当执行Bind程序,传递给它一个映像文件名时,它将执行下列操作: 1) 打开指定映像文件的输入节。 2) 对于输入节中列出的每个 DLL,它打开该DLL文件,查看它的头文件以确定它的首选基 地址。 3) 查看DLL的输出节中的每个输入符号。 4) 取出符号的RVA,并将模块的首选基地址与它相加。将可能产生的输入符号的虚拟地址 写入映像文件的输入节中。 5) 将某些辅助信息添加到映像文件的输入节中。这些信息包括映像文件绑定到的所有 DLL 模块的名字和这些模块的时戳。 在第19章中,我们使用 DumpBin实用程序来查看 Calc.exe的输入节的内容。该输出信息的 底部显示了第5个步骤中添加的已链接输入信息。下面是输出信息的有关部分: 508计计第四部分 动态链接库 下载 可以看到 Calc.exe连接到了哪些模块,方括号中的数字表示 Microsoft是在何时创建每个 DLL模块的。这个 32位的时戳值在方括号的后面展开了,并以人们能够识别的字符串显示出 来。 在执行整个进程期间, Bind程序做了两个重要的假设: • 当进程初始化时,需要的 DLL实际上加载到了它们的首选基地址中。可以使用前面介绍 的Rebase实用程序来确保这一点。 • 自从绑定操作执行以来, DLL的输出节中引用的符号的位置一直没有改变。加载程序通 过将每个DLL的时戳与上面第5个步骤中保存的时戳进行核对来核实这个情况。 当然,如果加载程序确定上面的两个假设中有一个是假的,那么 Bind就没有执行上面所说 的有用的操作,加载程序必须通过人工来修改可执行模块的输入节,就像它通常所做的那样。 但是,如果加载程序发现模块已经连接,需要的 DLL已经加载到它们的首选基地址中,而且时 戳也匹配,那么它实际上已经无事可做。它不必改变任何模块的位置,也不必查看任何输入函 数的虚拟地址。该应用程序只管启动运行就是了。 此外,它不需要来自系统的页文件的任何存储器。这太好了,我们简直拥有世界上最出色 的系统。目前销售的商用应用程序许多都不具备相应的移位和绑定特性。 好了,现在你已经知道应该将应用程序配有的所有模块连接起来。但是应该在什么时候进 行模块的连接呢?如果你在你的公司连接这些模块,可以将它们与你已经安装的系统 DLL绑定 起来,而这些系统DLL并不一定是用户已经安装的。由于不知道用户运行的是 Windows 98还是 Windows NT,或者是Windows 2000,也不知道这些操作系统是否已经安装了服务软件包,因 此应该将绑定操作作为应用程序的安装操作的一部分来进行。 当然,如果用户能够对 Windows 98和Windows 2000进行双重引导,那么绑定的模块可能 对这两个操作系统之一来说是不正确的。另外,如果用户在Windows 2000下安装你的应用程序, 然后又升级到你的服务软件包,那么模块的绑定也是不正确的。在这些情况下,你和用户都可 能无能为力。 Microsoft应该在销售操作系统时配备一个实用程序,使得操作系统升级后能够自 动重新绑定每个模块。不过,现在还不存在这样的实用程序。 下载 第21章 线程本地存储器 有时,将数据与对象的实例联系起来是很有帮助的。例如,窗口的附加字节可以使用 SetWindowsWord和SetWindowLong函数将数据与特定的窗口联系起来。可以使用线程本地存 储器将数据与执行的特定线程联系起来。例如,可以将线程的某个时间与线程联系起来。然后, 当线程终止运行时,就能够确定线程的寿命。 C/C++运行期库要使用线程本地存储器( TLS)。由于运行期库是在多线程应用程序出现前 的许多年设计的,因此运行期库中的大多数函数是用于单线程应用程序的。函数 strtok就是个 很好的例子。应用程序初次调用 strtok时,该函数传递一个字符串的地址,并将字符串的地址 保存在它自己的静态变量中。当你将来调用 strtok函数并传递NULL时,该函数就引用保存的字 符串地址。 在多线程环境中,一个线程可以调用 strtok,然后,在它能够再次调用该函数之前,另一 个线程也可以调用 Strtok。在这种情况下,第二个线程会在第一个线程不知道的情况下,让 strtok用一个新地址来改写它的静态变量。第一个线程将来调用 strtok时将使用第二个线程的字 符串,这就会导致各种各样难以发现和排除的错误。 为了解决这个问题, C/C++运行期库使用了 TLS。每个线程均被赋予它自己的字符串指针, 供strtok函数使用。需要予以同样对待的其他 C/C++运行期库函数还有asctime和gmtime。 如果你的应用程序需要严重依赖全局变量或静态变量,那么TLS能够帮助解决它遇到的问题。 但是编程人员往往尽可能减少对这些变量的使用,而更多地依赖自动(基于堆栈的)变量和通过 函数的参数传递的数据。这样做是很好的,因为基于堆栈的变量总是与特定的线程相联系的。 标准的C运行期库一直是由许多不同的编译器供应商来实现和重新实现的。如果 C编译器 不包含标准的C运行期库,那么就不值得去购买它。编程员多年来一直使用标准的 C运行期库, 并且将会继续使用它,这意味着 strtok之类的函数的原型和行为特性必须与上面所说的标准 C运 行期库完全一样。如果今天重新来设计 C运行期库,那么它就必须支持多线程应用程序的环境, 并且必须采取相应的措施来避免使用全局变量和静态变量。 在我的软件开发项目中,我总是尽可能避免使用全局变量和静态变量。如果你的应用程序 使用全局变量和静态变量,那么建议你务必观察每个变量,并且了解一下它能否改变成基于堆 栈的变量。如果打算将线程添加给应用程序,那么这样做可以节省大量时间,甚至单线程应用 程序也能够从中得到许多好处。 在编写应用程序和 DLL时,可以使用本章中介绍的两种 TLS方法,即动态TLS和静态TLS。 但是,当创建DLL时,这些TLS往往更加有用,因为 DLL常常不知道它们链接到的应用程序的 结构。不过,当编写应用程序时,你通常知道将要创建多少线程以及如何使用这些线程。然后 就可以创造一些临时性的方法,或者最好是使用基于堆栈的方法(局部变量),将数据与创建 的每个线程联系起来。不管怎样,应用程序开发人员也能从本章讲述的内容中得到一些启发。 21.1 动态TLS 若要使用动态 TLS,应用程序可以调用一组 4个函数。这些函数实际上是 DLL用得最多的 函数。图 21-1显示了 Windows用来管理 TLS的内部数据结构。 510计计第四部分 动态链接库 进程 线程本地存储器的位标志: 0-(TLS_MINIMUM_AVAILABLE-1) 下载 线程1 0 引索0 0 引索1 0 引索2 0 引索3 0 引索4 0 索引TLS_MINIMUM_AVAILABLE-2 0 索引TLS_MINIMUM_AVAILABLE-2 线程2 0 引索0 0 引索1 0 引索2 0 引索3 0 引索4 0 索引TLS_MINIMUM_AVAILABLE-2 0 索引TLS_MINIMUM_AVAILABLE-2 图21-1 用于管理TLS的内部数据结构 该图显示了系统中运行的线程正在使用的一组标志。每个标志均可设置为 FREE或者 INUSE,表示TLS时隙(slot)是否正在使用。Microsoft保证至少TLS_MINIMUM_AVAILABLE位 标志是可供使用的。另外, TLS_MINIMUM_AVAILABLE在WinNT.h中被定义为 64。Windows 2000将这个标志数组扩展为允许有 1000个以上的TLS时隙。对于任何一个应用程序来说,这个 时隙数量足够了。 若要使用动态TLS,首先必须调用TlsAlloc函数: 这个函数命令系统对进程中的位标志进行扫描,并找出一个 FREE标志。然后系统将该标 志从FREE改为INUSE,并且TlsAlloc返回位数组中的标志的索引。 DLL(或应用程序)通常将 该索引保存在一个全局变量中。这是全局变量作为一个较好选择的情况之一,因为它的值是每 个进程而不是每个线程使用的值。 如果 TlsAlloc在该列表中找不到 FREE标志,它就返回 TLS_OUT_OF_INDEXES(在 WinBase.h中定义为0xFFFFFFFF)。当TlsAlloc第一次被调用时,系统发现第一个标志是 FREE, 并将该标志改为 INUSE,同时TlsAlloc返回0。TlsAlloc这样运行的概率是 99%。下面介绍在另 外的1%的概率下TlsAlloc是如何运行的。 当创建一个线程时,便分配一个 TLS_MINIMUM_AVAILABLEPVOID值的数组,并将它 初始化为0,然后由系统将它与线程联系起来。如图 21-1所示,每个线程均得到它自己的数组, 数组中的每个PVOID可以存储任何值。 在能够将信息存储在线程的 PVOID数组中之前,必须知道数组中的哪个索引可供使用,这 就是前面调用 TlsAlloc所要达到的目的。按照设计概念, TlsAlloc为你保留了一个索引。如果 TlsAlloc返回索引 3,那么就说明目前在进程中运行的每个线程中均为你保留了索引 3,而且在 将来创建的线程中也保留了索引 3。 下载 511 第 21章 线程本地存储器计计 若要将一个值放入线程的数组中,可以调用 TlsSetValue函数: 该函数将一个PVOID值(用pvTlsValue参数标识)放入线程的数组中由 dwTlsIndex参数标 识的索引处。PvTlsValue的值与调用TlsSetValue的线程相联系。如果调用成功,便返回 TRUE。 线程在调用TlsSetValue时,可以改变它自己的数组。但是它不能为另一个线程设置 TLS值。 我希望有另一个 Tls函数能够用于使一个线程将数据存储到另一个线程的数组中,但是不存在 这样一个函数。目前,将数据从一个线程传递到另一个线程的唯一方法是,将单个值传递给 CreateThread或_beginthreadex,然后该函数将该值作为唯一的参数传递给线程的函数。 当调用 TlsSetValue时,始终都应该传递较早的时候调用的 TlsAlloc函数返回的索引。 Microsoft设计的这些函数能够尽快地运行,在运行时,将放弃错误检查。如果传递的索引是调用 TlsAlloc时从未分配的,那么系统将设法把该值存储在线程的数组中,而不进行任何错误检查。 若要从线程的数组中检索一个值,可以调用 TlsGetValue: 该函数返回的值将与索引 d w T l s I n d e x 处的 T L S时隙联系起来。与 T l s S e t Va l u e一样, TlsGetValue只查看属于调用线程的数组。还有, TlsGetValue并不执行任何测试,以确定传递 的索引的有效性。 当在所有线程中不再需要保留TLS时隙的位置的时候,应该调用 TlsFree: 该函数简单地告诉系统该时隙不再需要加以保留。由进程的位标志数组管理的INUSE标志再 次被设置为FREE。如果线程在后来调用TlsAlloc函数,那么将来就分配该INUSE标志。如果TlsFree 函数运行成功,该函数将返回TRUE。如果试图释放一个没有分配的时隙,将产生一个错误。 使用动态 TLS 通常情况下,如果 DLL使用 TLS,那么当它用 DLL_PROCESS_ATTACH标志调用它的 DllMain函数时,它也调用TlsAlloc。当它用DLL_PROCESS_DETACH调用DllMain函数时,它就 调用TlsFree。对TlsSetValue和TlsGetValue的调用很可能是在调用DLL中包含的函数时进行的。 将TLS添加给应用程序的方法之一是在需要它时进行添加。例如,你的DLL中可能有一个运 行方式类似strtok的函数。第一次调用这个函数时,线程传递一个指向 40字节的结构的指针。必 须保存这个结构,这样,将来调用函数时就可以引用它。可以像下面这样对你的函数进行编码: 512计计第四部分 动态链接库 下载 如果你的应用程序的线程从来不调用 MyFunction,那么也就从来不用为该线程分配内存 块。 64个TLS位置看来超出了你的需要。但是请记住,应用程序可以动态地链接到若干个 DLL。 第一个DLL可以分配10个TLS索引,第二个DLL可以分配5个TLS索引,依此类推。减少你需要 的TLS索引始终是个好思路。减少所用 TLS索引的最好的办法,是采用前面的代码中的 MyFunction使用的那种方法。当然,可以在多个 TLS索引中保存全部40个字节,但是这样做不 仅很浪费,而且使数据的操作很困难。相反,应该为数据分配一个内存块,并且像 MyFunction 那样,只将指针保存在单个 TLS索引中。正如前面讲过的那样, Windows 2000允许设置1000多 个TLS时隙。 Microsoft增加了时隙的数量,因为许多编程人员对时隙的使用采取一种只顾自己 不顾其他的态度,不给其他 DLL分配时隙,从而导致它们运行失败。 前面介绍 TlsAlloc函数时,只描述了该函数能够实现的 99%的功能。为了帮助了解剩下的 1%的功能,让我们观察一下下面的代码段: 在这个代码运行之后,你认为 pvSomeValue将包含什么信息呢?包含12345?答案是包含0。 TlsAlloc在返回之前,要遍历进程中的每个线程,在每个线程的数组中的新分配索引处放入 0。 下载 513 第 21章 线程本地存储器计计 这是很不错的,因为应用程序可能调用 LoadLibrary来加载DLL,而DLL则调用TlsAlloc来 分配索引,然后,线程调用FreeLibrary来删除DLL。DLL应该通过调用TlsFree来释放它的索引, 但是谁知道DLL的代码将哪些值放入线程的数组中呢?接着,线程调用 LoadLibrary,将另一个 DLL加载到内存中。该 DLL启动时也调用 TlsAlloc,并获得与前面的 DLL相同的索引。如果 TlsAlloc没有为进程中的所有线程设置返回的索引,那么线程就可能看到一个老的值,而代码 则无法正确地运行。 例如,这个新DLL可以查看对TlsGetValue函数的调用是否曾经为一个线程分配了内存,就 像前面的代码段显示的那样。如果 TlsAlloc没有删除每个线程的数组项目,那么第一个 DLL的 老数据仍然可以使用。如果线程调用 MyFunction,那么MyFunction就会认为内存块已经分配, 并且调用 memcpy函数,将新数据拷贝到它所认为的内存块中。这可能造成灾难性的后果,不 过,幸好 TlsAlloc会对数组元素进行初始化,使这样的灾难永远不会发生。 21.2 静态TLS 与动态TLS一样,静态 TLS也能够将数据与线程联系起来。但是,静态 TLS在代码中使用 起来要容易得多,因为不必调用任何函数就能够使用它。 比如说,你想要将起始时间与应用程序创建的每个线程联系起来。只需要将起始时间变量 声明为下面的形式: __declspec(thread)的前缀是Microsoft添加给Visual C++编译器的一个修改符。它告诉编译 器,对应的变量应该放入可执行文件或 DLL文件中它的自己的节中。 __declspec(thread)后面的 变量必须声明为函数中(或函数外)的一个全局变量或静态变量。不能声明一个类型为 __declspec(thread)的局部变量。这不应该是个问题,因为局部变量总是与特定的线程相联系的。 我将前缀 gt_用于全局 TLS变量,而将 st_用于静态 TLS变量。 当编译器对程序进行编译时,它将所有的TLS变量放入它们自己的节,这个节的名字是.tls。 链接程序将来自所有对象模块的所有 .tls节组合起来,形成结果的可执行文件或 DLL文件中的 一个大的 .tls节。 为了使静态TLS能够运行,操作系统必须参与其操作。当你的应用程序加载到内存中时, 系统要寻找你的可执行文件中的 .tls节,并且动态地分配一个足够大的内存块,以便存放所有 的静态 TLS变量。你的应用程序中的代码每次引用其中的一个变量时,就要转换为已分配内存 块中包含的一个内存位置。因此,编译器必须生成一些辅助代码来引用该静态 TLS变量,这将 使你的应用程序变得比较大而且运行的速度比较慢。在 x86 CPU上,将为每次引用的静态 TLS 变量生成 3个辅助机器指令。 如果在进程中创建了另一个线程,那么系统就要将它捕获并且自动分配另一个内存块,以 便存放新线程的静态 TLS变量。新线程只拥有对它自己的静态 TLS变量的访问权,不能访问属 于其他线程的TLS变量。 这就是静态 TLS变量如何运行的基本情况。现在让我们来看一看 DLL的情况。你的应用程 序很可能要使用静态 TLS变量,并且链接到也想使用静态 TLS变量的一个DLL。当系统加载你 的应用程序时,它首先要确定应用程序的 .tls节的大小,并将这个值与你的应用程序链接的 DLL中的任何 .tls节的大小相加。当在你的进程中创建线程时,系统自动分配足够大的内存块 来存放应用程序需要的所有 TLS变量和所有隐含链接的 DLL。 514计计第四部分 动态链接库 下载 下面让我们来看一下当应用程序调用 LoadLibrary,以便链接到也包含静态 TLS变量的一个 DLL时,将会发生什么情况。系统必须查看进程中已经存在的所有线程,并扩大它们的 TLS内 存块,以便适应新 DLL对内存的需求。另外,如果调用 FreeLibrary来释放包含静态 TLS变量的 DLL,那么与进程中的每个线程相关的的内存块应该被压缩。 对于操作系统来说,这样的管理任务太重了。虽然系统允许包含静态 TLS变量的库在运行 期进行显式加载,但是 TLS数据没有进行相应的初始化。如果试图访问这些数据,就可能导致 访问违规。这是使用静态 TLS的唯一不足之处。当使用动态 TLS时,不会出现这个问题。使用 动态TLS的库可以在运行期进行加载,并且可以在运行期释放,根本不会产生任何问题。 下载 第22章 插入DLL和挂接API 在Microsoft Windows中,每个进程都有它自己的私有地址空间。当使用指针来引用内存时, 指针的值将引用你自己进程的地址空间中的一个内存地址。你的进程不能创建一个其引用属于 另一个进程的内存指针。因此,如果你的进程存在一个错误,改写了一个随机地址上的内存, 那么这个错误不会影响另一个进程使用的内存。 Windows 98 在Windows 98下运行的各个进程共享2 GB的地址空间,该地址空间从 0 x 8 0 0 0 0 0 0 0至0 x F F F F F F F F。只有内存映像文件和系统组件才能映射到这个区域。详 细说明参见第 13、14章和第 17章的内容。 独立的地址空间对于编程人员和用户来说都是非常有利的。对于编程人员来说,系统更容 易捕获随意的内存读取和写入操作。对于用户来说,操作系统将变得更加健壮,因为一个应用 程序无法破坏另一个进程或操作系统的运行。当然,操作系统的这个健壮特性是要付出代价的, 因为要编写能够与其他进程进行通信,或者能够对其他进程进行操作的应用程序将要困难得 多。 有些情况下,必须打破进程的界限,访问另一个进程的地址空间,这些情况包括: • 当你想要为另一个进程创建的窗口建立子类时。 • 当你需要调试帮助时(例如,当你需要确定另一个进程正在使用哪个 DLL时)。 • 当你想要挂接其他进程时。 本章将介绍若干种方法,可以用来将 DLL插入到另一个进程的地址空间中。一旦你的 DLL 进入另一个进程的地址空间,就可以对另一个进程为所欲为。这一定会使你非常害怕,因此, 究竟应该怎样做,要三思而后行。 22.1 插入DLL:一个例子 假设你想为由另一个进程创建的窗口建立一个子类。你可能记得,建立子类就能够改变窗 口的行为特性。若要建立子类,只需要调用 SetWindowLongPtr函数,改变窗口的内存块中的窗 口过程地址,指向一个新的(你自己的) WndProc。Platform SDK文档说,应用程序不能为另 一个进程创建的窗口建立子类。这并不完全正确。为另一个进程的窗口建立子类的关键问题与 进程地址空间的边界有关。 当调用下面所示的 SetWindowsLongPtr函数,建立一个窗口的子类时,你告诉系统,发送 到或者显示在hwnd设定的窗口中的所有消息都应该送往 MySubclassProc,而不是送往窗口的正 常窗口过程: 换句话说,当系统需要将消息发送到指定窗口的 WndProc时,要查看它的地址,然后直接 调用WndProc。在本例中,系统发现 MySubclassProc函数的地址与窗口相关联,因此就直接调 用MySubclassProc函数。 为另一个进程创建的窗口建立子类时遇到的问题是,建立子类的过程位于另一个地址空间 中。图22-1显示了一个简化了的图形,说明窗口过程是如何接受消息的。进程 A正在运行,并 516计计第四部分 动态链接库 下载 且已经创建了一个窗口。文件 User32.dll被映射到进程 A的地址空间中。对 User32.dll文件的映 射是为了接收和发送在进程 A中运行的任何线程创建的任何窗口中发送和显示的消息。当 User32.dll的映像发现一个消息时,它首先要确定窗口的 WndProc的地址,然后调用该地址,传 递窗口的句柄、消息和 wParam和lParam值。当WndProc处理该消息后,User32.dll便循环运行, 并等待另一个窗口消息被处理。 进程A 进程B 图22-1 进程B中的线程试图为进程 A中的线程创建的窗口建立子类 现在假设你的进程是进程 B,你想为进程 A中的线程创建的窗口建立子类。你在进程 B中的 代码必须首先确定你想要建立子类的窗口的句柄。这个操作使用的方法很多。图 22-1显示的例 子只是调用FindWindow函数来获得需要的窗口。接着,进程 B中的线程调用SetWindowLongPtr 函数,试图改变窗口的 WndProc的地址。请注意我说的“试图”二字。这个函数调用并不进行 什么操作,它只是返回 NULL。SetWindowLongPtr函数中的代码要查看是否有一个进程正在试 图改变另一个进程创建的窗口的 WndProc地址,然后将忽略这个函数的调用。 如果SetWindowLongPtr函数能够改变窗口的 WndProc,那将出现什么情况呢?系统将把 MySubclassProc的地址与特定的窗口关联起来。然后,当有一条消息被发送到这个窗口中时, 进程A中的User32代码将检索该消息,获得 MySubclassProc的地址,并试图调用这个地址。但 是,这时可能遇到一个大问题。 MySubclassProc将位于进程 B的地址空间中,而进程 A却是个 活动进程。显然,如果 User32想要调用该地址,它就要调用进程 A的地址空间中的一个地址, 这就可能造成内存访问的违规。 为了避免这个问题的产生,应该让系统知道 MySubclassProc是在进程 B的地址空间中,然 后,在调用子类的过程之前,让系统执行一次上下文转换。 Microsoft没有实现这个辅助函数功 能,原因是: • 应用程序很少需要为其他进程的线程创建的窗口建立子类。大多数应用程序只是为它们 自己创建的窗口建立子类, Windows的内存结构并不阻止这种创建操作。 • 切换活动进程需要占用许多CPU时间。 • 进程B中的线程必须执行 MySubclassProc中的代码。系统究竟应该使用哪个线程呢?是现 有的线程,还是新线程呢? • User32.dll怎样才能说明与窗口相关的地址是用于另一个进程中的过程,还是用于同一个 下载 517 第 22章 插入DLL 和挂接 API计计 进程中的过程呢? 由于对这个问题的解决并没有什么万全之策,因此 Microsoft决定不让 SetWindowsLongPtr 改变另一个进程创建的窗口过程。 不过仍然可以为另一个进程创建的窗口建立子类 —只需要用另一种方法来进行这项操 作。这并不是建立子类的问题,而是进程的地址空间边界的问题。如果能将你的子类过程的代 码放入进程A的地址空间,就可以方便地调用 SetWindowLongPtr函数,将进程 A的地址传递给 MySubclassProc函数。我将这个方法称为将 DLL“插入”进程的地址空间。有若干种方法可以 用来进行这项操作。下面将逐个介绍它们。 22.2 使用注册表来插入DLL 如果你曾经多少使用过 Windows操作系统,你肯定熟悉注册表的情况。整个系统的配置都 是在注册表中维护的,可以通过调整它的设置来改变系统的行为特性。将要介绍的项目是在下 面的关键字中: Windows 98 Windows 98将忽略注册表的这个关键字。在 Windows 98下,无法使用 该方法插入 DLL。 图22-2显示了使用Registry Editor(注册表编辑器)时该关键字中的各个项目的形式。该关 键字的值包含一个 DLL文件名或者一组 DLL文件名(用空格或逗号隔开)。由于空格用来将文 件名隔开,因此必须避免使用包含空格的文件名。列出的第一个DLL文件名可以包含一个路径, 但是包含路径的其他 DLL均被忽略。由于这个原因,最好将你的 DLL放入Windows的系统目录 中,这样就不必设定路径。在窗口中,我将该值设置为单个 DLL路径名C:\MyLib.dll。 图22-2 注册表窗口 当重新启动计算机及 Windows进行初始化时,系统将保存这个关键字的值。然后,当 User32.dll库被映射到进程中时,它将接收到一个 DLL_PROCESS_ATTACH通知。当这个通知 被处理时, User32.dll便检索保存的这个关键字中的值,并且为字符串中指定的每个 DLL调用 LoadLibrary函数。当每个库被加载时,便调用与该库相关的 DllMain函数,其fdwReason的值是 DLL_PROCESS_ATTACH,这样,每个库就能够对自己进行初始化。由于插入的 DLL在进程 的寿命期中早早地就进行了加载,因此在调用函数时应该格外小心。调用 kernel32.dll中的函数 时应该不会出现什么问题,不过调用其他 DLL中的函数时就可能产生一些问题。 User32.dll并 518计计第四部分 动态链接库 下载 不检查每个库是否已经加载成功,或者初始化是否取得成功。 在插入DLL时所用的所有方法中,这是最容易的一种方法。要做的工作只是将一个值添加 到一个已经存在的注册表关键字中。不过这种方法也有它的某些不足: • 由于系统在初始化时要读取这个关键字的值,因此在修改这个值后必须重新启动你的计 算机—即使退出后再登录,也不行。当然,如果从这个关键字的值中删除 DLL,那么 在计算机重新启动之前,系统不会停止对库的映射操作。 • 你的DLL只会映射到使用User32.dll的进程中。所有基于GUI的应用程序均使用 User32.dll, 不过大多数基于 CUI的应用程序并不使用它。因此,如果需要将 DLL插入编译器或链接 程序,这种方法将不起作用。 • 你的DLL将被映射到每个基于 GUI的应用程序中,但是必须将你的库插入一个或几个进 程中。你的DLL映射到的进程越多,“容器”进程崩溃的可能性就越大。毕竟在这些进程 中运行的线程是在执行你的代码。如果你的代码进入一个无限循环,或者访问的内存不 正确,就会影响代码运行时所在进程的行为特性和健壮性。因此,最好将你的库插入尽 可能少的进程中。 • 你的DLL将被映射到每个基于 GUI的应用程序中。这与上面的问题相类似。在理想的情 况下,你的 DLL只应该映射到需要的进程中,同时,它应该以尽可能少的时间映射到这 些进程中。假设在用户调用你的应用程序时你想要建立 WordPad的主窗口的子类。在用 户调用你的应用程序之前,你的 DLL不必映射到 WordPad的地址空间中。如果用户后来 决定终止你的应用程序的运行,那么你必须撤消 WordPad的主窗口。在这种情况下,你 的DLL将不再需要被插入WordPad的地址空间。最好是仅在必要时保持 DLL的插入状态。 22.3 使用Windows挂钩来插入DLL 可以使用挂钩将DLL插入进程的地址空间。为了使挂钩能够像它们在 16位Windows中那样 工作,Microsoft不得不设计了一种方法,使得 DLL能够插入另一个进程的地址空间中。 下面让我们来看一个例子。进程 A(类似Microsoft Spy++的一个实用程序)安装了一个挂 钩WN_GETMESSAGE,以便查看系统中的各个窗口处理的消息。该挂钩是通过调用下面的 SetWindowsHookEx函数来安装的: 第一个参数WH_GETMESSAGE用于指明要安装的挂钩的类型。第二个参数 GetMsgProc用 于指明窗口准备处理一个消息时系统应该调用的函数的地址(在你的地址空间中)。第三个参 数hinstDll用于指明包含 GetMsgProc 函数的 DLL。在 Windows中, DLL的hinstDll的值用于标识 DLL被映射到的进程的地址空间中的虚拟内存地址。最后一个参数 0用于指明要挂接的线程。 对于一个线程来说,它可以调用 SetWindowsHookEx函数,传递系统中的另一个线程的 ID。通 过为这个参数传递0,就告诉系统说,我们想要挂接系统中的所有 GUI线程。 现在让我们来看一看将会发生什么情况: 1) 进程B中的一个线程准备将一条消息发送到一个窗口。 2) 系统查看该线程上是否已经安装了 WH_GETMESSAGE挂钩。 3) 系统查看包含GetMsgProc函数的DLL是否被映射到进程 B的地址空间中。 4) 如果该DLL尚未被映射,系统将强制该 DLL映射到进程B的地址空间,并且将进程 B中 的DLL映像的自动跟踪计数递增 1。 下载 519 第 22章 插入DLL 和挂接 API计计 5) 当DLL的hinstDll用于进程 B时,系统查看该函数,并检查该 DLL的hinstDll是否与它用 于进程A时所处的位置相同。 如果两个 hinstDll是在相同的位置上,那么 GetMsgProc函数的内存地址在两个进程的地址 空间中的位置也是相同的。在这种情况下,系统只需要调用进程 A的地址空间中的GetMsgProc 函数即可。 如果hinstDll的位置不同,那么系统必须确定进程 B的地址空间中GetMsgProc函数的虚拟内 存地址。这个地址可以使用下面的公式来确定: 将GetMsgProc A的地址减去 hinstDll A的地址,就可以得到 GetMsgProc函数的地址位移 (以字节为计量单位)。将这个位移与 hinstDll B的地址相加,就得出 GetMsgProc函数在用于进 程B的地址空间中该DLL的映像时它的位置。 6) 系统将进程B中的DLL映像的自动跟踪计数递增1。 7) 系统调用进程B的地址空间中的GetMsgProc函数。 8) 当GetMsgProc函数返回时,系统将进程B中的DLL映像的自动跟踪计数递减 1。 注意,当系统插入或者映射包含挂钩过滤器函数的 DLL时,整个DLL均被映射,而不只是 挂钩过滤器函数被映射。这意味着 DLL中包含的任何一个函数或所有函数现在都存在,并且可 以从进程 B的环境下运行的线程中调用。 若要为另一个进程中的线程创建的窗口建立子类,首先可以在创建该窗口的挂钩上设置一 个WH_GETMESSAGE挂钩,然后,当 GetMsgProc函数被调用时,调用 SetWindowLongPtr函数 来建立窗口的子类。当然,子类的过程必须与 GetMsgProc函数位于同一个 DLL中。 与插入DLL的注册表方法不同,这个方法允许你在另一个进程的地址空间中不再需要 DLL 时删除该 DLL的映像,方法是调用下面的函数: 当一个线程调用 UnhookWindowsHookEx函数时,系统将遍历它必须将 DLL插入到的各个 进程的内部列表,并且对 DLL的自动跟踪计数进行递减。当自动跟踪计数递减为 0时,DLL就 自动从进程的地址空间中被删除。应该记得,就在系统调用 GetMsgProc函数之前,它对 DLL的 自动跟踪计数进行了递增(见上面的第 6个步骤)。这可以防止产生内存访问违规。如果该自动 跟踪计数没有递增,那么当进程 B的线程试图执行GetMsgProc函数中的代码时,系统中运行的 另一个线程就可以调用 UnlookWindowsHookEx函数。 这一切意味着不能撤消该窗口的子类并且立即撤消该挂钩。该挂钩必须在该子类的寿命期 内保持有效状态。 桌面项目位置保存器实用程序 清单22-2中列出的 DIPS.exe应用程序使用窗口挂钩将一个 DLL插入Explorer.exe的地址空间。 该应用程序和 DLL的源代码和资源文件均位于本书所附光盘的 22-DIPS和22-DIPSlib目录下。 我基本上将我的计算机用于与商务有关的操作,我发现 1152 x 864的屏幕分辨率最适合我。 但是在计算机上玩游戏时,大多数游戏设计时使用的分辨率是 640 x 480。因此,当我想要玩游 戏时,我打开控制面板,使用 Display小应用程序,将分辨率改为 640 x 480。不玩游戏时,我 又使用Display小应用程序将分辨率重新改为1152 x 864 。 使用这种方法在运行过程中改变显示器的分辨率是非常麻烦的,但是它是 Windows的一个 受欢迎的特性。不过我忽略了改变显示器分辨率时的一个问题,那就是桌面图标无法记住它原 520计计第四部分 动态链接库 下载 来的位置。我的桌面上有若干个图标,可以立即访问各个应用程序,并可打开经常使用的文件。 当改变显示器的分辨率时,桌面窗口便改变其大小,我的图标重新安排其位置,使我无法找到 我要的东西。然后,当我将显示器的分辨率改为原来的样子时,我的所有图标又重新安排其位 置,采用一种新的顺序。为了解决这个问题,我不得不用手工将桌面上的所有图标重新改为我 喜欢的样子。真是烦死人了。 我非常讨厌用手工方式改变这些图标的位置,因此创建 了桌面项目位置保存器实用程序 DIPS。DIPS包含一个很小的 可执行文件和一个很小的 DLL。当运行这个可执行文件时, 就会出现图 22-3所示的消息框。 这个消息框显示了该实用程序如何使用的情况。当你将 S 作为命令行参数传递给 DIPS时,它就创建下面这个注册表子 图22-3 桌面项目位置保存 关键字,并且给桌面窗口上的每个项目添加一个值: 器实用工具窗口 每个项目都有一个与它一起保存的位置值。当改变屏幕分辨率以便玩游戏之前,运行 DIPS S。当玩完游戏后,将屏幕的分辨率改为原来的状态,并且运行 DIPS R。这使得DIPS打开注册 表子关键字,对于桌面上与注册表中保存的项目相匹配的每个项目来说,当运行 DIPS S时,项 目的位置将被重新设置为原来的值。 最初你可能认为, DIPS的实现是非常容易的,毕竟你只需要获得桌面的 ListView控件的窗 口句柄,为它发送枚举各个项目的消息,获得它们的位置,然后将这些信息保存在注册表中就 行了。但是,如果进行具体操作,就会发现事情并不那么简单。问题是大多数常用的控件窗口 消息,比如 LVM_GETITEM和LVM_GETITEMPOSITION,不能跨越进程的边界来运行。 原因是, LVM_GETITEM消息要求你为消息的 LPARAM参数传递一个 LV_ITEM数据结构 的地址。由于这个内存地址只对发送消息的进程有意义,接收消息的进程无法保证能够使用它。 因此,为了使 D I P S 能够按原定的要求来工作,必须将代码插入 E x p l o r e r. e x e ,以便将 LVM_GETITEM和LVM_GETITEMPOSITION消息成功地发送到桌面的 ListView控件中。 注意 可以跨越进程的边界发送窗口消息,以便与内置控件(如按钮、编辑框、 静态框、组合框和列表框等)进行交互操作,但是,对一些新的常用控件不能这样做。 例如,可以将一个 LB_GETTEXT消息发送给另一个进程中的线程创建的列表框控件, 其中的 LPARAM参数指向发送方进程中的一个字符串缓冲区。这是可行的,因为 Microsoft专门查看 LB_GETTEXT消息是否已经发送。如果已经发送,操作系统将在 内部创建内存映射文件,并且跨越进程的边界来拷贝该字符串数据。 为什么 Microsoft决定对内置控件这样做而不对新的常用控件这样做呢?答案是为 了实现可移植性。在 16位Windows中,所有应用程序都在单个地址空间中运行,一个 应用程序可以将一个 LB_GETTEXT消息发送给另一个应用程序创建的窗口。为了使 这些16位应用程序能够非常容易地移植到 Win32中,Microsoft采取了一些辅助措施来 确保跨越进程的消息发送仍然能够进行。但是 16位Windows中不存在新的常用控件, 因此不存在移植问题,所以 Microsoft没有为常用控件采取辅助的措施。 当运行DIPS.exe时,它首先得到桌面的ListView控件的窗口句柄: 下载 521 第 22章 插入DLL 和挂接 API计计 该代码首先寻找一个窗口,它的类是 ProgMan。尽管Program Manager(程序管理器)应用 程序正在运行,新外壳程序仍然要创建这个类的一个窗口,以便与较老版本的 Windows设计的 应用程序实现向后兼容。该 ProgMan窗口拥有单个子窗口,它的类是 SHELLDLL_DefView。这 个子窗口也拥有单个子窗口,它的类是 SysListView32。该SysListView32窗口是桌面的ListView 控件窗口(顺便说一下,我是使用 Spy++获得所有这些信息的)。 一旦拥有ListView的窗口句柄,通过调用 GetWindowThreadProcessId函数,就能够确定创 建窗口的线程的 I D 。将这个 I D 传递给 S e t D I P S H o o k 函数(在 D I P S L i b . c p p 中 实 现 )。 SetDIPSHook负责在该线程上安装一个 WH_GETMESSAGE挂钩,然后调用下面的函数,以强 制Windows Explorer的线程醒来: 由于已经在该线程上安装了一个 WH_GETMESSAGE挂钩,因此操作系统能够自动将 DIPSLib.dll文件插入Explorer的地址空间,并且调用 GetMsgProc函数。该函数首先查看它是否 是初次被调用,如果是,那么它就创建一个隐藏的窗口,其标题是“ Richter DIPS。”请记住, Explorer的线程正在创建这个隐藏窗口。当它进行这项操作时, DIPS.exe线程从SetDIPSHook 返回,然后调用下面的函数: 这次函数调用将使线程进入睡眠状态,直到队列中显示一条消息为止。尽管 DIPS.exe并没 有创建它自己的任何窗口,但是它仍然有一个消息队列,同时,只有调用 PostThreadMessage 函数,才能将消息放入该队列。如果观察 DIPSLib.cpp的GetMsgProc函数中的代码,将会发现, 在对CreateDialog的调用的后面,紧接着就是对 PostThreadMessage函数的调用,该函数将使 SIPS.exe线程再次醒来。线程的 ID保存在 SetDISPHook函数的共享变量中。 注意,我将线程的消息队列用于线程的同步。这样做绝对没有什么错误,并且有时能够比 使用各种内核对象(如互斥对象、信标和事件等)更容易实现线程的同步。 Windows拥有丰富 的API,应该充分利用它们。 当DIPS可执行文件中的线程醒来时,它知道服务器对话框已经创建,并调用 FindWindow 函数来获得窗口的句柄。这时可以使用窗口消息在客户机( DIPS应用程序)与服务器(隐藏 的对话框)之间进行通信。由于在 Windows Explorer的进程环境中运行的一个线程创建了这个 对话框,因此在使用Windows Explorer时将会遇到一些限制。 若要让对话框保存或者还原桌面图标的位置,只需要发送一条消息: 我对该对话框的过程进行了编码,以便查找 WM_APP消息。当它收到该消息时, WPARAM参数就会指明被操作的 ListView控件的句柄,而 LPARAM参数则是个布尔值,用于 指明当前项目的位置是否应该保存在注册表中,或者指明是否应该根据从注册表中读取的保存 信息来改变项目的位置。 由于我使用SendMessage而不是Postmessage,因此该函数直到运行完成才返回。如果愿意 的话,可以将消息添加给对话框的过程,使该程序能够进一步控制 Explorer的进程。当完成与 对话框的通信时,并且(因此)想要终止服务器的运行时,我发送了一个 WM_CLOSE消息, 告诉对话框将自己关闭。 522计计第四部分 动态链接库 下载 最后,就在DIPS应用程序终止运行之前,它再次调用 SetDIPSHook函数,但是传递 0作为 线程的 ID。0是个标记值,用于告诉函数撤消 WN_GETMESSAGE挂钩。当该挂钩被卸载时, 操作系统自动从Explorer的地址空间中卸载DIPSLib.dll文件。对话框首先撤消,然后卸载挂钩, 这一点很重要,否则对话框接收到的下一个消息将会导致 Explorer的线程引发一次访问违规。 如果发生这种情况,操作系统就会终止 Explorer的运行。当使用 DLL的插入操作时,必须非常 小心。 清单22-1 DIPS实用程序 下载 523 第 22章 插入DLL 和挂接 API计计 524计计第四部分 动态链接库 下载 下载 525 第 22章 插入DLL 和挂接 API计计 526计计第四部分 动态链接库 下载 下载 527 第 22章 插入DLL 和挂接 API计计 528计计第四部分 动态链接库 下载 下载 529 第 22章 插入DLL 和挂接 API计计 530计计第四部分 动态链接库 下载 下载 531 第 22章 插入DLL 和挂接 API计计 22.4 使用远程线程来插入DLL 插入DLL的第三种方法是使用远程线程。这种方法具有更大的灵活性。它要求你懂得若干 个Windows特性、如进程、线程、线程同步、虚拟内存管理、 DLL和Unicode等(如果对这些 特性不清楚,请参阅本书中的有关章节)。Windows的大多数函数允许进程只对自己进行操作。 这是很好的一个特性,因为它能够防止一个进程破坏另一个进程的运行。但是,有些函数却允 许一个进程对另一个进程进行操作。这些函数大部分最初是为调试程序和其他工具设计的。不 过任何函数都可以调用这些函数。 这个DLL插入方法基本上要求目标进程中的线程调用 LoadLibrary函数来加载必要的 DLL。 由于除了自己进程中的线程外,我们无法方便地控制其他进程中的线程,因此这种解决方案要 求我们在目标进程中创建一个新线程。由于是自己创建这个线程,因此我们能够控制它执行什 么代码。幸好, Windows提供了一个称为 CreateRemoteThread的函数,使我们能够非常容易地 在另一个进程中创建线程: CreateRemoteThread与CreateThread很相似,差别在于它增加了一个参数 hProcess。该参数 指明拥有新创建线程的进程。参数 pfnStartAddr指明线程函数的内存地址。当然,该内存地址 与远程进程是相关的。线程函数的代码不能位于你自己进程的地址空间中。 注意 在Windows 2000中,更常用的函数CreateThread是在内部以下面的形式来实现的: Windows 98 在Windows 98中,CreateRemoteThread函数不存在有用的实现代码,它 只是返回NULL。调用GetLastError函数将返回ERROR_CALL_NOT_IMPLEMENTED (C r e a t e T h r e a d 函 数 包 含 用 于 在 调 用 进 程 中 创 建 线 程 的 完 整 的 实 现 代 码 )。由于 CreateRemoteThread没有实现,因此,在 Windows 98 下,不能使用本方法来插入 D L L。 好了,现在你已经知道如何在另一个进程中创建线程了,但是,如何才能让该线程加载我 们的DLL呢?答案很简单,那就是需要该线程调用 LoadLibrary函数: 如果观察 WinBase.h文件中的 LoadLibrary函数,你将会发现下面的代码: 532计计第四部分 动态链接库 下载 实际上有两个LoadLibrary函数,即LoadLibraryA和LoadLibraryW。这两个函数之间存在的 唯一差别是,传递给函数的参数类型不同。如果将库的文件名作为 ANSI字符串来存储,那么 必须调用LoadLibraryA(A是指ANSI)。如果将文件名作为 Unicode字符串来存储,那么必须调 用LoadLibraryW(W是指通配符)。不存在单个 LoadLibrary的情况,只有 LoadLibraryA和 LoadLibraryW。对于大多数应用程序来说, LoadLibrary宏可以扩展为LoadLibraryA。 幸好 L o a d L i b r a r y 函 数 的 原 型 与 一 个 线 程 函 数 的 原 型 是 相 同 的 。 下 面 是 一 个 线 程 函 数 的 原 型: 这两个函数的原型并不完全相同,不过它们非常相似。两个函数都接受单个参数,并且都 返回一个值。另外,两个函数都使用相同的调用规则。这是非常幸运的,因为我们要做的事情 是创建一个新线程,并且使线程函数的地址成为 LoadLibraryA或LoadLibraryW函数的地址。本 质上,我们必须进行的操作是执行类似下面的一行代码: 或者,如果喜欢Unicode,则执行下面这行代码: 当在远程进程中创建新线程时,该线程将立即调用 LoadLibraryA(或LoadLibraryW)函数, 并将DLL的路径名的地址传递给它。这是非常容易的。但是这里存在另外两个问题。 第一个问题是,不能像我在上面展示的那样,将 LoadLibraryA或LoadLibraryW作为第四个 参数传递给 CreateRemoteThread。原因很简单。当你编译或者链接一个程序时,产生的二进制 代码包含一个输入节(第19章中做了介绍)。这一节由一系列输入函数的形式替换程序(thunk) 组成。所以,当你的代码调用一个函数如 LoadLibraryA时,链接程序将生成一个对你模块的输 入节中的形实替换程序的调用。接着,该形实替换程序便转移到实际的函数。 如果在对CreateRemoteThread的调用中使用一个对 LoadLibraryA的直接引用,这将在你的 模块的输入节中转换成 LoadLibraryA的形实替换程序的地址。将形实替换程序的地址作为远程 线程的起始地址来传递,会导致远程线程开始执行一些令人莫名其妙的东西。其结果很可能造 成访问违规。若要强制直接调用 LoadLibraryA函数,避开形实替换程序,必须通过调用 GetProcAddress函数,获取 LoadLibraryA的准确内存位置。 对CreateRemoteThread进行调用的前提是, Kernel32.dll已经被同时映射到本地和远程进程 的地址空间中。每个应用程序都需要 Kernel32.dll,根据我的经验,系统将 Kernel32.dll映射到 每个进程的同一个地址。因此,必须调用下面的 CreateRemoteThread函数: 下载 533 第 22章 插入DLL 和挂接 API计计 或者,如果喜欢Unicode的话,调用下面的函数: 好了,这就解决了第一个问题。第二个问题与 DLL路径名字符串有关。字符串“ C:\\ MyLib.dll”是在调用进程的地址空间中。该字符串的地址已经被赋予新创建的远程线程,该 线程将它传递给 LoadLibraryA。但是,当 LoadLibraryA取消对内存地址的引用时, DLL路径名 字符串将不再存在,远程进程的线程就可能引发访问违规;向用户显示一个未处理的异常条件 消息框,并且远程进程终止运行。记住,这是远程进程终止运行,不是你的进程终止运行。你 可能成功地终止另一个进程的运行,而你的进程则继续正常地运行。 为了解决这个问题,必须将 DLL的路径名字符串放入远程进程的地址空间中。然后,当 C r e a t e R e m o t e T h r e a d函数被调用时,我们必须将我们放置该字符串的地址(相对于远程进程的 地址)传递给它。同样, Windows提供了一个函数,即VirtualAllocEx,使得一个进程能够分配 另一个进程的地址空间中的内存: 另一个函数则使我们能够释放该内存: 这两个函数与它们的非Ex版本的函数(第 15章已经做了介绍)是类似的。唯一的差别是这 两个函数需要一个进程的句柄作为其第一个参数。这个句柄用于指明执行操作时所在的进程。 一旦为该字符串分配了内存,我们还需要一种方法将该字符串从我们的进程的地址空间拷 贝到远程进程的地址空间中。 Windows提供了一些函数,使得一个进程能够从另一个进程的地 址空间中读取数据,并将数据写入另一个进程的地址空间。 534计计第四部分 动态链接库 下载 远程进程由hProcess参数来标识。参数 pvAddressRemote用于指明远程进程中的地址,参数 pvBufferLocal是本地进程中的内存地址,参数 dwSize是需要传送的字节数, pdwNumBytesRead 和pdwNumBytesWritten用于指明实际传送的字节数。当函数返回时,可以查看这两个参数的 值。 既然已经知道了要进行操作,下面让我们将必须执行的操作步骤做一个归纳: 1) 使用VirtualAllocEx函数,分配远程进程的地址空间中的内存。 2) 使用WriteProcessMemory函数,将 DLL的路径名拷贝到第一个步骤中已经分配的内存 中。 3) 使用 GetProcAddress函数,获取 LoadLibraryA或LoadLibratyW函数的实地址(在 Kernel32.dll中)。 4) 使用CreateRemoteThread函数,在远程进程中创建一个线程,它调用正确的 LoadLibrary 函数,为它传递第一个步骤中分配的内存的地址。 这时, DLL已经被插入远程进程的地址空间中,同时 DLL的DllMain函数接收到一个 DLL_PROCESS_ATTACH通知,并且能够执行需要的代码。当 DllMain函数返回时,远程线程 从它对 L o a d L i b r a r y 的调用返回到 B a s e T h r e a d S t a r t 函数(第 6 章 中 已 经 介 绍 )。然后 BaseThreadStart调用ExitThread,使远程线程终止运行。 现在远程进程拥有第一个步骤中分配的内存块,而 DLL则仍然保留在它的地址空间中。若 要将它删除,需要在远程线程退出后执行下面的步骤: 5) 使用VirtualFreeEx函数,释放第一个步骤中分配的内存。 6) 使用GetProcAddress函数,获得FreeLibrary函数的实地址(在 Kernel32.dll中)。 7) 使用CreateRemoteThread函数,在远程进程中创建一个线程,它调用 FreeLibrary函数, 传递远程 DLL的HINSTANCE。 这就是它的基本操作步骤。这种插入 DLL的方法存在的唯一一个不足是 ,Windows 98并不 支持这样的函数。只能在 Windows 2000上使用这种方法。 22.4.1 Inject Library示例应用程序 清单22-2中列出的InjLib.exe应用程序使用CreateRemoteThread函数来插入DLL。该应用程 序和 DLL的源代码和资源文件位于本书所附光盘上的 22InjLib和22-ImgWalk目录下。该程序使用图 22-4所示的对话框 来接收运行的进程ID。 可以使用 Windows 2000配有的Task Manager(任务管理 图22-4 Inject Library Tester对话框 器)获取进程的 ID。使用这个 ID,该程序将设法通过调用 OpenProcess函数来打开正在运行的 进程的句柄,申请相应的访问权: 如果OpenProcess返回NULL,该应用程序就不是在允许它打开目标进程句柄的安全环境下 运行。有些进程,如 WinLogon、SvcHost和Csrss,是在本地系统帐户下运行的,这个帐户是已 经登录的用户不能改变的。如果你有权并且激活调试安全优先级,那么就能够打开这些进程的 句柄。第 4章中的 ProcessInfo 示例应用程序展示了进行这项操作的方法。 下载 535 第 22章 插入DLL 和挂接 API计计 如果OpenProcess函数运行成功,便使用要插入的 DLL的全路径名对一个缓存进程初始化。 然后InjectLib被调用,为它传递需要的远程进程的句柄和要插入的 DLL的路径名。最后,当 InjectLib返回时,该程序显示一个消息框,指明 DLL是否已经成功地加载到远程进程中,然后 它关闭进程的句柄。这就是它的全部运行过程。 你可能在代码中发现,我专门查看了传递的进程ID是否是0。如果是0,我就调用GetCurrent ProcessId函数,将进程的 ID设置为 InjectLib.exe自己的进程 ID。这样,当 InjectLib被调用时, DLL被插入到进程自己的地址空间中。这使得程序的调用比较容易。可以想象,当出现错误时, 有时很难确定这些错误是在本地进程中还是在远程进程中。原先我用两个调试程序来调试我的 代码,一个调试程序负责观察 InjLib,另一个调试程序负责观察远程进程。结果表明这样做是 很不方便的。后来我才明白, InjLib也能将DLL插入本身的程序中,也就是说插入与调用程序 相同的地址空间中。这样调试代码就容易多了。 在源代码模块的顶部你会发现, InjectLib实际上是个符号,根据你编译源代码所用的方法, 它可以展开为 InjectLibA或InjectLibW。函数InjectLibW正是一切魔力之所在。程序的注释本身 就说明了问题,当然也可以进一步补充说明。不过你将发现函数 InjectLibA比较短。它只是将 ANSI DLL的路径名转换成对应的 Unicode路径名,然后调用 InjectLibW函数进行实际的操作。 这种方法正是我在第 2章中建议你使用的。它也意味着只需要使插入代码运行一次就行了。 清单22-2 InjLib示例应用程序 536计计第四部分 动态链接库 下载 下载 537 第 22章 插入DLL 和挂接 API计计 538计计第四部分 动态链接库 下载 下载 539 第 22章 插入DLL 和挂接 API计计 540计计第四部分 动态链接库 下载 下载 541 第 22章 插入DLL 和挂接 API计计 22.4.2 Image Walk DLL 清单22-3列出的 ImgWalk.dll是个DLL,一旦它被插入进程 的地址空间,就能够报告该进程正在使用的所有 DLL(该DLL 的源代码和资源文件均在本书所附光盘上的 22-ImgWalk目录 下)。例如,如果我首先运行 Notepad,然后运行InjLib,为它传 递Notepad的进程ID,InjLib将ImgWalk.dll插入Notepad的地址 图22-5 查找结果对话框 542计计第四部分 动态链接库 下载 空间中。一旦进入该地址空间, ImgWalk便确定Notepad正在使用哪些文件映像(可执行文件 和DLL),并且显示图22-5所示的消息框,它显示了查找的结果。 ImgWalk遍历进程的地址空间,查找已经映射的文件映像,反复调用 VirtualQuery函数,填 入一个MEMORY_BASIC_ INFORMATION结构中。运用循环的每个重复操作, ImgWalk找出 一个文件路径名,并与一个字符串相连接。该字符串显示在消息框中。 首先我查看区域的基地址是否与插入的 DLL的基地址相匹配。如果匹配,则将 nLen设置为 0,这样,插入的库就不会出现在消息框中。如果不匹配,我将设法获取加载到该区域的基地 址中的模块的文件名。如果 nLen变量的值大于0,系统就知道该地址指明了一个已经加载的模 块,同时,系统用该模块的全路径名填入 szModName缓存。然后我将模块的 HINSTANCE(基 地址)和它的路径名与最终将显示在消息框中的 szBuf字符串链接起来。当这个循环终止运行 时,DLL显示了一个消息框,其内容是字符串。 清单22-3 ImgWalk.dll的源代码 下载 543 第 22章 插入DLL 和挂接 API计计 544计计第四部分 动态链接库 下载 22.5 使用特洛伊DLL来插入DLL 插入DLL的另一种方法是取代你知道进程将要加载的 DLL。例如,如果你知道一个进程将 要加载 Xyz.dll,就可以创建你自己的 DLL,为它赋予相同的文件名。当然,你必须将原来的 X y z . d l l改为别的什么名字。 在你的Xyz.dll中,输出的全部符号必须与原始的 Xyz.dll输出的符号相同。使用函数转发器 (第 20章做了介绍),很容易做到这一点。虽然函数转发器使你能够非常容易地挂接某些函数, 你应该避免使用这种方法,因为它不具备版本升级能力。例如,如果你取代了一个系统 DLL, 而Microsoft在将来增加了一些新函数,那么你的 DLL将不具备它们的函数转发器。引用这些新 函数的应用程序将无法加载和执行。 如果你只想在单个应用程序中使用这种方法,那么可以为你的 DLL赋予一个独一无二的名 字,并改变应用程序的 .exe模块的输入节。更为重要的是,输入节只包含模块需要的 DLL的名 字。你可以仔细搜索文件中的这个输入节,并且将它改变,使加载程序加载你自己的 DLL。这 种方法相当不错,但是必须要非常熟悉 .exe和DLL文件的格式。 22.6 将DLL作为调试程序来插入 调试程序能够对被调试的进程执行特殊的操作。当被调试进程加载时,在被调试进程的地 址空间已经作好准备,但是被调试进程的主线程尚未执行任何代码之前,系统将自动将这个情 况通知调试程序。这时,调试程序可以强制将某些代码插入被调试进程的地址空间中(比如使 用WriteProcessMemory函数来插入),然后使被调试进程的主线程执行该代码。 这种方法要求你对被调试线程的 CONTEXT结构进行操作,意味着必须编写特定 CPU的代 码。必须修改你的源代码,使之能够在不同的 CPU平台上正确地运行。另外,必须对你想让被 调试进程执行的机器语言指令进行硬编码。而且调试程序与它的被调试程序之间必须存在固定 的关系。如果调试程序终止运行, Windows将自动撤消被调试进程。而你则无法阻止它。 22.7 用Windows 98上的内存映射文件插入代码 在Windows 98 上插入你自己的代码是非常简单的。在 Windows 98 上运行的所有 32位 Windows应用程序均共享同样的最上面的 2 GB地址空间。如果你分配这里面的某些存储器,那 么该存储器在每个进程的地址空间中均可使用。若要分配 2 GB以上的存储器,只要使用内存 映射文件(第 17章已经介绍)。可以创建一个内存映射文件,然后调用 MapViewOfFile函数, 使它显示出来。然后将数据填入你的地址空间区域(这是所有进程地址空间中的相同区域)。 必须使用硬编码的机器语言来进行这项操作,其结果是这种解决方案很难移植到别的 CPU平台。 不过,如果进行这项操作,那么不必考虑不同的 CPU平台,因为Windows 98只能在x86 CPU上 运行。 这种方法的困难之处在于仍然必须让其他进程中的线程来执行内存映射文件中的代码。要 做到这一点,需要某种方法来控制远程进程中的线程。 CreateRemoteThread函数能够很好地执 行这个任务,可惜Windows 98不支持该函数的运行,而我也无法提供相应的解决方案。 22.8 用CreateProcess插入代码 如果你的进程生成了你想插入代码的新进程,那么事情就会变得稍稍容易一些。原因之一 是,你的进程(父进程)能够创建暂停运行的新进程。这就使你能够改变子进程的状态,而不 下载 545 第 22章 插入DLL 和挂接 API计计 影响它的运行,因为它尚未开始运行。但是父进程也能得到子进程的主线程的句柄。使用该句 柄,可以修改线程执行的代码。你可以解决上一节提到的问题,因为可以设置线程的指令指针, 以便执行内存映射文件中的代码。 下面介绍一种方法,它使你的进程能够控制子进程的主线程执行什么代码: 1) 使你的进程生成暂停运行的子进程。 2) 从.exe模块的头文件中检索主线程的起始内存地址。 3) 将机器指令保存在该内存地址中。 4) 将某些硬编码的机器指令强制放入该地址中。这些指令应该调用 LoadLibrary函数来加 载DLL。 5) 继续运行子进程的主线程,使该代码得以执行。 6) 将原始指令重新放入起始地址。 7) 让进程继续从起始地址开始执行,就像没有发生任何事情一样。 上面的步骤6和7要正确运行是很困难的,因为你必须修改当前正在执行的代码。不过这是 可能的。 这种方法具有许多优点。首先,它在应用程序执行之前就能得到地址空间。第二,它既能 在Windows 98上使用,也能在 Windows 2000上使用。第三,由于你不是调试者,因此能够很 容易使用插入的DLL来调试应用程序。最后,这种方法可以同时用于控制台和 GUI应用程序。 当然,这种方法也有某些不足。只有当你的代码是父进程时,才能插入 DLL。另外,这种 方法当然不能跨越不同的 CPU来运行,必须对不同的CPU平台进行相应的修改。 22.9 挂接API的一个示例 将DLL插入进程的地址空间是确定进程运行状况的一种很好的方法。但是,仅仅插入 DLL 无法提供足够的信息,人们常常需要知道某个进程中的线程究竟是如何调用各个函数的,也可 能需要修改 Windows函数的功能。 例如,我知道一家公司生产的DLL是由一个数据库产品来加载的。该DLL的作用是增强和扩 展数据库产品的功能。当数据库产品终止运行时,该 DLL就会收到一个 DLL_PROCESS _DETACH通知,并且只有在这时,它才执行它的所有清除代码。该 DLL将调用其他DLL中的函 数,以便关闭套接字连接、文件和其他资源,但是当它收到 DLL_PROCESS_ DETACH通知时, 进程的地址空间中的其他DLL已经收到它们的DLL_PROCESS_DETACH通知。因此,当该公司 的DLL试图清除时,它调用的许多函数的运行将会失败,因为其他DLL已经撤消了初始化信息。 该公司聘请我去帮助他们解决这个问题,我建议挂接函数 ExitProcess。如你所知,调用 ExitProcess将导致系统向该DLL发送DLL_PROCESS_DETACH通知。通过挂接ExitPrecess函数, 我们就能确保当 ExitProcess函数被调用时,该公司的 DLL能够得到通知。这个通知将在任何 DLL得到DLL_PROCESS_DETACH通知之前进来,因此进程中的所有 DLL仍然处于初始化状 态中,并且能够正常运行。此时,该公司的 DLL知道进程将要终止运行,并且能够成功地执行 它的全部清除操作。然后,操作系统的 ExitProcess函数被调用,使所有 DLL收到它们的 DLL_PROCESS_DETACH通知并进行清除操作。当该公司的 DLL收到这个通知时,它将不执 行专门的清除操作,因为它已经做了它必须做的事情。 在这个例子中,插入 DLL是可以随意进行的,因为数据库应用程序的设计已经允许进行这 样的插入,并且它加载了公司的 DLL。当该公司的 DLL被加载时,它必须扫描所有已经加载的 可执行模块和 DLL模块,以便找出对 ExitProcess的调用。当它发现对 ExitProcess的调用后, 546计计第四部分 动态链接库 下载 DLL必须修改这些模块,这样,这些模块就能调用公司的 DLL中的函数,而不是调用操作系统 的ExitProcess函数(这个过程比想象的情况要简单的多)。一旦公司的ExitProcess替换函数(即 通常所说的挂钩函数)执行它的清除代码,操作系统的 ExitProcess函数(在Kernel32.dll文件中) 就被调用。 这个例子显示了挂接API的一种典型用法。它用很少的代码解决了一个非常实际的问题。 22.9.1 通过改写代码来挂接API API挂接并不是一个新技术,多年来编程人员一直在使用 API挂接方法。如果要解决上面 所说的问题,那么人们首先看到的“解决方案”是通过改写代码来进行挂接。下面是具体的操 作方法: 1) 找到你想挂接的函数在内存中的地址(比如说 Kernel32.dll中的ExitProcess)。 2) 将该函数的头几个字节保存在你自己的内存中。 3) 用一个JUMP CPU指令改写该函数的头几个字节,该指令会转移到你的替换函数的内存 地址。当然,你的替换函数的标记必须与你挂接的函数的标记完全相同,即所有的参数必须一 样,返回值必须一样,调用规则必须一样。 4) 现在,当一个线程调用已经挂接的函数时, JUMP指令实际上将转移到你的替换函数。 这时,你就能够执行任何代码。 5) 取消函数的挂接状态,方法是取出(第二步)保存的字节,将它们放回挂接函数的开 头。 6) 调用挂接的函数(它已不再被挂接),该函数将执行其通常的处理操作。 7) 当原始函数返回时,再次执行第二和第三步,这样你的替换函数就可以被调用。 这种方法在16位Windows编程员中使用得非常普遍,并且用得很好。今天,这种方法存在 着若干非常严重的不足,因此建议尽量避免使用它。首先,它对 CPU的依赖性很大,在 x86、 Alpha和其他的 CPU上的JUMP指令是不同的,必须使用手工编码的机器指令才能使这种方法生 效。第二,这种方法在抢占式多线程环境中根本不起作用。线程需要占用一定的时间来改写函 数开头的代码。当代码被改写时,另一个线程可能试图调用该同一个函数。结果将是灾难性的。 因此,只有当你知道在规定的时间只有一个线程试图调用某个函数时,才能使用这种方法。 Windows 98 在Windows 98上,主要的Windows DLL(Kernel32、AdvAPI32、User32 和G D I 3 2)是这样受到保护的,即应用程序不能改写它们的代码页面。通过编写虚拟 设备驱动程序(VxD)才能够获得这种保护。 22.9.2 通过操作模块的输入节来挂接 API 另一种 API挂接方法能够解决我前面讲到的两个问题。这种方法实现起来很容易,并且相 当健壮。但是,要理解这种方法,必须懂得动态连接是如何工作的。尤其必须懂得模块的输入 节中保护的是什么信息。第 19章已经用了较多的篇幅介绍了输入节是如何生成的以及它包含的 内容。当阅读下面的内容时,可以回头参考第 19章的有关说明。 如你所知,模块的输入节包含一组该模块运行时需要的 DLL。另外,它还包含该模块从每 个DLL输入的符号的列表。当模块调用一个输入函数时,线程实际上要从模块的输入节中捕获 需要的输入函数的地址,然后转移到该地址。 要挂接一个特定的函数,只需要改变模块的输入节中的地址,就这么简单。它不存在依赖 下载 547 第 22章 插入DLL 和挂接 API计计 CPU的问题。同时,由于修改了函数的代码,因此不需要担心线程的同步问题。 下面这个函数就负责执行这个重要的操作。它接受一个模块的输入节,以便引用特定地址 上的一个符号。如果存在这样的引用,那么它就改变该符号的地址。 548计计第四部分 动态链接库 下载 为了说明如何调用该函数,让我们首先介绍一种可能存在的环境。比如说,我们有一个模 块称为DataBase.exe。该模块中的代码调用 Kernel32.dll中包含的ExitProcess函数,但是我们想 要调用我的 DBExtend.dll模块中包含的 MyExitProcess函数。为了完成这个操作,需要调用下面 的ReplaceIATEntryInOneMod函数: ReplaceIATEntryInOneMod函数要做的第一件事情是找出 hmodCaller模块的输入节,方法 是调用 ImageDirectoryEntryToData函数,给它传递 IMAGE_DIRECTORY_ENTRY_IMPORT。 如果该函数返回 NULL,DataBase.exe模块就没有输入节,并且不进行任何操作。 如果DataBase.exe有一个输入节,那么 ImageDirectoryEntryToData就返回该输入节的地址, 该地址是一个类型为 PIMAGE_IMPORT_DESCRIPTOR的指针。现在我们必须查看该模块的输 入节,找出包含我们想要修改的输入函数的 DLL。在这个例子中,我们查找从“ Kernel32.dll” 输入的符号(这是传递给 ReplaceIATEntryInOneMod函数的第一个参数)。for循环负责扫描 DLL模块的名字。注意,模块的输入节中的所有字符串都是用 ANSI(决不能用Unicode)编写。 这就是为什么要显式调用 lstrcmpiA而不是lstrcmpi宏的原因。 如果该循环终止运行,但是没有找到对“ Kernel32.dll”中的任何符号的引用,那么该函数 就返回,并且仍然无所事事。如果模块的输入节确实引用了“ Kernel32.dll”中的符号,那么将 得到包含输入符号信息的 IMAGE_THUNK_DATA结构的数组的地址。然后,重复引用来自 “Kernel32.dll”的所有输入符号,寻找与符号的当前地址相匹配的地址。在我们的例子中,我 们寻找的是与ExitProcess函数的地址相匹配的地址。 如果没有与我们寻找的地址相匹配的地址,那么这个方法决不能输入需要的的符号,而 R e p l a c e I AT E n t r y I n O n e M o d 函数则返回。如果找到了一个匹配的地址,便调用 WriteProcessMemory函数,以便改变替换函数的地址。使用 WriteProcessMemory函数,而不是 InterlockedExchangePointer函数是因为 WriteProcessMemory能够改变字节,而不管这些字节拥 有什么页面保护属性。例如,如果页面拥有 PAGE_READONLY保护属性,那么 Interlocked ExchangePointer函数将会引发访问违规,而 WriteProcessMemory函数则能够处理页面保护属性 的所有变更,并且仍然能够正常运行。 从现在起,当任何线程执行 DataBase.exe模块中调用ExitProcess的代码时,就能够很容易 得到Kernel32.dll中的ExitProcess函数的实地址,并在我们想要进行通常的 ExitProcess处理时调 用它。 注意, ReplaceIATEntryInOneMod函数能够改变由单个模块中的代码进行的函数调用。但 是,另一个 DLL可能位于该地址空间中,而该 DLL也可能调用ExitProcess。如果DataBase.exe 之外的一个模块试图调用 ExitProcess,那么在调用Kernel32.dll中的ExitProcess时,它的调用将 会成功。 如果想要捕获从所有模块对 ExitProcess进行的所有调用,必须为加载到进程的地址空间中 下载 549 第 22章 插入DLL 和挂接 API计计 的每个模块进行一次对 ReplaceIATEntryInOneMod函数的调用。为此,我编写了另一个函数, 称为ReplaceIATEntryInAllMods。该函数仅仅使用 ToolHelp函数来枚举加载到进程的地址空间 中的所有模块,然后为每个模块调用 ReplaceIATEntryInOneMod,并为最后一个参数传递相应 的模块句柄。 在少数几个地方可能发生一些问题。例如,如果在调用 ReplaceIATEntryInAllMods后,线 程又调用LoadLibrary函数来加载新DLL,将会出现什么情况呢?在这种情况下,新加载的 DLL 可能调用没有挂接的 ExitProcess函数。为了解决这个问题,必须挂接 LoadLibraryA、 LoadLibraryW、LoadLibraryExA和LoadLibraryExW等函数,这样,就能够捕获这些函数的调 用,并且为新加载的模块调用 ReplaceIATEntryInOneMod。 最后一个问题与GetProcAddress有关。比如说有一个线程执行下面的代码: 这个代码让系统去获取 Kernel32.dll中的ExitProcess函数的实地址,然后调用该地址。如果 一个线程执行该代码,你的替换函数将不会被调用。为了解决这个问题,你也必须挂接 GetProcAddress函数。如果它被调用,并且准备返回一个已经挂接的函数的地址,那么你必须 返回替换函数的地址。 下一节中展示的示例应用程序显示了如何进行 API挂接,同时也解决了所有的 LoadLibrary 和GetProcAddress函数的问题。 22.9.3 LastMsgBoxInfo示例应用程序 清单22-4中列出的LastMsgBoxInfo应用程序(“22 LastMsgBoxInfo.exe”)展示了 API挂接 的方法。它挂接了对 User32.dll中包含的所有MessageBox函数的调用。若要挂接所有进程中的 该函数,该应用程序使用 Windows挂接方法进行DLL的插入操作。该应用程序和 DLL的源代码 和资源文件均位于本书所附光盘上的 22- LastMsgBoxInfo和22- LastMsgBoxInfoLib目录下。 当运行该应用程序时,将出现图 22-6所示的对话框。 图22-6 运行LastMsgBoxInfo时出现的对话框 这时,该应用程序进入等待状态。现在运行任何一个应用程序,使它显示一个消息框。为 了测试的目的,我总是运行 Notepad,输入一些文字,然后设法关闭 Notepad,但是不保存输入 的文字。这使得Notepad显示图22-7所示的消息框。 当关闭这个消息框时,LastMsgBoxInfo对话框将如图22-8所示。 图22-7 运行 Notepad 时显示的消息框 图22-8 关闭Notepad 时显示的LastMsgBoxInfo对话框 550计计第四部分 动态链接库 下载 可以看到,LastMsgBoxInfo应用程序能够知道其他进程是如何调用 MessageBox函数的。 显示和管理Last MessageBox Info对话框的代码是非常简单的。 API挂接的设置正是全部工 作的难点之所在。为了使 API的挂接操作更加容易一些,我创建了一个 CAPIHook C++类。这 个类的定义是在 APIHook.h文件中,类的实现是在 APIHook.cpp文件中。这个类的使用是很方 便的,因为它只有很少几个公有成员函数:一个构造函数,一个析构函数,还有一个返回原始 函数的地址的函数。 若要挂接一个函数,只要像下面这样创建这个类的一个实例: 注意,我必须挂接两个函数,即 MessageBoxA和MessageBoxW。User32.dll包含了这两个 函数。当MessageBoxA被调用时,我要使我的 Hook_MessageBoxA被调用。当MessageBoxW被 调用时,我要使我的 Hook_MessageBoxW函数被调用。 我的 CAPIHook类的构造函数只记住你决定要挂接的是什么 API,并调用 ReplaceIAT EntryInAllMods,以便进行实际的挂接操作。 另一个公有成员函数是析构函数。当一个 CAPIHook对象超出作用域时,析构函数就调用 ReplaceIATEntryInAllMods,将符号的地址恢复成每个模块中它的原始地址,函数不再挂接。 第三个公有成员函数返回原始函数的地址。这个成员函数通常从替换函数内部进行调用, 以便调用原始函数。下面是 Hook_MessageBoxA函数中的代码: 这个代码引用全局 g_MessageBoxA的CAPIHook对象。将这个对象转换成一个 PROC数据类 型将会导致成员函数返回 User32.dll中的原始MessageBoxA函数的地址。 如果你使用这个 C++类,那么这就是挂接和撤消挂接输入函数的全部方法。如果你观察 CAPIHook.cpp文件结尾处的代码,将会发现 C++类会自动建立CAPIHook对象的实例,以便捕 获LoadLibraryA、LoadLibraryW、LoadLibraryExA和LoadLibraryExW。这样,CAPIHook类就 能自动解决前面讲到的一些问题。 清单22-4 LastMsgBoxInfo示例应用程序 下载 551 第 22章 插入DLL 和挂接 API计计 552计计第四部分 动态链接库 下载 下载 553 第 22章 插入DLL 和挂接 API计计 554计计第四部分 动态链接库 下载 下载 555 第 22章 插入DLL 和挂接 API计计 556计计第四部分 动态链接库 下载 下载 557 第 22章 插入DLL 和挂接 API计计 558计计第四部分 动态链接库 下载 下载 559 第 22章 插入DLL 和挂接 API计计 560计计第四部分 动态链接库 下载 下载 561 第 22章 插入DLL 和挂接 API计计 562计计第四部分 动态链接库 下载 下载 563 第 22章 插入DLL 和挂接 API计计 564计计第四部分 动态链接库 下载 下载 第五部分 结构化异常处理 第23章 结束处理程序 你可以闭上眼睛,想象有这样一个编程环境,在这个环境里,你编写的代码永远不会出错。 总有足够的内存,没有人会传递给你一个无效的指针,你需要的文件总是存在。如果按照这种 假想,编写程序不是很愉快的事情吗?那样的话,程序会非常容易编写、阅读和理解。我们不 会再让每个程序中通篇的 if语句和goto语句搞得昏头昏脑,只需从头到尾书写代码就是了。 如果说这种直接的编程环境只是一个美妙的梦想的话,那么结构化异常处理( SEH)就会 给你一个现实的惊喜。使用 SEH的好处就是当你编写程序时,只需要关注程序要完成的任务。 如果在运行时发生什么错误,系统会发现并将发生的问题通知你。 利用SEH,你可以完全不用考虑代码里是不是有错误,这样就把主要的工作同错误处理分 离开来。这样的分离,可以使你集中精力处理眼前的工作,而将可能发生的错误放在后面处 理。 微软在 Windows 中引入 SEH的主要动机是为了便于操作系统本身的开发。操作系统的开发 人员使用SEH,使得系统更加强壮。我们也可以使用 SEH,使我们的自己的程序更加强壮。 使用 S E H 所 造 成 的 负 担 主 要 由 编 译 程 序 来 承 担 , 而 不 是 由 操 作 系 统 承 担 。 当 异 常 块 (exception block)出现时,编译程序要生成特殊的代码。编译程序必须产生一些表( table)来 支持处理 SEH的数据结构。编译程序还必须提供回调( callback)函数,操作系统可以调用这 些函数,保证异常块被处理。编译程序还要负责准备栈结构和其他内部信息,供操作系统使用 和参考。在编译程序中增加 SEH支持不是一件容易的事。不同的编译程序厂商会以不同的方式 实现SEH,这一点并不让人感到奇怪。幸亏我们可以不必考虑编译程序的实现细节,而只使用 编译程序的 SEH功能。 由于各编译程序的实现上存在着差别,这样以特定的方式用特定的代码例子讨论 SEH的优 点就很困难。但大多数编译程序厂商都采用微软建议的文法。本书中的例子使用的文法和关键 字可能与其他一些公司编译程序所使用的不同,但主要的 SEH概念是一样的。本章使用 Microsoft Visual C++编译程序的文法。 注意 不要将结构化异常处理同 C++的异常处理相混淆。 C++异常处理是一种不同形 式的异常处理,其形式是使用 C++关键字catch和throw。微软的Visual C++也支持C++ 的异常处理,并且在内部实现上利用了已经引入到编译程序和 Windows操作系统的结 构化异常处理的功能。 SEH实际包含两个主要功能:结束处理( termination handling)和异常处理( exception handling)。本章讨论结束处理,下一章讨论异常处理。 一个结束处理程序能够确保去调用和执行一个代码块(结束处理程序,termination handler), 而不管另外一段代码(保护体, guarded body)是如何退出的。结束处理程序的文法结构(使 用微软的Visual C++编译程序)如下: 566计计第五部分 结构化异常处理 下载 --try和--finally关键字用来标出结束处理程序两段代码的轮廓。在上面的代码段中,操作系 统和编译程序共同来确保结束处理程序中的 --finally代码块能够被执行,不管保护体( try块) 是如何退出的。不论你在保护体中使用 return,还是 goto,或者是 longjump,结束处理程序 (finally块)都将被调用。下面将通过几个例子来说明这一点。 23.1 通过例子理解结束处理程序 由于在使用 SEH时,编译程序和操作系统直接参与了程序代码的执行,为了解释 SEH如何 工作,最好的办法就是考察源代码例子,讨论例子中语句执行的次序。 因此,在下面几节给出不同的源代码片段,对每一个片段解释编译程序和操作系统如何改 变代码的执行次序。 23.2 Funcenstein1 为了甄别使用结束处理程序的各种情况,我们来考察更具体的代码例子。 上面程序中加了标号的注释指出了代码执行的次序。在 Funcenstein1中,使用try-finally块 并没有带来很多好处。代码要等待信标( semaphore),改变保护数据的内容,保存局部变量 dwTemp的新值,释放信标,将新值返回给调用程序。 23.3 Funcenstein2 现在我们把这个程序稍微改动一下,看会发生什么事情。 下载 567 第 23章 结束处理程序计计 在Funcenstein2中,try块的末尾增加了一个 return语句。这个return语句告诉编译程序在这 里要退出这个函数并返回 dwTemp变量的内容,现在这个变量的值是 5。但是,如果这个 return 语句被执行,该线程将不会释放信标,其他线程也就不能再获得对信标的控制。可以想象,这 样的执行次序会产生很大的问题,那些等待信标的线程可能永远不会恢复执行。 通过使用结束处理程序,可以避免 return语句的过早执行。当 return语句试图退出 try块时, 编译程序要确保finally块中的代码首先被执行。要保证 finally块中的代码在try块中的return语句 退出之前执行。 Funcenstein2中,将对 ReleaseSemaphore的调用放在结束处理程序块中,保证 信标总会被释放。这样就不会造成一个线程一直占有信标,否则将意味着所有其他等待信标的 线程永远不会被分配 CPU时间。 在finally块中的代码执行之后,函数实际上就返回。任何出现在 finally块之下的代码将不 再执行,因为函数已在try块中返回。所以这个函数的返回值是 5,而不是9。 读者可能要问编译程序是如何保证在try块可以退出之前执行finally块的。当编译程序检查源代 码时,它看到在try块中有return语句。这样,编译程序就生成代码将返回值(本例中是5)保存在一 个编译程序建立的临时变量中。编译程序然后再生成代码来执行finally块中包含的指令,这称为局 部展开。更特殊的情况是,由于try块中存在过早退出的代码,从而产生局部展开,导致系统执行 finally块中的内容。在finally块中的指令执行之后,编译程序临时变量的值被取出并从函数中返回。 可以看到,要完成这些事情,编译程序必须生成附加的代码,系统要执行额外的工作。在 不同的CPU上,结束处理所需要的步骤也不同。例如,在 Alpha处理器上,必须执行几百个甚 至几千个CPU指令来捕捉try块中的过早返回并调用 finally块。在编写代码时,就应该避免引起 结束处理程序的 try块中的过早退出,因为程序的性能会受到影响。本章后面,将讨论 __leave 关键字,它有助于避免编写引起局部展开的代码。 设计异常处理的目的是用来捕捉异常的—不常发生的语法规则的异常情况(在我们的例 子中,就是过早返回)。如果情况是正常的,明确地检查这些情况,比起依赖操作系统和编译 568计计第五部分 结构化异常处理 下载 程序的 SEH功能来捕捉常见的事情要更有效。 注意当控制流自然地离开 try块并进入finally块(就像在Funcenstein1中)时,进入finally块 的系统开销是最小的。在 x86 CPU上使用微软的编译程序,当执行离开 try 块进入finally块时, 只有一个机器指令被执行,读者可以在自己的程序中注意到这种系统开销。当编译程序要生成 额外的代码,系统要执行额外的工作时(如同在 Funcenstein2中),系统开销就很值得注意了。 23.4 Funcenstein3 现在我们对函数再做修改,看会出现什么情况: 在Funcenstein3中,当编译程序看到 try块中的goto语句,它首先生成一个局部展开来执行 finally块中的内容。这一次,在 finally块中的代码执行之后,在 ReturnValue标号之后的代码将 执行,因为在 try块和finally块中都没有返回发生。这里的代码使函数返回 5。而且,由于中断 了从try块到finally块的自然流程,可能要蒙受很大的性能损失(取决于运行程序的 CPU)。 23.5 Funcfurter1 现在我们来考察另外的情况,这里可以真正显示结束处理的价值。看下面的函数: 下载 569 第 23章 结束处理程序计计 现在假想一下, try块中的 Funcinator函数调用包含一个错误,会引起一个无效内存访问。 如果没有SEH,在这种情况下,将会给用户显示一个很常见的 Application Error对话框。当用户 忽略这个错误对话框,该进程就结束了。当这个进程结束(由于一个无效内存访问),信标仍 将被占用并且永远不会被释放,这时候,任何等待信标的其他进程中的线程将不会被分配 CPU 时间。但若将对 ReleaseSemaphore的调用放在 finally块中,就可以保证信标获得释放,即使某 些其他函数会引起内存访问错误。 如果结束处理程序足够强,能够捕捉由于无效内存访问而结束的进程,我们就可以相信它 也能够捕捉setjump和longjump的结合,还有那些简单语句如 break和continue。 23.6 突击测验:FuncaDoodleDoo 现在做一个测试,读者判断一下下面的函数返回什么值? 我们一步一步地分析函数做了什么。首先 dwTemp被设置成0。try块中的代码执行,但两个 if语句的值都不为 TRUE。执行自然移到 finally块中的代码,在这里 dwTemp增加到 1。然后 finally块之后的指令又增加dwTemp,使它的值成为2。 570计计第五部分 结构化异常处理 下载 当循环继续,dwTemp为2,try块中的continue语句将被执行。如果没有结束处理程序在从 try块中退出之前强制执行 finally块,执行就立即跳回 while测试,dwTemp不会被改变,将出现 无限(死)循环。利用一个结束处理程序,系统知道 continue语句要引起控制流过早退出 try块, 而将执行移到finally块。在finally块中,dwTemp被增加到3。但finally块之后的代码不执行,因 为控制流又返回到 continue,再到循环的开头。 现在我们处理循环的第三次重复。这一次,第一个 if语句的值是 FALSE,但第二个语句的 值是TRUE。系统又能够捕捉要跳出 try块的企图,并先执行 finally块中的代码。现在dwTemp增 加到4。由于break语句被执行,循环之后程序部分的控制恢复。这样, finally块之后的循环中 的代码没有执行。循环下面的代码对 dwTemp增加10,这时dwTemp的值是14,这就是调用这个 函数的结果。当然,实际上我们不会写出 FuncaDoodleDoo这样的代