使用同步或异步的方式完成 I/O 访问和操作(Windows核心编程)

0x01 Windows 中对文件的底层操作

  • Windows 为了方便开发人员操作 I/O 设备(这些设备包括套接字、管道、文件、串口、目录等),对这些设备的差异进行了隐藏,所以开发人员在使用这些设备时不必关心使用的哪一种设备,只需要调用 CreateFile 这一个函数打开设备的操作即可
  • CreateFile 这个函数功能强大,不仅可以打开 I/O 设备、限制文件的访问、创建临时文件、定制缓存、甚至可以对 I/O 进行异步操作
    在这里插入图片描述

0x02 同步方式访问和操作 I/O 设备

  • 那么打开文件之后怎么操作文件中的数据呢,可以使用 ReadFile 和 WriteFile 两个函数,下面是读取文件的例子
#include <Windows.h>
#include <iostream>
#include <stdio.h>
#include <strsafe.h>

using namespace std;

DWORD WINAPI Create_File(WCHAR *FileName);
VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode);

int main(int argc, char **argv)
{
	WCHAR FileName[12] = TEXT("D:\post.txt");
	DWORD res = Create_File(FileName);

	// 打印错误函数
	if (res != TRUE) ErrorCodeTransformation(res);
	return 0;
}

DWORD WINAPI Create_File(WCHAR *FileName)
{
	// 以只读方式打开 D 盘中的文件,并且独占该文件的访问
	DWORD LastError; HANDLE file;
	file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, NULL, NULL);
	
	// 如果错误返回错误代码
	LastError = GetLastError();
	if (file == INVALID_HANDLE_VALUE) return LastError;

	// 查询文件大小
	LARGE_INTEGER FileSize = { 0 };
	GetFileSizeEx(file, &FileSize);
	cout << "[*] 打开文件成功" << endl;
	cout << "[*] 文件大小为: " << FileSize.LowPart << " 字节" << endl;
	
	// 判断文件大小是否小于 4096 个字节,太大就不打印了
	if (FileSize.LowPart < 4096)
	{
		// 读取文件当中的内容
		DWORD dwNumBytes;
		BYTE *Buffer = (BYTE *)malloc(FileSize.LowPart); 
		ReadFile(file, Buffer, FileSize.LowPart, &dwNumBytes, NULL);
		cout << "[*] 文件内容: " << endl << Buffer << endl;
	}

	// 关闭文件句柄
	CloseHandle(file);
	return TRUE;
}
  • 这一种方式就是同步 I/O 访问,也是菜鸟经常使用的一种方式。首先使用 CreateFile 打开 D 盘的文件,并且规定第二个参数为 GENERIC_READ 第五个参数为 OPEN_EXISTING,意思是以只读和独占方式打开文件,然后使用 GetFileSizeEx 获取文件的大小为之后读取文件做铺垫,最后如果文件小于 4096 就使用 ReadFile 函数打开文件,ReadFile 函数第二个参数是读取的数据存放的缓冲区,第三个参数表示读取的数据的大小

0x03 异步方式访问和操作 I/O 设备

  • 以同步方式访问和操作 I/O 接口的好处是非常的方便,因为只需要等待 I/O 操作完成就可以了,但是如果 I/O 操作变多缺点也随之而来,每次进行 I/O 操作时线程都会等待操作完成,更何况与计算机的大多数操作相比,I/O 操作是最慢的,会浪费掉大量的时间,不利于程序的伸缩性。这样的话异步 I/O 的优势得以显现,异步 I/O 并不会让线程等待,线程可以干其他的事情,等到异步 I/O 操作完成时应用程序就会接收到一个通知,通知 I/O 读写操作已经完成了,这样就可以处理剩下的工作
    在这里插入图片描述
  • 通过 CreateFile 函数就可以很轻易的以异步方式打开 I/O 设备,但是在异步 I/O 完成时通过什么样的手段才能接收到 I/O 完成的通知呢,目前有 4 中方法 (1) 触发设备内核对象 (2) 使用可提醒的 I/O (3)触发事件内核对象 (4) 使用 I/O 完成端口,其中使用 I/O 完成端口获取通知的方式是最好的,同时也是最复杂的

4 种获取异步 I/O 完成通知的方式,难度随序号顺序逐级增加

0x03 -> (1) 以触发设备内核对象的方式获取异步 I/O 完成通知

  • 以触发设备内核对象来获取异步 I/O 通知(将上面同步方式的代码稍作修改即可):
DWORD WINAPI Create_File(WCHAR *FileName)
{
	// 以只读方式打开 D 盘中的文件,并且独占该文件的访问,倒数第二个参数表示以异步方式进行 I/O 操作
	DWORD LastError; HANDLE file;
	file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
	
	// 如果错误返回错误代码
	LastError = GetLastError();
	if (file == INVALID_HANDLE_VALUE) return LastError;

	// 查询文件大小
	LARGE_INTEGER FileSize = { 0 };
	GetFileSizeEx(file, &FileSize);
	cout << "[*] 打开文件成功" << endl;
	cout << "[*] 文件大小为: " << FileSize.LowPart << " 字节" << endl;
	
	// 判断文件大小是否小于 4096 个字节,太大就不打印了
	if (FileSize.LowPart < 4096)
	{
		// 读取文件当中的内容
		BOOL res; DWORD dwNumBytes; OVERLAPPED overlapped = { 0 };

		// 设置文件偏移,0 表示从文件开头读取数据,10 表示从文件第 10 个字节读取文件内容
		overlapped.Offset = 0;
		BYTE *Buffer = (BYTE *)malloc(FileSize.LowPart);

		// 以异步方式读取文件中的内容
		res = ReadFile(file, Buffer, FileSize.LowPart, &dwNumBytes, &overlapped);
		LastError = GetLastError();
	
		// 之后就可以干别的事了

		// 满足条件说明异步 I/O 操作成功
		if (res == FALSE && (LastError == ERROR_IO_PENDING))
		{
			// 等待异步 I/O 完成通知
			WaitForSingleObject(file, INFINITE);
			cout << "[*] 文件内容: " << endl << Buffer << endl;
		}
		else
		{
			// 异步操作失败则返回错误代码
			return LastError;
		}
	}
	// 关闭文件句柄
	CloseHandle(file);
	return TRUE;
}
  • 从以上代码可以看出,CreateFile 函数如果想以异步方式打开文件,必须向倒数第二个参数传递 FILE_FLAG_OVERLAPPED 标志,之后使用 WaitForSingleObject 函数等待通知即可,当然在这之前可以干别的事

0x03 -> (2) 以触发事件内核对象方式获取异步 I/O 完成通知

  • 什么是事件内核对象,事件内核对象是内核模式下同步线程的一种方式,事件内核对象有两种状态,分别为触发态和非触发态,当异步 I/O 没有完成时为非触发态,当异步 I/O 对象完成时为触发态,这也就是为什么事件内核对象可以获取异步 I/O 通知。修改过的代码如下所示:
DWORD WINAPI Create_File(WCHAR *FileName)
{
	// 以只读方式打开 D 盘中的文件,并且独占该文件的访问,倒数第二个参数表示以异步方式进行 I/O 操作
	DWORD LastError; HANDLE file;
	file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
	
	// 如果错误返回错误代码
	LastError = GetLastError();
	if (file == INVALID_HANDLE_VALUE) return LastError;

	// 查询文件大小
	LARGE_INTEGER FileSize = { 0 };
	GetFileSizeEx(file, &FileSize);
	cout << "[*] 打开文件成功" << endl;
	cout << "[*] 文件大小为: " << FileSize.LowPart << " 字节" << endl;
	
	// 判断文件大小是否小于 4096 个字节,太大就不打印了
	if (FileSize.LowPart < 4096)
	{
		// 读取文件当中的内容
		BOOL res; DWORD dwNumBytes; OVERLAPPED overlapped = { 0 };

		// 将 overlapped 结构体中的 hEvent 成员绑定一个事件内核对象
		overlapped.Offset = 0; overlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
		BYTE *Buffer = (BYTE *)malloc(FileSize.LowPart);

		// 以异步方式读取文件中的内容
		res = ReadFile(file, Buffer, FileSize.LowPart, &dwNumBytes, &overlapped);
		LastError = GetLastError();

		// 满足条件说明异步 I/O 操作成功
		if (res == FALSE && (LastError == ERROR_IO_PENDING))
		{
			// 等待异步 I/O 完成通知
			WaitForSingleObject(overlapped.hEvent, INFINITE);
			cout << "[*] 文件内容: " << endl << Buffer << endl;
		}
		else
		{
			return LastError;
		}
	}
	// 关闭文件句柄
	CloseHandle(file);
	return TRUE;
}
  • 以上代码相对于以触发内核对象方式获取异步 I/O 完成通知的方式只改了两个部分,第一个是将 overlapped 结构体中的 hEvent 成员绑定一个事件内核对象,该事件内核对象是自动重置且处于未触发状态;第二个是将 WaitForSingleObject 函数等待的句柄变为了 overlapped.hEvent,看起来好像比上一个复杂一些

0x03 -> (3) 使用可提醒的 I/O 获取异步 I/O 完成通知

  • 使用可提醒的 I/O 获取异步 I/O 完成通知相对于上面两种方式要更为复杂,同时也显得高大上许多,因为借助了 APC 队列。当发出一个异步 I/O 请求时,系统会将其添加到调用线程的 APC 队列当中,当异步 I/O 完成之后会调用回调函数,相当于接收了通知,示例代码如下
#include <Windows.h>
#include <iostream>
#include <stdio.h>
#include <strsafe.h>

using namespace std;

DWORD WINAPI Create_File(WCHAR *FileName);
VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode);
VOID WINAPI CompletionRoutine(DWORD dwError, DWORD dwNumByte, OVERLAPPED *po);

BYTE *Buffer;

int main(int argc, char **argv)
{
	WCHAR FileName[12] = TEXT("D:\post.txt");
	DWORD res = Create_File(FileName);

	// 打印错误函数
	if (res != TRUE) ErrorCodeTransformation(res);
	return 0;
}

DWORD WINAPI Create_File(WCHAR *FileName)
{
	DWORD LastError; HANDLE file;
	file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
	
	LastError = GetLastError();
	if (file == INVALID_HANDLE_VALUE) return LastError;

	LARGE_INTEGER FileSize = { 0 };
	GetFileSizeEx(file, &FileSize);
	cout << "[*] 打开文件成功" << endl;
	cout << "[*] 文件大小为: " << FileSize.LowPart << " 字节" << endl;
	
	// 读取文件当中的内容
	BOOL res; OVERLAPPED overlapped = { 0 }; 
	overlapped.Offset = 0;
	Buffer = (BYTE *)malloc(FileSize.LowPart);

	// 以异步方式读取文件中的内容
	res = ReadFileEx(file, Buffer, FileSize.LowPart, &overlapped, &CompletionRoutine);
	
	// 将线程设置为可提醒状态
	SleepEx(0, TRUE);

	// 关闭文件句柄
	CloseHandle(file);
	return TRUE;
}

// 回调函数
VOID WINAPI CompletionRoutine(DWORD dwError, DWORD dwNumByte, OVERLAPPED *po)
{
	cout << "[*] 文件内容: " << endl << Buffer << endl;
}

// 如果返回错误,可调用此函数打印详细错误信息
VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode)
{
	LPVOID lpMsgBuf; LPVOID lpDisplayBuf; DWORD dw = ErrorCode;
	
	// 将错误代码转换为错误信息
	FormatMessage(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
		NULL, dw, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPTSTR)&lpMsgBuf, 0, NULL
	);
	lpDisplayBuf = (LPVOID)LocalAlloc(LMEM_ZEROINIT, (lstrlen((LPCTSTR)lpMsgBuf) + 40) * sizeof(TCHAR));
	StringCchPrintf((LPTSTR)lpDisplayBuf, LocalSize(lpDisplayBuf), TEXT("错误代码 %d :  %s"), dw, lpMsgBuf);
	
	// 弹窗显示错误信息
	MessageBox(NULL, (LPCTSTR)lpDisplayBuf, TEXT("Error"), MB_OK);
	LocalFree(lpMsgBuf); LocalFree(lpDisplayBuf); ExitProcess(dw);
}

  • 结合以上程序总结出使用可提醒的 I/O 需要具被这么几个条件:(1) 线程必须设置为可提醒状态 (2) 必须有回调函数 (3) 必须使用 ReadFileEx 而非 ReadFile,因为只有 ReadFileEx 才可以传入回调函数(好像是废话)
  • 当使用 ReadFileEx 开始进行异步 I/O 操作时,传入了 CompletionRoutine 作为回调函数,同时系统会将这个异步 I/O 请求添加到 APC 队列中去,之后使用 SleepEx 函数将当前线程设置为可提醒状态(其他可提醒函数也可以替代 SleepEx),当异步 I/O 操作完成之后,回调函数就会被调用,从而打印出文件中的数据,打印结果如下:
    在这里插入图片描述

0x03 -> (4) 使用 I/O 完成端口获取异步 I/O 完成通知

  • 接收异步 I/O 的最后一种方式就是使用 I/O 完成端口(大佬极力推荐,菜鸟表示无力),这是所有接收 I/O 通知方式中最复杂的一个,毕竟 Windows 团队花了数年的时间研究创建了这个机制,这个机制真的很强大,上到可以加速处理网络请求,下到可以增强 I/O 访问速度。来看看使用这套机制需要哪些函数:

(1) CreateIoCompletionPort:创建 I/O 完成端口或者将一个 I/O 完成端口与设备相绑定 (2) GetQueuedCompletionStatus:用于等待 I/O 完成端口等待队列中处理完的 I/O 操作

  • 函数很简单,下面来看一个例子:
#include <process.h>
#include <Windows.h>
#include <iostream>
using namespace std;

DWORD WINAPI IOTestFun(PWCHAR FileName);
unsigned __stdcall ThreadFun(void* pvParam);

// 打开的文件设备的句柄
HANDLE file;

// 文件的大小
DWORD Size;

// ReadFile 读取文件的缓冲区
PBYTE BufferFile;

// 创建的 I/O 完成端口的句柄
HANDLE IOPort;

int main(int argc, char **argv)
{
	WCHAR FileName[12] = TEXT("D:\post.txt");
	IOTestFun(FileName);
	return 0;
}

DWORD WINAPI IOTestFun(PWCHAR FileName)
{
	// 打开 D盘 post.txt 文件
	DWORD LastError; 
	// FILE_FLAG_OVERLAPPED 参数表示以异步方式打开文件
	file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
	LastError = GetLastError();

	// 获取文件的大小
	LARGE_INTEGER FileSize = { 0 };
	GetFileSizeEx(file, &FileSize);
	Size = FileSize.LowPart;
	// 用于 ReadFile 读取文件数据的缓冲区,下面会使用到
	BufferFile = (PBYTE)malloc(Size);

	// 创建 IO 完成端口并且绑定设备 file
	ULONG_PTR ptr = 0;
	// CreateIoCompletionPort 最后一个参数表示同时只有两个线程被唤醒
	IOPort  = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 2);
	// 将 IO 完成端口 IOPort 绑定设备 file
	CreateIoCompletionPort(file, IOPort, ptr, 0);

	// 创建 10 个线程并且立刻执行
	HANDLE Threads[10];
	for (size_t i = 0; i < 10; i++)
	{
		Threads[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadFun, (PVOID)&i, CREATE_SUSPENDED, NULL);
		ResumeThread(Threads[i]);
	}

	// 创建 10 个 IO 读取操作
	for (size_t i = 0; i < 10; i++)
	{
		// overlapped.Offset = 0 表示从文件中的开头读取文件
		OVERLAPPED overlapped = { 0 }; overlapped.Offset = 0;
		ReadFile(file, BufferFile, Size, NULL, &overlapped);
	}

	// 后续等待清理工作
	WaitForMultipleObjects(10, Threads, TRUE, INFINITE);
	for (size_t i = 0; i < 10; i++)
	{
		CloseHandle(Threads[i]);
	}
	CloseHandle(file);
	return TRUE;
}

unsigned __stdcall ThreadFun(void* pvParam)
{
	DWORD NumberByte; ULONG_PTR ptr; LPOVERLAPPED lapped = { 0 };

	// 等待 I/O 操作完成
	GetQueuedCompletionStatus(IOPort, &NumberByte, &ptr, &lapped, INFINITE);

	// 打印文件内容
	cout << "文件内容: " << BufferFile << endl;
	return 0;
}

注:这个程序将 CreateIoCompletionPort 函数拆开来使用,先创建后绑定,方便理解

  • 以上这个程序是干什么的呢,首先使用 CreateFile 打开 D 盘的 post.txt 文件设备,之后创建 I/O 完成端口并绑定这个文件设备,然后创建 10 个线程立刻执行,最后使用 ReadFile 循环 10 次读取文件当中的内容,打印结果如下:
    在这里插入图片描述
  • 值得注意的是 CreateFile 是以异步方式打开文件的,且每个线程的一开始都会使用 GetQueuedCompletionStatus 将线程变为等待状态,由于我们循环了 10 次读取文件的操作,而每一次读取文件的时间都很长,只要有一次读取文件成功,就会唤醒 10 个线程中的两个去处理剩下的工作,也就是将文件内容打印出来,那为什么是 10 个线程中的 2 个呢,因为在使用 CreateIoCompletionPort 函数时将最后一个参数传递的是 2,表示同时只有两个线程去处理剩下的工作

注:这个理解起来确实很复杂,可以想象为开始循环 10 次使用 ReadFile 读取文件内容时,I/O 完成端口会将这 10 个 I/O 请求按顺序放入一个队列中,并且此时有 10 个线程使用 GetQueuedCompletionStatus 函数等待,每当队列中的一个请求完成也就是 ReadFile 读取文件成功时就会从队列中出来,同时等待的线程中的 1 个也会被唤醒处理剩下的工作。需要知晓的是唤醒的线程是随机的,就像小鸟母亲叼着虫子喂小鸟,健壮的小鸟才会抢到食物

-参考资料:Windows 核心编程

原文地址:https://www.cnblogs.com/csnd/p/11800514.html