
23.1 通过重叠 I/O 理解 IOCP
本章的 IOCP (Input Output Completion Port, 输入输出完成端口) 服务器端模型是很多 Windows 程序员关注的焦点. 各位若急于求成而跳过了第21章内容, 建议大家最好回顾一下. 因为第21章和22章介绍的背景知识, 而且, 关于 IOCP 的内容实际上从第22章开始的.
热门话题: epoll 和 IOCP 的性能比较
为了突破 select 等传统I/O模型的限制, 每种操作系统(内核级别) 都会提供特有的 I/O 模型以提高性能. 其中最具有代表性的有 Linux 的 epoll, BSD 的 kqueue 及本章的 Windows 的 IOCP. 他们都在操作系统级别支持并完成功能, 但此处有一个持续的争议热点:

对此的争议仍然可以在 www.yahoo.com 上看到, 有时甚至会走入极端. 因为服务器端的响应时间和并发服务数是权衡服务器端好坏的重要因素, 所以存在这种争议也可以理解. 但对于我这种普通人来说, 这2种模型已经非常优秀了, 因此几乎不会如下这种情况. 至少我和我周围的开发人员之间未曾有过这类讨论.

另外, 在硬件性能和分配带宽充足的情况下, 如果响应和并发数量出了问题, 我会首先怀疑如下两点, 修改后通常会解决大部分问题.

如果有人问为何 IOCP 相对更优 (通常是网上说的言论), 我会回答: “不太清楚” 虽然 IOCP 拥有其他 I/O 模型不具备的优点, 但这并非左右服务端性能的绝对因素, 而且不可能在任何情况下都体会这种优点. 他们之间的差异主要在于操作系统内部的工作机制. 当然, 最终还是要靠各位自行判断. 我只是想说, 周围也有不少开发人员与我观点一致.
实现非阻塞模式的套接字
第22章中介绍了执行重叠 I/O 的 Sender 和 Receiver, 但还未利用该模型实现过服务器端. 因此, 我们先利用重叠 I/O 模型实现回声服务器端. 首先介绍创建非阻塞模式套接字的方法. 我们曾在第17章创建过非阻塞模式的套接字, 与之类似, 在 Windows 中通过如下函数调用将套机字属性改为非阻塞模式.

上述代码中调用的 ioctlsocket 函数负责控制套接字 I/O 方式, 其调用具有如下含义:

也就是说, FIONBIO 是用于更改套机字 I/O 模式的选项, 该函数的第三个参数中传入的变量中若存有0, 则说明套接字是阻塞模式的; 如果存有非0值, 则说明已将套接字模式改为非阻塞模式. 改为非阻塞模式后, 除了以非阻塞模式进行 I/O 外, 还具有如下特点.

因此, 针对非阻塞套接字调用 accept 函数, 并返回 INVALID_SOCKET 时, 应该通过 WSAGetLastError 函数确定返回 INVALID_SOCKET 的理由, 在进行适当的处理.
以纯重叠 I/O 方式实现回声服务器端
要想实现基于重叠 I/O 的服务器端, 必须具备非阻塞套接字, 所以先介绍及创建方法. 实际上, 因为有 IOCP 模型, 所以很少有人只用重叠 I/O 实现服务器端. 但我认为: “为了确认理解 IOCP, 应当尝试用纯重叠 I/O 方式实现服务端.”
即使坚持不用 IOCP, 也应具备仅用重叠 I/O 方式实现类似 IOCP 方式实现类似 IOCP 的服务器端的能力. 这样就可以在其他操作系统平台实现类似 IOCP 方式的服务端, 而且不会因 IOCP 的限制而忽略服务端功能的实现.
下面用纯重叠 I/O 模型实现回声服务器端, 希望各位亲自动手试试. 接下来介绍示例, 由于代码量较大, 我们分3部分学习.

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#define BUF_SIZE 1024
void CALLBACK ReadCompRoution(DWORD, DWORD, LPWSAOVERLAPPED, DWORD);
void CALLBACK WriteComRoution(DWORD, DWORD, LPWSAOVERLAPPED, DWORD);
void ErrorHandling(char *message);
typedef struct
{
SOCKET hClntSock;
char buf[BUF_SIZE];
WSABUF wsaBuf;
}PER_IO_DATA, *LPPER_IO_DATA;
代码说明: 第11行请注意观察此处的结构体. 该结构体包含套接字句柄, 缓冲相关信息.
该结构体中的信息足够进行数据交换, 下列代码将介绍该结构体的填充及使用方法.

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#define BUF_SIZE 1024
void CALLBACK ReadCompRoution(DWORD, DWORD, LPWSAOVERLAPPED, DWORD);
void CALLBACK WriteComRoution(DWORD, DWORD, LPWSAOVERLAPPED, DWORD);
void ErrorHandling(const char* message);
typedef struct
{
SOCKET hClntSock;
char buf[BUF_SIZE];
WSABUF wsaBuf;
}PER_IO_DATA, * LPPER_IO_DATA;
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hLisnSock, hRecvSock;
SOCKADDR_IN lisnAdr, recvAdr;
LPWSAOVERLAPPED lpOvLp;
DWORD recvBytes;
LPPER_IO_DATA hbInfo;
unsigned long mode = 1;
int recvAdrSz = 0;
unsigned long flagInfo = 0;
if (argc != 2)
{
printf("Usage: %s <port> \n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
ErrorHandling("WSAStartup() error");
}
hLisnSock = WSASocket(PF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
ioctlsocket(hLisnSock, FIONBIO, &mode); /* for non-blocking mode socket */
memset(&lisnAdr, 0, sizeof(lisnAdr));
lisnAdr.sin_family = AF_INET;
lisnAdr.sin_addr.s_addr = htonl(INADDR_ANY);
lisnAdr.sin_port = htons(atoi(argv[1]));
if (bind(hLisnSock, (SOCKADDR*)&lisnAdr, sizeof(lisnAdr)) == SOCKET_ERROR)
{
ErrorHandling("bind() error");
}
if (listen(hLisnSock, 5) == SOCKET_ERROR)
{
ErrorHandling("listen() error");
}
recvAdrSz = sizeof(recvAdr);
while (1)
{
SleepEx(100, TRUE); /* for alertable wait state */
hRecvSock = accept(hLisnSock, (SOCKADDR*)&recvAdr, &recvAdrSz);
if (hRecvSock == INVALID_SOCKET)
{
if (WSAGetLastError() == WSAEWOULDBLOCK)
{
continue;
}
else
{
ErrorHandling("accept() error");
}
}
puts("Client connected ...");
lpOvLp = (LPWSAOVERLAPPED)malloc(sizeof(WSAOVERLAPPED));
memset(lpOvLp, 0, sizeof(WSAOVERLAPPED));
hbInfo = (LPPER_IO_DATA)malloc(sizeof(PER_IO_DATA));
hbInfo->hClntSock = (DWORD)hRecvSock;
(hbInfo->wsaBuf).buf = hbInfo->buf;
(hbInfo->wsaBuf).len = BUF_SIZE;
lpOvLp->hEvent = (HANDLE)hbInfo;
WSARecv(hRecvSock, &(hbInfo->wsaBuf),
1, &recvBytes, &flagInfo, lpOvLp, ReadCompRoution);
}
closesocket(hRecvSock);
closesocket(hLisnSock);
WSACleanup();
return 0;
}
最后介绍2个Completion Routine 函数. 实际的回声服务是通过这2个函数完成的, 希望各位仔细观察提供服务的过程.

void CALLBACK ReadCompRoutine(
DWORD dwError, DWORD szRecvBytes, LPWSAOVERLAPPED lpOverlapped, DWORD flags)
{
LPPER_IO_DATA hbInfo = (LPPER_IO_DATA)(lpOverlapped->hEvent);
SOCKET hSock = hbInfo->hClntSock;
LPWSABUF bufInfo = &(hbInfo->wsaBuf);
DWORD sentBytes;
if (szRecvBytes == 0)
{
closesocket(hSock);
free(lpOverlapped->hEvent);
free(lpOverlapped);
puts("Client disconnected ...");
}
else
{
bufInfo->len = szRecvBytes;
WSASend(hSock, bufInfo, 1, &sentBytes, 0, lpOverlapped, WriteComRoution);
}
}
void CALLBACK WriteComRoution(
DWORD dwError, DWORD szSendBytes, LPWSAOVERLAPPED lpOverlapped, DWORD flags)
{
LPPER_IO_DATA hbInfo = (LPPER_IO_DATA)(lpOverlapped->hEvent);
SOCKET hSock = hbInfo->hClntSock;
LPWSABUF bufInfo = &(hbInfo->wsaBuf);
DWORD recvBytes;
unsigned long flagInfo = 0;
WSARecv(hSock, bufInfo, 1, &recvBytes, &flagInfo, lpOverlapped, ReadCompRoutine);
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
上述示例的工作原理整理如下.

通过交替调用 ReadCompRoutine 函数和 WriteCompRoutine 函数, 反复执行数据的接收和发送操作. 另外, 每次增加1个客户端都会定义 PER_IO_DATA结构体, 以便将新创建的套接字句柄和缓冲信息传递给 ReadCompRoutine 函数和 WriteCompRoutine 函数. 同时将该结构体地址值写入 WSAOVERLAPPED 结构体成员 hEvent, 并传递给 Completion
Routine 函数. 这非常重要, 可概括如下:

接下来需要验证运行结果, 先要编写回声客户端, 因为使用第4章的回声客户端会无法得到预想的结果.
重新实现客户端
其实第4章实现并使用至今的回声客户端存在一些问题, 关于这些问题及解决方案已在第5章进行了充分讲解. 虽然在目前为止的各种模型的服务器端中使用稍有缺陷的回声客户端也不会引起太大的问题, 但本章的回声服务器端则不同. 因此, 需要按照第5章的提示解决客户端存在的问题, 并结合改进后的客户端运行本章服务端. 之前已介绍过解决方法, 故只给出代码.

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#define BUF_SIZE 1024
void ErrorHandling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hSocket;
SOCKADDR_IN servAdr;
char message[BUF_SIZE];
int strLen, readLen;
if (argc != 3)
{
printf("Usage : %s <IP> <port> \n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
ErrorHandling("WSAStartup() error");
}
hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
{
ErrorHandling("socket() error");
}
memset(&servAdr, 0, sizeof(servAdr));
servAdr.sin_family = AF_INET;
servAdr.sin_addr.s_addr = inet_addr(argv[1]);
servAdr.sin_port = htons(atoi(argv[2]));
if (connect(hSocket, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
{
ErrorHandling("connect() error");
}
else
{
puts("Connected ...");
}
while (1)
{
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
{
break;
}
strLen = strlen(message);
send(hSocket, message, strLen, 0);
readLen = 0;
while (1)
{
readLen += recv(hSocket, &message[readLen], BUF_SIZE - 1, 0);
if (readLen >= strLen)
{
break;
}
}
message[strLen] = 0;
printf("Message from server: %s", message);
}
closesocket(hSocket);
WSACleanup();
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
上述代码第44行的循环语句考虑到 TCP 的传输特性而重复调用了 recv 函数, 直到接收所有数据, 将上述客户端结合之前的回声服务器端运行可以得到正确的有运行结果, 具体结果与一般的回声客户端服务器端 没有区别, 故省略.
运行结果:

从重叠 I/O 模型到 IOCP 模型
下面分析重叠 I/O 模型回声服务器端的缺点.

如果正确理解了之前的示例, 应该不难发现这一点. 既然为了处理连接请求而只调用accept 函数, 也不能为了 Completion Routine 而只调用 SleepEx 函数, 因此轮流调用了 非阻塞模式的 accept 函数 和 SleepEx 函数(设置较短的超时时间). 这恰恰是影响性能的代码结构.
我也不知道如何你补这一缺点, 这属于重叠 I/O 结构固有的缺陷, 但可以考虑如下方法:

其实这就是 IOCP 中采用的服务器端模型. 换言之, IOCP 将创建专用的 I/O 线程, 该线程负责与所有客户端进行 I/O .

23.2 分段实现 IOCP 程序
本节我们编写最后一种服务器模型 IOCP, 比阅读代码更重要的是理解 IOCP 本身.
创建 “完成端口”
IOCP 中已完成的 I/O 信息将注册到完成端口对象(Completion Port, 简称CP 对象), 但这个过程并非单纯的注册, 首先需要经过如下请求过程:

该过程称为 “套机字和CP对象之间的连接请求”. 因此, 为了实现基于 IOCP 模型的服务器端, 需要如下2项工作.

此时的套接字必须赋予重叠属性. 上述2项工作可以通过1个函数完成, 但为了创建 CP 对象, 先介绍如下函数.


以创建CP对象为目的调用上述函数时, 只有最后一个参数才真正具有含义. 可以用如下代码段将分配给 CP 对象的用于处理 I/O 的线程指定为2.

连接完成端口对象和套接字
既然有了 CP 对象, 接下来就要将该对象连接到套接字, 只有这样才能使已完成的套接字 I/O 信息注册到 CP 对象. 下面以创建连接为目的再次介绍 CreateIoCompetionPort 函数.

上述函数的第二种功能就是将 FileHandle 句柄指向的套机字和 ExistingCompletionPort 指向的 CP 对象相连. 该函数的调用方式如下.

调用 CreateIoCompletionPort 函数后, 只要针对 hSock 的I/O完成, 相关信息就将注册到 hCpObject 指向的 CP对象.
确认完成端口已完成的 I/O 和 线程的 I/O 处理
我们已经掌握了 CP 对象的创建及其与套接字建立连接的方法, 接下来就要学习如何确认 CP 中注册的已完成的 I/O. 完成该功能的函数如下.

虽然只介绍了2个 IOCP 相关函数, 但依然有些复杂, 特别是上述函数的第三个和第四个参数更是如此. 其实这2个参数主要是为了获取需要的信息而设置的, 下面介绍这2中信息的含义.

各位需要通过示例理解这2个参数的使用方法. 接下来讨论其调用主体, 究竟由谁(何时)调用上述函数比较合理呢? 如各位所料, 应该由处理 IOCP 中已完成 I/O 的线程调用.

如前所述, IOCP 中创建全职 I/O 线程, 由该线程针对所有客户端进行 I/O. 而且 CreateIoCompletionPort 函数中也有参数用于指定分配给 CP 对象的最大线程数, 所以各位或许会有如下疑问:

当然不是! 应该由程序员自行创建调用WSASend, WSARecv 等 I/O 函数的线程, 只是该线程为了确认 I/O 的完成会调用 GetQueuedCompletionStatus 函数. 虽然任何线程都能调用GetQueued-CompletionStatus 函数, 但实际得到 I/O 完成信息的线程数不会超过调用 CreateloCompletionStatus 函数时指定的最大线程数. 相信大家也理解了分配给 CP 对象线程具有的含义.
以上就是 IOCP 服务端实现需要的全部函数及其理论说明, 下面通过源代码理解程序的整体结构.

实现基于 IOCP 的回声服务器端
虽然介绍了 IOCP 相关的理论知识, 但离开示例很难真正掌握 IOCP 的使用方法. 因此, 我将介绍便于理解和运用的(极为普通的) 基于 IOCP 的回声服务器端. 首先给出 IOCP 回声服务器端的 main 函数之前的部分.

#include <stdio.h>
#include <stdlib.h>
#include <process.h>
#include <Windows.h>
#include <WinSock2.h>
#define BUF_SIZE 100
#define READ 3
#define WRITE 5
typedef struct /* socket info */
{
SOCKET hClntSock;
SOCKADDR_IN clntAdr;
}PER_HANDLE_DATA, *LPPER_HANDLE_DATA;
typedef struct /* buffer info */
{
OVERLAPPED overlapped;
WSABUF wsaBuf;
char buffer[BUF_SIZE];
int rwMode;
}PER_IO_DATA, *LPPER_IO_DATA;
DWORD WINAPI EchoThreadMain(LPVOID CompletionPortIO);
void ErrorHandling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
HANDLE hComPort;
SYSTEM_INFO sysInfo;
LPPER_IO_DATA ioInfo;
LPPER_HANDLE_DATA handleInfo;
SOCKET hServSock;
SOCKADDR_IN servAdr;
int recvBytes, i, flags = 0;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
ErrorHandling("WSAStartup() error");
}
hComPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
GetSystemInfo(&sysInfo);
for (i = 0; i < sysInfo.dwNumberOfProcessors; i++)
{
_beginthreadex(NULL, 0, EchoThreadMain, (LPVOID)hComPort, 0, NULL);
}
}

这里有Bug , 等我功力一定, 再搞定, 不能卡在这里, 不因为一棵树, 放弃整片深林
结语:
我最近 买了实体书 , 先看完电子版(先过一遍知识点, 我没有这么牛逼能记住, 可以复习的嘛! ), 再买实体版 , 避免它又成为收藏书没啥用, 这本书非常适合新手
你可以下面这个网站下载这本书<TCP/IP网络编程>
https://www.jiumodiary.com/
时间: 2020-06-22
本文深入探讨了Windows下IOCP(输入输出完成端口)的工作原理,对比了其与传统I/O模型的优势,并通过实例展示了如何使用IOCP实现高性能的回声服务器端。文章详细解析了非阻塞模式下套接字的创建与使用,以及如何从重叠I/O模型过渡到IOCP模型。

2282

被折叠的 条评论
为什么被折叠?



