前言

前面介绍了重叠IO模型,该模型的缺点是发出IO请求的线程必须同时对完成通知进行处理,若一个线程发出多个请求,那么即使其它线程完全处于空闲状态,该线程也必须对每个请求的完成通知做出响应,从而影响了性能。

为了解决这个问题,我们可以在主线程中调用 accept 函数,再单独创建 1 个线程来负责客户端IO。实际上,IOCP模型便是创建了专用的线程来处理客户端IO,不过为了充分发挥 CPU 性能,它创建了不止一个线程。

所以IOCP模型其实就是对于重叠IO模型的更加完善,在阅读本篇之前,需要懂得如何使用重叠IO模型。

在学习各种网络模型之时,我们主要应该去关注两点,在select模型的时候大家就看过这个图:

Flow chart

实际上这两点也就是理解各个模型的关键,我们要关注如何等待客户端连接,还有如何将数据从网卡缓冲区拷贝到程序缓冲区。这两部分若有一部分阻塞,那么性能就不是太好。

IOCP(I/O Completion Port),即IO完成端口模型,该模型会创建一个CP(完成端口)内核对象,然后将CP对象与套接字绑定起来,之后套接字若有IO消息便可通过相关函数获得到。

创建CP对象

使用如下函数创建一个CP对象:

HANDLE CreateIoCompletionPort (
  HANDLE FileHandle,              // file handle to associate with 
                                  // the I/O completion port
  HANDLE ExistingCompletionPort,  // handle to the I/O completion port
  DWORD CompletionKey,            // per-file completion key for I/O 
                                  // completion packets
  DWORD NumberOfConcurrentThreads // number of threads allowed to 
                                  // execute concurrently
);

这个函数有两个功能,既可以创建CP对象,也可以连接文件句柄与CP对象。在创建CP对象时,只需传入 -1,0,0,0 即可。

由于这里只是创建CP对象,所以前3个参数都不会用到,-1 表示无效的文件句柄,官方对于此提供的有一个专门的宏 INVALID_HANDLE_VALUE。所以可以这样创建一个CP对象:

HANDLE hCompPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, 0, 0, 0);

最后一个参数是这里唯一用上的,表示分配给CP对象用于处理IO的线程数,0 并不表示零个,这是一个默认值,此时会默认使用机器CPU的核心数。但是注意这里并没有真正的创建线程,那还是得我们自己创建。

连接文件句柄与CP对象

同样使用 CreateIoCompletionPort 函数来将文件句柄与CP对象关联起来:

HANDLE CreateIoCompletionPort (
  HANDLE FileHandle,  // 欲连接到CPU对象的套接字句柄
  HANDLE ExistingCompletionPort,  // CP对象句柄
  DWORD CompletionKey,  // 完成键,用于传递已完成IO相关信息
  DWORD NumberOfConcurrentThreads // 若第二个参数非NULL,则忽略
);

现在只需考虑前3个参数,第一个参数就是我们的套接字句柄,第二个参数是上一步创建的CP对象,第三个参数用于传递套接字的相关信息,即前面各种模型中用过的 PER_HANDLE_DATA,其中包含了套接字的句柄和地址。

所以可以这样连接一个套接字与CP对象:

LPPER_HANDLE_DATA lpPerHandleInfo = ...;
SOCKET sock;
// ...
CreateIoCompletionPort((HANDLE)sock, hCompPort, (ULONG_PTR)lpPerHandleInfo, 0);

获取IO消息

当将CP对象与套接字连接了后,系统便管理起该套接字的IO,当该套接字有消息时,系统便会通知我们。

通过如下函数来获取通知:

BOOL GetQueuedCompletionStatus(
  HANDLE CompletionPort,  // CP对象句柄
  LPDWORD lpNumberOfBytesTransferred,  // 指向传输的数据大小的变量地址
  LPDWORD lpCompletionKey,     // 接收完成键
  LPOVERLAPPED *lpOverlapped,  // 指向重叠结构
  DWORD dwMilliseconds         // 超时时间
);

下面来分别介绍每个参数的意义。

第一个参数就是我们创建的CP对象,这个CP对象在整个程序中就只会创建一个。第二个参数是实际收到的字节数,已经看过重叠IO模型的朋友自不陌生。

第三个参数就是得到的就是 CreateIoCompletionPort 中第三个传入的参数。

而第四个参数则是通过 WSASendWSARecv 函数传入的 WSAOVERLAPPED 结构体的变量地址值,实际上就是我们在重叠IO模型中所说的“小纸条”。

最后一个参数是超时时间,应该传入 INFINITE,表示一直等待。

获取IO消息属于前面所说的第二个关注点,这部分肯定是包含在线程中的,稍后便会在线程中使用这个来获取IO通知。

发送IO通知

使用下面这个函数,可以手动向完成端口发出IO通知:

BOOL PostQueuedCompletionStatus(
  HANDLE CompletionPort,  // CP对象句柄
  DWORD dwNumberOfBytesTransferred,  // 传输的字节数
  DWORD dwCompletionKey,  // 完成键
  LPOVERLAPPED lpOverlapped  // 重叠结构
);

一般来说,无需手动发送IO通知,但是在需要退出IO处理线程的时候可以使用这个函数来发出退出的标识。比如可以用 0xFFFFFFFF 来表示实际传输的字节数,这样在线程中收到字节数为 0xFFFFFFFF 大小的数据时就可以安全地退出线程了。

实例一

以上就是IOCP的全部基础内容,因为我们是从select一路学过来的,所以这里的新东西的确不多,实现起来也不太难,包括后面还会介绍的配合扩展函数实现起来其实也不太难。我感觉麻烦的是线程的同步,这里一不小心就会出错,而且不易查改,它可能会在大多数时间完美运行而在有时产生错误,追踪起来实在麻烦。而且大多书上都未给出IOCP完整的例子,网上CV现象严重,也很少能找到完整的C++例子,无处参考只能自己慢慢调试了。

第一个版本的实例是配合 accept 的IOCP,我们由易到难,先通过此例子明白IOCP的用法,之后再使用Windows扩展函数来配合IOCP来实现第二个版本。最后若有时间就再来写一个完整的高级一点的聊天程序,包括界面,服务器,客户端,聊天,到时候看到什么有用的功能就试着做一下,来写个稍微完整的项目吧。

先来包含可能需要用到的头文件:

#include <memory>  // 用到智能指针
#include <vector>  // 用来保存连接进来的用户
#include <thread>  // 开线程用
#include <mutex>  // 用于线程同步
#include <cassert>  // 断言
#include <iostream>
#include <algorithm>  // 可能会用到一些算法
#include <WinSock2.h>  // winsock库
#pragma comment(lib, "ws2_32.lib")

接着声明将会用到的结构体:

// 单句柄数据
typedef struct {
    SOCKET hSock;
    SOCKADDR_IN sockAddr;
}PER_HANDLE_DATA, *LPPER_HANDLE_DATA;

// 单IO数据
typedef struct {
    WSAOVERLAPPED overlapped;  // 重叠结构
    WSABUF wsaBuf;  // 数据缓冲区
    char buf[BUF_SIZE];  // 数据地址
    int opType;  // 操作类型
}PER_IO_DATA, *LPPER_IO_DATA;

主要看单IO数据,即我们的“小纸条”,第一个是个重叠结构,需要注意的是它必须放到第一个,为何在后面用到之时再说。接着是一些存数据的字段,最后是一个字段用于标记操作类型,因为系统并不知是发送的操作还是接收的操作,所以我们需要记录一下,之后便可通过此字段识别出具体操作类型。那么都有那些操作类型呢?

我们定义如下:

enum {
    OP_READ,
    OP_WRITE
};

分别表示读取数据和写入的数据,即接收与发送。

接着定义IOCP服务器类:

class IOCPServer
{
public:
    IOCPServer(int port);
    ~IOCPServer();
    void Accept();

private:
    void InitSock();  // 初始化套接字
    void CreateIocp();  // 创建CP对象并开启线程
    void AcceptHandler();  // 处理接受部分
    void RequestHandler();  // 处理IO消息部分的线程
    void RecvMsg(SOCKET);  // 接收消息
    void SendMsg(SOCKET, LPPER_IO_DATA, std::string&);  // 发送消息
    void CloseSock(SOCKET);  // 关闭套接字

private:
    std::shared_ptr<PER_HANDLE_DATA> m_servSock;  // 服务端Socket
    std::shared_ptr<PER_HANDLE_DATA> m_clntSock;  // 客户端Socket
    std::shared_ptr<PER_IO_DATA> m_ioInfo;  // 单IO数据
    std::vector<std::shared_ptr<PER_HANDLE_DATA>> m_vAcceptedSock;  //用于保存连接进来的用户信息
    HANDLE m_hCompPort;  // CP对象句柄
    int m_nPort;  // 端口
    std::recursive_mutex m_remtx;  // 用于线程同步的递归锁
};

实现起来内容也不少,便放到下篇(实际上我还没开始写呢,有点忙……)。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.