核心编程笔记9——内核对象的线程同步2

“信号量内核对象”用于对资源进行计数。

  在信号量内核对象内部,和其他内核对象一样,有一个使用计数,该使用计数表示信号量内核对象被打开的次数。

  信号量内核对象中还有两个比较重要的数据,分别表示最大资源数和当前资源数。最大资源数表示能够管理的资源的总数,当前资源数表示目前可以被使用的资源数量。

 

  可以使用CreateSeamphore函数来创建一个信号量内核对象,该函数成功返回句柄,失败返回NULL。

HANDLE CreateSemaphore(
   PSECURITY_ATTRIBUTE psa,     
// 安全属性结构指针
   LONG lInitialCount,           // 初始可用资源数
   LONG lMaximumCount,           // 最大资源数
   PCTSTR pszName);              // 信号量内核对象的名字(NULL表示匿名)

 

  在Windows Vista中,提供了一个新的创建信号量内核对象的函数CreateSemaphoreEx,该函数成功返回句柄,失败返回NULL。

HANDLE CreateSemaphoreEx(
   PSECURITY_ATTRIBUTES psa,     
// 安全属性结构指针
   LONG lInitialCount,            // 初始可用资源数
   LONG lMaximumCount,       // 最大资源数
   PCTSTR pszName,           // 信号量内核对象的名字(NULL表示匿名)
   DWORD dwFlags,                // 保留型参数,应设置为0
   DWORD dwDesiredAccess);       // 访问限制(参看MSDN)

 

  同样,可以打开一个指定名称的信号量,使用OpenSemaphore函数:

HANDLE OpenSemaphore(
   DWORD dwDesiredAccess,     
// 访问限制(参看MSDN)
   BOOL bInheritHandle,        // 是否允许返回的句柄子进程被继承
   PCTSTR pszName);            // 指定的信号量名称

 

  假如,作为一个服务器,有一个缓冲区需要用来存放客户的连接请求,还有一个线程池用来处理连接。但是该缓冲区和线程池的大小有限,比如至多只能同时接纳和处理10位客户的连接请求,而当有10位客户请求连接而尚未处理完成的时候,此时一个新客户也试图建立连接,那么这个连接过程应该被推后,直到有一个连接处理完成之后,这个新客户的连接才能被处理。

  这个时候,可以使用信号量机制来处理线程同步的问题。

  当服务器初始化的时候,最大资源数为10,没有任何服务器请求连接,可以使用如下代码创建信号量内核对象:

HANDLE hSem  =  CreateSemaphore(NULL,  0 10 , NULL);

 

  该函数创建了一个信号量内核对象,最大资源数为10,当前可用资源数为0。由于当前可用资源数为0,所以调用WaitForSingleObject等这些等待函数来等待该信号量句柄的线程都会进入等待状态。

  这些等待函数在内部会查看信号量内核对象的可用资源数,如果该值大于0,则将其减1,线程保持可调度状态,这些比较和设置可用资源数是以原子过程进行的,所以是线程安全的。

  如果可用资源数等于0,则线程进入等待状态,当一个线程将信号量的可用资源数递增之后,某个或某些等待的线程就可以进入就绪状态。

  可以调用ReleaseSemaphore函数来让信号量内核对象的可用资源数递增:

 

BOOL ReleaseSemaphore(
   HANDLE hSemaphore,     
// 信号量内核对象句柄
   LONG lReleaseCount,        // 可用资源增加个数
   PLONG plPreviousCount);     // 返回上次可用资源的数量,一般传递NULL忽略之

 

  可惜的是,Windows没有提供一种方法让我们仅仅是查询当前信号量的可用资源数。

 

  自己总结了一下信号量使用的模型:

HANDLE g_hSem;      // 信号量句柄,在其他线程(比如主线程)中创建
DWORD WINAPI ThreadProc(PVOID pvParam)      // 线程函数
{
     
// 等待信号量,如果可用资源大于0,递减资源,线程继续运行,否则线程等待
     WaitForSingleObject(g_hSem, INFINITE);

     
// 访问资源

     
// 访问完毕,释放,递增可用资源数1个(可以根据需要递增n个)
     ReleaseSemaphore(g_hSem,  1 , NULL);
}
互斥内核对象确保一个线程独占地访问资源。

  互斥内核对象的行为特征和关键代码段有点类似,但是它是属于内核对象,而关键代码段是用户模式对象,这导致了互斥内核对象的运行速度比关键代码段要低。所以,在考虑线程同步问题的时候,首先考虑用户模式的对象。

  但是,互斥内核对象可以跨进程使用,当需要实现多进程之间的线程同步,就可用考虑使用互斥内核对象。而这点,关键代码段无能为力。

 

  在互斥内核对象内部,有以下一些重要的数据:

1、使用计数:表明该互斥内核对象被打开的次数。

2、线程ID

3、递归计数器

  线程ID表明了该互斥内核对象被哪个线程所拥有,递归计数器表明了这个线程(拥有互斥对象)拥有这个互斥对象的次数。

 

  互斥对象的使用规则如下

  • 如果内部线程ID为0(或者是一个无效的线程ID),该互斥内核对象不被任何线程所拥有,会发出通知信号,即处于“已通知”状态。
  • 如果线程ID不为0,而是一个有效的线程ID,那么该互斥内核对象就被这个线程所拥有,而且该互斥内核对象为“未通知”状态。
  • 与其他内核对象不同的是,互斥内核对象在操作系统中有着特殊的代码,允许以不正常的规则进行使用。

 

设备的输入输出,即设备I/O,可以分为“同步”和“异步”两种方式。同步的设备I/O,调用的API函数总是等到设备I/O完成才返回。而异步的设备I/O,可以通过多种方法来实现,但是其根本原理是得到“设备I/O的完成通知”。

 

  本篇主要讨论如何打开和关闭一个设备。注意,这里的设备,不是指像键盘、显示器那种实体。而是一种抽象的概念,指一种与外界通信的对象,可以接受外界的输入,也可以对外界的请求作出响应,称之为设备I/O。这个概念比较抽象,这些设备往往和某个内核对象关联。要打开这些设备,就要创建相关的内核对象。

  这些设备包括文件、目录、逻辑磁盘驱动、物理磁盘驱动、串行端口、并行端口、邮槽、管道、套接字、控制台(如下表):

设备

主要用途 

文件

保存数据

目录

属性和文件压缩设置

逻辑磁盘驱动

磁盘格式化

物理磁盘驱动

访问分区表

串行端口

串行传输数据

并行端口

多位数据同时传输,主要是将数据传输给打印机

邮槽

一对多传输数据,往往适用于一个网络中的一台计算机向其他机器发送数据

命名管道

一对一传输数据,往往适用于一个网络中的一台计算机向其他机器发送数据

匿名管道

一对一传输数据,适用于简单的数据传输,不适用于网络

套接字

以流或数据报的形式发送数据,适用于一个网络中的通信

控制台

一个文字窗口显示缓冲区

 

  要使用这些设备,你首先应该打开这些设备。

  Windows努力隐藏这些设备的差异,所以,很多设备的打开的I/O工作可以通过同一个API函数完成,如下表:

设备

经常用来打开设备的API函数和用法

文件

CreateFile —— 打开设备的函数。

将参数pszName是一个文件路径名。

目录

CreateFile —— 打开设备的函数。

将参数pszName是一个目录名。Windows允许你打开一个目录,通过使用参数FILE_ FLAG_BACKUP_SEMANTICS旗标来呼叫CreateFile函数。打开目录之后,就可以这是目录属性,即文件夹属性,比如正常、隐藏、系统、只读等。

逻辑磁盘驱动

CreateFile —— 打开设备的函数。

将参数pszName设置为字符串“\\.\x:”。 比如要打开C盘,就将其设置为“\\.\C:”。

物理磁盘驱动

CreateFile —— 打开设备的函数。

将参数pszName设置为“\\.\PHYSICALDRIVEx”。比如打开第一个物理硬盘扇区:可以这么调用CreateFile函数:

CreateFile(TEXT("\\.\PHYSICALDRIVE0"), ...);

这样就可以打开一个物理磁盘驱动,并且可以直接访问硬盘分区表。

但是打开物理磁盘驱动是存在潜在危险的,特别是当错误的写入,会造成物理磁盘内容的破坏。

串行端口

CreateFile —— 打开设备的函数。

将参数pszName设置为“COMx”,比如打开COM1串口设备,只要将其设置为“COM1”。

并行端口

CreateFile —— 打开设备的函数。

将参数pszName设置为“LPTx”,比如打开LPT1并行端口,将其设置为“LPT1”。

邮槽(服务器端)

CreateMailslot —— 打开设备的函数。

将参数pszName设置为“\\.\mailslot\mailslotname”,其中,“mailsoltname”是为邮槽取的名字,可以任意,前面的字符串是固定的。

邮槽(客户端)

CreateFile —— 打开设备的函数。

将参数pszName设置为“\\servername\mailslot\mailslotname”,其中,“mailsoltname”是为邮槽取的名字,可以任意,前面的字符串是固定的。

命名管道

(服务器端)

CreateFile —— 打开设备的函数。

将参数pszName设置为“\\.\pipe\pipename”,其中,“pipename”是为命名管道取的名字,可以任意,前面的字符串是固定的。

命名管道

(客户端)

CreateFile —— 打开设备的函数。

将参数pszName设置为“\\servername\pipe\pipename”,其中,“pipename”是为命名管道取的名字,可以任意,前面的字符串是固定的。

匿名管道

CreatePipe —— 打开设备的函数。

无论是客户端还是服务器端都以该函数创建或打开匿名管道。

套接字

socket —— 创建一个套接字描述符accept, or AcceptEx.

控制台

Console

CreateConsoleScreenBuffer,GetStdHandle —— 打开设备的函数

 

  从上表可以发现,很多设备都以CreateFile函数来创建和打开。这个函数以后会讲。

  打开了设备,你得到了一个设备的句柄,你就可以通过该句柄使用其他函数,来对相关设备进行设置。

  比如,现在打开了一个串行端口,然后要设置它的传输波特率:

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

 

  或者,你获得了一个邮槽句柄,可以设置读取数据的等待时间:

BOOL SetMailslotInfo(
   HANDLE hMailslot,
   DWORD  dwReadTimeout);

 

  最后,不要忘记关闭句柄,从而正确地关闭设备:

BOOL CloseHandle(HANDLE hObject);
int  closesocket(SOCKET s);    // 套接字的关闭

 

  如果你有了一个设备句柄,你可以调查它的设备类型,通过使用GetFileType函数,该函数的返回值表明了它是一个什么类型的设备,可以参考MSDN。

DWORD GetFileType(HANDLE hDevice);

 

  好了,现在让我们来讨论一下CreateFile函数:

HANDLE CreateFile(
   PCTSTR pszName,     
// 指明设备类型或一个特定的设备实体
   DWORD dwDesiredAccess,      // 访问限制
   DWORD dwShareMode,          // 共享方式
   PSECURITY_ATTRIBUTES psa,      // 安全描述结构
   DWORD dwCreationDisposition,   // 创建和打开方式
   DWORD dwFlagsAndAttributes,    // 属性旗标,与缓冲区和文件操作属性有关
   HANDLE hFileTemplate);         // 设备模版,一个设备句柄

 

  该函数成功,返回句柄,失败返回INVALID_HANDLE_VALUE(值为-1)。

  如果设置了最后一个参数hFileTemplate,那么就照着这个参数所代表的设备,创建一个属性相同的设备,当然,这个参数所表示的设备要具有“可读”的权限,即有GENERIC_READ访问权限。

  至于该函数的具体用法,可以参看本书或MSDN。

  要使用互斥内核对象,首先必须创建它:

HANDLE CreateMutex(
   PSECURITY_ATTRIBUTES psa,  
// 安全属性
   BOOL bInitialOwner,         // 互斥对象是否开始就被调用该函数的线程所拥有
   PCTSTR pszName);            // 该互斥内核对象的名字

 

  Windows Vista中还提供了一个函数用于创建一个互斥内核对象:

HANDLE CreateMutexEx(
   PSECURITY_ATTRIBUTES psa,     
// 安全属性
   PCTSTR pszName,        // 该互斥内核对象的名字
   DWORD dwFlags,         // 互斥对象是否开始就被调用该函数的线程所拥有
   DWORD dwDesiredAccess);        // 访问限制


  第1个函数中的bInitialOwner参数如果为TRUE,则创建的互斥内核对象一开始就被调用这个函数的线程所拥有,它的线程ID被设置为该线程的ID,递归计数器被设置为1。

  如果传递FALSE给这个参数,则互斥内核对象的线程ID和递归计数器被设置为0,表明该互斥内核对象不被任何线程所拥有,该互斥内核对象处于“已通知”状态。

  第2个函数的dwFlags的意义和第1个函数的bInitialOwner参数其实是一样的,0就好比FALSE,CREATE_MUTEX_INITIAL_OWNER就相当于TURE。

  这两个函数成功,返回互斥内核对象的句柄,失败返回NULL。

 

  你通过“名字”来可以打开一个已经创建了的互斥内核对象:

HANDLE OpenMutex(
   DWORD dwDesiredAccess,     
// 访问限制
   BOOL bInheritHandle,        // 是否允许返回的句柄被子进程继承
   PCTSTR pszName);            // 名字

 

  创建了一个互斥内核对象,得到了它的句柄之后,就可以让它保护资源了。

  一个线程中(下面用T表示),在你需要访问资源之前,可以先调用“等待函数”,传递该互斥对象(下面用M表示)的句柄该这些等待函数,在等待函数内部,通过句柄查看M的线程ID,如果不为0,表明M处于“未通知”状态,线程T进入等待状态(有例外,下面会讲)。此时系统会记住这个情况,当M被其他线程释放,它的线程ID重新被设置为0的时候,系统会将一个等待在它上面的线程(比如T)的ID设置为M的线程ID,同时将M的递归计数器设置为1,允许该线程(比如T)进入可调度状态。

  注意,对于互斥对象的线程ID的比较和设置都是以“原子”的形式进行的,所以互斥内核对象是“线程安全”的。

 

  下面来讲那个例外的情况,这就是互斥内核对象允许以不正常的规则进行使用。也就是在一个互斥内核对象处于“未通知”状态的时候,一个等待在它上面的线程“或许”可以继续运行

  比如当前有一个处于“未通知状态”的互斥内核对象M,一个线程T(ID为X)。T调用等待函数等待M,这种情况下,通常T会进入等待状态。但是,系统查看T的ID和M的线程ID相同,都是X的情况下,线程并不会进入等待状态,而是保持在可调度状态。在线程成功等待互斥内核对象之后,互斥内核对象M的递归计数器加1。

  也就是说,一个互斥内核对象的递归计数器要大于1,就要让线程多次等待相同的互斥内核对象。

 

  一旦当前线程成功地等待到了一个互斥内核对象之后,该线程就可以独占某些资源,从而可以访问这些共享的资源了。试图访问这些资源的其他线程通过等待相同的互斥对象,就会进入等待状态之中。

  当前线程如果对资源访问结束,必须释放互斥内核对象,使用ReleaseMutex函数:

BOOL ReleaseMutex(HANDLE hMutex);      // 参数是互斥内核对象句柄

 

  该函数将互斥内核对象的递归计数减1。如果一个线程多次成功地等待一个互斥内核对象,就要同样以相同的次数调用ReleaseMutex函数,从而递减其递归计数,当互斥内核对象的递归计数减为0后,其线程ID被设置为0,进入“已通知”状态。
  当这个互斥内核对象进入“已通知”状态之时,系统查看当前是否有线程等待它,如果有,就以公平的原则选择其中一个线程,将这个互斥内核对象的线程ID设置为这个选中的线程的线程ID,互斥对象的递归计数被设置为1。

 

  综合上面所叙述的,可以总结出,互斥内核对象不同于其他内核对象,就是它有一个“线程所有权”的概念,这就使得互斥内核对象比较特殊。

  一个线程调用ReleaseMutex函数释放一个互斥对象,这时系统查看互斥对象的线程ID和这个线程的线程ID是否相同,如果相同,互斥对象的递归计数减1;否则ReleaseMutex不做任何工作,返回FALSE。

  还有一种现象,称做“互斥对象被抛弃”。

  假设一个互斥内核对象为一个线程所拥有,而这个线程却因为某些特殊的原因在终止,比如调用了ExitThread或TerminateThread函数,但是它在终止之前没有释放这个互斥对象。这个时候,系统能够跟踪拥有互斥内核对象的线程内核对象,系统知道这个互斥对象被一个线程抛弃了,就将互斥对象的线程ID设置为0, 将其递归计数设置为0。然后,系统查看是否有其他线程在等待这个互斥对象,如果有,就公平地选中一个,将互斥对象的线程ID设置为选中的线程的线程ID,这和前面的论述是一样的,差别是等待函数返回的值是WAIT_ABANDONED,而不是WAIT_OBJECT_0。这个时候,访问资源是不合适的,因为不知道资源处于何种状态。

 

本来不打算写这篇的,但是文件的重要性大家都知道。在设备I/O中,有一种设备叫文件设备,这是一个抽象的概念,就把它理解为文件就行了。

  文件设备,可以通过CreateFile函数打开,得到一个文件对象句柄。

 

  在文件中,有两个比较重要的属性:

1、文件大小:在32位中最大为4GB,64位中可以达到16EB。

2、文件读写指针:这个指针表明读写位置,大小范围可以超出文件的大小。

 

  先讨论文件的大小。

  要得到文件的大小,可以使用GetFileSizeEx函数:

BOOL GetFileSizeEx(
   HANDLE         hFile,     
// 文件对象句柄
   PLARGE_INTEGER pliFileSize);   // LARGE_INTEGER联合的指针,返回大小

 

  这个函数接受一个LARGE_INTEGER联合的指针,用来返回文件大小,这个结构可以表示64位值:

typedef union _LARGE_INTEGER {
   
struct  {
      DWORD LowPart;    
//  低32位值
      LONG HighPart;        //  高32位值

   };
   LONGLONG QuadPart;   
//  64位值得
} LARGE_INTEGER,  * PLARGE_INTEGER;

 

  从这个定义可以看出,该联合可以用QuadPart表示一个64位值,也可以差分成两个32位值。这个联合有一个无符号数版本,叫做ULAGER_INTEGER联合,对应的3个成员都是保存的无符号数。

 

  还有一个函数可以得到一个文件的大小:

DWORD GetCompressedFileSize(
   PCTSTR pszFileName,          
// 文件路径名
   PDWORD pdwFileSizeHigh);  // 文件大小如果大于4GB,高32位值由该参数返回

 

  这个函数接受一个文件的路径名称,返回文件大小的低32位值,高32位值由参数pdwFileSizeHigh返回。与GetFileSizeEx不同的是,该函数返回一个文件的物理大小,而GetFileSizeEx返回文件的逻辑大小。

  比如一个文件大小为100KB,它被压缩为85KB,如果使用GetFileSizeEx,则返回100KB,使用GetCompressedFileSize则返回85KB。

  与GetFileSizeEx不同的是,该函数接受一个字符串,指明文件路径,这就可以直接查询某个文件大小,而不要先打开它获得它的句柄。

  可以如下使用该函数:

ULARGE_INTEGER ulFileSize;      // 与LARGE_INTEGER联合类似,保存无符号数
ulFileSize.LowPart  =  GetCompressedFileSize(TEXT( " SomeFile.dat " ),
   
& ulFileSize.HighPart);      // 取得当前目录下的SomeFile.dat文件大小


  这样,64位的文件大小存储在ulFileSize.QuadPart中。

 

  讨论完了文件的大小,下面来讨论文件读写指针。

  CreateFile函数创建或打开了一个文件内核对象,该内核对象中管理着一个“文件读写指针”。该指针指明了一个64位的偏移量。初始情况下,该指针设置为0,即你读取或写入数据的时候从文件开始处进行,即从偏移量为0的地方开始。每次读取或写入N字节的数据,系统更新该读写指针,使偏移量加上N个字节。比如下面代码反映了读取文件前100个字节的数据:

BYTE pbFirst[ 50 ], pbSecond[ 50 ];
DWORD dwNumBytes;
HANDLE hFile 
=  CreateFile(TEXT( " MyFile.dat " ), ...);  // 指针初始化为0
ReadFile(hFile, pbFirst,  50 & dwNumBytes, NULL);     // 读取第0~49字节
ReadFile(hFile, pbSecond,  50 & dwNumBytes, NULL); // 读取第50~99字节

 

  需要注意的是,一个文件对象句柄对应一个读写指针,如果一个文件被打开多次,那么就有多个文件对象,每个文件对象管理着一个读写指针,这些指针相互之间不影响。比如下面的代码:

BYTE pb[ 10 ];
DWORD dwNumBytes;
HANDLE hFile1 
=  CreateFile(TEXT( " MyFile.dat " ), ...);  // 指针初始化为0
HANDLE hFile2  =  CreateFile(TEXT( " MyFile.dat " ), ...);  // 指针初始化为0
ReadFile(hFile1, pb,  10 & dwNumBytes, NULL);   // 读取第0~9字节
ReadFile(hFile2, pb,  10 & dwNumBytes, NULL);   // 也是读取第0~9字节

 

  上面这段代码,hFile1和hFile2是同一个文件的两个不同的文件内核对象的句柄,这两个内核对象管理着两个不同文件指针,所以改变其中一个的读写指针,不会影响另一个。

  下面这段代码更能说明问题:

BYTE pb[ 10 ];
DWORD dwNumBytes;
HANDLE hFile1 
=  CreateFile(TEXT( " MyFile.dat " ), ...);  // 读写指针初始化为0
HANDLE hFile2;      // 另一个文件句柄

// 将本进程内hFile1句柄值复制给本进程中的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字节

 

  上面这段代码,使用DuplicateHandle函数复制句柄,使得两个句柄hFile1和hFile2共用同一个文件内核对象,因此读写指针也是共用的。

 

  可以使用SetFilePointerEx函数来定位文件读写指针:

BOOL SetFilePointerEx(
   HANDLE         hFile,     
// 文件内核对象句柄
   LARGE_INTEGER  liDistanceToMove,      // 64位数,移动字节数
   PLARGE_INTEGER pliNewFilePointer,     // 返回新的文件读写指针位置
   DWORD          dwMoveMethod);         // 移动方式


  该函数中dwMoveMethod告诉系统如何移动。FILE_BEGIN,表示从文件头开始移动;FILE_END,表示从文件尾往前移动;FILE_CURRENT,表示从当前读写指针位置移动。移动的位移量在第2个参数liDistaceToMove中。

 

  有几点需要注意

  • 将文件读写指针的位置设置为超过文件大小范围是合法的。这么做不会使得文件大小变大,除非调用函数SetEndOfFile。
  • 当打开文件使用函数CreateFile时,该函数的dwFlagsAndAttributes参数中包括FILE_FLAG_NO_BUFFERING,文件读写指针只能被设置为硬盘扇区的单位大小。
  • 没有GetFilePointerEx函数来取得当前文件指针位置,可以调用SetFilePointerEx函数来得到其位置,要把第二个参数设置为0,如下代码:
LARGE_INTEGER liCurrentPosition  =  {  0  };
SetFilePointerEx(hFile, liCurrentPosition,
                        
& liCurrentPosition,FILE_CURRENT);

 

  当文件被关闭的时候,系统会在文件上设置一个结束位置,以确定该文件的大小。当然,你也可以自己设置文件的结束位置,以此来改变文件的大小。使用SetEndOfFile函数:

BOOL SetEndOfFile(HANDLE hFile);

 

  该文件在当前的文件读写指针处设置文件的结束标志,来截断或扩展文件的大小。比如,你想设置一个文件的大小为1024字节的话,可以通过以下代码实现:

HANDLE hFile  =  CreateFile(...);
LARGE_INTEGER liDistanceToMove;
liDistanceToMove.QuadPart 
=   1024 ;
// 设置文件指针
SetFilePointerEx(hFile, liDistanceToMove, NULL, FILE_BEGIN);
SetEndOfFile(hFile);     
// 在文件指针处设置结束标志
CloseHandle(hFile);
前面曾经讲过,设备I/O的方式有两种:同步和异步。本篇介绍一下同步设备I/O。主要涉及到两个函数:ReadFile和WriteFile。

  不要被这两个函数的名称迷惑,不仅可以将这两个作用于文件,也可以作用于其他设备:比如管道、邮槽等。

 

  最简单的设备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);

 

  在同步方式下使用这2个函数进行设备I/O,在同步模式下,两个函数的最后一个参数pOverlapped都要设置为NULL。另外,必须要注意这一点:就是在用CreateFile创建或打开设备之时,其FLAG参数不能包括FLAG_FILE_OVERLAPPED,否则系统认为你想要异步地实现设备I/O。

  另外,ReadFile只能读取这些设备,即在使用CreateFile创建或打开设备的时候,该函数的FLAG参数中包括GENERIC_READ。而WriteFile只能写入这些设备,即使用CreateFile函数的时候,FLAG参数包括GENERIC_WRITE。

 

  邮槽、管道、文件、串行端口等设备是有自己的高速缓存的。如果在CreateFile函数的FLAG参数中没有包括FILE_FLAG_NO_BUFFERING,也就是可以将写入的数据暂存在缓冲区中,那么可以通过FlushFileBuffers来强行将暂存在与设备有关的缓冲区中的全部数据写入到设备中。

BOOL FlushFileBuffers(HANDLE hFile);

 

  同步方式的设备I/O实现简单,但是缺点也是明显的,就是会阻碍有关线程中的其他与设备I/O无关的操作。因为设备I/O函数直到设备I/O请求结束才返回,如果数据量大,很可能会阻碍其他无关的操作。

  为了解决这个问题,你应该尽量使用异步的设备I/O。但是可惜的是,Windows API中,没有为CreaetFile这个函数提供任何异步的方式来实现。Windows Vista提供了另一种方法:中途取消同步设备I/O。可以通过使用函数CancelSynchronousIo来取消一个线程之内的正在进行的同步设备I/O操作。

BOOL CancelSynchronousIo(HANDLE hThread);      // 参数是线程句柄

 

  该函数接受一个线程句柄,该句柄是一个正在等待同步设备I/O操作完成的线程的句柄。该句柄在创建或打开的时候,必须具有THREAD_TERMINATE操作权限。如果你调用CreateThread或_beginthreadex函数来创建线程,那么返回的线程句柄就包含THREAD_TERMINATED的操作权限。如果使用OpenThread函数来获得一个已创建的线程的句柄,那么就传递THREAD_TERMINATED给dwDesiredAccess参数(第1个参数)。如果没有设置该权限,那么CancelSynchronousIo返回FALSE,调用GetLastError返回ERROR_ACCESS_DENIED(访问拒绝错误)。

  如果线程已经结束了等待设备I/O,那么再调用CancelSynchronousIo函数会返回TRUE,而不是FLASE,调用GetLastError则返回ERROR_OPERATION_ABORTED(操作失败错误)。

  如果线程并不是等待在设备I/O的返回上,那么调用该函数会返回FALSE,随后调用GetLastError会返回ERROR_NO_FOUND(未找到的错误)。

 

 异步设备I/O适用于大数据量和高性能的场合,比如服务器。

  要使用异步设备I/O,在调用CreateFile来打开或创建一个设备的时候,让参数dwFlagsAndAttributes包括FILE_FALG_OVERLAPPED,这意味着想让打开的设备可以被异步访问。

  为了发送一个I/O请求给一个设备,也就是让一个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。当使用异步I/O方式的时候,在调用这两个函数的时候,可以将NULL传递给pdwNumBytes参数,因为不知道何时设备I/O完成,因此使用这个参数没有多大意义。

  注意最后一个参数,是一个OVERLAPPED结构的指针:

typedef  struct  _OVERLAPPED {
   DWORD  Internal;     
//  错误代码(出口参数,返回)
   DWORD  InternalHigh;  //  传输的数据大小,以字节为单位(出口参数,返回)
   DWORD  Offset;        //  低32位偏移量(入口参数,输入)
   DWORD  OffsetHigh;    //  高32位偏移量(入口参数,输入)
   HANDLE hEvent;        //  事件内核对象句柄(入口参数,输入)
} OVERLAPPED,  * LPOVERLAPPED;

 

  该结构包含5个成员,其中的3个——Offset、OffsetHigh、hEvent应该在调用ReadFile和WriteFile之前被初始化,另外的2个——Internal、InternalHigh会在I/O完成的时候被设备驱动程序所设置,下面细述一下:

  • Offset、OffsetHigh —— 在使用异步设备I/O来操纵“文件设备”的时候,文件读写指针被忽略,此时I/O的偏移量由OVERLAPPED结构中的Offset和OffsetHigh决定。另外,在“非文件设备”中,这两个成员不会被忽略,一般必须要设置为0。
  • hEvent —— 一个事件内核对象句柄,可以有多种使用方法,后面会讲到。
  • Internal —— 保存I/O错误码,当你发送一个I/O请求的时候,该参数被设置为STATUS_PENDING,指明没有错误发生,因为操作还没有开始。你可以使用HasOverlappedIoCompleted宏来查看一个异步设备I/O是否完成,该结构接受一个OVERLAPPED结构指针,如果I/O请求完成返回TRUE。如果I/O请求仍然没有开始,返回FALSE。
  • InternalHigh —— 异步I/O请求完成的时候,该成员里保存了传送数据量的字节数。

  当异步I/O请求完成之后,你可以接受到一个OVERLAPPED结构的指针。一般可以让一个C++类从OVERLAPPED结构派生,类中加入一些其他信息,使得更容易处理。然后当使用ReadFile和WriteFile函数的时候,可以传递这个C++类对象的指针,当I/O完成之后,接受该结构的时候,可以将其转换为C++类对象,不但可以获得其5个成员,还可以获得类中的其他信息。

 

  使用异步设备I/O的时候,要注意以下三点:

  1、设备驱动程序不一定会按照一个“先进先出”(FIFO)的顺序来处理设备I/O请求,因此如下编码不会保证先读后写:

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

 

  2、以异步的方式进行I/O请求的是,驱动程序可能会选择同步的方式。当你读取一个文件的时候,如果系统发现读取的数据在cache中,且数据有效,那么该I/O请求就不需要驱动程序了,而是直接将cache中的数据复制到你的缓冲区中。驱动在某些操作上一直使用同步方式,比如在NTFS格式上的文件压缩,扩展文件长度,添加文件信息等。

  这个时候,如果ReadFile和WriteFile返回非0值,则表明它以同步方式进行。如果返回FLASE,说明发生了一个错误,这个时候可以通过GetLastError来取得信息,如果返回ERROR_IO_PENDING,则说明I/O请求成功提交,但没有完成。

 

  3、数据缓冲区和OVERLAPPED结构在异步I/O请求完成之前不能被移动或释放。当设备驱动准备处理你的I/O请求的时候,它将数据传送到pvBuffer参数对应的地址上去,并访问OVERLAPPED结构中的Offset等成员。当I/O请求完成之后,设备驱动更新OVERLAPPED结果中的Internal和InternalHigh成员。因此,不能在I/O请求完成之前移动或释放数据缓冲区和OVERLAPPED结构,否则,内存数据会被破坏,而且在每次调用ReadFile或WriteFile的时候,都必须分配一个单独的OVERLAPPED结构。

  比如,下面的代码是有BUG的:

VOID ReadData(HANDLE hFile)
{
   OVERLAPPED o 
=  {  0  };
   BYTE b[
100 ];
   ReadFile(hFile, b, 
100 , NULL,  & o);
}  
// 此时缓冲区b和OVERLAPPED结构o都被释放

 

  你可以将一个设备I/O请求取消排队,即撤消该请求。可以有如下方法:

 

  1、在一个线程中调用CancelIo函数,可以取消该线程发送给指定设备有关的所有I/O请求,除了指定的设备是“I/O完成端口”。

BOOL CancelIo(HANDLE hFile);      // 参数是设备对象句柄

 

  2、取消与一个设备有关的所有I/O请求,关闭这个设备句柄即可。

 

  3、当一个线程结束,系统自动取消该线程发送的所有I/O请求,除了发送给“I/O完成端口的”I/O请求。

 

  4、如果想取消某一个特定的I/O请求,可以使用CancelIoEx函数,传递一个OVERLAPPED结构指针给它:

BOOL CancelIoEx(HANDLE hFile, LPOVERLAPPED pOverlapped);

 

  该函数可以跨线程使用,也就是在T1线程内发送的I/O请求,可以在T2线程内通过该函数结束之。因为每个I/O请求都需要一个唯一的OVERLAPPED结构,所以该OVERLAPPED结构就标识了一个I/O请求。如果传递NULL给CancelIoEx函数的第2个参数,那么就会取消与hFile对应的设备的所有I/O请求。

 

  取消一个I/O请求,该I/O请求会结束,同时错误码被设置为ERROR_OPERATION_ABORTED。

 

上一篇,讨论了如何发送I/O请求。在异步的设备I/O请求方式下,要考虑的问题就是当I/O请求完成之后,驱动程序如何通知你的应用程序。本篇主要讨论获得通知的方法。

 

  Windows 提供了4种不同的技术方法来得到I/O完成的通知。

技术 

概要 

通知一个设备内核对象

当一个设备同时有多个I/O请求的时候,该方法不适用。

允许一个线程发送一个I/O请求,另一个线程处理之。

通知一个事件内核对象

允许一个设备同时有多个I/O请求。

允许一个线程发送一个I/O请求,另一个线程处理之。

告警I/O

允许一个设备同时有多个I/O请求。

必须在同一个线程中发送并处理同一个I/O请求。

I/O完成端口

允许一个设备同时有多个I/O请求。

允许一个线程发送一个I/O请求,另一个线程处理之。

该方法伸缩性好,而且性能高。

 

  本篇主要讨论前3种。

 

通知一个设备内核对象

  在Windows中,一个设备内核对象可以处于“已通知”或“未通知”状态。ReadFile和WriteFile在发送I/O请求之前让指定的设备内核对象处于“未通知”状态。当设备驱动程序完成了I/O请求,驱动程序将设备内核对象设置为“已通知”状态。

  一个线程可以查看一个异步的I/O请求是否完成,通过等待函数即可实现:WaitForSingleObject或WaitForMultipleObject等。这就意味着,这种实现的方式不是完完全全的“异步”,最终有点“同步”的味道,因为这些等待函数可能会导致线程进入阻塞状态。

  可以如下地编码来使用这种方法:

 

// 创建或打开设备内核对象,注意使用FILE_FLAG_OVERLAPPED旗标
HANDLE hFile  =  CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
BYTE bBuffer[
100 ];      // I/O缓冲区
OVERLAPPED o  =  {  0  };      // 重叠结构,不要忘记初始化
o.Offset  =   345 ;      // 偏移量
BOOL bReadDone  =  ReadFile(hFile, bBuffer,  100 , NULL,  & o);    // 读取数据
DWORD dwError  =  GetLastError();

// ReadFile返回FLASE,但是错误码dwError表明I/O即将开始
if  ( ! bReadDone  &&  (dwError  ==  ERROR_IO_PENDING))
{
     
// 等待I/O请求完成
     WaitForSingleObject(hFile, INFINITE);
     bReadDone 
=  TRUE;
}
if  (bReadDone)
{
     
//  操作成功,可以查看OVERLAPPED结构中的各个字段和缓冲区中的数据
     
//  o.Internal 包含了I/O错误码
     
//  o.InternalHigh 包含了I/O传输字节数
     
//  缓冲区包含了读取的数据
}
else
{
     
//  错误发生,bReadDone为FLASE,且错误码dwError指明一个错误
}

 

  这种方法是十分简单的,实现起来十分容易,但是有一个明显的缺点,就是无法处理多个I/O请求。因为一旦一个I/O请求完成,等待函数就会返回,无法识别是哪个I/O请求完成了。

 

通知一个事件内核对象

  这种方法可以处理多个同时的I/O请求。

  记得OVERLAPPED结构中有一个hEvent成员吧,该成员是一个事件内核对象。使用这种方法,你必须使用CreateEvent函数来创建一个事件内核对象,并初始化那个hEvent成员。当一个异步I/O请求完成设备驱动程序查看OVERLAPPED中的hEvent是否为NULL,如果不是,驱动程序通过SetEvent通知该事件内核对象,同时也使得设备内核对象进入“已通知”状态。但是,你应该等待在该事件内核对象上。

  你可以让Windows不通知“文件内核对象”,这样可以少许提高一点性能,通过呼叫函数SetFileCompletionNotificationModes即可,传递一个设备内核对象句柄和FILE_SKIP_SET_EVENT_ON_HANDLE旗标:

BOOL SetFileCompletionNotificationModes(HANDLE hFile, UCHAR uFlags);

  为了处理多个I/O请求,你必须为每个I/O请求创建一个独立的事件内核对象,并将之初始化OVERLAPPED结构中的hEvent。然后可以通过WaitForMultipleObject来等待这些事件内核对象。这种方法可以实现一个设备上的多个I/O请求的处理。可以如下编码:

 

// 创建或打开设备,注意使用FILE_FLAG_OVERLAPPED
HANDLE hFile  =  CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
BYTE bReadBuffer[
10 ];      // 读缓冲区
OVERLAPPED oRead  =  {  0  };      // 定义OVERLAPPED结构,并初始化之
oRead.Offset  =   0 ;
oRead.hEvent 
=  CreateEvent(...);      // 创建事件内核对象,与读操作相关
ReadFile(hFile, bReadBuffer,  10 , NULL,  & oRead);

BYTE bWriteBuffer[
10 =  {  0 1 2 3 4 5 6 7 8 9  };
OVERLAPPED oWrite 
=  {  0  };
oWrite.Offset 
=   10 ;
oWrite.hEvent 
=  CreateEvent(...);      // 另一个事件内核对象,与写操作相关
WriteFile(hFile, bWriteBuffer, _countof(bWriteBuffer), 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请求完成之后,收到通知之后,可以得到有关OVERLAPPED结构的信息,通过函数GetOverlappedResult:

BOOL GetOverlappedResult(
   HANDLE      hFile,        
// 设备对象句柄
   OVERLAPPED *  pOverlapped,   // OVERLAPPED结构指针,返回OVERLAPPED
   PDWORD      pdwNumBytes,   // 返回传输的字节数
   BOOL        bWait);        // 是否等到I/O结束才返回

 

告警I/O

  当一个线程被创建的时候,系统也创建一个与该线程关联的队列,这个队列称为“异步过程调用”(APC)队列。当发送一个I/O请求的时候,你可以告诉驱动程序在APC队列中加入一个记录。当I/O请求完成之后,如果线程处于“待命状态”,则该记录中的回调函数可以被调用。

  让I/O请求完成的通知进入线程的APC队列,即在APC队列中添加一个I/O请求完成通知的记录,可以使用如下两个函数:

BOOL ReadFileEx(
   HANDLE      hFile,        
// 设备对象句柄
   PVOID       pvBuffer,      // 数据缓冲区
   DWORD       nNumBytesToRead,   // 预期传输的数据
   OVERLAPPED *  pOverlapped,       // OVERLAPPED结构指针
   LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine); // 回调函数指针

 

BOOL WriteFileEx(
   HANDLE      hFile,
   CONST VOID  
* pvBuffer,
   DWORD       nNumBytesToWrite,
   OVERLAPPED
*  pOverlapped,
   LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);


  注意一下函数的最后一个参数pfnCompletionRoutine,是一个函数指针,接受一个回调函数,这个函数就是被记录到APC队列的函数,函数头必须按如下格式书写:

VOID WINAPI CompletionRoutine(      // 函数名可以任意
   DWORD       dwError,      // 错误码
   DWORD       dwNumBytes,   // 传输的数据
   OVERLAPPED *  po);          // OVERLAPPED结构

 

  当使用ReadFileEx和WriteFileEx函数的时候,传递回调函数的地址,当驱动程序完成I/O请求之后,它在线程APC队列中添加一个记录,这个记录包含这个回调函数的地址和起初发送I/O请求时候的OVERLAPPED结构地址。

  当线程进入“待命状态”,系统检测线程APC队列,然后调用回调函数,并设置其3个参数。

  当I/O请求完成,系统不会马上调用记录在APC队列中的回调函数,因为线程可能没有进入“待命状态”。为了调用回调函数,你必须让线程进入“待命状态”,可以通过一些带“Ex”的等待函数来完成:

 

DWORD SleepEx(
   DWORD dwMilliseconds,
   BOOL  bAlertable);

DWORD WaitForSingleObjectEx(
   HANDLE hObject,
   DWORD  dwMilliseconds,
   BOOL   bAlertable);

DWORD WaitForMultipleObjectsEx(
   DWORD   cObjects,
   CONST HANDLE
*  phObjects,
   BOOL    bWaitAll,
   DWORD   dwMilliseconds,
   BOOL    bAlertable);

BOOL SignalObjectAndWait(
   HANDLE hObjectToSignal,
   HANDLE hObjectToWaitOn,
   DWORD  dwMilliseconds,
   BOOL   bAlertable);

BOOL GetQueuedCompletionStatusEx(
   HANDLE hCompPort,
   LPOVERLAPPED_ENTRY pCompPortEntries,
   ULONG ulCount,
   PULONG pulNumEntriesRemoved,
   DWORD dwMilliseconds,
   BOOL bAlertable);

DWORD MsgWaitForMultipleObjectsEx(
   DWORD   nCount,
   CONST HANDLE
*  pHandles,
   DWORD   dwMilliseconds,
   DWORD   dwWakeMask,
   DWORD   dwFlags);     
// 使用MWMO_ALERTABLE使线程进入“待命状态”

 

  除了MsgWaitForMultipleObjectEx函数之外,上面其余5个函数的最后一个参数bAlertalbe,指明了是否要线程进入“待命状态”,如果需要,请传递TRUE。

  当你调用上面这些等待函数,并让线程进入“待命状态”,系统首先查看线程的APC队列,如果至少有一个记录在APC队列中,系统不会让你的线程进入阻塞状态,而是调用回调函数,并提供其3个参数。当回调函数返回给系统,系统再次检查APC队列中的记录,如果存在,继续调用回调函数。否则,回调函数返回给用户(即普通的返回)。

  注意,如果APC队列中存在记录,那么调用上述等待函数,不会让你的线程进入阻塞状态。只有当APC队列中没有记录,调用这些函数的时候才会让线程进入阻塞状态,直到等待的内核对象为“已通知”状态或APC队列中出现记录。由于线程处于“待命状态”,因此一点APC队列中出现一个记录,那么系统唤醒你的线程,呼叫回调函数,清空APC队列,回调函数返回,线程继续执行。

  这6个等待函数返回的值说明了它们是因为什么原因而返回的。如果返回WAIT_IO_COMPLETION,那么说明了你的线程继续执行,因为至少一个APC记录被处理。如果返回其他的值,那么说明这些等待函数等待的内核对象为“已通知”状态(也可能是互斥内核对象被抛弃)或者等待超时。

 

  还有需要注意的是,系统调用APC回调函数,不是按FIFO的顺序,而是随意的。注意如下代码:

hFile  =  CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
ReadFileEx(hFile, ..., ReadRoutine1);   
// 第一次读,回调函数ReadRoutine1
WriteFileEx(hFile, ..., WriteRoutine1);  // 第一次写,回调函数WriteRoutine1
ReadFileEx(hFile, ..., ReadRoutine2);    // 第二次读,回调函数ReadRoutine2
SomeFunc();    // 其他一些操作
SleepEx(INFINITE, TRUE);      // 等待,线程进入“待命状态”

 

  线程发起了3次I/O请求,并给出了3个回调函数ReadRoutine1、WriteRoutine1、ReadRoutine2。然后线程执行SomeFunc函数,执行完成之后进入无限等待,当I/O请求结束,会调用3个APC队列中的回调函数。

  需要注意的是,如果3个I/O请求都在SomeFunc函数执行的时候完成,那么回调函数的调用顺序可能不是ReadRountine1、WriteRoutine1、ReadRoutine2,这个顺序是任意的。

 

  Windows提供了一个函数可以手动在一个线程的APC队列加入一个记录(即加入一个回调函数):

DWORD QueueUserAPC(
   PAPCFUNC  pfnAPC,     
// APC回调函数指针
   HANDLE    hThread,     // 线程对象句柄
   ULONG_PTR dwData);   // 传递给参数pfnAPC所对应的回调函数的参数

 

  其中第1个参数是一个函数指针,是一个回调函数,被记录到线程的APC队列,其函数头格式如下:

VOID WINAPI APCFunc(ULONG_PTR dwParam);

 

  QueueUserAPC函数的第2个参数指明了你想要设置的哪个线程的APC队列。第3个参数dwData就是传递给回调函数APCFunc的参数。QueueUserAPC可以让你的线程摆脱阻塞状态,此时上述等待函数返回码为WAIT_IO_COMPLETION。

 

  最后要讲的就是告警I/O的缺点:

  • 告警I/O的回调函数所提供的参数较少,因此处理上下文内容只能通过全局变量来实现。
  • 使用告警I/O,意味着发送I/O请求和处理I/O完成通知只能放在同一个线程中,如果发送多个I/O请求,该线程就不得不处理每个I/O完成通知,其他线程则会比较 空闲,这样会造成不平衡。
上一篇讲了3种接受异步I/O请求完成的通知的方法,分别是:通知一个设备内核对象、通知一个事件内核对象、告警I/O。

  本篇主要讲另一种接受异步I/O请求的方法——I/O完成端口。这是性能最高,且扩充性最好的方法。但是实现比较复杂。

 

 

 

  介绍I/O完成端口之前介绍两种服务器线程模型:

 

  • 连续模型:单个线程等待一个客户的请求,一旦有一个客户发出请求,该线程唤醒然后处理客户的请求。
  • 并发模型:单个线程等待一个客户的请求,一旦有一个客户发出请求,该线程创建另一个线程来处理请求。在新创建的线程处理请求的同时,原来等待请求的线程通过循环继续等待另一个客户的请求。当处理请求的线程处理完毕之后,自动销毁。

  连续模型最大的缺点就是无法同时处理多个请求。它只能等待、处理、等待、处理……如此交替进行。当有2个请求同时到来时,只能处理其中之一,第2个请求必须等待直到第1个请求处理完毕。Ping服务器就是典型的连续模型。

 

  并发模型,让一个线程专门地等待请求,该线程可以为每一个请求创建一个线程来处理之。其优点是等待请求的线程所做的工作很少,默认状态为阻塞状态。当一个客户请求到来的时候,该线程被唤醒,然后创建一个新的线程来处理这个请求,然后这个线程继续等待另一个请求。这样,当有多个客户请求同时到来的时候,它们可以几乎同时被处理。但是当客户请求过多,那么就会存在太多的处理线程,这些线程都是可以被调度的,那么就会出现很多次的“线程转换”,这样,Windows内核会花费大量的时间在“线程转换”这个工作上,从而浪费了大量的时间。Windows为了解决这个问题,提供了“I/O完成端口”内核对象。

 

 

 

  不妨设想一下,如果事先创建了一些线程,让这些线程处于等待状态,然后将所有用户的请求都投递到一个消息队列中,然后这些线程被唤醒,逐一地从消息队列中取出请求并进行处理,就可以避免为每个用户开辟线程,节省资源,也提高了线程利用率。其实I/O完成端口就是基于这样思想的产物。感觉就是一个“消息队列”,与本身的名字“I/O完成端口”没有很大的联系。

 

 

 

创建I/O完成端口

  I/O完成端口可以称为是最复杂的内核对象,可以使用CreateIoCompletionPort创建一个I/O完成端口内核对象:

 

HANDLE CreateIoCompletionPort(
   HANDLE    hFile,          
// 设备句柄
   HANDLE    hExistingCompletionPort,  // 已经创建的I/O完成端口对象句柄
   ULONG_PTR CompletionKey,            // 一个完成Key,相当于完成标号
   DWORD     dwNumberOfConcurrentThreads);  // 允许同时运行的线程个数

 

 

  乍看一下这个函数,很难理解。其实,这个函数有两个功能:创建I/O完成端口,将一个I/O完成端口与一个设备关联起来。因此,可以将该函数拆开。下面的函数CreateNewCompletionPort用来创建一个I/O完成端口:

 

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

 

 

  这个函数接受一个参数,并在内部调用CreateIoCompletionPort,将其前3个参数设置为INVALID_HANDLE_VLAUE,NULL,0。并保留最后一个参数给用户,如此便创建了一个I/O完成端口,参数dwNumberOfConcurrentThreads告诉I/O完成端口当前允许有多少个线程可以执行,如果传递0,则表示允许执行的线程个数没有限制。这个参数就是为了防止“线程切换”过于频繁。你可以动态地增加它的值,这样来测试一个合理的可运行线程数,以达到性能最佳。

 

 

 

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

  当你创建了一个I/O完成端口,内核实际上创建了5个数据结构:

 

1、设备列表:与创建的I/O完成端口关联的设备

 

2、I/O请求完成队列(FIFO):

 

3、等待线程队列(LIFO)

 

4、释放线程列表

 

5、暂停线程列表

 

 

 

  第1个数据结构:设备列表指明了与这个I/O完成端口关联的设备,可以是一个设备,也可以是多个设备。你可以通过CreateIoCompletionPort函数关联设备和I/O完成端口,也可以将该函数拆开,使用如下函数:

 

 

BOOL AssociateDeviceWithCompletionPort(
     HANDLE hCompletionPort,     
//  I/O完成端口内核对象句柄
     HANDLE hDevice,                  //  设备内核对象句柄
     DWORD dwCompletionKey)   //  完成Key
{
     HANDLE h 
=  CreateIoCompletionPort(hDevice, hCompletionPort, 
                        dwCompletionKey, 
0 );
     
return  (h  ==  hCompletionPort);
}

 

 

 

  这个函数提供了一个I/O完成端口句柄和一个设备句柄,并将两者关联起来。其中最后一个参数,是一个完成Key,在处理I/O请求完成的通知的时候,这个值才有用,它只是对你有意义,系统不会注意它。

 

  每次调用这个,系统在I/O完成端口的“设备列表”这个数据结构中添加了一个记录,这个记录指明了与这个I/O完成端口相关联的设备。

 

  由于CreateIoCompletionPort函数比较复杂,因此建议将其拆开使用,或者也可以同时创建I/O完成端口并关联设备,如下编码:

 

 

#define  CK_FILE 1
HANDLE hFile 
=  Create(...);
//  创建I/O完成端口并将hFile最代表的设备关联起来,允许可运行线程数为2
HANDLE hCompletionPort  =  CreateCompletionPort(hFile, NULL, CK_FILE,  2 );

 

 

 

  第2个数据结构是“I/O请求完成队列”。当一个异步设备I/O请求完成,系统查看该设备是否与一个I/O完成端口关联,如果是,系统在“I/O请求完成队列”的队尾加上一个“已经完成的I/O请求”的记录。队列中的每个记录指明以下内容:1、传输的数据的字节数;2、设备与I/O完成端口关联时候的完成Key;3、I/O请求的OVERLAPPED结构指针;4、一个错误码。

 

 

 

取得I/O完成端口状态信息

  当你的服务器系统启动,应该创建一个I/O完成端口,然后创建一个线程池来处理客户请求,一般而言,线程池中线程的数量为CPU的2倍。

 

  线程池中的所有线程所执行的功能是一样的,这些线程往往进入阻塞状态来等待设备I/O完成,可以通过GetQueuedCompletionStatus函数实现:

 

 

BOOL GetQueuedCompletionStatus(
   HANDLE       hCompletionPort,          
//  I/O完成端口对象句柄
   PDWORD       pdwNumberOfBytesTransferred,      // 传输数据的字节数
   PULONG_PTR   pCompletionKey,        // 关联的完成Key
   OVERLAPPED **  ppOverlapped,          // OVERLAPPED结构的指针的地址
   DWORD        dwMilliseconds);       // 等待时间(毫秒)


 

  这个函数让线程等待一个特定的I/O完成端口,通过第一个参数指明这个I/O完成端口。这个函数使得呼叫它的线程进入等待状态,直到在这个I/O完成端口的“I/O请求完成队列”中出现了一个记录,或者参数dwMilliseconds指明的时间超出。

 

 

 

  第3个数据结构:“等待线程队列”,指明了所有等待在这个I/O完成端口上的线程,这些线程都是因为呼叫GetQueuedCompletionStatus函数而等待一个I/O完成端口的,这些线程的ID记录在这个队列中,使得I/O完成端口可以知道哪些线程正在等待。当一个与I/O完成端口关联的设备完成了一个异步设备I/O请求的时候,“I/O请求完成队列”的队尾会出现一个记录,此时I/O完成端口唤醒在“等待线程队列”中的一个线程,这个线程呼叫的GetQueuedCompletionStatus函数会返回,并得到传输数据的字节数、完成Key、OVERLAPPED结构的地址。

 

  确定GetQueuedCompletionStatus函数返回的原因比较复杂,可以通过下面编码确定之:

 

 

DWORD dwNumBytes;          // 传输数据的字节数
ULONG_PTR CompletionKey;   // 完成Key
OVERLAPPED *  pOverlapped;   // OVERLAPPED结构指针

//  hIOCP是一个I/O完成端口对象句柄,在其他地方被创建
BOOL bOk  =  GetQueuedCompletionStatus(hIOCP,
          
& dwNumBytes,  & CompletionKey,  & pOverlapped,  1000 );
DWORD dwError 
=  GetLastError();      // 取得错误码

if  (bOk)
{
     
//  等待成功,一个I/O请求完成了,可以处理之
}
else
{
     
if  (pOverlapped  !=  NULL)
     {
          
//  I/O请求失败,dwError错误码包含了错误的原因
      }
     
else
     {
          
if  (dwError  ==  WAIT_TIMEOUT)
          {
               
//  等待时间超出,没有记录出现在“I/O请求完成队列”
           }
          
else
          {
              
//  错误地呼叫GetQueuedCompletionStatus,比如句柄无效
                
//  dwError错误码中包含错误的原因
           }
     }
}

 

 

 

  要注意的是,“I/O请求完成队列”中的记录是按FIFO的方式入队和出队的。而“等待线程队列”中的线程是按LIFO的方式进出的,很像堆栈(但是作者就说是queue)。

 

 

 

  在Windows Vista中,如果你希望很多I/O请求被同时提交或处理,你不需要增加很多线程,而可以通过GetQueuedCompletionStatusEx来取得多个I/O请求完成的结果:

 

 

BOOL GetQueuedCompletionStatusEx(
  HANDLE hCompletionPort,     
// I/O完成端口句柄
  LPOVERLAPPED_ENTRY pCompletionPortEntries,      // I/O请求完成记录数组
  ULONG ulCount,      // I/O请求完成记录的个数
  PULONG pulNumEntriesRemoved,     // 实际取得的I/O请求完成记录
  DWORD dwMilliseconds,    // 等待时间
  BOOL bAlertable);        // 是否让线程进入“待命状态”,一般设置为FALSE


 

  该函数的第2个参数是一个指向结构OVERLAPPED_ENTRY的地址(一般是一个该结构的数组),该结构定义如下:

 

 

typedef  struct  _OVERLAPPED_ENTRY {
   ULONG_PTR lpCompletionKey;     
// 完成Key
   LPOVERLAPPED lpOverlapped;      // OVERLAPPED指针
   ULONG_PTR Internal;                  // 该字段应该避免使用
   DWORD dwNumberOfBytesTransferred;    // 传输数据的字节数
} OVERLAPPED_ENTRY,  * LPOVERLAPPED_ENTRY;

 

 

  本书中有一节“How the I/O Completion Port Manages the Thread Pool

 

”,感觉没有必要说了,看看就行,都是内部细节。

 

  还有要讲的就是线程池中应该有多少个线程。看过一些资料,本书上说是CPU个数的2倍,还有一些资料上说是2*CPU个数+2,这个感觉也没有什么好讲的,具体问题具体分析吧,呵呵。

 

 

 

模仿完成的I/O请求

  你可以模仿一个完成的I/O请求,让某个等待在I/O完成端口上的线程唤醒并执行。这也是一种线程间通信的机制。你可以通过PostQueuedCompletionStatus实现之:

 

BOOL PostQueuedCompletionStatus(
   HANDLE      hCompletionPort,    
//  I/O完成对象句柄
   DWORD       dwNumBytes,          //  预期传递数据的字节数
   ULONG_PTR   CompletionKey,       //  完成Key
   OVERLAPPED *  pOverlapped);       //  OVERLAPPED结构指针

 

 

  该函数在I/O完成端口的“I/O请求完成队列”中加入一个记录,这个记录对应的一些数据由该函数的第2、3、4个参数给出。调用成功,该函数返回TRUE。

 

 

 

I/O完成端口使用步骤

  我以网络服务的套接字为例,说明一下I/O完成端口的使用步骤:

 

1、初始化套接字(Ws2_32.dll)——WSAStartup

 

2、创建一个I/O完成端口

 

3、创建一些线程,可以包含一个监听线程和若干个等待状态的处理线程

 

4、创建一个套接字socket,并邦定(bind),然后监听(listen)

 

5、反复循环,调用accept等待客户请求连接,

 

6、将连接进来的套接字与I/O完成端口关联起来

 

7、投递一个处理信息的请求,可以使用PostQueuedCompletionStatus,唤醒处理线程,从而让处理线程进行连接请求处理。

 

  如此重复5~7即可。

 

你可能感兴趣的:(线程同步)