让我们与星光探索者一起,探索Windows吧!

线程是我们最好的工具,可以用来对工作进行划分。然而当线程发出一个异步设备I/O请求时,他被临时挂起,知道设备完成I/O请求为止。此类挂起会损害性能。为了不让线程闲下来,我们需要让各个线程就他们正在执行的操作相互通信。Microsoft开发出了一个非常好的机制进行这类通信–I/O完成端口(I/O completion port)他可以帮助我们创建高性能而且伸缩性好的应用程序。通过使用I/O完成端口,我们可以让线程在读取设备和写入设备时不必等待设备的响应,从而显著提高吞吐量。

打开和关闭设备

以下为常见的可称为设备的东西,一般设备使用CreateFile打开,CloseHandle关闭。这些函数都返回一个标识设备的句柄,我们可以将句柄传给许多函数与设备进行通信,如调用SetCommConfig设置串口的波特率,SetMailslotInfo在等待读取数据时,设置超时值。如果我们想知道设备的类型,可通过调用GetFileType函数获得

设备 常见用途
文件 永久储存任何数据
目录 属性和文件压缩的设置
逻辑磁盘驱动器 格式化驱动器
物理磁盘驱动器 访问分区表
串口 通过电话线传输数据
并口 将数据传输到打印机
邮箱槽 一对多数据传输,通常是通过网络传到另一台运行Windows的机器上
命名管道 一对一数据传输,通常是通过网络传到另一台运行Windows的机器上
匿名管道 单机上一对一数据传输,绝对不会跨网络
套接字 报文或数据流的传输,通常是通过网络传到任何支持套接字的机器上,机器不一定要运行Windows操作系统
控制台 文本窗口的屏幕缓存

以下是打开各种设备的函数

设备 用于打开设备的函数
文件 CreateFile(lpFileName为路径名或UNC路径名)
目录 CreateFile(lpFileName为路径名或UNC路径名),在调用时指定FILE_FLAG_BACKUP_SEMATICS标志,Windows允许我们打开一个目录。打开目录可以使我们更改他的属性和时间戳
逻辑磁盘驱动器 CreateFile(lpFileName为“\.{x}:”的形式),{x}为驱动器的盘符。打开驱动器可以使我们能格式化驱动器或检测驱动器媒介大小
物理磁盘驱动器 CreateFile(lpFileName为“\.\PHYSICALDRIV{x}”的形式),此时{x}为物理驱动器号,这样windows允许我们打开一个物理驱动器。如为了读写用户的第一个物理驱动器的扇区,我们可指定”.\PHYSICALDRIV0”。如果错误写入数据,会导致操作系统无法访问磁盘的内容
串口 CreateFile(lpFileName为“COMx”)
并口 CreateFile(lpFileName为“LPTx”)
邮件槽服务器 CreateMailslot(lpFileName为“\\.\mailslot\mailslotname”)
邮件槽客户端 CreateFile(lpName为 “\\servername\mailslot\mailslotname”)
命名管道服务器 CreateNamedFile(lpName为“\.\pipe\pipename”)
命名管道客户端 CreateFile(lpFileName为”\\servername\pipe\pipename”)
匿名管道 CreatePipe创建服务端或客户端
套接字 socketacceptAcceptEx
控制台 CreateConsoleScreenBufferGetStdHandle

下面深入探讨CreateFile函数。CreateFile不仅可以创建和打开磁盘上的文件,还可以打开其他设备。

1
2
3
4
5
6
7
8
9
HANDLE CreateFile(
LPCTSTR lpFileName, // 文件路径
DWORD dwDesiredAccess, // 访问模式
DWORD dwShareMode, // 共享模式
LPSECURITY_ATTRIBUTES lpSecurityAttributes, // 安全描述符
DWORD dwCreationDisposition, // 创建方式
DWORD dwFlagsAndAttributes, // 文件属性
HANDLE hTemplateFile
);

lpFileName为文件路径,文件路径长度不能超过MAX_PATH(260)。如果调用的是Unicode版本,可在路径名加“\\?\”前缀,我们可以超越这个限制,但此时传入的路径不再能是相对路径。更多详细请看官网解释

使用文件设备

Windows的设计允许我们处理非常大的文件。Microsoft最初设计者选择了64位值表示文件大小,这意味着理论上一个文件最大可以达到16EB。在32位操作系统处理64位值不太方便,因为Windows函数要求我们将一个64为的值分成两个32位值传入。但处理这个并不难。

我们可调用GetFileSizeEx 函数获得文件的逻辑大小,GetCompressedFileSize函数获得文件的物理大小,SetFilePointerEx设置文件指针,调用SetEndOfFile 函数设置文件尾

对于I/O操作时,我们可以进行同步设备IO也可以异步设备IO。下图为具体的说明

同步设备I/O

最方便最常用对设备数据进行读写的函数是ReadFileWriteFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
BOOL ReadFile(
HANDLE hFile, // 设备句柄
LPVOID lpBuffer, // 读入数据缓冲区
DWORD nNumberOfBytesToRead, // 读取字节数
LPDWORD lpNumberOfBytesRead, // 成功读到的字节数
LPOVERLAPPED lpOverlapped // 异步IO时使用
);

BOOL WriteFile(
HANDLE hFile, // 设备句柄
LPCVOID lpBuffer, // 写入数据缓冲区
DWORD nNumberOfBytesToWrite, // 写入字符数
LPDWORD lpNumberOfBytesWritten, // 成功写入字符数
LPOVERLAPPED lpOverlapped // 异步IO时使用
);

在使用同步I/O时,我们打开设备时不能指定FILE_FLAG_OVERLAPPED标志。否则系统认为我们想要与设备执行异步I/O。当然只有在用读模式打开设备才能读设备数据。写模式同理。

在CreateFile的时候,我们可以传一些标志来改变系统对设备数据进行缓存的方式。如果我们想要强制把缓存数据写入到设备,调用FlushFileBuffers 。当然,只有在用写模式打开的设备才会起作用。

1
BOOL FlushFileBuffers(HANDLE hFile /*设备句柄*/);

然而,在进行同步设备I/O时,线程由于正在等待CreateFile返回而被阻塞,那么窗口消息将无法得到处理,该线程创建的所有窗口都会停滞在那里。我们总是看到应用程序已停止响应,多半是因为等待同步I/O操作。因而为了创建响应性好的应用程序,我们应该尽可能执行异步操作。

不幸的是,某些Windows API没有提供任何方法来完成异步调用。我们可调用CancelSynchronousIo来取消正在进行的同步I/O操作。

异步设备I/O

与计算机执行的其他操作相比,IO是最慢不可预测的操作之一。CPU从文件中或跨网络读取数据的速度,以及CPU向文件或跨网络写入数据的速度,比他执行算数运算的速度,甚至比他绘制屏幕的速度都要慢很多。但是,使用异步设备I/O使我们能够更好的使用资源。

假设线程向设备发出异步I/O请求,这个请求被传给设备驱动程序,后者负责完成实际的I/O操作。当驱动程序在等待设备响应时,应用程序的线程并没有因为要等待I/O请求而挂起,而是继续执行其他任务。

为了以异步方式访问设备,我们在CreateFile时指定 FILE_FLAG_OVERAPPED 标志,这样就可以异步方式访问设备。对于异步方式访问设备,在ReadFile和WriteFile调用时,需要传第五个参数。lpOverlapped,即我们需要初始化一个OVERLAPPED 结构,他的Offset,OffsetHigh,hEvent必须初始化,默认可全部初始化为0。对于不同的I/O请求,OVERLAPPED结构要初始化不同的。

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct _OVERLAPPED {
ULONG_PTR Internal; // 错误码
ULONG_PTR InternalHigh; // 异步I/O请求完成时,已传输的字节数
union {
struct { // 表示设备读取的开始位置相对于设备起始位置的偏移
DWORD Offset; // 低32位设备偏移量
DWORD OffsetHigh; // 高32位设备偏移量
} DUMMYSTRUCTNAME;
PVOID Pointer;
} DUMMYUNIONNAME;
HANDLE hEvent; // 接收I/O通知时可能会用到,许多开发人员在这里保存C++对象地址
} OVERLAPPED, *LPOVERLAPPED;

在执行异步I/O时,我们应意识到,设备驱动程序不必以先进先出的方式来处理I/O请求。不按顺序执行I/O在某些方面上会提高性能。当我们试图添加一个异步I/O请求时,设备驱动程序可能会选择以同步方式处理请求。如我们从文件中读取数据时,系统会检查我们想要的数据是否在系统缓存,这是就很可能发生这种情况。如果数据在缓存中,系统就不把这个I/O请求添加到设备驱动程序队列中了,而是将高速缓存中的数据复制过来。对于NTFS文件压缩,增大文件长度,向文件追加信息,通常驱动程序以同步方式操作。

最重要的一点,在进行I/O操作时,一定不能移动或释放发出I/O请求时使用的数据缓存和OVERLAPPED结构,否则内存可能会遭到破坏。例如下面的代码:

1
2
3
4
5
6
void ReadData(HANDLE hFile)
{
OVERLAPPED o = {0};
BYTE b[256];
ReadFile(hFile, b, 256, NULL, &o);
}

调用了ReadData时,似乎没什么问题,但是真的吗?由于这里调用ReadFile时,如果hFile句柄是用异步方式打开的,异步I/O请求加入队列之后,此函数就返回了。这就导致局部变量o,b都被释放,但设备驱动程序无法意识到。I/O完成之时,设备驱动程序就去修改线程栈中内存,从而破坏数据。

如果在调用ReadFile或WriteFile,同步方式执行,那么这两个函数返回值为非零值;如果异步方式执行,发生了错误,这两个函数返回FALSE。调用GetLastError来检查到底发生了什么,如果GetLastError返回ERROR_IO_PENDING,那么I/O请求已加入队列,晚些时候完成;返回ERROR_IO_PENDING以外的值,表明I/O请求无法添加到设备驱动队列。

取消队列中的I/O请求

我们可能想在设备驱动程序对一个已经加入队列的设备I/O请求进行处理之前将其取消,我们可以:

  • 调用CancelIo取消给定句柄所标识的线程加入到队列中的所有I/O请求(除非该句柄具有与之相关联的I/O完成端口)

  • 关闭设备句柄,不管是哪个线程添加的

  • 当线程终止时,系统自动取消该线程发出的所有I/O请求,但如果请求被发往的设备句柄具有与之关联的I/O完成端口,那么它们不再被取消之列。

  • 调用CancelIoEx取消一个指定的I/O请求,这个函数要求你传入lpOverlapped,也就是调用ReadFile或WriteFile时的参数。如果lpOverlapped为NULL,那么将取消指定设备所有待处理的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请求完毕进行同步。设备内核对象可以进行线程同步,在ReadFile和WriteFile函数将I/O请求添加到队列之前,设备内核对象处于未触发状态,请求完成时设备内核对象处于触发状态。看下放代码例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ... 处省去了一些参数
HANDLE hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
// 用FILE_FLAG_OVERLAPPED来异步方式打开设备

BYTE bBuffer[66];
OVERLAPPED o = {0}; // 必须初始化OVERLAPPED结构

// 读文件,并尝试获取错误
BOOL bReadDone = ReadFile(hFile, bBuffer, 66, NULL, &o);
DWORD dwError = GetLastError();

// !bReadDone用于判断文件是否读完,有的时候文件一下就读完了
// dwError==ERROR_IO_PENDING用于判断是否已添加I/O请求
if (!bReadDone && (dwError == ERROR_IO_PENDING)
{
// 文件没读完,但是成功添加I/O请求,将执行这里的代码
WaitForSingleObject(hFile, INFINITE); // 等待设备内核对象触发
bReadDone = TRUE; // 标记我们已读完文件
}

if (bReadDone)
{
// 读文件读完了,解析bBuffer内容
}
else
{
// 读文件时发生了错误,可进行错误处理
}

这段代码先发出了一个异步I/O请求,然后立即等待请求完成,异步I/O设计来可不是为了这么用的。但是这段代码有利于各位巩固I/O请求的相关内容。

触发事件内核对象

假如我们想在文件中读取10个字节并同时向文件写入10字节(读和写的位置不同),那么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HANDLE hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);


BYTE bReadBuffer[10];
BYTE bWriteBuffer[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
OVERLAPPED oRead = {0};
OVERLAPPED oWrite = {0};

oWrite.Offset = 10;


ReadFile(hFile, bReadBuffer, 10, NULL, &oRead);
WriteFile(hFile, bWriteBuffer, _countof(bWriteBuffer), NULL, &oWrite);

// 等待hFile触发,hFile触发时,什么完成了:读?写?还是都完成了?
WaitForSingleObject(hFile, INFINITE);

我们不能通过等待设备内核对象被触发来对线程进行同步,这是因为任何一个操作完成,内核对象就被触发。我们需要一个更好的方式来同时执行多个异步I/O操作。

OVERLAPPED结构的hEvent用来标识一个事件内核对象。我们必须通过CreateEvent来创建他。当一个异步I/O请求完成时,设备驱动程序会检查OVERLAPPED结构hEvent成员是否为NULL,如果不为NULL,驱动程序调用SetEvent触发事件。在“触发事件内核对象”这个技术中,我们就不该等待设备对象触发,而是等待事件对象。

为了略微提高性能,我们可以使Windows在操作完成时不要触发文件对象。调用SetFileCompletionNotificationModes(hFile, FILE_SKIP_SET_EVENT_ON_HANDLE)来完成我们的目的。

可提醒I/O

最初Microsoft开发人员吹捧说,可提醒I/O(或叫可警告I/O,alertable I/O)是创建高性能且伸缩性好的引用的最佳途径,后来事实证明这并不能达到Microsoft的承诺。

当系统创建一个线程时,会同时创建一个与线程相关的队列,即异步过程调用(asynchronous procedure call, APC)队列。当发出一个I/O请求时,我们可以告诉设备驱动程序在调用线程的APC队列中添加一项。为了将I/O完成通知添加到线程APC队列中,我们应该调用ReadFileExWriteFileEx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
BOOL ReadFileEx(
HANDLE hFile,
LPCVOID lpBuffer,
DWORD nNumberOfBytesToRead,
LPOVERLAPPED lpOverlapped,
LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

BOOL WriteFileEx(
HANDLE hFile,
LPCVOID lpBuffer,
DWORD nNumberOfBytesToWrite,
LPOVERLAPPED lpOverlapped,
LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

与ReadFile和WriteFile相似,ReadFileEx和WriteFileEx在I/O请求发给设备驱动程序后,会立即返回。但是,这两个Ex结尾的函数没有一个指向DWORD的指针保存已传输的字节数,只有回调函数lpComplectionRoutine看得到。这个回调函数称为完成函数(complection routine)

1
2
3
4
5
VOID WINAPI ComplectionRoutine(
DWORD dwError, // 错误码
DWORD dwNumBytes, // 传输字节数
OVERLAPPED* po // 调用时传入
);

当我们用ReadFileEx和WriteFileEx发送I/O请求时,这两个函数会将回调函数的地址传给设备驱动程序。设备驱动程序完成I/O请求时,会在发出I/O请求时的APC队列中添加一项(并不一定按顺序添加),该项包含了完成函数地址和发I/O请求时OVERLAPPED地址。

当一个可提醒I/O完成时,设备驱动程序不会使用OVERLAPPED的hEvent成员,因此如果需要,我们可以将hEvent据为己用,这就是上方所说在hEvent存放C++对象地址原因。

当线程处于可提醒状态时,系统会检查他的APC队列,对队列中的每一项调用完成函数,并正确传入相关参数。我们通过调用SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectEx、SignalObjectAndWait、GetQueuedCompletionStatusEx、MsgWaitForMultipleObjectEx,将本线程设为可提醒状态。当调用这些函数时,系统会首先检查APC队列,如果队列中至少有一项,那线程不会进入睡眠状态,而是取出APC队列的那一项,调用回调函数。当回调函数返回时,就取出APC队列下一项如此类推。直至队列为空,然后可提醒函数返回。如果调用可提醒函数时,APC队列是空的,则线程才会将自己挂起,进入可提醒的睡眠状态,当APC队列出现一项或正在等待的那个内核对象被触发或超时,线程被唤醒,然后函数立即返回。

可提醒I/O的优劣:

  • 回调函数:必须创建回调函数,使代码变复杂。而且回调函数不能带额外的信息,使用不得不大量使用全局变量,幸运的是这些回调函数是被同一线程调用的,所以不需要同步。

  • 线程问题:发出I/O请求的线程必须同时对完成通知进行处理。可能使这个线程负载过大,而其他线程处于空闲状态却无事可做。

此外,我们可以调用QueueUserAPC 函数来手动添加一项到APC队列中。这个函数的第一个函数需要我们传入一个APC函数的地址,必须满足下面的函数原型:

1
VOID WINAPI APCFunc(ULONG_PTR);

上文提到了APC队列的一些性质,我们通常调用QueueUserAPC进行搞笑的线程间通信甚至能跨过进程界限,但遗憾的是我们只能传递一个值。我们也可以用来使线程强行退出等待状态,甚至还可以使一个线程杀死自己。

I/O完成端口

Windows设计目标是一个安全的健壮的操作系统。回顾历史,我们可以采用以下两种模型之一构架一个服务应用程序。

  • 串行模式(serial model):一个线程等待一个用户(通常是通过网络)发出请求,当线程到达时,线程被唤醒并对客户请求进行处理

  • 并发模式(concurrent model):一个线程等待一个客户请求,并创建一个新线程来处理请求。当新线程正在处理客户请求的时候,原来的线程进入下一次循环并等待另一个客户请求。当处理客户请求的线程完成整个处理过程时,该线程终止。

串行模型的问题在于他不能很好同时处理多个请求,因而并发模式极其受欢迎。Microsoft意识到并行模式下需要打造Windows一个出色的服务器环境,它设计出了I/O完成端口对象。