Windows2000 服务器端应用程序开发设计指南-设备I/O及线程间通讯

 2. 设备I/O及线程间通讯

我不会特别的强调这一章的重要性,本章包含二个当您在实作高效能、可伸缩性应用程序时的基本主题:设备I/O及线程间通讯。一个具有可伸缩性的应用程序在处理大量同时发生的操作时,就像在处理少量同时发生的操作一样有效率。对一个服务应用程序来说,这些操作处理在不可预知的时间到达且需要无法预测之处理能力总数的客户端请求。这些请求通常从如网路介面卡的I/O设备而来;频繁地处理这些请求需要如磁盘文件般的额外I/O设备。

在Microsoft Windows应用程序中,线程是帮助您划分工作的最佳可用工具。每个线程被分派给一个允许多重处理器同时执行多重操作的处理行程,以增加其总处理能力。当一个线程因设备I/O请求而产生时,线程会暂时停止执行,直到该设备完成I/O请求为止。因为线程无法执行初始化另一个客户端处理请求之类的工作,所以这个悬置状态会造成效能的损失。总之,您需要让您的线程一直执行着有帮助的工作。

为了让线程保持忙碌的状态,您必须使线程与其所执行的另一个相关操作互相通讯。Microsoft花了多年的时间在研究和测试这个领域,且开发了一个协调良好的机制来建立此通讯。这个机制称作I/O完成端口(I/O completion port),它能帮助您建立高效能、可调节性的应用程序。使用I/O完成端口,您可以不需等待设备的回应就读写设备,使得应用程序的线程达到惊人的总处理能力。

I/O完成端口起初被设计用在处理设备I/O的部份,但经过数年以后,Microsoft已经设计了一套越来越适合I/O完成连接模组的作业系统。一个例子是在Microsoft Windows 2000中采用了新的工作核心物件:像工作物件监视它的行程一样,它会传送事件通知给I/O完成端口。在《Programming Applications for Microsoft Windows, Fourth Edition》(Jeffrey Richter, Microsoft Press, 1999)中可以找到JobLab范例应用程序,它说明了I/O完成端口和工作物件如何一起工作的方式。

身为一个Windows开发人员,基于多年的经验,我发现越来越多人开始使用I/O完成端口,且觉得每位Windows开发人员都必须完全地了解I/O完成连接的工作方式。本书中有许多范例应用程序皆使用了I/O完成端口。虽然我在这章提出了关于设备I/O之I/O完成端口,但我察觉到I/O完成端口并不一定要与设备I/O一起使用-简单地说,它是一个无限制使用线程间通讯的可怕机制。

由于以上的夸耀,您或许会说我是I/O完成端口的狂热爱好者。希望在这章节结束时,您也会和我一样。跳过I/O完成端口的细节部份。我将说明Windows当初在线程间通讯和设备I/O的部份提供了什么给开发人员,这将使您更正确的评价I/O完成端口。本章的最后部分〈I/O完成端口〉将讨论I/O完成端口的内容。

启动与关闭设备
 

Windows的其中一个长处就是它支援所有的设备。以这个讨论为背景,我替设备所下的定义是任何允许通讯的东西。表2-1列出了一些设备及它们最常见的使用方式:

 表2-1 各种设备和其常见的使用方式
设备 最常见的使用方式
文件持久储存多变的资料。
目录属性和文件压缩设定。
逻辑磁盘驱动程序格式化的驱动程序。
实体磁盘驱动程序存取分割表格。
序列埠经由电话线传送资料。
串列埠将资料传送到打印机。
Mailslot一对多传送资料,通常经由网路连接到执行Windows的机器上。
命名管道(Named pipe)一对一传送资料,通常经由网路连接到执行Windows的机器上。
匿名管道(Anonymous pipe)在单机上一对一传送资料(不经由网路)。
Socket套接字资料包或资料流传送资料,通常经由一个网路到任何一台支援套接字的机器(机器不需要执行Windows)上。
控制台一个文字视窗萤幕缓冲器。

本章讨论在没有等待设备回应的情况下,应用程序的线程如何和它们通讯。Windows试着尽可能对软件开发人员隐藏这些设备的差异部份,也就是一旦您启动了一个设备,不管您正在和什么设备通讯,对您来说,允许您读写资料到设备的Windows函数都是相同的。例如,为序列埠指定传输速率是有意义的,但是当您使用命名管道通过网路(或透过本地机器)来通讯时,传输速率就没有意义了。设备彼此间有巧妙的不同处,我不会提出所有的细微差别,不过,会花些时间在文件上,因为它比较常见。

为了要执行任何类型的I/O,您首先必须启动所需的设备且获得对它的handle。获得设备handle的方法依据特定的设备而有所不同。表2-2列出各种设备和启动它们所应该呼叫的函数。

 表2-2 开始各种设备的函数
设备 启动设备所使用的函数
文件CreateFile(pszName是路径名称或UNC路径名称)。
目录CreateFile(pszName是目录名称或UNC目录名称)。假如您在呼叫CreateFile时指定了FILE_FLAG_BACKUP_SEMANTICS标记,Windows 2000将允许您去开启一个目录,并允许您改变目录的属性(标准、隐藏等)及时间戳记(time stamp)。
逻辑磁盘设备CreateFile(pszName是「//./x:」)。假如您您以「//./x:」的形式指定一个字串,x即是设备代号,Windows 2000允许您启动一个逻辑设备。例如,您指定「//./A:」来启动设备A。启动设备允许您格式化驱动程序或决定该设备的适当大小。
实体磁盘设备CreateFile(pszName是「//./PHYSICALDRIVEx」)。假如您以「//./PHYSICALDRIVEx」的形式指定一个字串,x即是代表实体设备编号,Windows 2000允许您启动一个实体设备。例如,您指定「//./PHYSICALDRIVE0」以读写使用者的第一个实体硬盘之实体区段。启动实体设备允许您直接存取硬盘的资料分割资料表。启动实体设备可能是危险的;对设备不正确的写入可能会使磁盘的内容无法经由作业系统的文件系统存取。
序列埠CreateFile(pszName是「COMx」)。
串列埠CreateFile(pszName是「LPTx」)。
Mailslot伺服端CreateMailslot(pszName是「//./mailslot/mailslotname」)。
Mailslot客户端CreateFile(pszName是「//servername/mailslot/mailslotname」)。
命名管道(Named pipe) 伺服端CreateNamedPipe(pszName是「//./pipe/pipename」)。
命名管道(Named pipe)客户端CreateFile(pszName是「//servername/pipe/pipename」)。
匿名管道(Anonymous pipe)CreatePipe客户端和伺服端。
套接字socketsocket,接受或AcceptEx。
控制台CreateConsoleScreenBuffe或GetStdHandle。

每个在表2-2中的函数皆会回传一个识别设备的handle。您可以传递handle到与设备通讯的各种函数中。例如,您呼叫SetCommConfig以设定序列埠的传输速率:

BOOL SetCommConfig(
HANDLE hCommDev,
LPCOMMCONFIG pCC,
DWORD dwSize);

并且当等待读取资料时,使用SetMailslotInfo设定逾时值:

BOOL SetMailslotInfo(
HANDLE hMailslot,
DWORD dwReadTimeout);

如您所见,这些函数需要使用设备的handle来当作它们的第一个参数。

当您要停止使用设备时,您必须关闭它。对大部分的设备来说,您可以经由呼叫一个很受欢迎的CloseHandle函数来关闭设备:

BOOL CloseHandle (HANDLE hObject);

然而,假如这个设备是一个套接字,您就必须呼叫closesocket来替代CloseHandle:

int closesocket (SOCKET s);

如果您有某个设备的handle,则您可以经由呼叫GetFileType来找出该设备的类型:

DWORD GetFileType (HANDLE hDevice);

所有您该做的事即是传递设备的handle到GetFileType函数中,该函数会传回表2-3所列出的其中一个值。

 表2-3 经由GetFileType函数的回传值
描述
FILE_TYPE_UNKNOWN不知道所指定文件的类型。
FILE_TYPE_DISK所指定的文件是磁盘文件。
FILE_TYPE_CHAR所指定的文件是字元文件,通常是一个LPT设备或控制台。
FILE_TYPE_PIPE所指定的文件不是具名就是匿名的管道。

细看CreateFile
 

当然,CreateFile函数会建立和开始磁盘文件,但不要让这个名字愚弄您-它同样可以开始很多其他的设备:

HANDLE CreateFile( PCTSTR pszName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
PSECURITY_ATTRIBUTES psa,
DWORD dwCreationDistribution,
DWORD dwFlagsAndAttrs,
HANDLE hfileTemplate);

如您所见,CreateFile需要相当多参数,当要启动一个设备时需要考虑到许多弹性。关于这点,我将详细地讨论这些参数。

当您呼叫CreateFile,pszName参数代表设备的类型和特定实体。

dwDesiredAccess参数具体指定您如何从设备来回地传送资料。您可以传送表2-4中叙述的四个可能的值。

 表2-4 可以传递给CreateFile的dwDesiredAccess参数值
意义
0您不想在设备中读写资料。当您只想改变设备的安装设定时,即传递0-例如,您只想改变文件的时间戳记的情形。
GENERIC_READ允许对设备做唯读存取。
GENERIC_WRITE允许只能写入到设备。举例来说,这个值可被用来传送资料到打印机和经由备份软件传送资料。注意,GENERIC_WRITE并不意味着GENERIC_READ。
GENERIC_READ | GENERIC_WRITE允许对设备的读写存取。这个值是最常见的,因为它允许自由的交换资料。

dwShareMode参数指定分享设备的权利。它可能是单一设备在同一时间内能被数台电脑(在一个网路环境里)或是数个行程存取(在一个多线程环境里)的情形。设备分享可能意味着您必须考虑到是否应该且如何限定其他电脑或行程来存取设备的资料。表2-5叙述可以传递给dwShareMode参数的值。

 表2-5 可以被传递给CreateFile之dwShareMode参数的相关I/O值
意义
0要求没有其他的行程正在读写设备。假如另一个行程已经启动了这个设备,则您在呼叫CreateFile时会失败。假如您已经成功地启动了这个设备,则另一个行程对CreateFile的呼叫会一直失败。
FILE_SHARE_READ要求没有其他行程正在写入设备。假如另一个行程已经以写入或独占存取的方式启动了这个设备时,则您对CreateFile的呼叫失败。假如您已成功地启动了这个设备,而另一个行程要求以GENERIC_WRITE存取该设备,则呼叫CreateFile会失败。
FILE_SHARE_WRITE要求没有其他行程正在读取设备。假如另一个行程已经以读取或独占存取的方式启动了这个设备时,则您对CreateFile的呼叫会失败。假如您已成功地启动了这个设备,而另一个行程要求GENERIC_READ存取,则呼叫CreateFile失败。
FILE_SHARE_READ | FILE_SHARE_WRITE不管另一个形式是否正在读写设备。假如另一个行程已经以独占存取的方式启动了设备,您对CreateFile的呼叫会失败。假如 您已成功地启动设备,当要求唯一的读、写、或读/写存取时,另一个呼叫CreateFile的行程会失败。

说明

假如您开启了一个文件,其最长的路径名称是 _MAX_PATH(规定为260)的字元长度。然而,您可以经由呼叫CreateFileW(CreateFile的Unicode版本)并且可在路径名称前加上「//?/」来超越这个限制。呼叫CreateFileW可移除前缀字元,并允许您传递一个差不多32,000个Unicode字元长度的路径。然而,当您使用前缀字元时,必须记得使用完全有效的路径;系统不处理相对路径如「.」 及「..」。同样地,每个路径的个别元件仍旧限制为 _MAX_PATH字元。


psa参数指向一个SECURITY_ATTRIBUTES结构,它可允许您指定安全资讯且无论您是否喜欢,CreateFile会回传一个可被继承的handle。在这个结构里的安全描述符号只有在如NTFS的安全文件系统上建立文件时使用;在其他所有的实例中,安全描述符号是被忽略的。您通常只需传递NULL给psa参数,指示文件以预设的安全方式被建立,其回传的handle是不可继承的。

当您呼叫CreateFile用来开启与另一个设备型态相反的文件时,dwCreationDistribution参数是很有意义的。表2-6列出可传递给这个参数的值。

 表2-6 可以传递给CreateFile的dwCreationDistribution参数值
意义
CREATE_NEW命令CreateFile建立一个新的文件,假如有一个相同名称的文件已经存在,则建立失败。
CREATE_ALWAYS命令CreateFile建立一个新的文件,不管是否已有一个相同名称的文件存在。假如已经存在一个相同名称的文件,则CreateFile会覆盖现存的文件。
OPEN_EXISTING命令CreateFile开启一个已存在的文件或设备,假如该文件或设备不存在则失败。
OPEN_ALWAYS如果文件存在则命令CreateFile开启它,如果不存在则建立一个新的文件。
TRUNCATE_EXISTING命令CreateFile开启一个已存在的文件,并将它的文件大小缩短为0位元组,假如文件不存在则失败。

说明

当您呼叫CreateFile去启动一个除了文件以外的设备时,您必须传递OPEN_EXISTING给dwCreationDistribution参数。


CreateFile的dwFlagsAndAttrs有两个目的:它允许您设定标记以使与设备的通讯稳定,假如该设备是一个文件,您也能设定文件的属性。这些通讯标记大部分是告诉系统您想要如何存取设备的讯号。于是系统能有效地执行它的快取演算法来帮助您的应用程序工作得更有效率。我首先将会叙述通讯标记,接着再讨论文件属性。

CreateFile快取标记
 

 FILE_FLAG_NO_BUFFERING 这个标记指出当存取文件时,不使用任何的资料缓冲。为了增进效能,系统会来回快取磁盘资料。通常不会指定这个标记,且快取管理者保有最近存取文件系统的部分在内存中。这样一来,假如您从文件读取了几个位元组,然后又读取了更多的位元组,该文件的资料很有可能已经被载入内存,则磁盘原本要存取二次的动作现在只需一次,大大地改进了效能。然而,这个程序意味着在内存中有二份文件资料:快取管理者有缓冲器,及您所呼叫的某些函数(例如ReadFile )也从快取管理者的缓冲器中复制一些资料到您自己的缓冲器上。

当快取管理者(cache manager)正在缓冲资料时,它可能也会事先读取以使您可能要读取的下一个位元组已经存在于内存中。再者,执行速度是藉由从文件中读取比所需要还更多的位元组来改进的。假如您从不试图对文件做更进一步地读取,则有可能会浪费内存。(请参阅〈FILE_FLAG_SEQUENTIAL_SCAN和 FILE_FLAG_RANDOM_ACCESS标记〉,以获得更多有关事先读取的讨论内容)。

经由指定FILE_FLAG_NO_BUFFERING标记,您可以命令快取管理者不要缓冲任何资料-即您自己承担这个责任!依您正在执行的事,这个标记可以改进应用程序的速度和内存的使用情形。由于文件系统的设备驱动程序直接将文件的资料写入您所提供的缓冲器中,所以您必须采用某些规则:

  • 您必须要使用位移(offsets)—即磁盘容量区段大小的正确倍数—来存取文件(使用GetDiskFreeSpace函数来决定磁盘容量的区段大小)。
     
  • 您必须读/写的位元组即是正确倍数的区段大小。
     
  • 您必须确定行程位址空间中的缓冲器之所在的位址是可被区段大小所整除的。
     

 FILE_FLAG_SEQUENTIAL_SCAN及FILE_FLAG_RANDOM_ACCESS 这些标记只在您允许系统为您缓冲文件资料时有帮助。假如您指定了FILE_FLAG_NO_BUFFERING标记,则这两个标记会被忽略。

假如您指定了FILE_FLAG_SEQUENTIAL_SCAN标记,则系统会认为您正在连续地存取文件。当您从文件读取一些资料时,系统实际上读取的文件资料会比您要求的数量还多。这个行程会减少与硬盘碰撞的次数,且增加应用程序的速度。假如您在文件上执行任何的直接搜寻动作,系统已经花费了一些额外的时间和内存来快取您不存取的资料。这是绝对可以的,但是假如您常这样做,最好能指定FILE_FLAG_RANDOM_ACCESS标记。这个标记会告诉系统不要预先读取文件资料。

为了管理文件,快取管理者必须为文件维持某些内部的资料结构-越大的文件越需要资料结构。当与非常大的文件一起工作时,快取管理者可能会无法分配它所需要的内部资料结构,且开启文件的动作将会失败。为了要存取非常大的文件,您必须使用FILE_FLAG_NO_BUFFERING标记来开启文件。

 FILE_FLAG_WRITE_THROUGH 这是与快取相关的最后一个标记。它会使文件写入的中间快取操作失效以减少资料流失的可能性。

当您指定这个标记时,系统会直接将所有修改过的文件写入磁盘。然而,系统仍然维持内部快取的文件资料,并且以在文件读取操作时使用快取的资料(假如可得到)取代直接从磁盘读取资料的方式。当这个标记在网路服务器上被用来开启一个文件时,Windows文件写入函数不会返回呼叫的线程,直到资料被写入服务器的磁盘为止。

这就是与缓冲器相关的通讯标记。现在,让我们来讨论剩馀的通讯标记。

各种的CreateFile标记
 

 FILE_FLAG_DELETE_ON_CLOSE 使用这个标记来命令文件系统在所有使用它的handles关闭后删除文件。这个标记最常与FILE_ATTRIBUTE_TEMPORARY属性一起使用。当这二个标记一起使用时,您的应用程序可以建立一个暂存档,对它写入、读取然后关闭它。当文件被关闭后,系统会自动地删除文件-多么方便啊!

 FILE_FLAG_BACKUP_SEMANTICS 使用这个标记来备份和回存软件。在开启或建立任何文件前,系统通常会执行安全检查以确保试图开启或建立文件的行程拥有存取权。然而,备份和回存软件是特殊的,它可能会覆盖某些文件的安全检查。当您指定FILE_FLAG_BACKUP_SEMANTICS标记时,系统会检查呼叫者的存取权杖,看看备份/回存文件及目录的权限是否有效。假如有适当的权限,系统将会允许该文件被开启。您也可以使用FILE_FLAG_BACKUP_SEMANTICS标记来开启一个指向目录的handle。

 FILE_FLAG_POSIX_SEMANTICS 在Windows中,文件名称是保留大小写的,反之,文件名称的搜寻则是不分大小写的。然而,POSIX子系统要求对文件名称搜寻还是有大小写之分的。当您建立或开启一个文件时,FILE_FLAG_ POSIX_SEMANTICS标记会让CreateFile使用有大小写之分的文件名称搜寻方式。使用FILE_FLAG_POSIX_ SEMANTICS标记要非常的小心,一旦您在建立文件时使用它,则该文件有可能会发生无法被Windows应用程序存取的情形。

 FILE_FLAG_OPEN_REPARSE_POINT 依我的看法,这个标记应该有呼叫FILE_FLAG_IGNORE_REPARSE_POINT,因为它命令系统忽略文件的重新分析属性(假如它存在)。重新分析属性允许文件系统过滤器可以做修改开启、读取、写入和关闭文件的行为。通常修改的行为都会被要求使用,所以不建议使用FILE_FLAG_OPEN_REPARSE_POINT标记。

 FILE_FLAG_OPEN_NO_RECALL 这个标记命令系统不要从离线的储存设备(例如磁带)回存文件的内容到线上储存设备(例如硬盘)上。当文件并没有被长时间的存取时,系统便可以转换文件的内容到离线的储存设备,以释放硬盘空间。当系统这样做时,硬盘上的文件并不会被毁坏;只有文件中的资料会被毁坏。当文件被开启时,系统会自动地从离线储存设备回存资料。FILE_FLAG_OPEN_NO_RECALL标记指示系统不要回存资料及使离线储存设备媒体的I/O操作被执行。

 FILE_FLAG_OVERLAPPED 这个标记告诉系统您想要使用非同步的方式存取一个设备。您会注意到预设开启设备的方式是使用同步I/O(未指定FILE_FLAG_OVERLAPPED),大部分的开发人员习惯使用同步I/O。当您从文件读取资料时,您的线程会被延迟,并等待被读取的资讯。一旦资讯被读取,线程便会收回控制权并继续执行。与大部分其他的操作比较时,由于设备I/O是迟缓的,您可能想考虑与一些设备使用非同步的通讯方式。它是这样运作的:基本上,您呼叫一个函数去命令作业系统读取或写入资料,除了等待I/O的完成以外,您的呼叫会立即回传且作业系统会代表您使用它自己的线程来完成I/O。当操作系统已经完成了您的I/O要求,您便会收到通知。非同步I/O是建立高效能服务应用程序的关键。Windows提供了几个非同步I/O的不同方法,它们都会在这章被讨论到。

文件属性标记
 

现在是为CreateFile's dwFlagsAndAttrs参数检查属性标记的时候了,如表2-7所述,这些标记完全地被系统忽视,除非您建立一个新文件且传递NULL值给CreateFile的hfileTemplate参数。大部分的属性应该是您所熟悉的。

 表2-7 可以传递给CreateFile之dwFlagsAndAttrs参数的文件属性标记
标记 意义
FILE_ATTRIBUTE_ARCHIVE一个文件档。应用程序使用这个标记来标记文件,以备份或移除。当CreateFile建立一个新文件时,会自动设定此标记。
FILE_ATTRIBUTE_ENCRYPTED一个被加密的文件。
FILE_ATTRIBUTE_HIDDEN被隐藏的文件。它不会被包括在一般的的目录清单。
FILE_ATTRIBUTE_NORMAL文件没有其他的属性设定。这个属性只在它单独被使用时有效。
FILE_ATTRIBUTE_NOT_CONTENT_INDEXED文件不会被内容索引服务索引。
FILE_ATTRIBUTE_OFFLINE文件存在,但它的资料已经被移到离线的储存设备上。这个标记对阶层储存系统是有帮助的。
FILE_ATTRIBUTE_READONLY文件是唯读的。应用程序可以读文件但不能写入或删除它。
FILE_ATTRIBUTE_SYSTEM文件是操作系统的一部份或是操作系统专用的文件。
FILE_ATTRIBUTE_TEMPORARY文件资料只能在短时间内被使用。文件系统会试图在RAM而不是磁盘中保存文件的资料,以维持最小的存取时间。

假如您要建立一个暂存档,可以使用FILE_ATTRIBUTE_TEMPORARY。当CreateFile建立了一个拥有暂存属性的文件时,CreateFile会试图在内存中保有文件的资料以代替磁盘,这样会使存取文件内容的速度更快。假如您持续对文件写入且系统不再于RAM中保有资料,则操作系统将会被迫开始将资料写入硬盘。您可以经由结合FILE_ATTRIBUTE_TEMPORARY标记和FILE_FLAG_DELETE_ ON_CLOSE标记(先前所讨论的)来增进系统的效能。当文件被关闭时,系统通常会注入文件的快取资料。然而,假如它在关闭时文件被删除,系统就不需要注入文件的快取资料。

除了这些通讯和属性标记外,当开启一个命名管道(Named-Pipe)设备时,有一些标记允许您去控制服务的安全等级。由于这些标记为命名管道所独有,所以我不在这里讨论它们。若您想学习它们,请阅读Platform SDK说明文件中有关CreateFile函数的部份。

CreateFile的最后一个参数hfileTemplate表示为被开启文件的handle或是NULL。假如hfileTemplate确认一个文件之handle,CreateFile完全忽略参数中的属性标记,并且使用与经由hfileTemplate指示之文件相关属性。由hfileTemplate所指示的文件必须已经被该工作之GENERIC_READ标记开启。假如CreateFile开启了一个已存在的文件(相对于建立一个新文件),则hfileTemplate参数被忽略。

如果CreateFile成功地建立或开启了一个文件或设备,该文件或设备的handle会被回传。假如失败,则INVALID_HANDLE_VALUE被回传。


说明

当函数执行失败时,大部分的Windows函数会传回一个NULL的handle。然而,CreateFile则传回INVALID_HANDLE_VALUE(定义为 -1)来取代。我常看到如下的错误程序代码:

HANDLE hfile = CreateFile(...);If (hfile == NULL)
// 我们永远不会进入这里
} else {
// 文件不一定会建立成功
}...

以下是检查一个无效文件handle之正确方式:

HANDLE hfile = CreateFile(...);
if (hfile == INVALID_HANDLE_VALUE) // 文件不会建立
} else {
// 文件建立成功
}...

与文件设备一起工作
 

与文件一起工作是很常见的,所以我想花些时间来说明文件设备之特别应用的议题。本节会说明如何放置文件指标及改变文件大小的方法。

第一个您必须知道的议题是Windows被设计来与非常大的文件一起运作。新的Microsoft设计者选择使用64位元值来代替以32位元值表示文件大小的方式,这表示理论上一个文件可以达到16 EB(exabytes)。

在32位元作业系统中处理64位元的值会使得在与文件一起工作时感到有点讨厌,因为有许多Windows函数皆要求您将64位元值分割成二个32位元值传递。不过如您即将看到的一般,在标准的正常操作时,这些工作并不会很难,您可能不会需要与一个大于4 GB的文件一起工作。这意味着在64位元文件大小中,其高32位元的内含值皆会是0。

获得文件的大小
 

当与文件一起工作时,您时常会需要取得文件的大小。最容易的方式是呼叫GetFileSizeEx:

BOOL GetFileSizeEx( HANDLE hfile,
PLARGE_INTEGER pliFileSize);

第一个hfile参数是一个已开启文件的handle,而pliFileSize参数则是LARGE_INTEGER等位(union)的位址。这个等位允许参考一个有号的64位元值,如同二个32位元值或是单一的64位元值一般,而且在与文件大小及位移(offset)一起工作时,它可以相当方便。以下就是基本等位看起来的样子:

typedef union _LARGE_INTEGER { struct {
DWORD LowPart; //Low 32-bit unsigned value
LONG HighPart; //High 32-bit signed value
};
LONGLONG QuadPart; //Full 64-bit signed value
} LARGE_INTEGER, *PLARGE_INTEGER;

除了LARGE_INTEGER之外,有一个ULARGE_INTEGER结构代表一个无号的64位元值:

typedef union _ULARGE_INTEGER { struct {
  DWORD LowPart; // 低32位元之无号值
  DWORD HighPart; //高32位元之无号值
};
ULONGLONG QuadPart; // 完整的64位元无号值
} ULARGE_INTEGER, *PULARGE_INTEGER;

另一个能获得文件大小的有用函数是GetCompressedFileSize:

DWORD GetCompressedFileSize( PCTSTR pszFileName,
PDWORD pdwFileSizeHigh);

这个函数会回传文件的实际大小值;反之,GetFileSizeEx则会回传文件的逻辑大小。例如,一个100 KB的文件可被压缩为85 KB的大小。呼叫GetFileSizeEx以回传文件之逻辑大小-即100 KB-反之,GetCompressedFileSize会回传磁盘上被文件占用的实际位元组数量-即85 KB。

不像GetFileSizeEx一般,GetCompressedFileSize会将文件名称以字串的方式传递给第一个参数,而非使用handle。GetCompressedFileSize函数会以特别的方式回传文件大小之64个位元:文件大小之低32位元式函数回传值。文件大小之高32位元被放置在DWORD内,由pdwFileSizeHigh参数表示。此时就会用到ULARGE_INTEGER结构:

ULARGE_INTEGER ulFileSize; ulFileSize.LowPart =GetCompressedFileSize("SomeFile.dat",
&ulFileSize.HighPart);// 64位元文件大小现在位于ulFileSize.QuadPart内

适当的放置文件指标
 

呼叫CreateFile会使系统在管理文件的操作中建立一个文件核心物件。在这个核心物件内部即是一个文件指标。此文件指标指出在文件中下一个将被执行的同步读取或写入之64位元位移(offset)。一开始这个文件指标会被设定为0,所以若是在您呼叫CreateFile后立即呼叫ReadFile,您会从offset 0开始读取文件。假如您读取文件中的10个位元组到内存中,则系统会更新与文件handle有关的指标,以便下一次呼叫ReadFile时,从文件的第10个位元组开始读取。举例来说,以下这个程序代码从文件读取第一个10位元组到缓冲器中,接着读取下一个10位元组:

BYTE pb [10]; DWORD dwNumBytes;HANDLE hfile = CreateFile("MyFile.dat", ...); // 指标设为0

ReadFile(hfile, pb, 10, &dwNumBytes, NULL); // 读取0-9位元组
ReadFile(hfile, pb, 10, &dwNumBytes, NULL); // 读取10-19位元组

每个文件核心物件皆有自己的文件指标,所以如果开启相同的文件二次,会产生稍微不同的结果:

BYTE pb[10]; DWORD dwNumBytes;HANDLE hfile1 = CreateFile("MyFile.dat", ...); // 指标设为0
HANDLE hfile2 = CreateFile("MyFile.dat", ...); // 指标设为0
ReadFile(hfile1, pb, 10, &dwNumBytes, NULL); // 读取0-9位元组
ReadFile(hfile2, pb, 10, &dwNumBytes, NULL); // 读取0-9位元组

在这个范例中,两个不同的核心物件管理着相同的文件。由于每个核心物件皆有自己的文件指标,所以使用一个文件物件来操作文件并不会影响其他物件所维持的文件指标,该文件的第一个10位元组会被读取两次。

以下范例会使这个观念更清楚:

BYTE pb[10]; DWORD dwNumBytes;HANDLE hfile1 = CreateFile("MyFile.dat",...); // 指标设为0
HANDLE hfile2;
DuplicateHandle(
GetCurrentProcess(), hfile1,
GetCurrentProcess(), &hfile2,
0, FALSE, DUPLICATE_SAME_ACCESS);
ReadFile(hfile1, pb, 10, &dwNumBytes, NULL); // 读取0-9位元组
ReadFile(hfile2, pb, 10, &dwNumBytes, NULL); // 读取10-19位元组

在这个范例中,一个文件核心物件同时被两个文件handles参考。不管使用哪个handle来操作文件,该文件指标皆会被更新。就像第一个范例一样,每次都会读取不同的位元组。

若您需要随机存取文件,就必须改变与文件核心物件有关的文件指标,可以经由呼叫SetFilePointerEx达成。

BOOL SetFilePointerEx(
HANDLE hfile,
LARGE_INTEGER liDistanceToMove,
PLARGE_INTEGER pliNewFilePointer,
DWORD dwMoveMethod);

hfile参数指出您希望改变的文件核心物件之文件指标;liDistanceToMove参数则告诉系统您想要将指标移动多少个位元组。您所指定的数量会被加到文件指标的当前值,所以负数在文件中为向后的作用。SetFilePointerEx的最后一个参数dwMoveMethod告诉SetFilePointerEx该如何解译liDistanceToMove参数。表2-8叙述了叁个您可以经由dwMoveMethod传递的值,用来指定移动的起始点。

 表2-8 可以传递给SetFilePointerEx之dwMoveMethod参数值
意义
FILE_BEGIN文件物件之文件指标设为liDistanceToMove参数所指定的值。请注意,liDistanceToMove被视为一个无号之64位元值。
FILE_CURRENT文件物件的文件指标是加上liDistanceToMove以后的值。请注意,liDistanceToMove被视为一个有号之64位元值,允许您在文件中向后搜寻。
FILE_END文件物件的文件指标值设为逻辑文件大小加上liDistanceToMove参数值。请注意,liDistanceToMove被视为一个有号之64位元值,允许您在文件中向后搜寻。

在SetFilePointerEx更新文件物件的文件指标后,文件指标的新值会指向pliNewFilePointer参数,并在LARGE_INTEGER中被传回。如果您对新的指标值没有兴趣,可以传递NULL给pliNewFilePointer。

这里有一些关于SetFilePointerEx必须注意的事项:

  • 一个文件指标的设定超出文件当前大小的末端是无效的,这样做并不会增加磁盘上文件的大小,除非您在这个位置写入文件或呼叫SetEndOfFile。
     
  • 当SetFilePointerEx与一个具有FILE_FLAG_NO_BUFFERING标记的已开启文件一起使用时,该文件指标只能被放置在对齐磁区之边界。在本章稍后的FileCopy范例应用程序中将会示范如何正确地做这件事。
     
  • Windows没有提供GetFilePointerEx函数,但您可以使用SetFilePointerEx来得到想要的效果:
     
LARGE_INTEGER liCurrentPosition = { 0 };
SetFilePointerEx(hfile, liCurrentPosition, &liCurrentPosition,
  FILE_CURRENT);

设定文件的结尾
 

通常在关闭文件时,系统会处理设定文件的结尾;不过,有时候您可能会想要强迫一个文件变得更小或更大,在这些场合中,可以呼叫:

BOOL SetEndOfFile(HANDLE hfile);

这个SetEndOfFile函数会把文件的大小截短或延长,并转变成文件物件之文件指标所指示的大小。例如,您想要迫使一个文件变成1024位元组的长度,可用这个方式来使用SetEndOfFile:

HANDLE hfile = CreateFile(...);
LARGE_INTEGER liDistanceToMove;
liDistanceToMove.QuadPart = 1024;
SetFilePointerEx(hfile, liDistanceToMove, NULL, FILE_BEGIN);
SetEndOfFile(hfile);
CloseHandle(hfile);

执行后使用Windows Explorer来检查文件所显示的属性,文件恰好是1024位元组的长度。

执行同步设备I/O
 

本节会讨论执行同步设备I/O的Windows函数。请记住,设备可以是一个文件、mailslot、管道(pipe)、套接字(Socket)等等。无论使用了那个设备,I/O皆会被相同的函数执行。

最简单且最常用来从设备读取和写入的函数即是ReadFile及WriteFile:

BOOL ReadFile(
HANDLE hfile,
PVOID pvBuffer,
DWORD nNumBytesToRead,
PDWORD pdwNumBytes,
OVERLAPPED* pOverlapped);
BOOL WriteFile(
HANDLE hfile,
CONST VOID *pvBuffer,
DWORD nNumBytesToWrite,
PDWORD pdwNumBytes,
OVERLAPPED* pOverlapped);

hfile参数表示您所要存取设备的handle。当设备被开启后,您不需指定FILE_FLAG_OVERLAPPED标记,否则系统将认为您想要对该设备执行非同步I/O的动作。pvBuffer参数说明了哪一些设备的资料应该被缓冲器读取,或是内含应被写入设备之资料的缓冲器。nNumBytesToRead和nNumBytesToWrite参数分别指出ReadFile和WriteFile要从设备中读取多少个位元组,以及要写入多少位元组到设备上。

pdwNumByte参数会指出DWORD的位址,以使函数填满一些被顺利地传送至设备上与来自该设备的位元组。最后一个pOverlapped参数在执行同步I/O时应该为NULL。当我们讨论非同步I/O时,您将会更详细地察看这个参数的内容。

假如已经成功地执行,则ReadFile及WriteFile两者皆会传回TRUE。顺便一提,ReadFile可以只被拥有GENERIC_READ标记之已开启设备呼叫。同样地,WriteFile则可以只被具有GENERIC_WRITE标记之已开启设备呼叫。

注满资料到设备中
 

记得我们所看过的CreateFile函数,它可以传递相当多的标记以改变系统之快取文件资料的方式。有一些其他的设备,例如序列埠、mailslots及管道,也会执行快取资料动作。假如您想要迫使系统将快取资料写入设备时,可以呼叫FlushFileBuffers:

BOOL FlushFileBuffers(HANDLE hfile);

FlushFileBuffers函数会迫使所有与hfile参数指定设备有关的缓冲资料被写入。为了执行这个工作,必须开启拥有GENERIC_WRITE标记的设备。假如函数执行成功,则会传回TRUE。

基本的非同步设备I/O
 

与电脑所执行之多数其他操作比较起来,设备I/O是其中最慢且无法预料的。CPU在执行算术运算甚至是描绘出萤幕图形时,比读取或写入文件资料或存取网路还要更快。然而,使用非同步设备I/O可使您能够更适当地利用资源从而建立更有效率的应用程序。

考虑一个发布非同步I/O请求到设备上的线程。此I/O请求会被传递至设备驱动程序上,它会承担实际执行I/O动作的责任。当设备驱动程序正在等待设备回应时,应用程序的线程不会像它等待I/O请求完成一样被暂缓执行。反之,线程会继续执行其他有帮助的任务。

由另一方面来看,设备驱动程序完成了处理伫列的I/O请求,并且必须通知应用程序该资料已经被传送、已接收到资料或是发生了错误。您将在下一节〈接收完整的I/O请求通知〉中得知设备驱动程序如何通知您的I/O完成这些动作。现在,让我们全神贯注于如何要求非同步I/O的请求。要求非同步I/O请求是设计一个具有高效能、可伸缩性之应用程序的基本,这也是本章稍后所要讨论的部分。

为了要非同步地存取设备,您首先必须呼叫CreateFile来开启设备,并在dwFlagsAndAttrs参数中指定FILE_FLAG_OVERLAPPED标记,这个标记会通知系统您打算非同步地存取设备。

为设备驱动程序要求执行一个I/O请求,您可以使用在〈执行同步设备I/O〉一节中所学到的ReadFile和WriteFile函数。为了方便起见,我再次列出该函数的原型:

BOOL ReadFile(
HANDLE hfile,
PVOID pvBuffer,
DWORD nNumBytesToRead,
PDWORD pdwNumBytes,
OVERLAPPED* pOverlapped);
BOOL WriteFile(
HANDLE hfile,
CONST VOID *pvBuffer,
DWORD nNumBytesToWrite,
PDWORD pdwNumBytes,
OVERLAPPED* pOverlapped);

当这些函数的其中之一被呼叫时,该函数会检查由hfile参数指定的设备是否具有FILE_FLAG_OVERLAPPED标记且已被开启。假如标记被指定,则函数会执行非同步设备I/O。顺便一提,当您为了非同步I/O而呼叫任何一个函数时,您可以(且通常会)在pdwNumBytes参数中传递NULL。

毕竟,您预期这些函数会在I/O请求完成前传回,所以在此时检查那些已被传送的位元组数量是无意义的。

OVERLAPPED结构
 

在执行非同步的设备I/O时,您必须经由pOverlapped参数传递位址到已被初始化的OVERLAPPED结构中。「overlapped」这个字在这里意味着执行I/O请求的时间与您的线程用来执行其他任务的时间重叠。以下, , 就是OVERLAPPED结构的样子:

typedef struct _OVERLAPPED {
DWORD Internal; // [out] 错误码
DWORD InternalHigh; // [out] 转换的位元组数量
DWORD Offset; // [in] 低32位元之文件位移量
DWORD OffsetHigh; // , [in] , , 高32位元之文件位移量
HANDLE hEvent; // [in] 事件的handle或资料
} OVERLAPPED, *LPOVERLAPPED;
 

这个结构包含了五个成员。其中叁个成员—Offset、OffsetHigh及hEvent—必须在呼叫ReadFile或WriteFile前即初始化;其他两个成员—Internal及InternalHigh—当I/O操作完成时由设备驱动程序设定及检查。以下有这些成员变数之更详细说明:

  •  Offset  OffsetHigh 当某个文件被存取时,这些成员会指出文件中您想要开始进行I/O操作之64位元位移量。回想每个核心物件中有一个与它相关联的文件指标。当发布了一个同步的I/O请求时,系统会知道由文件指标所指出的位置开始存取文件。在操作完成后,系统便会自动地更新文件指标以便下一个操作能取得最后一个停止操作的位址。
     

    当执行非同步I/O时,这个文件指标会被系统忽视。您可以想像假如程序代码中被放置了两个非同步的呼叫到ReadFile(对相同的文件核心物件)时会发生什么事。在这种情况下,系统不会知道第二个对ReadFile的呼叫要从哪里开始读取。您或许不想在第一个呼叫ReadFile所使用之相同位置开始读取文件。您可能想要接在第一次呼叫ReadFile时所读取的最后一个位元组后开始第二次读取文件。为了避免对相同物件之数个非同步呼叫产生混淆,所有的非同步I/O请求必须在OVERLAPPED结构中指定开始的文件位移量。

    请注意,位移量和OffsetHigh成员不会被nonfile设备所忽视-即您必须初始化这两个成员,使其为0,否则I/O请求会失败而且GetLastError会传回ERROR_INVALID_PARAMETER。

  •  hEvent 这个成员被四个方法的其中一个用来接收I/O完成通知。当您使用可警告的I/O通知方法时,这个成员可被用来达到您的目的。我知道有许多开发人员将C++ 物件的位址储存在hEvent。(这个成员将在〈用信号通知事件核心物件〉一节中有更多的讨论)。
     
  •  Internal 这个成员持有处理I/O的错误程序代码。一旦您发布非同步的I/O请求,设备驱动程序会把Interna置放为到STATUS_PENDING,以指出没有因为未启动的操作而发生错误。事实上,定义在WinBase.h中的巨集指令HasOverlappedIoCompleted可让您检查一个非同步的I/O操作是否已经完成。假如这个请求仍旧未被决定,则传回FALSE;若这个I/O请求已经完成,则传回TRUE。以下为一个巨集指令的定义:
     
    #define HasOverlappedIoCompleted(pOverlapped) /
    ((pOverlapped)->Internal != STATUS_PENDING)
  •  InternalHigh 当一个非同步的I/O请求完成时,这个成员会持有已传送的位元组数量。
     

起初设计OVERLAPPED结构时,Microsoft即决定不提供Internal及InternalHigh成员的文件(解释它们的名称)。随着时间过去,Microsoft了解到加入这些成员的资讯对开发人员是有帮助的,所以将它们写成文件。然而,因为作业系统之原始程序代码常常会参考它们,且Microsoft又不想修改程序代码,所以没有改变成员的名称。


说明

当一个非同步的I/O请求完成时,您将会接收到OVERLAPPED结构被初始化时使用的位址之请求。使用OVERLAPPED结构四处传递相关的资讯是非常有用的-例如,您想要储存OVERLAPPED结构内用来初始I/O请求的handle。OVERLAPPED结构不为正在储存的内容提供设备handle成员或其他可能有帮助的成员,但您可以很容易地解决这个问题。

笔者常建立从OVERLAPPED结构衍生的C++ 类别。这个C++ 类别可以持有任何额外的资讯。当我的应用程序接收到OVERLAPPED结构的位址时,我只不过把位址扔给C++ 类别的指标。现在我可以使用OVERLAPPED成员及应用程序所需要的额外相关资讯。在本章结尾的FileCopy范例应用程序示范了这个技巧。有关相关细节,请参阅FileCopy范例应用程序中的CIOReq。


非同步设备I/O的警告
 

当您在执行非同步I/O时,您应意识到几个议题。首先,设备驱动程序在先进先出(FIFO)的模式中不必处理等候中的I/O请求。举例来说,假如线程正在执行以下的程序代码,则设备驱动程序很可能会先写入文件然后再从文件读取:

OVERLAPPED o1 = { 0 };
OVERLAPPED o2 = { 0 };
BYTE bBuffer[100];
ReadFile (hfile, bBuffer, 100, NULL, &o1);
WriteFile(hfile, bBuffer, 100, NULL, &o2);

假如这样做对效能有帮助的话,设备驱动程序通常会不按顺序执行I/O请求。例如,为了减少读写头移动及寻找时间,文件系统驱动程序可能会察看伫列中的I/O请求清单,以寻找与硬盘相同实体位置接近的请求。第二个您应意识到的议题是执行错误检查的适当方式。多数的Windows函数会回传FALSE以表示失败,或非零的值表示成功。然而,ReadFile及WriteFile函数却表现得有点不同。举个例子可能有助于说明。

当试图去要求一个非同步的I/O请求时,设备驱动程序可能宁愿以同步的方式处理请求。假如您正在读取文件且系统正在检查您所要的资料是否已经在系统的快取中,这是可能发生的。如果资料是可得到的,您的I/O请求不会被设备驱动程序放置到伫列中;反之,系统会从快取中复制资料到您的缓冲器且完成I/O操作。

假如以同步的方式执行被请求的I/O,则ReadFile及WriteFile会回传一个非零值。如果以非同步的方式执行所请求的I/O,或是当呼叫ReadFile或WriteFile时发生错误,则回传FALSE。一旦回传FALSE,就必须呼叫GetLastError来明确地判定发生了什么事。假如GetLastError传回ERROR_IO_PENDING,则表示I/O请求已被成功地储存在伫列中且将在稍后完成。

假如GetLastError传回除了ERROR_IO_PENDING以外的值,则该I/O请求无法被放置到设备驱动程序伫列中。以下是当I/O请求无法被放置到设备驱动程序伫列时,从GetLastError传回之最常见的错误程序代码:

  •  ERROR_INVALID_USER_BUFFER或ERROR_NOT_ENOUGH_ MEMORY 每个设备驱动程序皆会维护一个未完成之I/O请求的固定大小清单(在无页码的集区中)。假如这个清单满了,系统便无法将您的请求放置到伫列中,如此一来,ReadFile及WriteFile会传回FALSE且GetLastError会报告这两个错误程序代码的其中一个(取决于驱动程序)。
     
  •  ERROR_NOT_ENOUGH_QUOTA 某些设备会要求您锁定分页资料缓冲器的储存,因此当I/O未完成时,资料无法从RAM交换。当您使用FILE_FLAG_NO_BUFFERING标记时,这个锁定分页的储存规定对文件I/O而言是正确的。然而,系统也限制了单一行程能够锁定分页的储存数量。假如ReadFile及WriteFile不能对您的缓冲器做锁定分页的储存动作,则函数会传回FALSE,且GetLastError会报告ERROR_NOT_ENOUGH_QUOTA。您可以经由呼叫SetProcessWorkingSetSize来增加一个行程的配额。
     

您该如何处理这些错误?在根本上,这些错误会因为一些重要的I/O请求还未完成而发生,您必须完成某些悬而未决的I/O请求,然后再重新发布对ReadFile及WriteFile的呼叫。

第叁个您应意识到的议题是在I/O请求完成前,务必不能移动或毁坏该用来发布非同步I/O请求的资料缓冲器及OVERLAPPED结构。当I/O请求位于设备驱动程序的伫列时,资料缓冲器及OVERLAPPED结构的位址会被传递给驱动程序。请注意,只有位址而非实际的区块被传递。这个结果应该是非常明显的:内存的复制是非常昂贵且浪费很多CPU时间的。

当设备驱动程序准备好去处理伫列的请求时,它会转换pvBuffer位址所参考的资料,并存取文件的位移量成员及指向OVERLAPPED结构内之其他成员的pOverlapped参数。设备驱动程序对应到I/O错误程序代码更新之Internal成员及转移位元组的数量以更新InternalHigh成员。


说明

这些缓冲器不能被移动或毁坏,直到I/O请求完成或者内存内容被更改为止。并且,您必须为每个I/O请求分派及初始化一个唯一的OVERLAPPED结构。


前面所提内容非常的重要,而且它是开发人员在实作一个非同步设备I/O结构时,最常见的一个错误。以下是个什么都不做的范例:

VOID ReadData(HANDLE hfile) {
OVERLAPPED o = { 0 };
BYTE b[100];
ReadFile(hfile, b, 100, NULL, &o);
}

这个程序代码看起来相当地无害,而且完美地呼叫了ReadFile。唯一的问题是函数会在要求非同步I/O请求后返回。从函数返回时,从线程的堆叠中实际地释放了缓冲器之OVERLAPPED及结构,但是设备驱动程序并没有察觉到ReadData的返回。设备驱动程序仍旧有两个指向线程堆叠的内存位址。当I/O完成时,不管那时在内存被占用的地点发生了什么情形,设备驱动程序皆会去修改线程堆叠上的内存。因为内存非同步地发生改变,所以这个错误特别难找。有时设备驱动程序可能会同步地执行I/O,在此个案里您将不会看到这个错误。有时在函数返回后,I/O可能会正确的完成,或者可能在它完成超过一个小时后;但是谁会知道堆叠从什么时候开始被使用呢?

取消伫列中的设备I/O请求
 

有时您可能会想要在设备驱动程序处理存在伫列中的设备I/O请求前取消它。Windows提供了一些方法:

  • 您可以呼叫CancelIo,以取消一个为了特定handle而呼叫之线程的I/O请求。
     
    BOOL CancelIo(HANDLE hfile);
  • 不管是哪个线程之请求,您皆可以经由关闭设备本身的handle来取消所有伫列中之I/O请求。
     
  • 当一个线程停止时,系统会自动地取消线程所发布的所有I/O请求。
     

    如您所看到的,没有一个方法可以取消单一且特定的I/O请求。


说明

取消I/O请求完成会产生一个ERROR_OPERATION_ABORTED的错误程序代码。


接收完成的I/O请求通知
 

此时,您已知道如何要求一个非同步设备I/O请求,但还没讨论到设备驱动程序在I/O请求完成后会如何通知您。

为了接收I/O完成通知,Windows提供了四个不同的方法(表2-9中有简短的叙述),本章涵盖了所有的方法。这些方法依复杂性的高低列出,从最容易理解及实作(通知设备核心物件)到最难理解及实作(I/O完成端口)。

 表2-9 接收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完成通知方法中最容易且有效的。经由学会这四个方法,您将会得知Microsoft将I/O完成端口加到Windows的原因以及I/O通讯埠解决因其他方法而存在的所有问题之方法。

通知设备核心物件
 

一旦线程发布了非同步的I/O请求,它仍旧会执行其他有用的工作,到了最后,线程会需要与I/O操作同时完成。换言之,除非将从设备传入来的资料完全载入缓冲器中,否则您将在线程的程序代码中碰撞到某个线程无法继续执行的位置。

在Windows中,设备核心物件能被当成线程同步化物件来使用,所以该物件可以是通知或非通知状态。在伫列I/O请求之前,ReadFile及WriteFile函数会将设备核心物件设定为非通知状态。当设备驱动程序完成请求时,驱动程序则将设备核心物件设定为通知状态。一个线程可以决定某个I/O请求是否经由呼叫WaitForSingleObject或WaitForMultipleObjects完成。以下有个简单的例子:

HANDLE hfile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...); 
BYTE bBuffer[100];
OVERLAPPED o = { 0 };
o.Offset = 345;
BOOL fReadDone = ReadFile(hfile, bBuffer, 100, NULL, &o);
DWORD dwError = GetLastError();
if (!fReadDone && (dwError == ERROR_IO_PENDING)) {
// I/O被非同步地执行;等待它完成
WaitForSingleObject(hfile, INFINITE);
fReadDone = TRUE;
}
if (fReadDone) {
// o.Internal包含I/O错误
// o.InternalHigh包含转移位元组的数量
// bBuffer包含读取的资料
} else {
// 发生某个错误:请查看dwError
}

这个程序代码发布了一个非同步I/O请求,然后立即等待请求的结束,最后使得非同步I/O的目的失败!显然地,您大概从未实际地编写过类似的程序代码,但这个程序代码的确说明了即将在这里叙述的重要观念:

  • 经由使用FILE_FLAG_OVERLAPPED标记,设备必须为非同步I/O而开启。
     
  • OVERLAPPED结构必须初始化它的Offset、OffsetHigh及hEvent成员。在这个程序代码范例中,除了Offset设定为345以便从文件的第346位元组开始读取资料外,其他的设定为0。
     
  • ReadFile的传回值储存在fReadDone,以指出I/O请求是否被同步地执行。
     
  • 假如I/O请求没有被同步地执行,则要检查是否有发生错误或者I/O被非同步地执行。将GetLastError的结果与ERROR_IO_PENDING做比较会给我们这些资讯。
     
  • 为了等待资料,我呼叫WaitForSingleObject传递设备核心物件的handle。在核心物件变成通知状态前呼叫这个函数以使线程暂停。当它完成了I/O时,设备驱动程序会通知这个物件。在WaitForSingleObject返回后,I/O会完成并且将fReadDone设定为TRUE。
     
  • 在读取完成后,您可以检查bBuffer中的资料、OVERLAPPED结构之Internal成员中的错误程序代码以及OVERLAPPED结构之InternalHigh成员中的转移位元组数量。
     
  • 假如发生了真实的错误,dwError所包含的错误程序代码会带来更多资讯。
     

通知事件核心物件
 

刚才叙述之接收I/O完成通知方法是非常简单且明确的,但其结果并非完全有帮助,因为它没有把多重I/O请求处理的很好。举例来说,假设您试图于同一时间在单一的文件上实作多重之非同步操作。假定您想要同时从文件中读取10个位元组并且写入10个位元组至文件中。它的程序代码可能看起来像这个样子:

HANDLE hfile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...); 
BYTE bBuffer[10];
OVERLAPPED oRead = { 0 };
oRead.Offset = 0;
ReadFile(hfile, bBuffer, 10, NULL, &oRead);
OVERLAPPED oWrite = { 0 };
oWrite.Offset = 10;
WriteFile(hfile, "Jeff", 5, NULL, &oWrite);
...WaitForSingleObject(hfile, INFINITE);
// 我们不知道完成了什么:读取?写入?或两者都是?

您不能经由等待设备变成通知状态而使线程同时产生,因为操作一完成,物件就会变成通知的状态。假如您呼叫WaitForSingleObject并且传递设备handle给它,因为在读取、写入操作的完成或者两者皆已完成的情形下,您将没有把握函数是否会被返回。显然地,那里需要一个更好的方式去执行多重、同步之非同步I/O请求,以使您不会遇到这个困境-幸运的是,那里的确有。

OVERLAPPED结构的最后一个成员—hEvent,它识别某个事件之核心物件。您必须经由呼叫CreateEvent来建立这个事件物件。当一个非同步I/O请求完成时,设备驱动程序会检查OVERLAPPED结构的hEvent成员是否为NULL。假如hEvent不是NULL,则设备会经由呼叫SetEvent来通知这个事件。驱动程序也将设备物件设定为通知状态就如同之前所做的。然而,假如您使用事件去决定一个设备操作什么时候完成,则您不应等待设备物件变成通知状态-即以等待事件来取代。

假如您想要同时执行多重之非同步I/O请求,您必须为每个请求建立个别的事件物件,在每个请求的OVERLAPPED结构中初始化hEvent成员然后呼叫ReadFile或WriteFile。当您到达程序代码中必须与I/O请求完成同时发生的位置时,简单地呼叫WaitForMultipleObjects并传入与每个未完成I/O请求的OVERLAPPED结构有关之事件handles。由于这个方案,您可以容易且可*地同时执行多重的非同步设备I/O操作以及使用相同的设备物件。以下的程序代码说明了这个方法:

HANDLE hfile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...); 
BYTE bBuffer[10];
OVERLAPPED oRead = { 0 };
oRead.Offset = 0;
oRead.hEvent = CreateEvent(...);
ReadFile(hfile, bBuffer, 10, NULL, &oRead);
OVERLAPPED oWrite = { 0 };
oWrite.Offset = 10;
oWrite.hEvent = CreateEvent(...);
WriteFile(hfile, "Jeff", 5, NULL, &oWrite);
...HANDLE h[2];
h[0] = oRead.hEvent;
h[1] = oWrite.hEvent;
DWORD dw = WaitForMultipleObjects(2, h, FALSE, INFINITE);
switch (dw - WAIT__OBJECT_0) {
case 0: // 读取完成
  break;
case 1: // 写入完成
  break;
}

这个程序代码有点不自然且不完全是您在真实的应用程序中所要做的,但它阐明了我的论点。一个典型的真实应用程序会有一个等待I/O请求完成的回圈。当每个请求完成,线程会执行被要求的任务、伫列另一个非同步I/O请求以及循环回圈,以及等待更多的I/O请求完成。

GetOverlappedResult
 

回想当初Microsoft不为OVERLAPPED结构的Internal及InternalHigh成员提供文件,意味着它必须提供另一个能让您知道在I/O行程期间有多少位元组已被转移以及获得I/O错误程序代码的方式。为了使您可得到这个资讯,Microsoft建立了这个GetOverlappedResult函数:

BOOL GetOverlappedResult(
HANDLE hfile,
OVERLAPPED* pOverlapped,
PDWORD pdwNumBytes,
BOOL fWait);

如今Microsoft为Internal及InternalHigh成员提供了文件,因此GetOverlappedResult函数即变得不是很有帮助。然而,当我第一次学习非同步I/O时,决定将这个函数做逆向工程以使我头脑里的观念变得稳固。以下的程序代码显示出内部如何实作GetOverlappedResult之方法:

BOOL GetOverlappedResult( HANDLE hfile,
OVERLAPPED* po,
PDWORD pdwNumBytes,
BOOL fWait) {
if (po->Internal == STATUS_PENDING) {
  DWORD dwWaitRet = WAIT_TIMEOUT;
  if (fWait) {
   // 等待I/O完成
   dwWaitRet = WaitForSingleObject(
    (po->hEvent != NULL) ? po->hEvent : hfile, INFINITE);
  }
  if (dwWaitRet == WAIT_TIMEOUT) {
   // I/O未完成而且我们不可以等待
   SetLastError(ERROR_IO_INCOMPLETE);
   return(FALSE);
  }
  if (dwWaitRet != WAIT_OBJECT_0) {
   // 呼叫WaitForSingleObject错误
   return(FALSE);
  }
}
// I/O完成:传回转移位元组的数量
*pdwNumBytes = po->InternalHigh;
if (SUCCEEDED(po->Internal)) {
  return(TRUE);   // No I/O error
}
// 将最后的错误设定为I/O错误
SetLastError(po->Internal);
return(FALSE);
}

可警告的I/O
 

第叁个可以用来接收I/O完成通知的方法是呼叫可警告的I/O。首先,对想要创造高效能、可伸缩性应用程序的开发人员来说,Microsoft侦查可警告的I/O作为绝对适当的机制。但是当开发人员开始使用可警告的I/O后,他们会很快地了解到它并没有实践这个承诺。

我对可警告的I/O已经了解得很彻底,而且会是第一个跟您说可警告的I/O糟透了的人,您应该避免使用它。然而,为了使可警告的I/O能够工作,我发现Microsoft在操作系统中加进了一些非常有帮助且有价值的基础建设。当您阅读这节时,请集中精神于适当的基础建设上,并且不要陷入I/O的泥沼。

无论线程何时被建立,系统也会建立一个与线程有关的伫列。这个伫列被称为非同步程序呼叫(Asynchronous Procedure Call, APC)伫列。当发布一个I/O请求时,您可以命令设备驱动程序附加一个入口到呼叫线程的APC伫列中。为了使完成的I/O通知被伫列到线程的APC伫列,可以呼叫ReadFileEx及WriteFileEx函数:

BOOL ReadFileEx(
HANDLE hfile,
PVOID pvBuffer,
DWORD nNumBytesToRead,
OVERLAPPED* pOverlapped,
LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);
BOOL WriteFileEx(
HANDLE hfile,
CONST VOID *pvBuffer,
DWORD nNumBytesToWrite,
OVERLAPPED* pOverlapped,
LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);

就像ReadFile及WriteFile,ReadFileEx及WriteFileEx发布I/O请求到某个设备驱动程序后,函数立即返回。ReadFileEx及WriteFileEx有与ReadFile和WriteFile函数相同的参数,其中有两个例外。第一,*Ex函数没有传递指标给填满转移位元组数量的DWORD;这个资讯只能经由回呼函数而被撷取。第二,*Ex函数需要您传递回呼函数的位址,称为完成例行程序。这个例行程序必须有以下的原型:

VOID WINAPI CompletionRoutine(
DWORD dwError,
DWORD dwNumBytes,
OVERLAPPED* po);

当您与ReadFileEx及WriteFileEx一起发布非同步的I/O请求时,函数会传递这个函数的位址到设备驱动程序。当设备驱动程序完成I/O请求后,它会在发布线程的APC伫列里附加一个入口(entry)。这个入口包含了用来初始化I/O请求之完成例行程序的位址以及OVERLAPPED结构的位址。


说明

顺便一提,当某个可警告的I/O被完成时,设备驱动程序将不试图通知事件物件。事实上,设备一点也没有参考到OVERLAPPED结构的hEvent成员。因此,如果您喜欢的话,可以为您自己的目的而使用hEvent成员。


当线程为可警告的状态时,系统会检查它的APC伫列以及伫列中的每个入口,系统呼叫完成函数并传递I/O错误程序代码、转移位元组的数量以及OVERLAPPED结构的位址给它。请注意,错误程序代码及转移位元组的数量也可以在OVERLAPPED结构的Internal及InternalHigh成员中找到。(如前所述,Microsoft最初并不想为此提供文件,所以将它们当作参数传递给函数)。

我们不久将回到这里完成例行程序函数。现在让我们先看看系统如何处理非同步的I/O请求。以下的程序代码会伫列叁个不同的非同步操作:

hfile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...); 
ReadFileEx(hfile, ...); // 执行第一个ReadFileEx
WriteFileEx(hfile, ...); // 执行第一个WriteFileEx
ReadFileEx(hfile, ...); // 执行第二个ReadFileEx
SomeFunc();

假如呼叫SomeFunc占用了一些执行时间,系统会在SomeFunc返回之前完成这叁个操作。当线程正在执行SomeFunc函数时,设备驱动程序会附加完成的I/O入口到线程的APC伫列中。APC伫列可能看起来有点像这个:

第一个WriteFileEx完成
第二个ReadFileEx完成
第一个ReadFileEx完成

APC伫列由系统内部维持。您亦可从清单中注意到系统可以使用任何顺序来执行伫列中的I/O请求,所以最后发布的I/O请求可能会最先完成,反之亦然。在线程之APC伫列中的每个入口包含收回函数的位址以及被传递到这个函数的值。

当I/O请求完成时,它们只简单地被伫列到线程的APC伫列中-即收回例行程序不会立即被呼叫,因为线程可能忙于作其他事情而不能被中断。为了处理线程之APC伫列中的入口,线程必须把它自己加进可警告的状态。这意味着线程已经达到执行中的位置,在那里它可以处理中断。Windows提供五个可以将线程加进可警告状态的函数:

DWORD SleepEx(
DWORD dwTimeout,
BOOL fAlertable);
DWORD WaitForSingleObjectEx(
HANDLE hObject,
DWORD dwTimeout,
BOOL fAlertable);
DWORD WaitForMultipleObjectsEx(
DWORD cObjects,
PHANDLE phObjects,
BOOL fWaitAll,
DWORD dwTimeout,
BOOL fAlertable);
BOOL SignalObjectAndWait(
HANDLE hObjectToSignal,
HANDLE hObjectToWaitOn,
DWORD dwMilliseconds,
BOOL fAlertable);
DWORD MsgWaitForMultipleObjectsEx(
DWORD nCount,
PHANDLE pHandles,
DWORD dwMilliseconds,
DWORD dwWakeMask,
DWORD dwFlags);

前面四个函数的最后一个参数为布林值,指示呼叫线程是否应该把自己加进可警告的状态。对MsgWaitForMultipleObjectsEx,您必须使用MWMO_ALERTABLE标记,以让线程进入可警告的状态。假如您熟悉Sleep、WaitForSingleObject及WaitForMultipleObjects函数,您就不应该在学习它们时感到惊讶。在内部,这些总是传递FALSE以作为fAlertable参数之non-Ex函数以呼叫它们的*Ex副本。

当您呼叫刚才提到的五个函数中的一个,并且将线程加进可警告的状态时,系统首先会检查线程的APC伫列。假如至少有一个入口在伫列中,系统不会将线程放进睡眠状态;取代它的是,系统从APC伫列中拉出入口,而且线程呼叫收回例行程序,以传递完成I/O请求的错误程序代码、转移位元组的数量及OVERLAPPED结构的位址到例行程序中。当收回例行程序返回到系统时,系统会检查APC伫列中的更多入口。假如存在更多入口,则它们会被处理。然而,假如不再有入口存在,则可警告的函数呼叫会返回。有几件要记住的事情,假如当您呼叫这些函数中的任何一个时,只要有入口存在线程的APC伫列中,您的线程绝不会进入睡眠状态!

这些函数唯一会暂停线程的时间是当您在线程的APC伫列中没有入口时呼叫函数时。当线程被暂停时,假如您等待的核心物件(或物件)变成通知状态或是APC入口在线程的伫列中出现时,则线程将会醒来。由于线程在可警告的状态中,APC入口一出现,系统立即把您的线程叫醒并且清空伫列(经由呼叫收回例行程序)。然后函数立即返回呼叫者处-即您的线程不会回到等待核心物件变成通知状态时的睡眠状态。

从这五个函数传回的值指出它们为什么会返回的原因。假如传回值是WAIT_IO_COMPLETION,您可以知道线程正在继续执行,因为至少有一个来自线程的APC伫列的入口被处理。假如传回值是别的结果,因为睡眠期间到期所以线程是醒着的;指定的核心物件或物件会变成通知状态。

可警告I/O之利与弊
 

我们已经讨论过执行可警告I/O的机制。现在,您必须知道有两个因为实行设备I/O而使可警告I/O方法变得很糟的相关议题。

  •  回呼函数 为了建立回呼函数而发出可警告I/O请求将使您在实作程序代码变得更加困难。在特殊问题方面,这些回呼函数通常不会有足够资讯指引您,因此总有一天您会将许多资讯放置到全域变数中。幸运的是,这些全域变数并不需要被同步,因为呼叫其中一个可警告函数的线程和执行回呼函数的线程是相同的。单一的线程不能于同一时间存在两个位置中,所以变数是安全的。
     
  •  线程的发布 可警告I/O的真正问题是:发布I/O请求的线程也必须处理完成通知。假如某个线程发布了几个请求,即使它已经进入完全闲置的状态也必须回答每个请求的完成通知。由于没有载入平衡,所以应用程序并没有调适的很好。
     

这两个问题是相当严重的,因此我强烈地劝阻因为设备I/O而使用可警告I/O的做法。相信您正在想下一节所讨论的I/O完成端口机制能解决我刚才所讨论的这两个问题,但在进行到I/O完成端口之前,我会先说明一些有关可警告I/O之基础建设的不错元素。

Windows提供了可让您手动将一个入口放入线程APC伫列的函数:

DWORD QueueUserAPC(
PAPCFUNC pfnAPC,
HANDLE hThread,
ULONG_PTR dwData);

第一个参数是指向APC函数的指标,必须有下列的原型:

VOID WINAPI APCFunc(ULONG_PTR dwParam);

第二个参数是您想要放入伫列入口的线程handle。请注意,这个线程可以是系统中的任何一个。假如hThread指出了某个在不同程序位址空间的线程,则pfnAPC就必须指出在目标线程之行程位址空间中的函数内存位址。QueueUserAPC的最后一个参数dwData,是简单地传递给回呼函数的值。

虽然QueueUserAPC传回DWORD的原型,但函数实际上会传回一个BOOL来指示成功或失败。您可以看到QueueUserAPC非常有效地执行线程之间的通讯,甚至跨越了程序的界限。然而不幸的是您只能传递一个单一的值。

QueueUserAPC也可以用来强迫线程从等待状态中离开。假定您有某个线程呼叫了WaitForSingleObject以等待核心物件变成通知状态,当线程正在等待时,使用者想要终止应用程序;您知道线程应该乾净地毁坏它们自己,但您如何强迫伺候核心物件的线程醒来并且终止自己呢?QueueUserAPC就是答案。

以下的程序代码示范了如何强迫线程从等待状态中离开以便线程可以完全清除自己并离开。主要的函数产生了一个新的线程,并传递某些核心物件的handle给它。当第二个线程在执行时,主要的线程也在执行。第二个线程(执行ThreadFunc函数)呼叫暂停线程之WaitForSingleObjectEx并将它加进可警告的状态。然后由使用者命令主要的线程终止应用程序。当然,主要线程可以合理的存在且系统会终止整个行程,但这个应用程序不会被清除的非常乾净,而且在许多情况下,您只是想在未终止整个行程前即删除某个操作。

因此,呼叫QueueUserAPC的主要线程放置了一个APC入口到第二个线程的APC伫列中。由于第二个线程为可警告的状态,所以经由呼叫函数可使它醒来并且清空它的APC伫列。这个函数完全没做事即返回。由于APC伫列现在是空的,使用它传回的伫列所跟随的一个WAIT_IO_COMPLETION传回值来呼叫WaitForSingleObjectE。ThreadFunc函数会特别地检查这个传回值以知道它接收了一个指出线程应该存在的APC入口。

// APC回呼函数没有做任何事VOID WINAPI APCFunc(ULONG_PTR dwParam) {
// 这里没有什么要做的
}UINT WINAPI ThreadFunc(PVOID pvParam) {
HANDLE hEvent = (HANDLE) pvParam; // Handle被传递此线程
// 在可警告的状态中等待以便我们可以被强迫清除自己并离开
DWORD dw = WaitForSingleObjectEx(hEvent, INFINITE, TRUE);
if (dw == WAIT_OBJECT_0) {
  // 物件变成通知状态
} if (dw == WAIT_IO_COMPLETION) {
  // QueueUserAPC强迫我们自等待状态中离开
  return(0);   // 线程被乾净地删除
} . . .
return(0);
}void main() {
HANDLE hEvent = CreateEvent(...);
HANDLE hThread = (HANDLE)_beginthreadex(NULL, 0,
  ThreadFunc, (PVOID) hEvent, 0, NULL);
. . . // 强迫第二个线程清除自己并离开
QueueUserAPC(APCFunc, hThread, NULL);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hEvent);
}

我知道有些人会认为这个问题可以经由呼叫WaitFo-MultipleObjects来取代WaitForSingleObjectEx,以及建立另一个事件核心物件来发出第二个线程终止的通知而使此问题被解决。以我举的例子来说,您的解决办法可能会有用。然而,假设第二个线程呼叫了WaitForMultipleObjects,等待所有的物件变成通知状态为止;此时QueueUserAPC会是强迫线程自等待状态中离开的唯一方法。

关于如何以这种方式使用QueueUserAPC的范例,请看本书第五章中的 

I/O完成端口
 

Windows 2000被设计成一个安全、强健的作业系统,用来正确地执行服务数千个使用者的应用程序。依据以往的经验来说,您已经能够经由以下二种模式之一创造出服务应用程序:

  •  序列模式 等待客户端产生一个请求之单一线程(通常是在网路上)。当请求到达时,线程会醒来并且处理客户端的请求。
     
  •  协同模式 等待客户端的请求之单一线程,并且建立一个新的线程处理请求。当新的线程正在处理客户端的请求时,最初的线程会回到最初的状态并且等待另一个客户端请求。当处理客户端请求的线程处理完成,该线程就被清除。
     

序列模式的问题是它对多重、同步的请求处理得不是很好。假如两个客户端在相同时间产生请求,则一次只能处理一个,第二个请求必须等待第一个请求结束行程。一个设计为使用序列方式的服务不能用于多重处理器。显然地,序列模式只在最简单的服务器应用程序上有效,在那里,少数的客户端请求被产生而且可被非常快速地处理,Ping服务器是即序列服务器的一个例子。

因为序列模式中的限制,使得协同模式非常受欢迎。在协同模式中,为了处理每个客户端请求而产生线程。其优点是等待请求之线程所做的工作很少。大部分的时间里,线程是处于闲置状态的。当某个客户端请求到达时,线程会醒来并且产生一个新的线程以处理请求,然后继续等待另一个客户端请求。这意味着到达的客户端请求可被方便地处理。再者,因为每个客户端请求皆可取得它自己的线程,所以服务器应用程序可调适得很好而且可以简单地利用多重处理器。因此,假如您正在使用协同模式且更新了硬体设备(增加另一个CPU),服务器应用程序效能可被增加。

服务器应用程序使用Windows来实作协同模式。Windows团队注意到应用程序效能并没有如想像中的高。尤其是注意到处理许多同时产生的请求即表示有许多线程在系统上。因为这些线程皆是可执行的(不暂停且等待某些事发生),所以Microsoft了解到Windows核心花了太多时间在线程之间交换,而且线程并没有取得足够的CPU时间执行工作。为了使Windows成为更完善之服务器环境,Microsoft必须正视这个问题,而结果就是I/O完成端口核心物件。

产生I/O完成端口
 

I/O完成端口之背后的理论即是同时执行的线程数量必须有个上限;即500个同时发生的客户端请求不能允许500可执行的线程同时存在。那么,什么是适当的可执行之线程数量?假如您曾经思考这个问题,那么您将会领悟到如果一台有二个CPU的机器,拥有多于二个可执行之线程(即每个处理器一个)是没有任何意义的。一旦您所拥有的可执行之线程多于可用的CPU数,则系统必须延长执行中的线程内容交换时间,因而浪费了宝贵的CPU周期-此为协同模式的潜在缺点。另一个协同模式的缺点是它会为每个客户端请求建立一个新的线程。与使用它自己之虚拟位址空间来建立一个新的程序相比较,建立一个线程较不费力,但是线程的建立方式并不自由。假如在应用程序初始化时建立线程的集区,则可以改进服务应用程序的效能,而且这些线程会因为应用程序的持续而四处徘徊。I/O完成端口被设计为与线程的集区一起工作。

I/O完成端口可能是最复杂的核心物件。为了要建立一个I/O完成端口,您要呼叫CreateIoCompletionPort:

HANDLE CreateIoCompletionPort( 
  HANDLE hfile,
  HANDLE hExistingCompPort,
  ULONG_PTR CompKey,
  DWORD dwNumberOfConcurrentThreads);

这个函数执行了二个不同的任务:它建立一个I/O完成端口并把设备和I/O完成端口联系在一起。这个函数非常地复杂,依我的看法,Microsoft应该将它切成二个独立的函数。当我在使用I/O完成端口工作时,会经由建立两个抽象化CreateIoCompletionPort的极小程序来分割这两个功能。我编写的第一个函数称为CreateNewCompletionPort并且实作如下:

HANDLE CreateNewCompletionPort(DWORD dwNumberOfConcurrentThreads) { 
  return(CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0,
   dwNumberOfConcurrentThreads));
}

这个函数会接收一个参数dwNumberOfConcurrentThreads,然后呼叫Windows之CreateIoCompletionPort函数,并且传递硬体呼叫的值作为前叁个参数,而dwNumberOfConcurrentThreads为最后一个参数。您可以看到CreateIoCompletionPort的前叁个参数只有在当您将设备和完成端口联系在一起时才会被使用(我将会作简短地谈论)。为了要建立一个完成端口,我分别地传递了INVALID_HANDLE_VALUE、NULL、和0给CreateIoCompletionPort的前叁个参数。

dwNumberOfConcurrentThreads参数告诉I/O完成端口在同一时间可执行之线程最大数量。假如您传递0给dwNumberOfConcurrentThreads参数,那么完成端口之预设值是允许与主机上之CPU数量一样的线程同时发生。这通常会是您所要的,以便避免过度的内文交换的情形发生。假如客户端请求的程序需要一个冗长的计算而只有很少的区块可用时,您可能会想要增加这个值,但是增加这个值会使您相当沮丧。您可以经由测试不同的值和比较应用程序效能来对dwNumberOfConcurrentThreads参数进行试验。

您会注意到CreateIoCompletionPort几乎是建立核心物件唯一的一个Windows函数,但却不能让您传递SECURITY_ATTRIBUTES结构位址,这是因为完成端口本意是只在单一的程序中使用。当我说明如何使用完成端口时,您会清楚地知道这个原因。

联系设备与I/O完成端口
 

当您建立了一个I/O完成端口,核心实际上会建立五个不同的资料结构,如图2-1所示。在您继续阅读时会参考到这个图。

 


 
图2-1 )。这允许完成端口记得它所叫醒的线程并且监视这些线程的执行。假如某个被释放的线程呼叫了任一个放置线程至等待状态的函数,则完成端口会察觉并且将它从被释放的线程清单中移到暂停线程清单中,以更新它内部的资料结构(第五个和最后一个资料结构是I/O完成端口的一部份)。


 

 图2-1 I/O完成端口之内部操作

第一个资料结构是指出设备或把设备和通讯埠联系在一起的设备清单。您可以经由呼叫CreateIoCompletionPort而把设备和通讯埠联系在一起。再者,我建立了自己的函数,AssociateDevice-WithCompletionPort,以抽象化CreateIoCompletionPort:

BOOL AssociateDeviceWithCompletionPort( 
  HANDLE hCompPort, HANDLE hDevice, DWORD dwCompKey) {
  HANDLE h = CreateIoCompletionPort(hDevice, hCompPort, dwCompKey, 0);
  return(h == hCompPort);
}

AssociateDeviceWithCompletionPort附加了一个从入口到现行完成端口的设备清单。您会传递一个现行完成端口的handle(被先前CreateNewCompletionPort的呼叫所传回)、设备的handle(可以是文件、套接字、mailslot、管道等等)、完成的关键(一个对您有意义的值;作业系统不会管您在此传递的是什么)到函数中。每当您把设备和通讯埠联系在一起,系统便会附加从这个资讯到完成端口的设备清单。


说明

我建议您在心理上分隔这两个呼叫的原因是因为CreateIoCompletionPort函数很复杂。使这个函数变得如此复杂有一个好处:您可以在同一时间内建之一个I/O完成端口并且把它和设备联系在一起。举例来说,以下的程序代码会开启一个文件并且建立一个新的完成端口,并把它和文件联系在一起。所有对文件的I/O请求将会随着CK_FILE之完成关键而完成,通讯埠允许两个线程同时地执行。

#define CK_FILE   1 
HANDLE hfile = CreateFile(...);
HANDLE hCompPort = CreateIoCompletionPort(hfile, NULL, CK_FILE, 2);



第二个资料结构是I/O完成伫列。当一个对设备的非同步I/O请求完成时,系统会检查设备和完成端口是否已联系在一起,假如有,系统会附加一个完成I/O请求入口到完成端口之I/O完成伫列的尾端。每个在伫列中的入口皆指出转移位元组的数量、当设备和通讯埠联系在一起时所设定的完成关键值、一个指向I/O请求OVERLAPPED结构的指标以及错误程序代码。我会简短地讨论如何从这个伫列中移除入口的方式。


说明

对设备发布一个I/O请求并且不让I/O完成入口伫列到可能的I/O完成端口中。这通常不是必要的,但是它偶尔会有用。举例来说,当您不管资料传递成功与否皆透过套接字传送资料时。为了要发布一个没有完成入口伫列的I/O请求,必须使用一个有效的事件handle来载入OVERLAPPED结构的hEvent成员并且与1作bitwise-OR的运算,就像这样:

Overlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);  
Overlapped.hEvent = (HANDLE) ((DWORD_PTR) Overlapped.hEvent | 1);
ReadFile(..., &Overlapped);

现在您可以利用传递OVERLAPPED结构的位址到要求的函数中(如上面的ReadFile)来发布您的I/O请求。

最好您不需建立一个只用来停止I/O完成的伫列。我想要利用如下的方法来做,但是它并没有作用:

Overlapped.hEvent = 1;  
ReadFile(..., &Overlapped);

再者,不要忘了在关闭事件handle之前重放low-order位元。

CloseHandle((HANDLE) ((DWORD_PTR) Overlapped.hEvent & ~1));

I/O完成端口周围的结构
 

当您的服务应用程序被初始化时,它应经由呼叫如CreateNewCompletionPort的函数来建立I/O完成。然后,应用程序应建立一个线程的集区。您现在问的这个问题是,「有多少线程应该在集区里?」这是个难以回答的问题,我将在稍后的〈有多少线程在集区中?〉一节中提出更多的细节。现在,标准的基本原则是取得在主机上的CPU数量然后将它乘以2。因此在双倍处理器的机器上,您应该建立四个线程的集区。

所有在集区中的线程皆应执行相同的函数。通常在服务程序被命令停止时,这个线程函数会执行一些初始化的动作,然后再进入一个应该终止的回圈。在该回圈内,线程会将自己放置到睡眠状态以等待对完成端口的设备I/O请求完成呼叫GetQueuedCompletionStatus之动作,它是这样子的:

BOOL GetQueuedCompletionStatus( 
  HANDLE hCompPort,
  PDWORD pdwNumBytes,
  PULONG_PTR CompKey,
  OVERLAPPED** ppOverlapped,
  DWORD dwMilliseconds);

第一个hCompPort参数指出线程对监视哪个完成端口有兴趣。许多服务应用程序会使用单一的I/O完成端口,并且完成所有对这个通讯埠的I/O请求通知。基本上,GetQueuedCompletionStatus的工作是将呼叫线程放置到睡眠状态,直到某个入口在特定完成端口之I/O完成伫列中出现,或是指定的逾时情形发生(在dwMilliseconds参数中指定)为止。

第叁个与I/O完成端口相关联的资料结构即是等待线程伫列。当线程集区中的每个线程呼叫GetQueuedCompletionStatus时,呼叫线程的ID被放置到等待线程伫列中,使I/O完成端口发布核心物件可以随时知道哪个线程正在等待处理完成的I/O请求。当某个入口在通讯埠的I/O完成伫列中出现时,完成端口便会叫醒等待线程伫列的其中一个线程。这个线程取得构成完成I/O入口的部分资讯:转移位元组的数量、完成的关键以及OVERLAPPED结构的位址。这个资讯会被传递到GetQueuedCompletionStatus的pdwNumBytes、pCompKey以及ppOverlapped参数中,并被传回给线程。

要判断GetQueuedCompletionStatus被传回的原因有点困难,以下的程序代码示范了恰当的实作方式:

DWORD dwNumBytes;  
ULONG_PTR CompKey;
OVERLAPPED* pOverlapped;
// hIOCP在程序的其他地方被初始化
BOOL fOk = GetQueuedCompletionStatus(hIOCP,
  &dwNumBytes, &CompKey, &pOverlapped, 1000);
DWORD dwError = GetLastError();
if (fOk) {
  // 程序成功地完成I/O请求
} else {
  if (pOverlapped != NULL) {
   // 处理一个失败的完成I/O请求
   // dwError包括失败的原因
  } else {
   if (dwError == WAIT_TIMEOUT) {
    // 等待完成的I/O入口逾时
   } else {
    // 对GetQueuedCompletionStatus不适当的呼叫
    // dwError包括不适当呼叫的原因
   }
  }
}

如您所预期的,入口以先进先出(FIFO)的方式从I/O完成伫列中移除。然而,您可能没有预料到呼叫GetQueuedCompletionStatus的线程却是以后进先出(LIFO)的方式被叫醒。这样做的原因是为了改进效能。举例来说,假定四个线程在等待线程伫列中等待。此时若某个单一完成的I/O入口出现,则最后一个呼叫GetQueuedCompletionStatus的线程会醒来处理这个入口。当这个线程完成处理入口后,线程会再次呼叫GetQueuedCompletionStatus并进入等待线程伫列。此刻假如另一个I/O完成入口出现,刚才处理入口的同一个线程会被叫醒并处理新的入口。

只要I/O请求缓慢地完成以使单一线程可以处理,此时系统只会保持一个线程为清醒的状态,而其他叁个线程则持续睡眠状态。经由使用LIFO演算法,没有排程的线程可以使它们的内存资源(例如堆叠空间)被交换到磁盘并且使处理器的快取被注满。这个使许多线程伺候一个完成端口的方法相当不错,假如您要使数个线程等待而完成少数的I/O请求,则额外的线程会使大部分资源自系统离开后交换。

I/O完成端口如何管理线程集区
 

此刻是讨论为何I/O完成端口是如此有帮助的时候了。第一,当您建立I/O完成端口时,您会具体指定线程可以同时执行的数量。就像我说的,您通常会设定这个值为主机上CPU的数量。当完成的I/O入口被伫列后,I/O完成端口应该叫醒等待中的线程。然而,完成端口将只叫醒您所指定的线程数量。因此,假如四个I/O请求完成,且四个线程在等待对GetQueuedCompletion Status的呼叫,此时I/O完成端口只允许两个线程醒来;其他两个线程则继续保持睡眠状态。当每个线程处理了一个完成的I/O入口后,它再次呼叫GetQueuedCompletionStatus。此时系统看到更多的入口被伫列并叫醒相同的线程去处里剩馀的入口。

如果您小心谨慎地思考,应该会注意到有些事实在毫无意义:如果完成端口至今只允许指定数量的线程同时醒来,为什么要让那么多线程在线程集区中等待?举例来说,假设我正在一台有两个CPU的机器上执行并且建立I/O完成端口,并命令它只能允绪两个线程同时地处理入口。但是我会在线程集区中建立四个线程(CPU数量的两倍)。它看起来好像是建立两个不会处理任何事情的线程。

但I/O完成端口是非常聪明的。当某个完成端口叫醒一个线程时,完成端口会将线程的ID放置到与它相关的第四个资料结构中—即一个释放的清单(请看 

完成端口的目标是保持释放线程清单中的入口与建立完成端口时所指定使用同时发生的线程数量相同。假如某个被释放的线程因为任一理由而进入等待状态,则被释放的线程清单会缩短,而完成端口则会释放另一个等待中的线程。假如某个暂停的线程醒来,它会离开暂停的线程清单并且再进入被释放的线程清单中。这个被释放线程清单的方法可以拥有比最大的同时值所允许的入口还多。


说明

一旦线程呼叫了GetQueuedCompletionStatus,这个线程即表示为指定的完成端口。系统会假定所有被分配的线程,以代表完成端口在做事。只要执行分配的线程数量少于完成端口的最大同时值,完成端口将会从集区叫醒线程。您可以用下列叁种方式之一来中断线程/完成端口分配的任务:

  • 使线程离开。
     
  • 使线程呼叫GetQueuedCompletionStatus并传递一个不同I/O完成端口的handle。
     
  • 毁坏线程分配的I/O完成端口。
     

现在让我们将这些内容联系起来。假定我们还是在一台有两个CPU的机器上执行。我们建立一个只允许两个线程同时醒来的完成端口,并且建立四个等待完成I/O请求的线程。假如叁个伫列到通讯埠的完成I/O请求中,只使两个线程察觉到并处理请求,则减少了可执行的线程数量并且省下内文交换的时间。此时如为目前其中一个线程呼叫了Sleep、WaitForSingleObject、WaitForMultipleObjects和SignalObjectAndWait,一个同时的I/O呼叫,或是任何一个导致线程不可执行的函数,I/O完成将会察觉并且立即叫醒第叁个线程。完成端口的目标是使CPU保持工作的状态。

最后,第一个线程将再次变为可执行的。当它发生时,可执行的线程数量会比系统中的CPU数量还高。然而,完成端口会再次意识到它并且不允许任何额外的线程醒来,直到线程的数量降到CPU数量以下为止。I/O完成端口架构假设了可执行之线程数量只在短时间内停留在最大值之上,然后当线程四处徘徊且再次呼叫GetQueued-CompletionStatus时,会很快地逐渐消失。这解释了为什么线程集区中包含的线程应该多于完成端口所设定同时发生的线程总数。

有多少线程在集区中?
 

这是讨论线程集区中应该有多少线程的好时机。我们要考虑到两个议题。首先,当服务应用程序初始化时,您应该建立一组最小量之线程以使其不须在标准的基础上建立及毁坏线程。要记得建立和毁坏线程会浪费CPU时间,所以您最好尽可能使这个程序减到最少。第二,您应该设定线程的最大数量,因为建立太多线程会浪费系统资源。即使大部分的资源可以自RAM交换离开,使得系统资源的使用减到最少,如果您能做到的话,甚至一页的文件空间也不要浪费。

您大概想要实验不同的线程数量。大部分的服务(包括Microsoft Internet Information Services)皆使用尝试错误的演算法去管理它们的线程集区。我建议您也这样做。举例来说,您可以建立如下的变数去管理线程集区:

LONG g_nThreadsMin;  // 集区中线程的最小数量 
LONG g_nThreadsMax;  // 集区中线程的最大数量
LONG g_nThreadsCrnt;  // 集区中线程的当前数量
LONG g_nThreadsBusy;  // 集区中忙碌的线程数量

当您的应用程序初始化时,您可以指定g_nThreadsMin数量的线程皆执行相同的线程集区函数。以下的虚拟程序代码说明了这个线程函数可能的样子:

DWORD WINAPI ThreadPoolFunc(PVOID pv) { 
  // 线程进入集区
  InterlockedIncrement(&g_nThreadsCrnt);
  InterlockedIncrement(&g_nThreadsBusy);
  for (BOOL fStayInPool = TRUE; fStayInPool;) {
   // 线程停止执行并且等待做某些事
   InterlockedDecrement(&m_nThreadsBusy);
   BOOL fOk = GetQueuedCompletionStatus(...);
   DWORD dwIOError = GetLastError();
   // 线程在做某些事,所以它是忙碌的
   int nThreadsBusy = InterlockedIncrement(&m_nThreadsBusy);
   // 我们应该加另一个线程到集区吗?
   if (nThreadsBusy == m_nThreadsCrnt) { // 所有的线程都是忙碌的
    if (nThreadsBusy < m_nThreadsMax) { // 集区不是满的
     if (GetCPUUsage() < 75) { // CPU使用率低于75%
      // 增加线程到集区
      CloseHandle(chBEGINTHREADEX(...));
     }
    }
   }
   if (!fOk && (dwIOError == WAIT_TIMEOUT)) {   // 线程逾时
    if (!ThreadHasIoPending()) {
     // 服务器没有重要的事做,而且这个线程
     // 可能会死掉,因为它没有未完成的I/O请求
     fStayInPool = FALSE;
    }
   }
   if (fOk || (po != NULL)) {
    // 线程醒来处理某些事;处理它
    ...
    if (GetCPUUsage() > 90) { // CPU使用率高于90%
     if (!ThreadHasIoPending()) { // 没有未决定的I/O请求
      if (g_nThreadsCrnt > g_nThreadsMin)) { // 集区在最小量之上
       fStayInPool = FALSE; //从集区中移除线程
      }
     }
    }
   }
  }
  // 线程离开集区
  InterlockedDecrement(&g_nThreadsBusy);
  InterlockedDecrement(&g_nThreadsCurrent);
  return(0);
}

这个虚拟程序代码显示出当您在使用I/O完成端口时所能获得的创造力。GetCPUUsage及ThreadHasIoPending函数不是Windows API的一部分。假如您想要使用它们,您必须自己实作这个函数。另外,您必须确保线程集区中至少随时包含一个线程,否则客户端将永远不会得到照顾。您可以使用我的虚拟程序代码为指导,但是若为您的特殊服务建立不同的程序代码的话,可能会执行得更好。


说明

本章稍早的〈取消伫列中的设备I/O请求〉一节中,曾经提到当线程结束时,系统会自动取消所有这个线程所发布之悬而未决的I/O请求。这就是为什么在虚拟程序代码中类似Thread HasIoPending的函数是必要的。假如线程拥有未完成的I/O请求,请不要允许它们终止。


许多服务提供了一个可让管理者控制线程集区行为的管理工具-举例来说,为了设定线程的最小和最大数量、CPU时间使用的门槛以及当建立I/O完成端口时所使用的最大同步值。

模拟已完成的I/O请求
 

I/O完成端口根本不必与设备I/O一起使用,不过本章还是会说明线程之间的通讯技巧,而I/O完成端口核心物件是个用来帮助这件事的了不起机制。在本章〈可警告I/O〉一节中介绍了QueueUserAPC函数,它允许线程分发某个APC入口到另一个线程中。I/O完成端口也有个类似的函数,即PostQueuedCompletionStatus:

BOOL PostQueuedCompletionStatus( 
  HANDLE      hCompPort,
  DWORD       dwNumBytes,
  ULONG_PTR     CompKey,  
  OVERLAPPED* pOverlapped);

这个函数将一个己完成之I/O通知附加到I/O完成端口伫列中。第一个hCompPort参数确认您希望伫列入口的完成端口;剩馀的叁个参数:dwNumBytes、CompKey以及pOverlapped,则指出线程呼叫GetQueuedCompletionStatus后应该传回的值。当线程从I/O完成伫列取出一个模拟的入口时,GetQueuedCompletionStatus会传回TRUE以表示一个执行成功的I/O请求。

PostQueuedCompletionStatus函数极为有-它提供您一个与集区中所有与线程通讯的方法。举例来说,当使用它来终止一个服务应用程序时,您想要让所有的线程都离开;然而如果线程正在等待某个完成端口而且没有I/O请求到达,则该线程不能被唤醒。在集区中的每个线程只要经由呼叫PostQueuedCompletionStatus就可以醒来,检查从GetQueuedCompletionStatus所传回的值,您会发现应用程序结束并且已被适当地清理及离开。

当您使用像刚才所叙述的线程终止技巧时必须很小心。因为在集区中的线程即将结束并且不再呼叫GetQueuedCompletionStatus,所以范例可以行得通。然而,假如您想要通知每个集区的线程并且使它们四处徘徊,以便再次呼叫GetQueuedCompletionStatus时,会有个问题产生,因为线程会以LIFO的次序醒来。所以为了保证每个集区线程皆能获得察看它I/O入口的机会。如果没有这个额外的线程同步,一个线程可能会察看相同的通知好几次。

FileCopy范例应用程序
 

在本章结尾列表2-1的FileCopy范例应用程序(「02 FileCopy.exe」)示范了I/O完成端口的使用方法。应用程序的原始码和原始文件在随书光碟里的02-FileCopy目录中。这个程序会简单地复制使用者所指定的文件到一个名为FileCopy.cpy的新文件中。当使用者执行了FileCopy程序,会出现图2-2中的对话方块。


 

 图2-2 FileCopy应用程序的对话方块

使用者可以点选路径名称按钮以选择被复制的文件,然后路径名称及文件大小栏位会被更新。当使用者点选复制按钮后,程序所呼叫的FileCopy函数会将所有困难的工作完成。

让我们全神贯注在FileCopy函数的讨论上。

当准备复制时,FileCopy会打开来源文件并撷取它的大小。我想要让文件复制的执行越快越好,所以使用了FILE_FLAG_NO_BUFFERING标记来开启文件。使用FILE_FLAG_NO_BUFFERING标记开启文件可让我直接存取文件,当允许系统的快取去「帮助」存取文件时可越过间接带来的额外内存复制。当然,直接存取文件对我来说意味着更多的工作:必须随时使用以磁盘容量区段大小为倍数的位移量来存取文件,而且也必须读取和写入以区段大小为倍数的资料。我选择以肯定是区段大小倍数的BUFFSIZE(64 KB)区块(chunks)来转移文件的资料。这就是为什么我会集中来源文件的大小,使其成为BUFFSIZE的倍数的缘故。您将注意到来源文件是以FILE_FLAG_OVERLAPPED标记开启的,以使对文件的I/O请求以非同步的方式执行。

目标文件以相同地方式被开启:FILE_FLAG_NO_BUFFERING及FILE_FLAG_OVERLAPPED标记都被指定。当建立目标文件时,也传递来源文件的handle作为CreateFile的hfileTemplate参数,使目的文件拥有和来源文件相同的属性。


说明

一旦这两个文件被开启,目标文件大小会经由呼叫SetFilePointerEx及SetEndOfFile而立即被设定成它最大的大小。立刻调整目标文件的大小是非常重要的,因为NTFS维持着一个高水平线,它会指出文件被写入的最高位置。假如您在这个记号前读取,系统会知道要回传0。假如您在这个记号前写入,从旧的高水位线到写入位移量的文件资料会被0填满,您的资料被写入文件且文件的高水位线会被更新。这种行为满足了有关不要呈现先前资料的C2安全规定。当您写入NTFS分割上的文件结尾使得高水位线移动时,即使被要求使用非同步I/O,NTFS也必须以同步的方式来执行I/O请求。假如FileCopy函数没有设定目标文件的大小,则没有任何重叠的I/O请求会被非同步地执行。


现在文件已经开启且准备好被处理了,FileCopy建立了一个I/O完成端口。为了使其容易与I/O完成端口一起工作,我建立了一个小的C++ 类别—CIOCP,这是一个非常简单封装的I/O完成端口函数。这个类别可以在附录B之〈类别库〉中所讨论的IOCP.h文件中找到。FileCopy经由建立一个CIOCP类别的实例(称为iocp)来创造I/O完成端口。

来源文件及目标文件会经由呼叫CIOCP的AssociateDevice成员函数来与完成端口关联。当与完成端口关联时,每个设备会被分配到一个完成的识别码。当某个对来源文件的I/O请求完成时,该完成识别码会是CK_READ,以指出读取操作必须被完成。同样地,对目标文件的I/P请求完成时,完成识别码是CK_WRITE,它指出读写操作必须被完成。

现在我们准备好初始化一组I/O请求(OVERLAPPED结构)以及它们的内存缓冲器。FileCopy函数在任何时间内皆会保留四个(MAX_PENDING_IO_REQS)重要的I/O请求。对您自己的应用程序来说,您可能宁愿允许I/O请求的数量在需要的时候动态地增加或减少。在FileCopy程序中之CIOReq类别封装了一个单一的I/O请求。如您所看到的,C++ 类别封装了单一的I/O请求。该C++ 类别来自OVERLAPPED结构,但是它包含了一些附加的内容资讯。FileCopy分配一个CIOReq物件的阵列并呼叫AllocBuffer方法将BUFFSIZE大小的资料缓冲器与每个I/O请求物件相关联。使用VirtualAlloc函数来分配资料缓冲器。使用VirtualAlloc来确保区块从适当间距的边界开始,这个界限满足了FILE_FLAG_ NO_BUFFERING标记的需求:缓冲器必须经由容量之区段大小平均地分割。为了发布来源文件初始的读取请求,我执行了一些小技巧:分发四个CK_WRITE I/O完成通知到I/O完成端口中。当主要的回圈被执行时,线程会在通讯埠上等待并且立即醒来,且认为写入操作已经完成。这会导致线程发布一个对来源文件的读取请求,即实际开始文件复制。

当没有未完成的I/O请求时,主要的回圈结束。

只要I/O请求未被完成,回圈内部便会经由呼叫CIOCP的GetStatus方法(在内部呼叫GetQueuedCompletionStatu)在完成端口上等待。这个呼叫将线程改变成睡眠状态,直到对完成端口的I/O请求完成为止。当GetQueuedCompletion Status返回时,会检查被传回CompKey的完成识别码。假如CompKey为CK_READ,则表示对来源文件的I/O请求已完成。然后再呼叫CIOReq的Write方法来发布对目标文件的写入I/O请求。假如CompKey为CK_WRITE,则表示对目标文件的I/O请求已完成。如果没有读取到来源文件的结尾,则呼叫CIOReq的Read方法继续读取来源文件。

当不再有未完成的I/O请求时,可以经由关闭来源及目标文件handles来使回圈结束及清理。在FileCopy返回前,它必须多做一个工作:它必须将目标文件的大小与来源文件的大小调整为相同。为了达成目的,并没有指定FILE_FLAG_NO_BUFFERING标记以重新开启目标文件。也正因为没有使用这个标记,所以文件不必在区段的界线上执行操作,这能让目标文件的大小缩至与来源文件大小相同。

FileCopy.cpp  
/********************************************************************  
模组:FileCopy.cpp  
通告:Copyright (c)2000 Jeffrey Richter  
********************************************************************/  
#include "../CmnHdr.h" // 请参阅附录A  
#include   
#include "../ClassLib /IOCP.h" //请参阅附录B  
#include "../ClassLib /EnsureCleanup.h" //请参阅附录B  
#include "Resource.h"  
///  
// 每个I/O请求需要OVERLAPPED结构及资料缓冲区  
class CIOReq :public OVERLAPPED {  
public:  
CIOReq() {  
  Internal = InternalHigh = 0;  
  Offset = OffsetHigh = 0;  
  hEvent = NULL;  
  m_nBuffSize = 0;  
  m_pvData = NULL;  
}  
~CIOReq() {  
  if (m_pvData != NULL)  
   VirtualFree(m_pvData, 0, MEM_RELEASE);  
}  
BOOL AllocBuffer(SIZE_T nBuffSize) {  
  m_nBuffSize = nBuffSize;  
  m_pvData = VirtualAlloc(NULL, m_nBuffSize, MEM_COMMIT, PAGE_READWRITE);  
  return(m_pvData != NULL);  
}  
BOOL Read(HANDLE hDevice, PLARGE_INTEGER pliOffset = NULL) {  
  if (pliOffset != NULL) {  
   Offset = pliOffset->LowPart;  
   OffsetHigh =pliOffset->HighPart;  
  }  
  return(::ReadFile(hDevice, m_pvData, m_nBuffSize, NULL, this));  
}  
BOOL Write(HANDLE hDevice, PLARGE_INTEGER pliOffset = NULL) {  
  if (pliOffset != NULL) {  
   Offset = pliOffset->LowPart;  
   OffsetHigh = pliOffset->HighPart;  
  }  
  return(::WriteFile(hDevice, m_pvData, m_nBuffSize, NULL, this));  
}  
private:  
SIZE_T m_nBuffSize;  
PVOID m_pvData;  
};  
///  
#define BUFFSIZE    (64 * 1024) // I/O缓冲器的大小  
#define MAX_PENDING_IO_REQS 4  // I/O的最大数量  
// 完成识别值,以指出完成的I/O型态  
#define CK_READ 1  
#define CK_WRITE 2  
///  
BOOL FileCopy(PCTSTR pszFileSrc, PCTSTR pszFileDst) {  
BOOL fOk = FALSE; // 假定文件复制失败  
LARGE_INTEGER liFileSizeSrc = {0 }, liFileSizeDst;  
try {  
  {  
  // 没有缓冲就开启来源文件并撷取文件的大小  
  CEnsureCloseFile hfileSrc = CreateFile(pszFileSrc, GENERIC_READ,  
   FILE_SHARE_READ, NULL, OPEN_EXISTING,  
   FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED, NULL);  
  if (hfileSrc.IsInvalid()) goto leave;  
  // 撷取文件的大小  
  GetFileSizeEx(hfileSrc, &liFileSizeSrc);  
  // 非缓冲的I/O请求区段大小转移  
  // 我使用缓冲器大小的转移,因为它较容易计算  
  liFileSizeDst.QuadPart = chROUNDUP(liFileSizeSrc.QuadPart, BUFFSIZE);  
  // 没有缓冲就开启目标文件并撷取文件的大小  
  CEnsureCloseFile hfileDst = CreateFile(pszFileDst, GENERIC_WRITE,  
   0, NULL, CREATE_ALWAYS,  
   FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED, hfileSrc);  
  if (hfileDst.IsInvalid()) goto leave;  
  // 文件系统同时扩大文件。现在扩大目标文件  
  // 以至于I/O非同步地执行改进了效能  
  SetFilePointerEx(hfileDst, liFileSizeDst, NULL, FILE_BEGIN);  
  SetEndOfFile(hfileDst);  
  // 建立I/O完成端口并且与文件相关联。  
  CIOCP iocp(0);  
  iocp.AssociateDevice(hfileSrc, CK_READ); // 从来源文件读取  
  iocp.AssociateDevice(hfileDst, CK_WRITE); // 写入目标文件  
  // 初始化保持记录变数  
  CIOReq ior[MAX_PENDING_IO_REQS];  
  LARGE_INTEGER liNextReadOffset = { 0 };  
  int nReadsInProgress = 0;  
  int nWritesInProgress = 0;  
  // 最初的文件复制经由模拟写入已经完成来发动  
  //这导致读取操作被发布  
  for (int nIOReq = 0;nIOReq < chDIMOF(ior); nIOReq++) {  
   // 每个I/O请求需要转移的资料缓冲器  
   chVERIFY(ior[nIOReq].AllocBuffer(BUFFSIZE));  
   nWritesInProgress++;  
   iocp.PostStatus(CK_WRITE, 0, &ior[nIOReq]);  
  }  
  // 当未完成的I/O仍然存在时继续执行回圈  
  while ((nReadsInProgress > 0) || (nWritesInProgress > 0)) {  
   // 悬置线程直到I/O完成  
   ULONG_PTR CompKey;  
   DWORD dwNumBytes;  
   CIOReq* pior;  
   iocp.GetStatus(&CompKey, &dwNumBytes, (OVERLAPPED**) &pior,  
    INFINITE);  
   switch (CompKey) {  
   case CK_READ: // 读取完成,写至目的地  
    nReadsInProgress--;  
   pior->Write(hfileDst); // 写入与从来源读取出来相同的位移量  
   nWritesInProgress++;  
   break;  
   case CK_WRITE: // 写入完成,从来源读取  
    nWritesInProgress--;  
    if (liNextReadOffset.QuadPart < liFileSizeDst.QuadPart) {  
     // 不是EOF,读取下一个来源文件的区块资料  
     pior->Read(hfileSrc, &liNextReadOffset);  
     nReadsInProgress++;  
     liNextReadOffset.QuadPart += BUFFSIZE; // 推进来源位移量  
    }  
    break;  
   }  
  }  
  fOk =TRUE;  
  }  
leave:;  
}  
catch (...) {  
}  
if (fOk) {  
  // 目标文件大小是分页大小的倍数。开启有缓冲  
  // 的文件以缩减它的大小,使其成为来源文件的大小  
  CEnsureCloseFile hfileDst = CreateFile(pszFileDst, GENERIC_WRITE,  
   0, NULL, OPEN_EXISTING, 0, NULL);  
  if (hfileDst.IsValid()) {  
   SetFilePointerEx(hfileDst, liFileSizeSrc, NULL, FILE_BEGIN);  
   SetEndOfFile(hfileDst);  
  }  
}  
return(fOk);  
}  
///  
BOOL Dlg_OnInitDialog(HWND hwnd, HWND hwndFocus, LPARAM lParam) {  
chSETDLGICONS(hwnd, IDI_FILECOPY);  
// 使复制按钮失效,因为还没选择文件  
EnableWindow(GetDlgItem(hwnd, IDOK), FALSE);  
return(TRUE);  
}  
///  
void Dlg_OnCommand(HWND hwnd, int id, HWND hwndCtl, UINT codeNotify) {  
TCHAR szPathname [_MAX_PATH ];  
switch (id) {  
case IDCANCEL:  
  EndDialog(hwnd, id);  
  break;  
case IDOK:  
  // 复制来源文件到目标文件  
  Static_GetText(GetDlgItem(hwnd, IDC_SRCFILE),  
  szPathname, sizeof(szPathname));  
  SetCursor(LoadCursor(NULL, IDC_WAIT));  
  chMB(FileCopy(szPathname, TEXT("FileCopy.cpy"))  
   ? "File Copy Successful" : "File Copy Failed");  
  break;  
case IDC_PATHNAME:  
  OPENFILENAME ofn = { OPENFILENAME_SIZE_VERSION_400 };  
  ofn.hwndOwner = hwnd;  
  ofn.lpstrFilter = TEXT("*.*/0 ");  
  lstrcpy(szPathname, TEXT("*.*"));  
  ofn.lpstrFile = szPathname;  
  ofn.nMaxFile = chDIMOF(szPathname);  
  ofn.lpstrTitle = TEXT("Select file to copy");  
  ofn.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST;  
  BOOL fOk = GetOpenFileName(&ofn);  
  if (fOk) {  
   // 显示来源文件大小给使用者  
   Static_SetText(GetDlgItem(hwnd, IDC_SRCFILE), szPathname);  
   CEnsureCloseFile hfile = CreateFile(szPathname, 0, 0, NULL,  
    OPEN_EXISTING, 0, NULL);  
   if (hfile.IsValid()) {  
    LARGE_INTEGER liFileSize;  
    GetFileSizeEx(hfile, &liFileSize);  
    // 注意:只出现大小的下面(bottom)32位元  
    SetDlgItemInt(hwnd, IDC_SRCFILESIZE, liFileSize.LowPart, FALSE);  
   }  
  }  
  EnableWindow(GetDlgItem(hwnd, IDOK), fOk);  
  break;  
}  
}  
///  
INT_PTR WINAPI Dlg_Proc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {  
switch (uMsg) {  
chHANDLE_DLGMSG(hwnd, WM_INITDIALOG, Dlg_OnInitDialog);  
chHANDLE_DLGMSG(hwnd, WM_COMMAND, Dlg_OnCommand);  
}  
return(FALSE);  
}  
///  
int WINAPI _tWinMain(HINSTANCE hinstExe, HINSTANCE, PTSTR pszCmdLine, int) {  
DialogBox(hinstExe, MAKEINTRESOURCE(IDD_FILECOPY), NULL, Dlg_Proc);  
return(0);  
}   End of File //
RegNotify 范例应用程序,以及《Programming Applications for Microsoft Windows, Fourth Edition》(Jeffrey Richter, Microsoft Press, 1999)书中第十一章的WaitForMultipleExpressions范例应用程序.
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值