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

一个简单的IOCP服务器/客户端类

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (188投票s)

2005年5月9日

CPOL

29分钟阅读

viewsIcon

11938491

downloadIcon

53919

此源代码使用了先进的IOCP技术,可以高效地服务多个客户端。它还提出了一些IOCP编程API中出现的实际问题的解决方案,并提供了一个带有文件传输功能的简单回声客户端/服务器。

1.1 要求

  • 本文要求读者熟悉C++、TCP/IP、套接字编程、MFC和多线程。
  • 源代码使用Winsock 2.0和IOCP技术,需要:
    • Windows NT/2000或更高版本:需要Windows NT 3.5或更高版本。
    • Windows 95/98/ME:不支持。
    • Visual C++ .NET,或完全更新的Visual C++ 6.0。

1.2 摘要

在开发不同类型的软件时,迟早会遇到客户端/服务器开发。编写一个全面的客户端/服务器代码对程序员来说是一项艰巨的任务。本文档提供了一个简单但功能强大的客户端/服务器源代码,可以扩展到任何类型的客户端/服务器应用程序。此源代码使用了先进的IOCP技术,可以高效地服务多个客户端。IOCP通过仅使用少量处理线程和异步输入/输出发送/接收,为“每个客户端一个线程”的瓶颈问题(以及其他问题)提供了一个高效的解决方案。IOCP技术广泛用于各种高性能服务器,如Apache等。源代码还提供了一组在处理通信和客户端/服务器软件时常用到的功能,如文件接收/传输功能和逻辑线程池处理。本文重点关注IOCP编程API中出现的实际问题解决方案,并提供了源代码的整体文档。此外,本文还介绍了一个可以处理多个连接和文件传输的简单回声客户端/服务器。

2.1 简介

本文介绍了一个可以在客户端和服务器代码中使用的类。该类使用了IOCP(输入输出完成端口)和异步(非阻塞)函数调用,这些将在后面解释。源代码基于许多其他源代码和文章:[1、2和3]。

通过这个简单的源代码,您可以:

  • 服务或连接到多个客户端和服务器。
  • 异步发送或接收文件。
  • 创建和管理逻辑工作线程池,以处理更繁重的客户端/服务器请求或计算。

很难找到一个全面但简单的源代码来处理客户端/服务器通信。网上找到的源代码要么过于复杂(20多个类),要么效率不足。此源代码旨在尽可能简单且文档齐全。在本文中,我们将简要介绍Winsock API 2.0提供的IOCP技术,并解释在编码过程中出现的棘手问题及其解决方案。

2.2 异步输入/输出完成端口(IOCP)简介

如果服务器应用程序不能同时服务多个客户端,那么它的意义就不大,通常为此目的使用异步I/O调用和多线程。根据定义,异步I/O调用会立即返回,使I/O调用处于挂起状态。在某个时间点,I/O异步调用的结果必须与主线程同步。这可以通过不同的方式完成。同步可以通过以下方式执行:

  • 使用事件 - 异步调用完成后立即设置信号。这种方法的缺点是线程必须检查或等待事件被设置。
  • 使用 GetOverlappedResult 函数 - 这种方法与上述方法具有相同的缺点。
  • 使用异步过程调用(APC) - 这种方法有几个缺点。首先,APC总是在调用线程的上下文中被调用;其次,为了能够执行APC,调用线程必须处于所谓的“可变等待状态”。
  • 使用IOCP - 这种方法的缺点是必须解决许多实际棘手的编程问题。编写IOCP代码可能有点麻烦。

2.2.1 为什么要使用IOCP?

通过使用IOCP,我们可以克服“每个客户端一个线程”的问题。众所周知,如果软件不在真正的多处理器机器上运行,性能会大幅下降。线程是系统资源,它们既不是无限的,也不是廉价的。

IOCP提供了一种让少量(I/O工作)线程“公平地”处理多个客户端输入/输出的方法。线程被挂起,直到有任务要做才使用CPU周期。

2.3 IOCP是什么?

我们已经说过,IOCP不过是一个线程同步对象,类似于信号量,因此IOCP并不是一个复杂的概念。一个IOCP对象与几个支持挂起异步I/O调用的I/O对象相关联。一个可以访问IOCP的线程可以被挂起,直到一个挂起的异步I/O调用完成。

3 IOCP是如何工作的?

关于这部分的更多信息,我参考了其他文章。[1, 2, 3,见参考文献。]

在使用IOCP时,您必须处理三件事:将套接字与完成端口关联,进行异步I/O调用,以及与线程同步。要从异步I/O调用中获取结果并了解(例如)哪个客户端进行了调用,您必须传递两个参数:CompletionKey 参数和 OVERLAPPED 结构。

3.1 完成键参数

第一个参数 CompletionKey 只是一个 DWORD 类型的变量。您可以传递任何您想要的唯一值,该值将始终与对象关联。通常,指向可以包含一些客户端特定对象的结构或类的指针会随此参数传递。在源代码中,一个指向 ClientContext 结构的指针作为 CompletionKey 参数传递。

3.2 OVERLAPPED参数

此参数通常用于传递异步I/O调用使用的内存缓冲区。重要的是要注意,此数据将被锁定,并且不会从物理内存中分页出去。我们将在后面讨论这一点。

3.3 将套接字与完成端口关联

创建完成端口后,可以通过以下方式调用 CreateIoCompletionPort 函数将套接字与完成端口关联:

BOOL IOCPS::AssociateSocketWithCompletionPort(SOCKET socket, 
               HANDLE hCompletionPort, DWORD dwCompletionKey)
   {
       HANDLE h = CreateIoCompletionPort((HANDLE) socket, 
             hCompletionPort, dwCompletionKey, m_nIOWorkers);
       return h == hCompletionPort;
   }

3.4 进行异步I/O调用

为了进行实际的异步调用,会调用 WSASendWSARecv 函数。它们还需要一个 WSABUF 参数,其中包含指向将要使用的缓冲区的指针。经验法则是,通常当服务器/客户端想要调用I/O操作时,它们不会直接进行,而是发布到完成端口,并由I/O工作线程执行。这样做的原因是,我们希望CPU周期能够公平地分配。I/O调用是通过向完成端口发布状态来完成的,如下所示:

BOOL bSuccess = PostQueuedCompletionStatus(m_hCompletionPort, 
                       pOverlapBuff->GetUsed(), 
                       (DWORD) pContext, &pOverlapBuff->m_ol);

3.5 与线程同步

与I/O工作线程的同步是通过调用 GetQueuedCompletionStatus 函数(见下文)完成的。该函数还提供了 CompletionKey 参数和 OVERLAPPED 参数(见下文)。

BOOL GetQueuedCompletionStatus(
   HANDLE CompletionPort, // handle to completion port

   LPDWORD lpNumberOfBytes, // bytes transferred

   PULONG_PTR lpCompletionKey, // file completion key

   LPOVERLAPPED *lpOverlapped, // buffer

   DWORD dwMilliseconds // optional timeout value

   );

3.6 四个棘手的IOCP编码难题及其解决方案

在使用IOCP时,会出现一些问题,其中一些并不直观。在多线程场景中使用IOCP时,线程函数控制流并不简单,因为线程和通信之间没有关系。在本节中,我们将介绍在使用IOCP开发客户端/服务器应用程序时可能出现的四个不同问题。它们是:

  • WSAENOBUFS错误问题。
  • 包重排序问题。
  • 访问冲突问题。

3.6.1 WSAENOBUFS错误问题

这个问题不直观且难以检测,因为乍一看,它似乎是一个正常的死锁或内存泄漏“bug”。假设您已经开发了您的服务器,并且一切运行良好。当您对服务器进行压力测试时,它突然挂起。如果您幸运的话,您可以发现它与WSAENOBUFS错误有关。

对于每个重叠的发送或接收操作,提交的数据缓冲区可能会被锁定。当内存被锁定时,它不能从物理内存中分页出去。操作系统对可以锁定的内存量施加了限制。当达到此限制时,重叠操作将以WSAENOBUFS错误失败。

如果服务器在每个连接上发布了许多重叠接收,当连接数增加时,将达到此限制。如果服务器预期处理大量并发客户端,则服务器可以在每个连接上发布一个零字节接收。因为接收操作没有缓冲区,所以不需要锁定内存。通过这种方法,每个套接字接收缓冲区应该保持不变,因为一旦零字节接收操作完成,服务器只需执行非阻塞接收即可检索套接字接收缓冲区中缓冲的所有数据。当非阻塞接收失败并返回 WSAEWOULDBLOCK 时,不再有数据挂起。这种设计适用于那些在牺牲每个连接的数据吞吐量的同时,需要最大可能并发连接的用户。当然,您越了解客户端如何与服务器交互,就越好。在前面的示例中,一旦零字节接收完成检索缓冲数据,就执行非阻塞接收。如果服务器知道客户端以突发方式发送数据,那么一旦零字节接收完成,它可能会发布一个或多个重叠接收,以防客户端发送大量数据(大于默认的8 KB的每个套接字接收缓冲区)。

源代码中提供了一个解决WSAENOBUFS错误问题的简单实用方案。我们执行了一个带有零字节缓冲区的异步 WSARead(..)(参见 OnZeroByteRead(..))。当此调用完成时,我们知道TCP/IP堆栈中有数据,并通过执行多个带有 MAXIMUMPACKAGESIZE 缓冲区的异步 WSARead(..) 来读取它。此解决方案仅在数据到达时锁定物理内存,并解决了WSAENOBUFS问题。但此解决方案降低了服务器的吞吐量(参见第9节F.A.Q中的Q6和A6)。

3.6.2 包重排序问题

[3]也在讨论这个问题。尽管使用IO完成端口提交的操作总是按照提交的顺序完成,但线程调度问题可能意味着与完成相关的实际工作以未定义的顺序处理。例如,如果您有两个I/O工作线程,并且应该接收“字节块1,字节块2,字节块3”,您可能会以错误的顺序处理字节块,即“字节块2,字节块1,字节块3”。这也意味着当您通过在I/O完成端口上发布发送请求来发送数据时,数据实际上可能以重排序的方式发送。

这可以通过仅使用一个工作线程,并只提交一个I/O调用并等待其完成来解决,但是如果这样做,我们将失去IOCP的所有优势。

解决此问题的一个简单实用方法是在我们的缓冲区类中添加一个序列号,并在缓冲区序列号按顺序排列时处理缓冲区中的数据。这意味着具有不正确编号的缓冲区必须保存以备后用,并且出于性能原因,我们将把缓冲区保存在哈希映射对象中(例如,m_SendBufferMapm_ReadBufferMap)。

有关此解决方案的更多信息,请查看源代码,并查看 IOCPS 类中的以下函数:

  • GetNextSendBuffer (..)GetNextReadBuffer(..),用于获取有序的发送或接收缓冲区。
  • IncreaseReadSequenceNumber(..)IncreaseSendSequenceNumber(..),用于增加序列号。

3.6.3 异步挂起读取和字节块包处理问题

最常见的服务器协议是基于数据包的协议,其中前X个字节表示一个包头,包头包含整个数据包的长度详细信息。服务器可以读取包头,计算还需要多少数据,并继续读取直到获得一个完整的包。当服务器一次只进行一个异步读取调用时,这工作正常。但如果我们想充分利用IOCP服务器的潜力,我们应该有几个挂起的异步读取等待数据到达。这意味着几个异步读取会乱序完成(如前3.6.2节所述),并且由挂起读取返回的字节块流将不会按顺序处理。此外,字节块流可以包含一个或几个包,以及部分包,如图1所示。

图1。该图显示了部分数据包(绿色)和完整数据包(黄色)如何在不同的字节块流(标记为1、2、3)中异步到达。

这意味着我们必须按顺序处理字节流块才能成功读取一个完整的包。此外,我们必须处理部分包(在图1中用绿色标记)。这使得字节块包处理更加困难。此问题的完整解决方案可以在 IOCPS 类中的 ProcessPackage(..) 函数中找到。

3.6.4 访问冲突问题

这是一个小问题,是代码设计而不是IOCP特定问题导致的结果。假设客户端连接丢失,并且I/O调用返回错误标志,那么我们就知道客户端已经断开。在 CompletionKey 参数中,我们传递一个指向 ClientContext 结构的指针,该结构包含客户端特定数据。如果我们在释放此 ClientContext 结构占用的内存后,同一客户端执行的其他I/O调用返回错误代码,并且我们将 CompletionKeyDWORD 变量转换为指向 ClientContext 的指针,并尝试访问或删除它,会发生什么?会发生访问冲突!

解决此问题的方法是在包含挂起I/O调用数量的结构中添加一个数字 (m_nNumberOfPendlingIO),并且当我们知道不再有挂起I/O调用时,删除该结构。这通过 EnterIoLoop(..) 函数和 ReleaseClientContext(..) 完成。

3.7 源代码概述

源代码的目标是提供一组简单的类,用于处理所有与IOCP相关的复杂代码。源代码还提供了一组在处理通信和客户端/服务器软件时常用的函数,如文件接收/传输函数、逻辑线程池处理等。

图2。上图展示了 IOCP 类源代码功能的概述。

我们有几个IO工作线程,通过完成端口(IOCP)处理异步I/O调用,这些工作线程调用一些虚拟函数,这些虚拟函数可以将需要大量计算的请求放入工作队列。逻辑工作线程从队列中取出任务,处理并使用类提供的一些函数将结果发回。图形用户界面(GUI)通常使用Windows消息(因为MFC不是线程安全的)与主类通信,并通过调用函数或使用共享变量。

图3。上图显示了类概述。

图3中可以看到的类是:

  • CIOCPBuffer:一个用于管理异步I/O调用所使用的缓冲区的类。
  • IOCPS:处理所有通信的主类。
  • JobItem:一个结构,包含逻辑工作线程要执行的任务。
  • ClientContext:一个结构,保存客户端特定信息(状态、数据等)。

3.7.1 缓冲区设计——CIOCPBuffer类

在使用异步I/O调用时,我们必须提供一个私有缓冲区以用于I/O操作。在分配缓冲区时需要考虑一些因素:

  • 分配和释放内存的开销很大,因此我们应该重用已经分配的缓冲区(内存)。因此,我们将缓冲区保存在下面给出的链表结构中:
    // Free Buffer List.. 
    
       CCriticalSection m_FreeBufferListLock;
       CPtrList m_FreeBufferList;
    // OccupiedBuffer List.. (Buffers that is currently used) 
    
       CCriticalSection m_BufferListLock;
       CPtrList m_BufferList; 
    // Now we use the function AllocateBuffer(..) 
    
    // to allocate memory or reuse a buffer.
  • 有时,当异步I/O调用完成时,缓冲区中可能存在部分数据包,因此需要拆分缓冲区以获取完整消息。这通过 CIOCPS 类中的 SplitBuffer 函数完成。此外,有时我们需要在缓冲区之间复制信息,这通过 IOCPS 类中的 AddAndFlush(..) 函数完成。
  • 正如我们所知,我们还需要向缓冲区添加序列号和状态(IOType 变量、IOZeroReadCompleted 等)。
  • 我们还需要将数据转换为字节流和将字节流转换为数据的方法,其中一些函数也在 CIOCPBuffer 类中提供。

我们上面讨论的所有问题的解决方案都存在于 CIOCPBuffer 类中。

3.8 如何使用源代码?

通过从 IOCP 继承您自己的类(如图3所示),并使用 IOCPS 类提供的虚拟函数和功能(例如,线程池),可以实现任何类型的服务器或客户端,这些服务器或客户端可以通过仅使用少量线程高效地管理大量连接。

3.8.1 启动和关闭服务器/客户端

要启动服务器,请调用函数:

BOOL Start(int nPort=999,int iMaxNumConnections=1201,
   int iMaxIOWorkers=1,int nOfWorkers=1,
   int iMaxNumberOfFreeBuffer=0,
   int iMaxNumberOfFreeContext=0,
   BOOL bOrderedSend=TRUE, 
   BOOL bOrderedRead=TRUE,
   int iNumberOfPendlingReads=4);
  • nPortt

    服务器将监听的端口号。(客户端模式下设为 -1。)

  • iMaxNumConnections

    允许的最大连接数。(使用一个大的素数。)

  • iMaxIOWorkers

    输入/输出工作线程数。

  • nOfWorkers

    逻辑工作线程数。(可在运行时更改。)

  • iMaxNumberOfFreeBuffer

    为重用而保存的最大缓冲区数量。(-1表示不保存,0表示无限数量)

  • iMaxNumberOfFreeContext

    为重用而保存的最大客户端信息对象数量。(-1表示不保存,0表示无限数量)

  • bOrderedRead

    进行顺序读取。(我们之前在3.6.2节讨论过。)

  • bOrderedSend

    进行顺序写入。(我们之前在3.6.2节讨论过。)

  • iNumberOfPendlingReads

    等待数据的挂起异步读取循环数。

要连接到远程连接(客户端模式 nPort = -1),请调用函数:

Connect(const CString &strIPAddr, int nPort)
  • strIPAddr

    远程服务器的IP地址。

  • nPort

    端口号。

要关闭,使服务器调用函数:ShutDown()

例如

MyIOCP m_iocp;
if(!m_iocp.Start(-1,1210,2,1,0,0))
AfxMessageBox("Error could not start the Client");
….
m_iocp.ShutDown();

4.1 源代码说明

有关源代码的更多详细信息,请查看源代码中的注释。

4.1.1 虚拟函数

  • NotifyNewConnection

    建立新连接时调用。

  • NotifyNewClientContext

    分配空 ClientContext 结构时调用。

  • NotifyDisconnectedClient

    客户端断开连接时调用。

  • ProcessJob

    逻辑工作线程需要处理作业时调用。

  • NotifyReceivedPackage

    通知新数据包已到达。

  • NotifyFileCompleted

    通知文件传输已完成。

4.1.2 重要变量

请注意,所有变量都必须由使用共享变量的函数独占锁定,这对于避免访问冲突和重叠写入非常重要。所有需要锁定的名为 XXX 的变量都带有一个 XXXLock 变量。

  • m_ContextMapLock;

    保存所有客户端数据(套接字、客户端数据等)。

  • ContextMap m_ContextMap;
  • m_NumberOfActiveConnections

    保存已连接的连接数。

4.1.3 重要函数

  • GetNumberOfConnections()

    返回连接数。

  • CString GetHostAdress(ClientContext* p)

    给定客户端上下文,返回主机地址。

  • BOOL ASendToAll(CIOCPBuffer *pBuff);

    将缓冲区内容发送给所有已连接的客户端。

  • DisconnectClient(CString sID)

    给定唯一标识号,断开客户端连接。

  • CString GetHostIP()

    返回本地IP地址。

  • JobItem* GetJob()

    从队列中移除一个 JobItem,如果没有作业则返回 NULL

  • BOOL AddJob(JobItem *pJob)

    将一个作业添加到队列中。

  • BOOL SetWorkers(int nThreads)

    设置逻辑工作线程的数量,可以随时调用。

  • DisconnectAll();

    断开所有客户端连接。

  • ARead(…)

    进行异步读取。

  • ASend(…)

    进行异步发送。向客户端发送数据。

  • ClientContext* FindClient(CString strClient)

    根据字符串ID查找客户端。注意!非线程安全!

  • DisconnectClient(ClientContext* pContext, BOOL bGraceful=FALSE);

    断开客户端连接。

  • DisconnectAll()

    断开所有已连接的客户端。

  • StartSendFile(ClientContext *pContext)

    使用优化的 transmitfile(..) 函数发送 ClientContext 结构中指定的文件。

  • PrepareReceiveFile(..)

    准备连接以接收文件。调用此函数后,所有传入的字节流都将被写入文件。

  • PrepareSendFile(..)

    打开一个文件并向远程连接发送一个包含文件信息的包。该函数还会在文件传输完成或中止之前禁用 ASend(..) 函数。

  • DisableSendFile(..)

    禁用发送文件模式。

  • DisableRecevideFile(..)

    禁用接收文件模式。

5.1 文件传输

文件传输通过使用 Winsock 2.0 的 TransmitFile 函数完成。TransmitFile 函数通过已连接的套接字句柄传输文件数据。此函数使用操作系统的缓存管理器检索文件数据,并提供套接字上的高性能文件数据传输。以下是异步文件传输的一些重要方面:

  • 除非 TransmitFile 函数返回,否则不应执行其他发送或写入套接字的操作,因为这会损坏文件。因此,在 PrepareSendFile(..) 函数之后,所有对 ASend 的调用都将被禁用。
  • 由于操作系统顺序读取文件数据,因此您可以通过使用 FILE_FLAG_SEQUENTIAL_SCAN 打开文件句柄来提高缓存性能。
  • 我们正在发送文件时使用内核异步过程调用 (TF_USE_KERNEL_APC)。使用 TF_USE_KERNEL_APC 可以带来显著的性能优势。然而,在其中启动上下文 TransmitFile 的线程可能正在进行大量计算;这种情况可能会阻止 APC 启动 (虽然不太可能)。

文件传输按以下顺序进行:服务器通过调用 PrepareSendFile(..) 函数初始化文件传输。当客户端收到文件信息后,通过调用 PrepareReceiveFile(..) 准备接收,并向服务器发送一个包以启动文件传输。当包到达服务器端时,服务器调用 StartSendFile(..) 函数,该函数使用高性能 TransmitFile 函数传输指定文件。

6 源代码示例

提供的源代码示例是一个回声客户端/服务器,它还支持文件传输(图4)。在源代码中,一个从 IOCP 继承的 MyIOCP 类通过使用4.1.1节中提到的虚拟函数处理客户端和服务器之间的通信。

客户端或服务器代码最重要的部分是虚拟函数 NotifyReceivedPackage,如下所述:

void MyIOCP::NotifyReceivedPackage(CIOCPBuffer *pOverlapBuff,
                           int nSize,ClientContext *pContext)
   {
       BYTE PackageType=pOverlapBuff->GetPackageType();
       switch (PackageType)
       {
         case Job_SendText2Client :
             Packagetext(pOverlapBuff,nSize,pContext);
             break;
         case Job_SendFileInfo :
             PackageFileTransfer(pOverlapBuff,nSize,pContext);
             break; 
         case Job_StartFileTransfer: 
             PackageStartFileTransfer(pOverlapBuff,nSize,pContext);
             break;
         case Job_AbortFileTransfer:
             DisableSendFile(pContext);
             break;};
   }

该函数处理传入消息并执行远程连接发送的请求。在这种情况下,它只是一个简单的回声或文件传输。源代码分为两个项目,IOCP和IOCPClient,它们分别是连接的服务器端和客户端。

6.1 编译器问题

使用VC++ 6.0或.NET编译时,您可能会遇到一些与 CFile 类相关的奇怪错误,例如:

“if (pContext->m_File.m_hFile != 
INVALID_HANDLE_VALUE) <-error C2446: '!=' : no conversion "
"from 'void *' to 'unsigned int'”

如果更新头文件 (*.h) 或您的VC++ 6.0版本,或者只是更改类型转换错误,这些问题都可以解决。经过一些修改后,服务器/客户端源代码可以不使用MFC。

7 特殊考虑与经验法则

当您在其他类型的应用程序中使用此代码时,可以避免一些与此源代码和“多线程编程”相关的编程陷阱。非确定性错误是随机发生的错误,很难通过执行创建错误的相同任务序列来重现这些非确定性错误。这些类型的错误是最糟糕的错误类型,通常它们是由于源代码的核心设计实现中的错误而发生的。当服务器运行多个IO工作线程,服务连接的客户端时,如果程序员没有考虑源代码的多线程环境,可能会发生访问冲突等非确定性错误。

经验法则 #1

永远不要在不使用上下文锁(如以下示例所示)锁定的情况下读取/写入客户端上下文(例如 ClientContext)。通知函数(例如 Notify*(ClientContext *pContext))已经是“线程安全的”,您可以在不锁定和解锁上下文的情况下访问 ClientContext 的成员。

//Do not do it in this way

// … 

If(pContext->m_bSomeData)
pContext->m_iSomeData=0;
// …
// Do it in this way. 

//….

pContext->m_ContextLock.Lock(); 
If(pContext->m_bSomeData) 
pContext->m_iSomeData=0; 
pContext->m_ContextLock.Unlock(); 
//…

另外,请注意,当您锁定上下文时,其他线程或GUI可能会等待它。

经验法则 #2

避免或“特别小心”使用在“上下文锁”内部包含复杂的“上下文锁”或其他类型锁的代码,因为这可能导致“死锁”(例如,A等待B,B等待C,C等待A => 死锁)。

pContext-> m_ContextLock.Lock();
//… code code .. 

pContext2-> m_ContextLock.Lock(); 
// code code.. 

pContext2-> m_ContextLock.Unlock(); 
// code code.. 

pContext-> m_ContextLock.Unlock();

上面的代码可能会导致死锁。

经验法则 #3

永远不要在通知函数(例如 Notify*(ClientContext *pContext))之外访问客户端上下文。如果这样做,您必须将其用 m_ContextMapLock.Lock();m_ContextMapLock.Unlock(); 括起来。请参阅下面的源代码。

ClientContext* pContext=NULL ; 
m_ContextMapLock.Lock(); 
pContext = FindClient(ClientID); 
// safe to access pContext, if it is not NULL

// and are Locked (Rule of thumbs#1:) 

//code .. code.. 

m_ContextMapLock.Unlock(); 
// Here pContext can suddenly disappear because of disconnect. 

// do not access pContext members here.

8 未来工作

未来,源代码将按时间顺序更新,以具有以下功能:

  1. 将在源代码中添加 AcceptEx(..) 函数的实现,以接受新连接,处理短时连接爆发和DOS攻击。
  2. 源代码将可移植到Win32、STL和WTL等其他平台。

9 常见问题

Q1:随着客户端连接的增加,内存使用量(服务器程序)正在稳步上升,如使用“Windows任务管理器”所见。即使客户端断开连接,内存使用量也不会减少。问题是什么?

A1:代码试图重用已分配的缓冲区,而不是释放和重新分配。您可以通过修改参数 iMaxNumberOfFreeBufferiMaxNumberOfFreeContext 来更改此行为。请参阅第3.8.1节。

Q2:我在.NET下编译时遇到编译错误:“错误 C2446: '!=' : 无法从 'unsigned int' 转换为 'HANDLE'”等。问题出在哪里?

A2:这是由于SDK的不同头文件版本造成的。只需将转换更改为 HANDLE,编译器就会满意。您也可以直接删除 #define TRANSFERFILEFUNCTIONALITY 这行代码并尝试编译。

Q3:源代码可以在没有MFC的情况下使用吗?纯Win32,作为服务?

A3:该代码最初是为了与GUI配合短时间使用而开发的(不是几天或几年)。我开发这个客户端/服务器解决方案是为了在MFC环境中与GUI一起使用。当然,您可以将其用于普通的服务器解决方案。许多人都在使用。只需删除MFC特定的东西,如 CStringCPtrList 等,并用Win32类替换它们。我也不喜欢MFC,所以当您更改代码时,请给我发一份副本。谢谢。

Q4:出色的工作!谢谢。您何时会实现 AcceptEx(..) 而不是连接监听线程?

A4:代码稳定后就会实现。目前已经相当稳定了,但我知道多个I/O工作线程和多个挂起读取的组合可能会导致一些问题。很高兴您喜欢我的代码。请投票!

Q5:为什么要启动多个I/O工作线程?如果您的计算机不是真正的多处理器,这有必要吗?

A5:不,不一定需要多个I/O工作线程。一个线程就可以处理所有连接。在普通家用电脑上,一个I/O工作线程能提供最佳性能。您也不需要担心可能存在的访问冲突威胁。但是随着计算机每天都在变得更强大(例如,超线程、双核等),为什么不提供多个线程的可能性呢?:=)

Q6:为什么要使用多个挂起的读取?它有什么好处?

A6:这取决于开发者采用的服务器开发策略,即“多并发连接”与“高吞吐量服务器”。拥有多个挂起的读取可以增加服务器的吞吐量,因为TCP/IP数据包将直接写入传入缓冲区而不是TCP/IP堆栈(没有双缓冲)。如果服务器知道客户端以突发方式发送数据,挂起的读取会提高性能(高吞吐量)。但是,每个挂起的接收操作(使用 WSARecv())都会强制内核将接收缓冲区锁定到非分页池中。当物理内存不足时(许多并发连接),这可能导致 WSAENOBUFFS 错误。挂起读取/写入的使用必须小心进行,并且必须考虑“体系结构上的页面大小”和“非分页池的大小(物理内存的1/4)”等方面。此外,如果您有多个IO工作线程,数据包的顺序会被打乱(由于IOCP结构),并且维护顺序的额外工作使得多个挂起的读取变得不必要。在这种设计中,当IO工作线程数大于1时,多个挂起的读取会被关闭,因为实现无法处理重排序。(序列号必须存在于数据负载中。)

Q7:在之前的文章中,您提到我们必须使用 VirtualAlloc 函数而不是 new 来实现内存管理,为什么您没有实现它?

A7:当你用 new 分配内存时,内存会分配在虚拟内存或物理内存中。内存分配在哪里是未知的,内存可以分配在两个页面之间。这意味着当我们访问某个数据时(如果使用 new),我们会加载过多的内存到物理内存中。此外,你不知道分配的内存是在物理内存中还是在虚拟内存中,你也不能告诉系统何时“写回”硬盘是不必要的(如果我们不再关心内存中的数据)。但请注意!!任何使用 VirtualAlloc* 的新分配总是会向上取整到 64 KB(页面文件大小)的边界,所以如果你分配一个绑定到物理内存的新 VAS 区域,操作系统将消耗向上取整到页面大小的物理内存量,并消耗向上取整到 64 KB 边界的进程 VAS。使用 VirtualAlloc 可能很困难:newmalloc 内部使用 VirtualAlloc,但每次你用 new/delete 分配内存时,都会进行很多其他的计算,而且你无法控制将数据(彼此相关的数据)整齐地放在同一页中(不重叠两个页面)。然而,堆最适合管理大量小对象,我将修改源代码,使其只使用 new/delete,以提高代码的整洁性。我发现与源代码的复杂性相比,性能提升太小。

10 参考文献

11 修订历史

  • 版本 1.0 - 2005-05-10
    • 首次公开发布。
  • 版本 1.1 - 2005-06-13
    • 修复了一些内存泄漏(例如,~CIOCPBuffer())。
    • TransmitFile 现在在源代码中是可选的(通过使用 #define TRANSFERFILEFUNCTIONALITY)。
    • 添加了一些额外的功能(通过使用 #define SIMPLESECURITY)。
  • 版本 1.11 - 2005-06-18
    • 修改了 IOCPS::ProcessPackage(…) 以避免访问冲突。
    • 修复了 CIOCPBuffer::Flush(..) 中的错误。
    • 修改了 IOCPS::Connect(..) 以在发生错误时释放套接字。
  • 版本 1.12 - 2005-11-29
    • 修改了 IOCPS::OnWrite(....) 以避免进入无限循环。
    • 修改了 OnRead(…)OnZeroByteRead (…) 以避免在内存已满且 AllocateBuffer 失败时发生访问冲突。
    • 修改了 OnReadCompleted(…) 以避免访问冲突。
    • 修改了 AcceptIncomingClient(..),以在达到最大连接数时更好地处理新连接。
  • 版本 1.13 - 2005-12-29
    • ReleaseBuffer(…) 添加到 ARead(..)ASend(..)AZeroByteRead(..) 以避免内存泄漏。
    • 修改了 DisconnectClient(…)ReleaseClientContext(…),以避免客户端快速连接和断开时出现“重复键”错误。
    • 修改了 IOWorkerThreadProc(…)OnWrite(ClientContext *pContext,…) 等,以避免缓冲区泄漏。
    • 修改了 DisconnectClient( unsigned int iID) 以避免访问冲突。
    • StartSendFile(..)OnTransmitFileCompleted(..) 中添加了 EnterIOLoop(..)/ExitIPLoop(..) 以避免访问冲突。
    • 从发布模式中删除了不必要的错误消息,并在调试模式下向源代码添加了额外的调试信息(例如 TRACE(..))。
    • 函数 AcceptIncomingClients(..) 已更改并替换为 AssociateIncomingClientWithContext(..)
    • Connect(..) 函数现在使用 AssociateIncomingClientWithContext(..)
    • 传输文件功能现在通过 #define TRANSFERFILEFUNCTIONALITY 完全可选。
    • 修改了 DisableSendFile(..) 和其他文件传输函数,以避免访问冲突。
    • 从源代码中删除了一些不必要的函数和注释。现在,适当的函数被设为私有、受保护或公共。
    • 现在将多个函数“内联”以避免函数调用开销并提高性能。
    • OnWrite(ClientContext *pContext,…) 中删除并替换了 EnterIOLoop(..) 和其他代码以避免访问冲突。信息在源代码中。
    • 为演示服务器添加了“随机断开”,为演示客户端添加了“自动重连”,并对演示项目进行了额外清理,现在我遵循我自己的建议。:=)
  • 版本 1.14 - 2006-02-18
    • 修改了 IOWorkerThreadProc(LPVOID pParam) 以避免在关闭时出现内存泄漏(例如 new ClientContext)(Maxim Y. Mluhov 检测到的“bug”)。
    • OnReadCompleted(..) 中的小改动。
    • IOCPS::DisconnectIfIPExist(..) 中的小改动,以提高性能(spring 修复)。
    • CIOCPBuffer::Flush(...) 中的小改动(spring 修复)。
    • 当使用多个挂起读取(例如 m_iNumberOfPendlingReads>1)与多个I/O工作线程(例如 m_iMaxIOWorkers>1)时,数据包的顺序会被打乱。在 IOCPS::startup() 中添加了临时修复(例如 if(m_iMaxIOWorkers>1) m_iNumberOfPendlingReads=1;)。
    • 更新了文章的第8节和第6.3.2节。
  • 版本 1.15 - 2006-06-19
    • CIOCPBuffer 类和 AllocateBuffer(..) 进行了更改。现在,所有内存分配/释放都使用 new/delete 在堆上完成,不再使用 VirtualAlloc(..)(有关更多信息,请阅读问题7)。
    • 修改了 IOCPS::OnInitialize(..) 以避免 WSAENOBUFS,交换了 ARead(..)AZeroByteRead(..) 的顺序。
    • 当使用多个I/O工作线程时,移除了多个挂起读取。(临时修复现在是永久修复,请阅读A6和Q6。)
    • #define SIMPLESECURITY 函数在 ConnectAcceptCondition(..) 内部与 SO_CONDITIONAL_ACCEPT 参数一起使用 WSAAccept(..),从而提高了安全性。我们可以在内核的较低级别拒绝连接(不发送 ACK => 攻击者认为服务器已关闭)。
    • 由于优化,IsAlreadyConnected(..)IsInBannedList(..) 替换了 DisconnectIfIPExist(..)DisconnectIfBanned(..),IP 比较使用 sockaddr_in 而不是字符串比较。
  • 版本 1.16 - 2008-12-08
    • 修复:修改了 IOCPS::GetNextReadBuffer(ClientContext *pContext, CIOCPBuffer *pBuff) 以避免 CMap 造成的内存泄漏。
    • 修复:在方法 IOCPS::ARead() 的以下代码中,AllocateBuffer() 可能返回 NULL 指针。
    • 修复:在 IOCPS::AssociateIncomingClientWithContext(SOCKET clientSocket) 关闭时套接字泄漏。
© . All rights reserved.