Windows中的I/O完成通知与事件内核对象
Windows提供了一套灵活机制来处理异步I/O操作,其中事件内核对象是较早期但至今仍有价值的通知方式之一。这种方式让程序能够发起I/O请求后继续执行其他任务,当I/O操作完成时,通过事件对象来通知调用线程。
基本原理
在同步I/O中,当线程调用读取或写入操作时,它会一直等待直到操作完成。异步I/O改变了这个模式:线程发起请求后立即返回,I/O操作在后台进行。为了知道操作何时完成,程序需要某种通知机制,事件内核对象就是其中一种选择。
系统在I/O操作完成时会设置事件对象为"有信号"状态,等待这个事件的线程就会被唤醒。这种方式特别适合那些需要同时处理多个I/O请求的场景,程序可以用WaitForMultipleObjects等待一组事件,任何一个I/O完成都可以立即处理。
使用模式
通过事件内核对象接收I/O完成通知通常遵循这样的步骤:准备重叠结构、发起异步操作、等待事件、获取结果。
c
HANDLE hFile = CreateFile(
TEXT("example.dat"),
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED, // 关键:使用重叠I/O
NULL
);
if (hFile == INVALID_HANDLE_VALUE) {
// 错误处理
return;
}
// 准备重叠结构和事件
OVERLAPPED overlapped = {0};
overlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
if (overlapped.hEvent == NULL) {
CloseHandle(hFile);
return;
}
BYTE buffer[4096];
DWORD bytesRead = 0;
// 发起异步读取
BOOL result = ReadFile(
hFile, // 文件句柄
buffer, // 数据缓冲区
sizeof(buffer), // 要读取的字节数
&bytesRead, // 实际读取的字节数(异步时通常为NULL)
&overlapped // 重叠结构
);
if (!result) {
DWORD error = GetLastError();
if (error != ERROR_IO_PENDING) {
// 不是"操作进行中"错误,表示真的出错了
CloseHandle(overlapped.hEvent);
CloseHandle(hFile);
return;
}
// 错误是ERROR_IO_PENDING,表示操作正在异步进行
// 这是正常情况
} else {
// 如果ReadFile立即成功(比如数据已在缓存中)
// 事件可能已经处于有信号状态,或即将被设置
}
// 等待I/O完成
DWORD waitResult = WaitForSingleObject(overlapped.hEvent, INFINITE);
if (waitResult == WAIT_OBJECT_0) {
// 获取操作结果
BOOL ioSucceeded = GetOverlappedResult(
hFile,
&overlapped,
&bytesRead,
FALSE // 不等待
);
if (ioSucceeded) {
// 处理读取到的数据
printf("成功读取 %lu 字节\n", bytesRead);
} else {
// I/O操作失败
error = GetLastError();
printf("I/O失败,错误代码: %lu\n", error);
}
} else {
// 等待失败
printf("等待事件失败\n");
}
// 清理
CloseHandle(overlapped.hEvent);
CloseHandle(hFile);
处理多个并发I/O
事件内核对象的真正优势在于能同时管理多个I/O操作。通过创建多个事件对象,程序可以并发发起多个请求,然后统一等待它们完成。
c
#define MAX_OPERATIONS 5
HANDLE hEvents[MAX_OPERATIONS];
OVERLAPPED overlappedArray[MAX_OPERATIONS];
BYTE buffers[MAX_OPERATIONS][1024];
HANDLE hFile;
// 初始化:为每个操作创建事件
for (int i = 0; i < MAX_OPERATIONS; i++) {
overlappedArray[i].Offset = i * 1024; // 从不同位置读取
overlappedArray[i].OffsetHigh = 0;
overlappedArray[i].hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
hEvents[i] = overlappedArray[i].hEvent;
}
// 并发发起多个读取请求
for (int i = 0; i < MAX_OPERATIONS; i++) {
ReadFile(hFile, buffers[i], 1024, NULL, &overlappedArray[i]);
// 注意:这里不检查返回值,因为ERROR_IO_PENDING是预期结果
}
// 等待所有操作完成
DWORD completedCount = 0;
while (completedCount < MAX_OPERATIONS) {
DWORD index = WaitForMultipleObjects(
MAX_OPERATIONS - completedCount,
hEvents, // 注意:需要调整等待的事件数组
FALSE, // 等待任意一个完成
INFINITE
);
if (index >= WAIT_OBJECT_0 && index < WAIT_OBJECT_0 + MAX_OPERATIONS) {
int operationIndex = index - WAIT_OBJECT_0;
// 获取这个操作的完成状态
DWORD bytesTransferred = 0;
BOOL success = GetOverlappedResult(
hFile,
&overlappedArray[operationIndex],
&bytesTransferred,
FALSE
);
if (success) {
printf("操作 %d 完成,传输了 %lu 字节\n",
operationIndex, bytesTransferred);
// 处理这个缓冲区的数据...
// ProcessBuffer(buffers[operationIndex], bytesTransferred);
}
// 从等待列表中移除这个事件
// 可以关闭事件句柄,或重新用于其他操作
CloseHandle(hEvents[operationIndex]);
hEvents[operationIndex] = INVALID_HANDLE_VALUE;
completedCount++;
}
}
事件重置的重要性
在使用手动重置事件时,需要特别注意重置时机。如果事件是手动重置的,在WaitForSingleObject返回后,事件仍然保持有信号状态。如果要复用这个事件进行另一次I/O操作,必须显式重置它。
c
// 使用手动重置事件,可以多次等待同一个事件
HANDLE hManualEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
OVERLAPPED overlapped = {0};
overlapped.hEvent = hManualEvent;
// 第一次I/O操作
ReadFile(hFile, buffer1, 1024, NULL, &overlapped);
// 等待完成
WaitForSingleObject(hManualEvent, INFINITE);
GetOverlappedResult(hFile, &overlapped, &bytesRead1, FALSE);
// 重置事件以便重用
ResetEvent(hManualEvent);
// 使用同一个事件进行第二次操作
overlapped.Offset += 1024; // 更新读取位置
ReadFile(hFile, buffer2, 1024, NULL, &overlapped);
WaitForSingleObject(hManualEvent, INFINITE);
GetOverlappedResult(hFile, &overlapped, &bytesRead2, FALSE);
对于自动重置事件,系统会在唤醒一个等待线程后自动重置事件状态。但要注意,如果多个线程在等待同一个自动重置事件,每次触发只能唤醒一个线程。
实际应用中的考量
在实际代码中,通过事件内核对象处理异步I/O需要考虑几个方面。超时处理很重要,特别是在网络I/O中。WaitForSingleObject和WaitForMultipleObjects都支持超时参数,但需要合理设置时间。
c
DWORD waitResult = WaitForSingleObject(overlapped.hEvent, 5000); // 5秒超时
if (waitResult == WAIT_TIMEOUT) {
// 超时处理
CancelIo(hFile); // 尝试取消未完成的I/O
// 然后必须等待操作真正完成
WaitForSingleObject(overlapped.hEvent, INFINITE);
GetOverlappedResult(hFile, &overlapped, &bytesRead, FALSE);
}
资源管理也要注意。每个未完成的I/O操作都需要一个事件对象,大量并发操作会占用不少资源。有些设计会复用事件对象,但这样增加了复杂性。
这种方法在较新的Windows版本中已经不是首选方案。I/O完成端口通常能提供更好的性能,特别是对于高并发的服务器应用。但对于客户端应用或并发不高的场景,事件机制仍然简单有效。
与I/O完成端口的比较
通过事件内核对象接收I/O完成通知是较早期的异步I/O模型,它有几个特点值得注意。这种方法相对简单,容易理解,但扩展性有限。每个I/O操作都需要一个事件对象,大量并发操作时需要创建大量内核对象,这会消耗系统资源。
I/O完成端口采用不同模型。它使用一个队列来管理所有完成通知,工作线程池从队列中取出完成项并处理。这种方式减少了内核对象的数量,线程切换也更高效。但对于简单的应用程序,事件模型的简单性有时更有吸引力。
选择哪种方式取决于具体需求。如果只是偶尔进行异步I/O,或者并发量不大,事件模型完全够用。如果构建高并发的服务器应用,I/O完成端口通常是更好的选择。