🌹 作者: 云小逸
🤟 个人主页: 云小逸的主页
🤟 motto : 要敢于一个人默默的面对自己,强大自己才是核心 。不要等到什么都没有了,才下定决心去做。种一颗树,最好的时间是十年前,其次就是现在!学会自己和解,与过去和解,努力爱自己。希望春天来之前,我们一起面朝大海,春暖花开!
🥇 专栏:
文章目录
-
- [📚 前言](#📚 前言)
- [一. Overlapped I/O基础概念](#一. Overlapped I/O基础概念)
-
- [1. 什么是Overlapped I/O(FAQ21)](#1. 什么是Overlapped I/O(FAQ21))
- [2. 为什么需要Overlapped I/O?](#2. 为什么需要Overlapped I/O?)
- [3. Overlapped I/O与多线程的关系](#3. Overlapped I/O与多线程的关系)
- [二. Win32文件操作核心函数](#二. Win32文件操作核心函数)
-
- [1. 核心I/O函数解析](#1. 核心I/O函数解析)
- [2. OVERLAPPED结构:异步I/O的"身份证"](#2. OVERLAPPED结构:异步I/O的“身份证”)
- [3. 不能用C Runtime的stdio函数(FAQ23)](#3. 不能用C Runtime的stdio函数(FAQ23))
- [二. 激发的File Handles:最简单的Overlapped I/O](#二. 激发的File Handles:最简单的Overlapped I/O)
-
- [1. 原理:用文件句柄做"同步信号"](#1. 原理:用文件句柄做“同步信号”)
- [2. GetOverlappedResult:获取I/O结果](#2. GetOverlappedResult:获取I/O结果)
- [3. 示例:读文件的Overlapped I/O](#3. 示例:读文件的Overlapped I/O)
- [4. 关键注意点](#4. 关键注意点)
- [三. 激发的Event对象:区分多个异步I/O](#三. 激发的Event对象:区分多个异步I/O)
-
- [1. 为什么需要Event对象?](#1. 为什么需要Event对象?)
- [2. 必须用"手动重置"的Event](#2. 必须用“手动重置”的Event)
- [3. 示例:多I/O操作的管理(节选自文档列表6-2)](#3. 示例:多I/O操作的管理(节选自文档列表6-2))
- [4. 执行结果分析](#4. 执行结果分析)
- [四. 异步过程调用(APCs):用回调函数处理I/O结果](#四. 异步过程调用(APCs):用回调函数处理I/O结果)
-
- [1. APC解决的问题](#1. APC解决的问题)
- [2. 核心函数:ReadFileEx与WriteFileEx(FAQ26)](#2. 核心函数:ReadFileEx与WriteFileEx(FAQ26))
- [3. 线程的"Alertable"状态(FAQ27)](#3. 线程的“Alertable”状态(FAQ27))
- [4. 传递用户自定义数据(FAQ28)](#4. 传递用户自定义数据(FAQ28))
- [5. C++成员函数作为回调(FAQ29)](#5. C++成员函数作为回调(FAQ29))
- [五. 对文件进行Overlapped I/O的缺点](#五. 对文件进行Overlapped I/O的缺点)
-
- [1. 小数据I/O可能"同步执行"](#1. 小数据I/O可能“同步执行”)
- [2. 两种"必然同步"的情况](#2. 两种“必然同步”的情况)
- [3. 效率对比](#3. 效率对比)
- [六. I/O Completion Ports:高并发服务器的核心](#六. I/O Completion Ports:高并发服务器的核心)
-
- [1. 为什么I/O Completion Ports特殊?(FAQ31)](#1. 为什么I/O Completion Ports特殊?(FAQ31))
- [2. 服务器的3种线程模型](#2. 服务器的3种线程模型)
- [3. I/O Completion Ports的核心流程](#3. I/O Completion Ports的核心流程)
-
- [步骤1:创建I/O Completion Port](#步骤1:创建I/O Completion Port)
- 步骤2:关联文件/设备句柄到Port
- 步骤3:创建线程池
- 步骤4:发起异步I/O请求
- 步骤5:处理完成包
- [4. 线程池数量建议(FAQ32)](#4. 线程池数量建议(FAQ32))
- [5. 示例:ECHO服务器(基于Sockets和Completion Ports)](#5. 示例:ECHO服务器(基于Sockets和Completion Ports))
- [七. 对Sockets使用Overlapped I/O](#七. 对Sockets使用Overlapped I/O)
-
- [1. Socket默认支持Overlapped I/O](#1. Socket默认支持Overlapped I/O)
- [2. 为什么不用select()?(FAQ33)](#2. 为什么不用select()?(FAQ33))
- [3. ECHO范例的执行流程](#3. ECHO范例的执行流程)
- [八. 提要](#八. 提要)
- [📣 结语](#📣 结语)
📚 前言
在Win32多线程程序设计中,我们之前学过用线程处理后台任务(比如后台打印),但有些场景下,直接用"异步I/O"(即Overlapped I/O)能更高效地解决问题。比如当程序需要同时处理多个文件读写、网络通信等I/O操作时,Overlapped I/O可以让操作系统在后台完成I/O,操作结束后再通知程序,避免线程等待I/O时浪费CPU资源。
举个直观的例子:假设你通过拨号网络(RAS)从服务器读取100KB数据,在9600bps的网速下要近两分钟。如果程序用传统同步I/O,会卡在读取操作上,窗口完全没反应;但用Overlapped I/O,程序可以继续响应用户操作,直到数据读取完成再处理结果。
需要特别注意的是,Windows 95对Overlapped I/O有严格限制:仅支持named pipes(命名管道)、mailslots(邮槽)、serial I/O(串行I/O)和Sockets,不支持磁盘或光盘文件的Overlapped I/O。本章所有例子仅在Windows NT下有效(FAQ22)。
本章会从基础到进阶,逐步讲解Overlapped I/O的4种实现方式:激发的File Handles、激发的Event对象、异步过程调用(APCs)、I/O Completion Ports,最后还会介绍如何对Sockets使用Overlapped I/O。
一. Overlapped I/O基础概念
1. 什么是Overlapped I/O(FAQ21)
Overlapped I/O(重叠I/O)是Win32的一项技术,简单说就是"让操作系统帮你执行I/O操作,操作完成后通知你"。它的核心是"异步"------程序发起I/O请求后,不需要等待I/O完成,能继续做其他事;当I/O结束(比如数据读完、写完),操作系统会通过特定方式(如激发对象、回调函数)告知程序。
操作系统内部其实是用线程实现Overlapped I/O的,但对我们来说,不用手动管理线程,就能享受"并行I/O"的好处。
2. 为什么需要Overlapped I/O?
I/O设备(打印机、磁盘、网络)比CPU慢得多。比如磁盘传输率约5MB/秒,移动读写头平均要10ms,而CPU每秒能执行数千万条指令。如果程序"傻等"I/O完成,就是在浪费CPU资源。
举个租车店的例子:租车店有几位代理人,窗外有3辆敞篷车。如果4个客户同时来租敞篷车,每个代理人都先看"有车",再写派车单,最后发现车不够------这就是没做好同步的问题。Overlapped I/O就像一个"车辆计数器",能准确记录可用车辆,避免多个操作冲突。
3. Overlapped I/O与多线程的关系
Overlapped I/O可以替代部分线程的功能(比如处理多个I/O),但高阶场景下(如高并发服务器),通常会把"Overlapped I/O + I/O Completion Ports"和多线程结合,让线程池处理完成的I/O请求,实现高效的"可扩展(scalable)"架构。
二. Win32文件操作核心函数
要使用Overlapped I/O,必须先掌握3个核心Win32文件函数:CreateFile(打开文件/设备)、ReadFile(读数据)、WriteFile(写数据),以及关键的OVERLAPPED结构。
1. 核心I/O函数解析
(1)CreateFile:打开文件/设备
CreateFile不仅能打开普通文件,还能打开串行口、Sockets、named pipes等设备。要启用Overlapped I/O,必须在第6个参数dwFlagsAndAttributes中指定FILE_FLAG_OVERLAPPED。
函数原型关键部分:
c
HANDLE CreateFile(
LPCTSTR lpFileName, // 文件名/设备名
DWORD dwDesiredAccess, // 读写权限(如GENERIC_READ)
DWORD dwShareMode, // 共享模式(如FILE_SHARE_READ)
LPSECURITY_ATTRIBUTES lpSecurityAttributes, // 安全属性(NULL为默认)
DWORD dwCreationDisposition, // 文件创建方式(如OPEN_EXISTING)
DWORD dwFlagsAndAttributes, // 关键!指定FILE_FLAG_OVERLAPPED启用异步I/O
HANDLE hTemplateFile // 模板文件(NULL)
);
一旦用FILE_FLAG_OVERLAPPED打开文件,后续对该文件的所有读写操作都必须是Overlapped I/O。
(2)ReadFile与WriteFile:异步读写
ReadFile和WriteFile是执行异步读写的函数,它们的最后一个参数是LPOVERLAPPED(指向OVERLAPPED结构的指针),这是Overlapped I/O的核心参数。
以ReadFile为例,函数原型关键部分:
c
BOOL ReadFile(
HANDLE hFile, // 要读的文件/设备句柄(CreateFile返回)
LPVOID lpBuffer, // 接收数据的缓冲区
DWORD nNumberOfBytesToRead, // 要读取的字节数
LPDWORD lpNumberOfBytesRead, // 实际读取的字节数(异步时可能为NULL)
LPOVERLAPPED lpOverlapped // 关键!Overlapped结构指针
);
WriteFile的参数类似,只是把"读"换成"写"。
2. OVERLAPPED结构:异步I/O的"身份证"
OVERLAPPED结构有两个核心作用:一是标识每个异步I/O请求(比如区分"读文件A"和"读文件B");二是传递I/O相关参数(如文件读取的起始位置)。
结构定义如下:
c
typedef struct _OVERLAPPED {
DWORD Internal; // 系统保留,用于存储状态
DWORD InternalHigh; // 系统保留,用于存储实际传输的字节数
DWORD Offset; // 文件读写的起始偏移(低32位)
DWORD OffsetHigh; // 文件读写的起始偏移(高32位,支持大文件)
HANDLE hEvent; // 事件对象句柄(I/O完成时激发)
} OVERLAPPED, *LPOVERLAPPED;
各成员的详细说明如下表(表格6-1):
| 成员名称 | 说明 |
|---|---|
| Internal | 系统保留。当GetOverlappedResult返回FALSE且GetLastError不是ERROR_IO_PENDING时,存储系统状态 |
| InternalHigh | 系统保留。当GetOverlappedResult返回TRUE时,存储实际传输的字节数 |
| Offset | 文件读写的起始偏移(从文件头开始算),单位字节。不支持文件位置的设备(如管道)会忽略此值 |
| OffsetHigh | 64位文件偏移的高32位,用于处理超过4GB的大文件。不支持文件位置的设备会忽略此值 |
| hEvent | 手动重置的Event对象句柄(I/O完成时会被激发)。ReadFileEx/WriteFileEx会忽略此值,可用于传递用户数据 |
注意 :OVERLAPPED结构的生命周期要超过ReadFile/WriteFile的调用------不能定义为局部变量(函数结束后会被销毁),最好在堆(heap)上分配。
3. 不能用C Runtime的stdio函数(FAQ23)
C语言标准库的stdio.h函数(如fopen、fread、printf)不支持Overlapped I/O。比如fgets虽然能读一行文字,但无法异步执行。如果要做异步文本I/O,只能用Win32 API手动处理(如用ReadFile读字节,再自己解析换行符)。
二. 激发的File Handles:最简单的Overlapped I/O
1. 原理:用文件句柄做"同步信号"
打开支持Overlapped I/O的文件后,文件句柄(HANDLE)本身就是一个核心对象 ------当异步I/O操作完成时,文件句柄会自动变成"激发状态"。我们可以用WaitForSingleObject或WaitForMultipleObjects等待这个句柄,就能知道I/O是否完成。
流程如下:
- 用
FILE_FLAG_OVERLAPPED打开文件,得到文件句柄hFile; - 初始化
OVERLAPPED结构(指定读写偏移等); - 调用
ReadFile/WriteFile发起异步I/O; - 用
WaitForSingleObject(hFile, INFINITE)等待I/O完成; - 调用
GetOverlappedResult获取I/O结果(如实际读写的字节数、是否成功)。
2. GetOverlappedResult:获取I/O结果
GetOverlappedResult是关键函数,它能获取异步I/O的最终结果,功能类似"WaitForSingleObject + 结果查询"的结合。
函数原型:
c
BOOL GetOverlappedResult(
HANDLE hFile, // 异步I/O对应的文件句柄
LPOVERLAPPED lpOverlapped, // 发起I/O时用的OVERLAPPED结构
LPDWORD lpNumberOfBytesTransferred, // 实际传输的字节数
BOOL bWait // 是否等待I/O完成(TRUE=等待,FALSE=不等待)
);
- 返回值:
TRUE表示I/O成功,FALSE表示失败(如读文件出错); - 若
bWait=TRUE,函数会等待I/O完成后再返回;若bWait=FALSE且I/O未完成,GetLastError会返回ERROR_IO_INCOMPLETE。
3. 示例:读文件的Overlapped I/O
以下是从C:\WINDOWS\WINFILE.EXE的第1500字节开始,异步读取300字节的核心代码(节选自文档列表6-1):
c
int ReadSomething() {
BOOL rc;
HANDLE hFile;
DWORD numread;
OVERLAPPED overlap;
char buf[512];
// 1. 用Overlapped模式打开文件
hFile = CreateFile(
"C:\\WINDOWS\\WINFILE.EXE",
GENERIC_READ,
FILE_SHARE_READ|FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED, // 启用Overlapped I/O
NULL
);
if (hFile == INVALID_HANDLE_VALUE) return -1;
// 2. 初始化OVERLAPPED结构(从第1500字节开始读)
memset(&overlap, 0, sizeof(overlap));
overlap.Offset = 1500; // 读取起始偏移
// 3. 发起异步读请求
rc = ReadFile(hFile, buf, 300, &numread, &overlap);
if (rc) {
// 情况1:I/O立即完成(如数据在缓存中)
printf("数据已立即读取完成\n");
} else {
// 情况2:I/O排队等待(需要等操作系统完成)
if (GetLastError() == ERROR_IO_PENDING) {
// 等待I/O完成(文件句柄激发)
WaitForSingleObject(hFile, INFINITE);
// 获取I/O结果
rc = GetOverlappedResult(hFile, &overlap, &numread, FALSE);
} else {
// 真的出错了(如文件不存在)
printf("ReadFile失败\n");
}
}
// 4. 关闭文件句柄
CloseHandle(hFile);
return TRUE;
}
4. 关键注意点
- I/O可能立即完成 :即使指定了
FILE_FLAG_OVERLAPPED,如果数据已在系统缓存中,ReadFile/WriteFile会立即返回TRUE,文件句柄也会处于激发状态; - 区分"排队"和"错误" :
ReadFile返回FALSE时,必须用GetLastError()判断是"I/O排队(ERROR_IO_PENDING)"还是真的出错(如ERROR_FILE_NOT_FOUND); - 无"当前文件位置" :用Overlapped I/O时,文件没有"当前位置"的概念------每次读写都必须在
OVERLAPPED结构中指定Offset和OffsetHigh(起始偏移)。
三. 激发的Event对象:区分多个异步I/O
1. 为什么需要Event对象?
用"激发的File Handles"有个明显缺点:如果对同一个文件发起多个异步I/O(比如同时读文件的不同部分),所有操作共用一个文件句柄------当句柄激发时,无法区分是哪个I/O操作完成了。
比如对一个文件同时发起"读偏移100字节"和"读偏移200字节"两个请求,文件句柄激发后,不知道该调用哪个GetOverlappedResult。
解决方案是:给每个异步I/O分配一个独立的Event对象 ,通过OVERLAPPED结构的hEvent成员关联Event------I/O完成时,对应的Event会被激发,这样就能通过等待Event来区分不同I/O。
2. 必须用"手动重置"的Event
Event对象有两种类型:自动重置(Auto Reset)和手动重置(Manual Reset)。在Overlapped I/O中,必须用手动重置的Event (CreateEvent的第二个参数bManualReset=TRUE),原因如下:
如果用自动重置Event,系统在激发Event后会立即将其重置为"未激发状态"。如果I/O完成时,程序还没来得及等待Event,Event的"激发状态"就会丢失,导致WaitFor...函数永远等待。而手动重置Event被激发后,会一直保持激发状态,直到程序调用ResetEvent手动重置。
3. 示例:多I/O操作的管理(节选自文档列表6-2)
以下代码演示如何用Event对象管理多个异步读请求(如同时读一个文件的5个不同部分):
c
// 全局变量:存储每个I/O的Event、OVERLAPPED、缓冲区
#define MAX_REQUESTS 5 // 最多5个异步请求
HANDLE ghEvents[MAX_REQUESTS]; // 每个I/O对应一个Event
OVERLAPPED gOverlapped[MAX_REQUESTS];// 每个I/O的OVERLAPPED结构
HANDLE ghFile; // 目标文件句柄
char gBuffers[MAX_REQUESTS][512]; // 每个I/O的缓冲区
// 函数:发起一个异步读请求
int QueueRequest(int nIndex, DWORD dwLocation, DWORD dwAmount) {
BOOL rc;
DWORD dwNumread;
// 1. 创建手动重置的Event
ghEvents[nIndex] = CreateEvent(
NULL, // 安全属性(默认)
TRUE, // 手动重置(关键!)
FALSE, // 初始状态:未激发
NULL // 无名称
);
if (ghEvents[nIndex] == NULL) return -1;
// 2. 关联Event到OVERLAPPED结构
gOverlapped[nIndex].hEvent = ghEvents[nIndex];
gOverlapped[nIndex].Offset = dwLocation; // 读起始偏移
// 3. 发起异步读
rc = ReadFile(
ghFile,
gBuffers[nIndex], // 缓冲区
dwAmount, // 要读的字节数
&dwNumread,
&gOverlapped[nIndex]
);
if (rc) {
// I/O立即完成
printf("读请求%d:立即完成\n", nIndex);
return TRUE;
} else {
if (GetLastError() == ERROR_IO_PENDING) {
// I/O排队等待
printf("读请求%d:已排队\n", nIndex);
return TRUE;
} else {
// 出错(如内存不足)
printf("ReadFile失败\n");
return -1;
}
}
}
4. 执行结果分析
当我们发起5个读请求时,可能出现部分请求"立即完成"、部分"排队"的情况。比如Windows NT会对已缓存的数据直接返回结果,未缓存的数据则排队异步读取。以下是一个实际执行结果:
读请求0:已排队
读请求1:立即完成
读请求2:立即完成
读请求3:立即完成
读请求4:立即完成
// 等待所有I/O完成后
读请求0:返回1,读取512字节
读请求1:返回1,读取512字节
...
四. 异步过程调用(APCs):用回调函数处理I/O结果
1. APC解决的问题
用Event对象虽然能区分多个I/O,但有两个局限:
WaitForMultipleObjects最多只能等待64个对象(MAXIMUM_WAIT_OBJECTS),无法支持更多I/O;- 需要手动维护"Event数组"和"OVERLAPPED数组"的对应关系,代码繁琐。
异步过程调用(APCs,Asynchronous Procedure Calls)能解决这些问题:通过ReadFileEx/WriteFileEx指定一个"回调函数"(I/O Completion Routine),当I/O完成且线程处于"可提醒(Alertable)"状态时,系统会自动调用这个回调函数,直接在回调中处理I/O结果。
2. 核心函数:ReadFileEx与WriteFileEx(FAQ26)
ReadFileEx和WriteFileEx是支持APCs的异步I/O函数,它们比ReadFile/WriteFile多一个参数------回调函数地址。以ReadFileEx为例:
c
BOOL ReadFileEx(
HANDLE hFile,
LPVOID lpBuffer,
DWORD nNumberOfBytesToRead,
LPOVERLAPPED lpOverlapped,
LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine // 回调函数
);
其中lpCompletionRoutine是回调函数指针,函数原型固定为:
c
VOID WINAPI FileIOCompletionRoutine(
DWORD dwErrorCode, // I/O结果(0=成功,ERROR_HANDLE_EOF=文件尾)
DWORD dwNumberOfBytesTransferred, // 实际传输的字节数
LPOVERLAPPED lpOverlapped // 关联的OVERLAPPED结构
);
3. 线程的"Alertable"状态(FAQ27)
系统不会"强行"调用回调函数,只有当线程处于"可提醒(Alertable)"状态时,才会执行等待中的APC回调。线程进入Alertable状态的方式是:调用以下5个函数时,将"可提醒标记"设为TRUE:
SleepEx():带可提醒的睡眠;WaitForSingleObjectEx():带可提醒的等待单个对象;WaitForMultipleObjectsEx():带可提醒的等待多个对象;MsgWaitForMultipleObjectsEx():带可提醒的等待消息和对象;SignalObjectAndWait():带可提醒的信号和等待。
比如调用WaitForSingleObjectEx(ghEvent, INFINITE, TRUE),线程会等待ghEvent激发,同时处于Alertable状态------如果此时有I/O完成,系统会先调用回调函数,再回到等待。
4. 传递用户自定义数据(FAQ28)
回调函数只有3个参数,要传递自定义数据(如"这个I/O对应的业务标识"),可以利用OVERLAPPED结构的hEvent成员------因为ReadFileEx/WriteFileEx会忽略hEvent,我们可以用它存储自定义数据的指针。
示例:在OVERLAPPED的hEvent中存储请求索引,回调中通过lpOverlapped->hEvent获取:
c
// 发起I/O时:将请求索引存入hEvent
gOverlapped[nIndex].hEvent = (HANDLE)nIndex;
// 回调函数中:获取索引
VOID WINAPI FileIOCompletionRoutine(
DWORD dwErrorCode,
DWORD dwNumberOfBytesTransferred,
LPOVERLAPPED lpOverlapped
) {
int nIndex = (int)(lpOverlapped->hEvent); // 取出自定义索引
printf("请求%d:%d字节已读取,结果:%d\n",
nIndex, dwNumberOfBytesTransferred, dwErrorCode);
}
5. C++成员函数作为回调(FAQ29)
C++的非静态成员函数有this指针,不能直接作为APC回调(回调函数要求无this指针)。解决方案是:
- 定义一个静态成员函数作为回调;
- 在
OVERLAPPED的hEvent中存储this指针(对象地址); - 在静态回调中,通过
hEvent取出this,再调用非静态成员函数。
示例代码逻辑:
c
class MyClass {
public:
// 静态回调函数(符合APC回调原型)
static VOID WINAPI StaticCompletionRoutine(
DWORD dwErrorCode,
DWORD dwBytesTransferred,
LPOVERLAPPED lpOverlapped
) {
// 从hEvent取出this指针
MyClass* pThis = (MyClass*)(lpOverlapped->hEvent);
// 调用非静态成员函数处理结果
pThis->OnIOComplete(dwErrorCode, dwBytesTransferred);
}
// 非静态函数:实际处理I/O结果
void OnIOComplete(DWORD dwErrorCode, DWORD dwBytesTransferred) {
// 处理逻辑...
}
// 发起异步I/O
void StartAsyncRead(HANDLE hFile) {
OVERLAPPED overlap;
memset(&overlap, 0, sizeof(overlap));
overlap.hEvent = this; // 存储this指针
ReadFileEx(hFile, m_buf, 512, &overlap, StaticCompletionRoutine);
}
private:
char m_buf[512];
};
五. 对文件进行Overlapped I/O的缺点
虽然Overlapped I/O很强大,但对文件操作时存在一些反直觉的缺点:
1. 小数据I/O可能"同步执行"
Windows NT会根据I/O请求的大小决定是否"真异步":如果请求的数据很小(比如小于32KB),系统可能会"同步执行"I/O,即ReadFile/ReadFileEx会卡住直到I/O完成,失去异步的意义。
原因是:小数据的I/O时间(如读8KB数据约1.8ms)远小于线程切换、系统通知的额外开销(约12ms),同步执行反而更高效。但这会导致程序在小数据I/O时依然卡顿。
2. 两种"必然同步"的情况
即使指定了FILE_FLAG_OVERLAPPED,以下两种情况的I/O必然同步执行:
- 文件扩展:写入操作导致文件大小增加(如在文件末尾追加数据);
- 压缩文件:读写NTFS压缩文件时,系统需要实时压缩/解压数据,只能同步执行。
3. 效率对比
测试表明:对一系列小于32KB的文件传输,用Overlapped I/O比传统同步I/O慢15%。因此,在Web服务器(大部分请求是小数据)等场景,用"线程池+同步I/O"可能比Overlapped I/O更高效。
六. I/O Completion Ports:高并发服务器的核心
I/O Completion Ports(I/O完成端口)是Windows NT 3.5引入的高阶Overlapped I/O机制,专门解决"高并发I/O"场景(如Web服务器、数据库服务器),是本章最重要的内容(FAQ30、31)。
1. 为什么I/O Completion Ports特殊?(FAQ31)
它解决了之前所有方案的局限:
- 无handle数量限制 :突破
WaitForMultipleObjects的64个对象限制,支持成千上万的并发I/O; - 跨线程服务:发起I/O的线程和处理I/O结果的线程可以是不同的,适合线程池架构;
- 自动负载均衡:根据CPU数量调整并发线程数,避免过多线程导致的上下文切换开销,支持SMP(对称多处理器)系统。
2. 服务器的3种线程模型
在了解Completion Ports前,先明确服务器的线程模型,Completion Ports对应"每CPU一线程"的最优模型:
| 模型 | 特点 | 缺点 |
|---|---|---|
| 单一线程 | 用Overlapped I/O处理所有I/O | 若线程需处理计算(如解析数据),会阻塞所有I/O |
| 每Client一线程 | 为每个客户端连接创建一个线程 | 线程过多(如2000个),系统资源耗尽 |
| 每CPU一线程(最优) | 线程池大小=CPU数量,处理所有I/O结果 | 需Completion Ports支持负载均衡 |
3. I/O Completion Ports的核心流程
使用Completion Ports需要5个步骤,流程清晰且可复用:
步骤1:创建I/O Completion Port
用CreateIoCompletionPort创建Completion Port(核心对象):
c
HANDLE CreateIoCompletionPort(
HANDLE FileHandle, // 文件/设备句柄(INVALID_HANDLE_VALUE=仅创建Port)
HANDLE ExistingCompletionPort, // 已有的Completion Port(NULL=新建)
DWORD CompletionKey, // 用户自定义Key(关联到文件句柄,如业务标识)
DWORD NumberOfConcurrentThreads // 并发线程数(0=默认=CPU数量)
);
- 若要"仅创建Port",将
FileHandle设为INVALID_HANDLE_VALUE,ExistingCompletionPort设为NULL; NumberOfConcurrentThreads设为0时,系统会根据CPU数量自动调整并发线程数(如4核CPU允许4个线程同时处理I/O结果)。
步骤2:关联文件/设备句柄到Port
对每个需要异步I/O的文件/设备(如Sockets、named pipes),调用CreateIoCompletionPort将其与Completion Port关联:
c
// 假设hPort是已创建的Completion Port,hFile是用FILE_FLAG_OVERLAPPED打开的文件句柄
CreateIoCompletionPort(
hFile, // 要关联的文件句柄
hPort, // 目标Completion Port
(DWORD)pKey, // 自定义Key(如指向业务数据的指针)
0 // 并发线程数(用Port的默认值)
);
关联后,该文件的所有异步I/O完成时,系统会自动向Completion Port发送一个"完成包(Completion Packet)"。
步骤3:创建线程池
创建一组线程(数量通常=CPU数量×2+2),每个线程的唯一工作是:调用GetQueuedCompletionStatus等待Completion Port的完成包,收到后处理I/O结果。
线程函数核心逻辑:
c
DWORD WINAPI WorkerThread(LPVOID pParam) {
HANDLE hPort = (HANDLE)pParam;
DWORD dwBytesTransferred;
DWORD dwCompletionKey; // 步骤2关联的自定义Key
LPOVERLAPPED lpOverlapped;
while (TRUE) {
// 等待Completion Port的完成包
BOOL bResult = GetQueuedCompletionStatus(
hPort,
&dwBytesTransferred, // 实际传输的字节数
&dwCompletionKey, // 取出自定义Key
&lpOverlapped, // 关联的OVERLAPPED结构
INFINITE // 无限等待
);
if (bResult) {
// 情况1:收到正常完成包,处理I/O结果
// 从dwCompletionKey和lpOverlapped获取业务数据,处理逻辑...
} else {
if (lpOverlapped == NULL) {
// 情况2:函数调用失败(如Port无效)
break;
} else {
// 情况3:I/O操作失败(如读文件出错)
// 处理错误逻辑...
}
}
}
return 0;
}
步骤4:发起异步I/O请求
用ReadFile/WriteFile(或ReadFileEx/WriteFileEx)发起异步I/O,不需要手动等待------I/O完成后,系统会自动向Completion Port发送完成包,线程池中的线程会被唤醒处理。
步骤5:处理完成包
线程池中的线程通过GetQueuedCompletionStatus获取完成包后,根据dwCompletionKey(自定义业务Key)和lpOverlapped(I/O参数)处理结果,比如解析读取的数据、回写响应等。
4. 线程池数量建议(FAQ32)
Completion Port的线程池数量不是越多越好,建议设为"CPU数量×2+2"。比如4核CPU设10个线程,原因是:
- 当部分线程因I/O(如等待网络响应)阻塞时,其他线程能继续处理完成包;
- 避免线程过多导致的上下文切换开销,保持CPU利用率最大化。
5. 示例:ECHO服务器(基于Sockets和Completion Ports)
文档中的ECHO范例是Completion Ports的经典应用:服务器监听TCP端口5554,收到客户端数据后原样回显(类似标准TCP的7号端口)。核心逻辑如下:
(1)服务器初始化(节选自列表6-4)
c
int main() {
SOCKET listener; // 监听Socket
HANDLE hPort; // Completion Port
// 1. 创建Completion Port
hPort = CreateIoCompletionPort(
INVALID_HANDLE_VALUE,
NULL,
0,
0 // 并发线程数=CPU数量
);
// 2. 创建线程池(数量=CPU×2+2)
CreateWorkerThreads(hPort);
// 3. 初始化监听Socket,开始接受客户端连接
listener = socket(AF_INET, SOCK_STREAM, 0);
bind(listener, ...); // 绑定端口
listen(listener, 5);
// 4. 循环接受连接,关联Socket到Completion Port
while (TRUE) {
SOCKET clientSock = accept(listener, ...); // 接受客户端连接
// 创建业务数据结构(存储Socket、缓冲区等)
ContextKey* pKey = (ContextKey*)calloc(1, sizeof(ContextKey));
pKey->sock = clientSock;
// 关联客户端Socket到Completion Port
CreateIoCompletionPort(
(HANDLE)clientSock,
hPort,
(DWORD)pKey, // 业务Key=业务数据指针
0
);
// 5. 发起第一个异步读请求(读客户端数据)
IssueRead(pKey);
}
return 0;
}
(2)线程池创建(节选自列表6-5)
c
void CreateWorkerThreads(HANDLE hPort) {
SYSTEM_INFO sysInfo;
GetSystemInfo(&sysInfo); // 获取CPU数量
DWORD dwThreads = sysInfo.dwNumberOfProcessors * 2 + 2; // 线程数
for (DWORD i=0; i<dwThreads; i++) {
HANDLE hThread = CreateThread(
NULL,
0,
WorkerThread, // 线程函数
hPort, // 参数=Completion Port句柄
0,
&dwThreadId
);
CloseHandle(hThread);
}
}
(3)线程函数处理完成包(节选自列表6-6)
c
DWORD WINAPI WorkerThread(LPVOID pParam) {
HANDLE hPort = (HANDLE)pParam;
DWORD dwBytesTransferred;
ContextKey* pKey; // 业务数据
LPOVERLAPPED lpOverlapped;
while (TRUE) {
// 等待完成包
BOOL bResult = GetQueuedCompletionStatus(
hPort, &dwBytesTransferred, (DWORD*)&pKey, &lpOverlapped, INFINITE
);
if (bResult && dwBytesTransferred > 0) {
// 处理客户端数据:将收到的字节回显给客户端
char* pData = pKey->InBuffer;
WriteFile(
(HANDLE)pKey->sock, pData, dwBytesTransferred,
&pKey->dwWritten, &pKey->ovOut
);
// 发起下一次异步读,继续接收客户端数据
IssueRead(pKey);
} else {
// 客户端断开连接,清理资源
closesocket(pKey->sock);
free(pKey);
}
}
return 0;
}
七. 对Sockets使用Overlapped I/O
Sockets是Overlapped I/O的重要应用场景,比如Web服务器、即时通讯软件等。以下是关键要点:
1. Socket默认支持Overlapped I/O
创建Socket时,不需要额外标记,默认就支持Overlapped I/O。但要将Socket与I/O Completion Port关联(通过CreateIoCompletionPort),才能用Completion Port管理Socket的异步I/O。
2. 为什么不用select()?(FAQ33)
select()是传统的"多路复用I/O"方法,能同时监听多个Socket的读写事件,但存在明显缺点:
- 效率低 :每次调用
select()都要传递所有Socket的集合,系统需要遍历检查,Socket越多越慢; - 不支持SMP :
select()无法利用多CPU的优势,线程只能单进程处理所有事件; - 有句柄限制 :
select()支持的Socket数量有限(通常是1024)。
而I/O Completion Port没有这些问题,是高并发Socket服务器的首选方案。
3. ECHO范例的执行流程
文档中的ECHO范例包含服务器(ECHOSRV)和客户端(ECHOCLI):
- 服务器启动后,创建Completion Port和线程池,监听TCP端口5554;
- 客户端连接服务器,发送输入的字符串;
- 服务器的Worker线程收到"读完成包",将数据原样回写给客户端;
- 客户端收到回显数据,打印结果。
该范例支持同时处理多个客户端连接,且能充分利用多CPU资源。
八. 提要
本章我们系统学习了Overlapped I/O的4种实现方式,从简单到复杂:
- 激发的File Handles:最简单的异步I/O,用文件句柄作为同步信号,适合单个I/O操作;
- 激发的Event对象:用独立Event区分多个I/O,适合少量并发I/O;
- APCs:用回调函数处理I/O结果,避免手动管理Event数组,适合中等并发;
- I/O Completion Ports:最强大的方案,支持高并发、跨线程服务、负载均衡,是高并发服务器(如Web、数据库)的核心。
其中,I/O Completion Ports是重点,它解决了Overlapped I/O的所有局限,能在SMP系统上实现"可扩展"的高并发I/O处理。
📣 结语
恭喜你完成了Win32 Overlapped I/O的学习!从基础的File Handles到高阶的I/O Completion Ports,你已经掌握了Win32异步I/O的核心技术,这些知识在网络编程、文件处理等场景中能大幅提升程序效率。
如果你觉得本章内容有帮助,记得点赞、收藏和关注,后续会继续深入Win32多线程的其他高级主题(如MFC中的线程、DLL与线程等)。
学习编程没有捷径,多敲代码、多调试范例(比如本章的ECHO服务器),才能真正掌握这些技术。让我们一起加油,从"新手"逐步成长为"Win32开发高手"!