65.9K
CodeProject 正在变化。 阅读更多。
Home

IOCPNet - 终极 IOCP

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.95/5 (50投票s)

2005年8月24日

8分钟阅读

viewsIcon

289291

downloadIcon

17326

易于使用,高性能,通过使用 IO 完成端口实现大数据传输。

引言

关于 IOCP(输入/输出完成端口)的文章有很多。但它们不容易理解,因为 IOCP 本身就有些晦涩难懂,而且也没有足够解释或代码示例的标准文档。因此,我决定创建一个高性能的 IOCP 示例(OIOCPNet),并撰写一篇文档,介绍 IOCP 的工作方式及其相关的关键问题。

目标

我重点关注

  1. 超过 65,000 个并发连接(IPv4 的最大端口号(无符号短整型 (65535))) 。
  2. 传输超过千字节数据的能力。
  3. OIOCPNet 类的用户使用的便捷方法。

实现目标的关键思路

IOCP

是的,第一件事就是 IOCP。那么,为什么我们应该使用 IOCP 呢?如果我们使用众所周知的 select 函数(带有 FD_SETFD_ZERO 等),我们就不可避免地需要循环来检测套接字事件,这意味着套接字有接收或发送的数据包。当我们开发游戏服务器或聊天服务器时,套接字被用作用户操作的 ID。因此,为了在服务器上查找用户数据,我们会使用查找循环或带有套接字编号的哈希表。当用户数量超过数万时,循环会非常严重地导致服务器变慢。但是使用 IOCP,我们无需执行这些循环。因为 **IOCP 在内核级别检测套接字事件**,并且 **IOCP 提供了将套接字(即完成端口)直接与用户数据指针关联的机制**。简而言之,使用 IOCP 我们可以避免循环,更快地在服务器端获取用户数据。

AcceptEx

使用 Accept(或 WSAAccept)时,当(几乎)并发连接数超过 30,000(取决于系统资源)时,我们会收到 WSAENOBUFS (10055) 错误。错误的原因是系统无法像连接建立那样快地准备套接字结构的系统资源。因此,我们需要一种在实际使用套接字资源之前就准备好的方法,而 AcceptEx 就是答案。AcceptEx 的主要优点就是这个——在使用套接字之前就准备好它们!AcceptEx 的其他功能也很令人头疼,而且难以理解。(请参见 MSDN 库。)

静态内存

在服务器端应用程序中使用静态内存(或预分配内存)是自然且至关重要的。当我们接收或发送数据包时,我们必须使用静态内存。在 OIOCPNet 中,我使用了自己的类(OPreAllocator)来获取预分配的内存区域。

切片数据块

您是否曾遇到过需要通过一个函数调用(如 WriteFileWSASendsend)发送一个大数据包(超过千字节)的情况,而接收方却没有收到您发送的数据包?如果您遇到过,那么您可能遇到了网络硬件(路由器、集线器等)和缓冲区——MTU(最大传输单元)的问题。网络硬件的最小 MTU 是 576 字节,因此最好将大数据包切成许多小于最小 MTU 大小的小数据包。在 OIOCPNet 中,我将单位数据块大小定义为 BUFFER_UNIT_SIZE(512 字节)。如果您需要更大的单位,可以进行更改。

不要创建过多线程

如果您的服务器逻辑包含某种 IO 操作,那么创建许多线程可能是更好的选择。因为线程只有在环境中有 IO 操作时才有意义。但不要忘记“线程越多,CPU 在线程调度上付出的努力就越多”。如果存在超过 10,000 个线程并且它们正在运行,操作系统和进程将无法保持其正常运行状态,因为 CPU 会将所有能力都用于寻找下一个要运行的线程——调度或上下文切换。作为参考,OIOCPNet 每个 CPU 有两个(实验值)线程,并且不会创建更多线程。

OIOCPNet - 关键

OIOCPNet 是应用了上述思想的类。OIOCPNet 的操作步骤如下:

  1. OIOCPNet 准备其资源,例如预分配的内存区域、完成端口、其他句柄等。
  2. OIOCPNet 创建一个监听套接字。
  3. OIOCPNet 预先生成套接字(65,000 个,但我将其在 IOCPNet.h 中定义为 30,000 个,适用于非 Win 2003 的操作系统,请根据您的需求更改 MAX_ACCEPTABLE_SOCKET_NUM),以及其自身的缓冲套接字,然后使用 AcceptEx 将它们置于可接受模式。
  4. 当用户尝试连接到服务器时,OIOCPNet 会接受连接。
  5. 当套接字读取数据包时,OIOCPNet 将它们放入其预分配的读取槽中,然后放置一个事件供服务器逻辑使用。
  6. 当服务器逻辑写入数据包时,OIOCPNet 将它们放入其预分配的写入块中,然后调用 PostQueuedCompletionStatus,以便工作线程发送数据包。
  7. 当用户关闭连接时,OIOCPNet 会关闭套接字,但不会释放缓冲套接字所占用的内存,只是重新分配它。

下图显示了 OIOCPNet 的整个机制。它非常简单:

编写代码时的要点

LPOVERLAPPED 参数

GetQueuedCompletionStatusPostQueuedCompletionStatus 缺少用于表示 IO 操作结果的参数。除了 GetQueuedCompletionStatus(或 PostQueuedCompletionStatus)的默认参数外,OIOCPNet 还需要更多参数来区分 IO 操作的类型和一些附加信息。因此,我将 GetQueuedCompletionStatusPostQueuedCompletionStatusLPOVERLAPPED 参数用作自定义参数,就像 CreateThread 的线程参数(LPVOID lpParameter,第四个参数)一样。OVERLAPPEDExtOVERLAPPED 结构的扩展类型,它包含更多信息。请参见下面的定义代码:

struct OVERLAPPEDExt
{
  OVERLAPPED OL;
  int IOType;
  OBufferedSocket *pBuffSock;
  OTemporaryWriteData *pTempWriteData;
}; // OVERLAPPEDExt

异步函数使用的变量的生命周期

OIOCPNet 中,WSASendWSARecv 以异步方式运行。因此,请注意传递给异步函数的变量的生命周期。

// pTempWriteData will be freed when send IO ends.
pTempWriteData = (OTemporaryWriteData *)
m_SMMTempWriteData.Allocate(sizeof (OTemporaryWriteData));

...

// the size of pData 
// (the second parameter of GetBlockNeedsExternalLock)
// does not be over BUFFER_UNIT_SIZE.
m_pWriteBlock->GetBlockNeedsExternalLock
  (&pBuffSockToWrite, pTempWriteData->Data, 
  &ReadSizeToWrite, &DoesItHaveMoreSequence);

...

try
{
  ResSend = WSASend(pTempWriteData->Socket, 
    &pTempWriteData->DataBuf, 1, 
    &WrittenSizeUseless, Flag, 
    (LPOVERLAPPED)&pTempWriteData->OLExt, 0);
}

在上面的代码片段中,pTempWriteData 是为 WSASend 使用而分配的。WSASend 会立即返回,但 pTempWriteData 必须一直存在,直到 WSASend 在操作系统级别的实际发送操作完成。发送操作完成后,像这样释放 pTempWriteData

if (0 != pOVL)
{
  if ((IO_TYPE_WRITE_LAST == 
    ((OVERLAPPEDExt *)pOVL)->IOType 
    || IO_TYPE_WRITE == 
    ((OVERLAPPEDExt *)pOVL)->IOType))
  {
    if (0 != ((OVERLAPPEDExt *)pOVL)->pTempWriteData)
    {
      m_SMMTempWriteData.Free(
        ((OVERLAPPEDExt *)pOVL)->pTempWriteData);
    }    
    continue;
  }
}

套接字唯一性

普通的 SOCKET 号码本身是唯一的。但是操作系统会任意分配套接字号码,最近关闭的套接字号码可能会被分配给紧随其后的新套接字。所以可能会出现:

  1. 套接字分配了套接字号码 3947(作为示例)用于新的连接。
  2. 服务器逻辑使用该套接字读取数据包。
  3. 由于用户关闭连接,套接字突然关闭,而服务器逻辑并不知道这个事实。
  4. 另一个套接字被分配了相同的套接字号码 3947(该套接字号码的“复活”)。
  5. 服务器逻辑向套接字写入数据包,服务器可以顺利完成。但数据包可能因此发送给了另一个用户。

为了防止这种情况发生,OIOCPNet 管理自己的套接字编号 SocketUnique,它是 OBufferedSocket 的一个成员。

如何使用 OIOCPNet

用法

OIOCPNet 的用法很简单。请参阅以下代码片段:

int _tmain(int argc, _TCHAR* argv[])
{
  ...

  WSAStartup(MAKEWORD(2,2), &WSAData);

  pIOCPNet = new OIOCPNet(&EL);
  pIOCPNet->Start(TEST_IP, TEST_PORT);
    
  hThread = CreateThread(0, 0, LogicThread, 
    pIOCPNet, 0, 0);

  ...
  
  InterlockedExchange((long *)&g_dRunning, 0);
  WaitForSingleObject(hThread, INFINITE);

  ...

  pIOCPNet->Stop();
  delete pIOCPNet;

  WSACleanup();

  return 0;
} // _tmain()

DWORD WINAPI LogicThread(void *pParam)
{
  ...
  
  while (1 == InterlockedExchange((long *)&g_dRunning, 
    g_dRunning))
  {
    iRes = pIOCPNet->GetSocketEventData(WAIT_TIMEOUT_TEST,
      &EventType, &SocketUnique, &pReadData, 
      &ReadSize, &pBuffSock, &pSlot, &pCustData);
    if ...
    else if (RET_SOCKET_CLOSED == iRes)
    {
      // release pCustData.
      continue;
    }

    // Process main logic.
    MainLogic(pIOCPNet, SocketUnique, pBuffSock, 
      pReadData, ReadSize);
        
    pIOCPNet->ReleaseSocketEvent(pSlot);
  }

  return 0;
} // LogicThread()

void MainLogic(OIOCPNet *pIOCPNet, DWORD SocketUnique,
  OBufferedSocket *pBuffSock, BYTE *pReadData, DWORD ReadSize)
{
  pIOCPNet->WriteData(SocketUnique, pBuffSock, 
    pReadData, ReadSize); // echo.
} // MainLogic()

我们可以使用 Start 设置 IP 地址和端口号,它会准备必要的资源。在逻辑线程中,我们可以使用 GetSocketEventData 获取数据包,并使用 WriteData 发送数据包。使用完数据后,通过 ReleaseSocketEvent 释放 pSlot(它指向 pReadData,即数据包)。最后,当主逻辑结束时,调用 Stop 来让 OIOCPNet 释放其资源。就是这样。

客户端的读写注意事项

OIOCPNet 将大数据包切分成小数据包。它会在原始数据包中添加 4 字节的包长度信息。但是,切分和组合操作由 OIOCPNetGetSocketEventDataWriteData 抽象完成。因此,我们无需关心。但是,在制作连接服务器的客户端应用程序时,您应该使用 TCPWriteTCPRead(参见 NetTestClient 项目中的 TCPFunc.hTCPFunc.cpp)。

测试

我的报告

我在 .NET 1.1 环境下编译了 OIOCPNet。(也包括 VC++ 6.0,阻塞 #include "stdafx.h")。我将服务器(IOCPNetTest)部署在 Windows 2003 Enterprise Edition 上,并将测试客户端(NetTestClient)部署在多台机器上。规格和性能结果如下:

  • 测试服务器 - 操作系统:Windows 2003 Enterprise Edition
  • 测试服务器 - CPU:Intel 2.8GHz (x 2)
  • 测试服务器 - RAM:2GB
  • 测试客户端:Windows XP (使用 3~5 台机器,改变线程数)
  • 结果:CPU 使用率约为 15% ~ 20%(在建立 TCP 连接数为 65,000 时)

其他提示

当客户端无法向服务器建立超过 5,000 个(约 2,000 个)连接时,请检查注册表。检查步骤包括:

  1. 运行 regedit
  2. 打开“HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters”
  3. 添加“MaxUserPort”作为 DWORD 值并设置其值(十进制数的最大值为 65534)。

如果您需要将测试客户端的线程数增加到 2,0xx 以上,请使用编译选项“/STACK:BYTE”或 CreateThread 的参数来修改客户端应用程序的函数堆栈大小。在运行测试服务器和测试客户端之前,请将 TEST_IPTEST_SERVER_IP 设置为您的服务器的 IP 地址。要查看连接数,请使用性能监视器或命令提示符中的“netstat -s”。

历史

  • 2005 年 8 月
    • IOCPNet 首个版本。
    • 修复了结束过程中的一个 bug。
    • 添加了一个新的演示和源代码,使用 Windows 线程池。(因为有一些关于示例使用 BindIoCompletionCallback 的请求。)
© . All rights reserved.