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

使用 IOCP 编写可扩展的服务器应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.15/5 (27投票s)

2000 年 9 月 29 日

viewsIcon

464356

downloadIcon

9123

一篇关于使用 I/O 完成端口 (IOCP) 和 Winsock 编写健壮且可伸缩的 Windows 服务器应用程序的文章。

引言

我研究 Winsock 已有一段时间,甚至还接触过 IOCP。我读过几本书和几篇文章,探讨了各种相关问题,但它们都未能为实际应用程序提供一个完整的体系。我决定将我的大部分发现整合到一篇文章中,引导您创建一个可伸缩且健壮的、支持 IOCP 的实际回显服务器!

本文档面向同时具备 Winsock 2 和多线程编程经验的读者。本文中的所有代码都已成功使用 Visual C++ 6 SP4 和 Windows 2000 Server 编译并运行。

本文提出的模型仅适用于 Windows 2000 Server、Advanced Server 和 Data Center 操作系统。它利用了一个在 Windows 2000 之前不可用的 API 函数:BindIoCompletionCallback()

此处提供的代码通过少量修改即可移植到早期版本的 Windows (Windows NT 3.51+)。

使用本文中提供的建议和代码,风险自负。我已对这些代码进行过测试,据我所知,代码是无错误的。如果您发现代码中有任何错误/问题,请告知我。请记住,对于因使用或误用本文中的任何信息或代码而直接或间接造成的任何损害,我概不负责。

您可以自由地将此处提供的代码用于您自己的应用程序中,但如果您这样做了,我希望您能告知我!如果您在产品中使用了我的代码,请给我发一封电子邮件,我将不胜感激。

未经许可,请勿重新发布本文或其中的任何部分。

服务器应用程序的源代码已稍作修改,以修复 buffer.h 中存在的错误。非常感谢 **Dave Flores** 深入研究了该文件并找到了错误!

现在,让我们开始吧。

I/O 完成端口 (IOCP) 到底是什么?

要更全面地了解 IOCP 是什么,以及它能为您的线程带来什么,我建议您参阅 Jeffrey Richter 和 Jason D. Clark 的著作《Programming Server-Side Applications for Microsoft Windows 2000》,第 2 章:“设备 I/O 和线程间通信”。我将在 Winsock 和服务器应用程序开发的背景下对其进行简要讨论。

IOCP 可能是理解套接字最困难的方式。如果您以前使用过 Winsock(我假设您用过),那么您可能使用过 WSAAsyncSelect()WSAEventSelect(),以便 Winsock 通知您相关的套接字事件。您使用 WSAAsyncSocket() 来请求 Winsock 在事件发生时向您的窗口过程发布消息。您使用 WSAEventSelect() 来请求 Winsock 在事件发生时发出信号。如果您想利用 Windows 提供的多线程模型(为了在多 CPU 机器上进行扩展,您肯定应该这样做),您就必须自己创建和管理线程。

当您使用 IOCP 时,您只需创建一次线程池,然后用它们来处理应用程序中的网络 I/O。从技术上讲,在 Windows 2000 中,您甚至不必自己创建线程池——您可以让 Windows 负责创建和管理线程池中的线程,这正是我将在本文中要做的。

主程序

  1. 创建并初始化 500 个套接字。
  2. 创建一个监听套接字。
  3. 为每个套接字调用 AcceptEx()
  4. 将监听套接字与适当的回调函数(ThreadFunction())绑定。
  5. 创建一个事件对象,命名为 My_Die_Event
  6. 等待 My_Die_Event
  7. 清理(稍后将提供相关详细信息)。

ThreadFunction()

(当 I/O 操作完成时,Windows 会调用此函数。Windows 使用之前创建的线程池中的一个线程来执行该函数。)

  1. 检查是哪个客户端的 I/O 操作完成了。
  2. 检查是哪个 I/O 操作完成了。
  3. 执行一些处理。
  4. 发出另一个 I/O 操作(套接字上的另一个读取或另一个写入)。
  5. 从函数返回。当另一个 I/O 操作完成时,它将再次被调用。

那么我们有什么呢?

我们有许多线程都在等待 I/O 操作完成。如前所述,这些线程是在我们首次尝试将套接字绑定到回调函数时由 Windows 自动创建的。请注意,这种自动线程创建仅在 Windows 2000(及未来版本的 Windows)中可用。如果您打算为 Windows NT 4 或 3.51 开发,您将不得不自己创建线程,并将它们和套接字与完成端口关联起来。我不会在本文中展示如何做到这一点,但更改相对较小。一旦 I/O 操作完成,操作系统就会将一个所谓的“I/O 完成包”发布到我们的完成端口。一旦发送了数据包,Windows 就会从线程池中恢复一个线程,让它运行 ThreadFunction(),并为该函数设置适当的参数。这一切都在后台发生。对我们来说,当 I/O 操作完成时,我们的回调函数会自动由 Windows 分配给它的某个线程执行。

让我们看看我们设置的数字。

问:Windows 创建的线程池中有多少个线程?
答:这取决于 Windows。在 2000 年之前的 Windows 版本中,您需要自己创建线程池。这样做的好处是您可以控制线程池中线程的实际数量。缺点是它更复杂,可读性更差。

问:为什么要创建 500 个套接字?
答:我们创建了一组将在整个程序中使用的套接字。只创建 500 个套接字会将我们限制在 500 个并发连接——这对于不同的应用程序来说可能没问题。最好将此值设为可配置的。套接字的总创建和销毁非常耗时。通过在程序开始时创建一个套接字池,我们启用了套接字重用并提高了性能。

动手实践

在我们深入编写 IOCP 操作代码之前,我想暂停一下对 IOCP 的讨论——来引入另一个问题。如果您正在编写服务器应用程序,那么您会有客户端。当您处理客户端时,您必须处理缓冲区。我将向您展示我在这些情况下的通用方法——这包括创建一个通用的缓冲区类,以及我称之为“数据包类”(稍后会介绍)。

要从客户端接收数据,您需要调用 ReadFile()。在该调用中,您提供一个要将数据检索到的缓冲区。此缓冲区在 I/O 操作完成之前必须保持有效。到目前为止一切顺利。您可以将缓冲区大小设置为 1024 字节或其他任何值——并分块读取 1024 字节的数据。让我们看一下各种场景——当我们收到读取操作已完成的通知时,缓冲区可能是什么样子。

场景 #1

只收到了 321 字节。
您解释数据,弄清楚客户端想要什么,执行一些处理,将一些数据发送回客户端,然后再次调用 ReadFile() 来接收更多数据。

场景 #2

只收到了 321 字节。
您尝试解释数据,但无法弄清楚客户端想要什么——这 321 字节不是一个完整的命令。

您需要再次调用 ReadFile() 来检索更多数据,但这将覆盖缓冲区中的前 321 字节!

场景 #3

收到了 1024 字节。
您尝试解释数据,但无法弄清楚客户端想要什么——这 1024 字节不是一个完整的命令。您需要再次调用 ReadFile() 来检索更多数据,但这将覆盖缓冲区的内容!

您可以看到,场景 #1 没有问题,但场景 #2 和 #3 更具挑战性。有时您可以立即处理客户端的请求,但不能依赖这一点。

输出缓冲区的情况略有不同。您提供给 WriteFile() 的缓冲区必须在 I/O 操作期间保持有效。但是,我们希望能够自由地添加要写出的数据,而不管输出 I/O 操作的当前状态。

对于输出缓冲区,我创建了一个可扩展缓冲区类。发送操作的逻辑非常简单,您将在后面的代码中看到。基本上,每当您尝试向客户端写入数据时,程序都会尝试立即发送数据。如果无法发送,它会将数据存储在可扩展缓冲区中,并在当前 I/O 操作完成后发送数据。

对于输入缓冲区,情况略有不同。使用这样的缓冲区类来处理输入缓冲区会带来过多的开销。相反,代码会在需要时自动扩展输入缓冲区。输入缓冲区的管理非常有趣,您可以按照我展示的方式检查代码。

关于线程安全

可扩展缓冲区类是线程安全的。我使用临界区使其线程安全。在某些应用程序中,您不需要使缓冲区类线程安全,因为由于应用程序的性质,对它的调用总是串行的。(如果一个场景中两个线程无法同时向同一客户端写入数据,那么移除线程安全机制是安全的)。要移除线程安全机制,您可以简单地从类继承,并重写相关的成员函数。(InitializeInUse()EnterInUse()LeaveInUse()DeleteInUse())。

查看 buffer.h 以了解缓冲区类的代码。

在向客户端发送数据时,我们向 WriteFile() 提供一个要发送的缓冲区。此缓冲区必须在 I/O 操作完成之前保持有效。我通过维护两个缓冲区来实现这一点。第一个是传递给 WriteFile() 的缓冲区。第二个缓冲区累积数据,并在发送完成时复制到第一个缓冲区。

在向您展示更多代码之前,我想讨论另一个问题。您正在编写的服务器应用程序可能需要从客户端接收命令,并以命令进行响应。如果您正在设计自己的协议(而不是实现像 FTP 服务器或 Web 服务器这样的标准协议服务器),那么您就有权决定服务器和客户端之间传输数据的实际格式。

我喜欢将所有内容都基于我称之为“数据包基础设施”的东西。客户端通过发送一个完整的数据包来发布请求,服务器通过发送一个完整的数据包来响应。您可以按任何您想要的方式定义数据包。在本文中,我实现了一种我认为是最通用的数据包类型。

在本文中,一个数据包是一个由一个整数和一个二进制数据组成的结构。当客户端发布请求时,它首先发送 4 个字节来描述请求的长度,然后是请求本身。这使得服务器非常容易知道请求是否已完全从客户端到达(它所需要做的就是检查请求的长度,并查看它是否已收到足够的数据)。服务器的响应方式几乎相同。

在内部,我创建了一个 tagPacket 类,它包含两个整数和一个缓冲区。第二个整数保存缓冲区的当前大小。这可以始终与保存缓冲区中数据长度的另一个整数相同——这取决于您实现应用程序的方式。如果您为每个收到的数据包创建一个新的数据包实例,那么您只需要一个描述数据长度的整数,缓冲区的大小始终与数据相同。但是,如果您决定不为每个客户端请求分配和释放数据包,那么您可以通过分隔缓冲区大小和数据长度来做到这一点。每当收到新数据包时,都会检查缓冲区大小。如果缓冲区足够大以包含新数据,则将数据复制到缓冲区,并将其长度存储在另一个整数中。

查看 general.h 以查看数据包操作代码。

我相信这段代码相当直观。您可以看到,这个文件里还有另一个函数——用于记录错误的函数。您可能会希望这个函数做其他事情,例如将错误记录到系统的事件日志中。在 buffer.h 中,您还会找到一个从缓冲区检索数据包的函数。

您可以看到,很容易判断数据包是否已到达。请注意,这种方法可能会导致一些问题。例如——如果由于错误或黑客攻击,前 4 个字节指示的值为 2GB 怎么办?在这种情况下,攻击者可以继续发送数据,消耗大量服务器资源。稍后我们将通过限制请求大小和终止滥用连接来处理这些情况。

现在是时候讨论客户端类了。我在设计客户端类和操作机制时所采用的方法是重用。我宁愿一次分配内存,然后重用它,而不是为每个新连接分配内存并在连接终止时释放它。我认为,在大多数情况下,内存分配和释放是耗时的操作,应尽量避免——即使是以消耗大量资源为代价。

关于 client.hclient.cpp 的一些说明。

每当我们使用 ReadFile()WriteFile() 执行 I/O 操作时,我们都应该将一个 OVERLAPPED 结构作为参数之一传递。我们实际上传递的是一个扩展的 OVERLAPPED 结构(一个派生自 OVERLAPPED 结构的结构)。我们传递的结构包含一些上下文信息。上下文信息包括请求 I/O 操作的客户端类实例的内存地址以及请求的操作类型(读取或写入)。在回调函数中需要此信息。

当我们调用 ReadFile() 来接收数据时,我们传递的是 end_in_buf_pos,而不是 actual_in_buf。我们还从 start_in_buf_pos 开始读取数据,而不是从 actual_in_buf 开始。基本上,这些操作是为了避免不必要的 actual_in_buf 扩展和不必要的 memmove(..) 调用。查看 CClient::Read(..) 以了解其实现方式。

每当 ReadFile(..) 操作完成时,都会记录一个时间戳。类的其他部分使用此数据来确保将不活跃的客户端(可能是滥用者)与服务器断开连接。负责此功能的是 CClient::Maintenance(..) 函数,稍后将对此进行讨论。

CClient 是一个 抽象 类,这意味着您必须从它派生自己的类。在您自己的类中,您必须重写三个函数——这些函数现在将进行解释。

  • int CClient::ProcessPacket(tagPacket *p)

    每当收到一个完整的数据包时,都会用数据包的地址 p 调用此函数。在我将要提供的代码中,该数据包是 CClient 的一个成员。我在整个应用程序的生命周期中只为每个客户端使用一个数据包,以避免频繁分配和释放 tagPacket。此函数负责处理从客户端接收到的数据——您可以在其中执行任何您想做的操作——包括使用 Write(..) 将数据发送回客户端。函数返回的值告诉服务器应用程序是否需要执行某些操作。我定义了三个可能的值:CMD_DO_NOTHING——无需执行任何操作。CMD_DISCONNECT——必须断开客户端连接(可能是发送了无效数据包的滥用者)。CMD_SHUTDOWN——服务器应立即关闭。这些命令值在 commands.h 中声明,稍后将显示。

  • void CClient::CreateInvalidPacket(tagPacket *p)

    CClient::Read(..) 中,客户端可能被发现是滥用者。例如,当客户端尝试在一个数据包中发送过多数据时。在这种情况下,CClient::Read(..) 将调用 CClient::CreateInvalidPacket(..) 来创建数据包,而不是提供一个真实的数据包。该函数负责创建一个由 CClient::ProcessPacket(..) 识别为特殊用途的无效数据包,以便它可以采取适当的行动(可能断开客户端连接)。

  • void CClient::Maintenance()

    此函数应偶尔为每个客户端调用一次。其目的是确保没有客户端滥用系统。目前,它执行两个不同的检查,您可以在其代码中看到。它从主线程调用。

查看 client.hclient.cpp 以了解客户端类的代码。
查看 client_0.hclient_0.cpp 以了解我为这三个函数编写的代码。

整合一切(或者——进入:IOCP)

现在让我们将这些部分整合起来,使用 IOCP。各位,这就是你们一直在等待的。首先,我们将创建 I/O 操作完成时调用的函数。函数的声明实际上是由 Windows 规定的。

查看 callback.h 以了解此函数的声明。

现在是函数体。它确实不复杂。请注意,它包含 extern HANDLE dieEvent 这一行。当需要关闭时,它会向主线程正在等待的 dieEvent 发出信号。

查看 callback.cpp 以了解此函数的定义。

此函数计算收到的数据包数量,并在屏幕上打印一些内容。最终,您可能需要更改它。
现在是启动代码。它实现了我们一开始所说的所有内容——初始化客户端、套接字并等待 dieEvent。一个有趣的点是维护方式。每隔 CHECK_CYCLE 秒(我设置为 10),它就会恢复(它会在 CHECK_CYCLE 秒内等待 dieEvent),并在系统中的每个客户端上执行 CClient::Maintenance() 函数。此函数确保客户端没有滥用系统。滥用系统的一种方式是连接到它而不发送任何数据,从而不允许 AcceptEx(..) 接受连接。您可能需要调整那里的值以满足您自己的需求。您还可能希望在 CClient::Maintenance() 函数中添加其他类型的维护。

差不多就这些了。它不是一个真正意义上的回显服务器,因为它实际上并没有精确地重复它收到的内容。该应用程序期望数据包并返回与接收到的相同数据包;但是,它无法正确响应纯文本。使用 telnet 测试此类服务器几乎是不可能的。这就是为什么我创建了一个小型 Visual Basic 实用程序,它连接到服务器,并允许您发送任意数量的数据包。

请注意,在检索数据包并显示其内容的代码中似乎存在某种错误。我没有怎么仔细查找——它只在一次接收大量数据时发生。

我希望这篇文章和示例代码对您有所帮助。如有任何问题/意见/其他,请随时给我发电子邮件。:-)

历史

  • 2001 年 2 月 6 日:初版

许可证

本文没有明确的许可,但可能在文章文本或下载文件中包含使用条款。如有疑问,请通过下方的讨论区联系作者。作者可能使用的许可证列表可以在此处找到。

© . All rights reserved.