一个可复用、高性能的套接字服务器类 - 第 3 部分






4.84/5 (21投票s)
当服务器需要处理大量短连接客户端时,建议使用 WinSock 的 Microsoft 扩展函数 AcceptEx() 来接受连接。
下面的源代码是使用Visual Studio 6.0 SP5和Visual Studio .NET构建的。你需要安装一个版本的Microsoft Platform SDK。
概述
当服务器需要处理大量短连接客户端时,建议使用 WinSock 的 Microsoft 扩展函数 AcceptEx()
来接受连接。创建套接字是一个相对“昂贵”的操作,通过使用 AcceptEx()
,您可以在连接发生之前而不是发生时创建套接字,从而加快连接的建立。更重要的是,AcceptEx()
可以在建立连接的同时执行初始数据读取,这意味着您可以通过一次调用接受连接并检索数据。
在本文中,我们开发了一个使用 AcceptEx()
和相关 Microsoft 扩展函数的套接字服务器类。生成的服务器类与我们在上一篇文章中开发的服务器类相似,因为它为您完成了所有繁重的工作,并提供了一种简单的方法来开发功能强大且可扩展的套接字服务器。
文档错误,还是未文档化的行为?
“当此操作成功完成时,sAcceptHandle
可以传递,但仅限于以下函数
ReadFile
WriteFile
send
recv
TransmitFile
closesocket”
请注意,WSARecv
和 WSASend
明显缺失,DisconnectEx
也是如此。本文假定这是由于文档错误造成的,并且 AcceptEx
旨在与这些函数一起操作。无论哪种方式,我们都进入了未文档化的行为,因此如果这对您很重要,那么您可能不希望以这种方式行事。我们发现它在我们需要的平台上可以工作。
在 WinSock 中使用 Microsoft 扩展函数
Windows Sockets 2 规范定义了一种扩展机制,允许 Windows Sockets 服务提供商向应用程序程序员公开高级传输功能。Microsoft 提供了其中一些扩展函数,但使用它们会限制您的软件只能在支持这些功能的 Windows Sockets 提供商上运行。通常,这不是问题...
自 WinSock 1.1 以来,一些扩展函数就已经可用,并从 MSWsock.dll 导出,但建议不要直接链接到此 DLL,因为这会将您绑定到 Microsoft WinSock 提供商。访问这些扩展函数的一种提供商中立的方法是使用 SIO_GET_EXTENSION_FUNCTION_POINTER
操作码通过 WSAIoctl
动态加载它们。理论上,这应该允许您从任何支持它们的提供商访问这些函数...
在 Windows XP 中,Microsoft 添加了一些新的 WinSock 扩展函数,这些函数只能通过 WSAIoctl
路由访问,因此,为了保持一致性和可移植性,我们将使用一个简单的包装类 CMSWinSock
访问所有扩展函数,该类包装了对 WSAIoctl
的所需调用。
AcceptEx()
可以重用已以适当方式准备好重用的套接字。Windows XP 提供了 DisconnectEX()
作为准备套接字句柄以供重用的方法。在 Windows XP 之前,唯一可以准备套接字以供重用的函数是 TransmitFile()
。幸运的是,TransmitFile()
可以用于准备套接字以供重用,而无需实际传输文件... 为了使代码更容易理解,CMSWinSock
提供了一个函数来断开套接字以供重用。DisconnectSocketForReuse()
将在可用时调用 DisconnectEx()
,否则将使用适当的参数调用 TransmitFile()
以简单地重用套接字。
使用 AcceptEx() 接受连接
我们之前的服务器一直使用 WSAAccept()
上的阻塞循环来接受连接。当连接发生时,会创建一个套接字并从 WSAAccept()
调用返回,然后我们的接受线程会循环再次调用 WSAAccept()
以处理下一个连接。创建套接字不仅是一个耗时的操作,而且这种设计意味着所有连接建立都必须通过单个线程中的一段代码。AcceptEx()
使用不同的模型,您首先创建套接字,然后将接受请求“发布”到监听套接字上,当这些请求完成时,您会收到关联的 IO 完成端口上的 IO 完成数据包。我不知道这在底层是如何工作的,但至少,使用 IO 完成端口进行通知允许我们多线程处理建立连接所需的工作。
那么,我们需要提前创建多少个套接字,以及我们如何知道何时需要创建更多套接字?您需要提前创建的套接字数量将取决于您的服务器需要处理的连接数量,因此这是一个可配置的参数。您不希望创建过多的套接字,因为这会浪费服务器资源,但如果您创建的太少,那么您的服务器将运行缓慢,或者拒绝连接... 我们会跟踪已创建的套接字数量,当接受完成时,我们将套接字从“待接受”列表移动到“活动”列表。通过这种方式,我们可以监控何时需要创建更多套接字并发出更多对 AcceptEx()
的调用。然而,如果客户端连接后立即发送数据,那么在连接上至少有一个字节到达之前,接受不会完成。因此,恶意客户端可能会通过打开连接而不发送任何数据来尝试对我们的服务器进行拒绝服务攻击。这最终会用尽我们发布的所有接受请求,并导致我们的服务器开始使用监听积压队列。最终,服务器将填满监听积压队列并开始拒绝连接尝试。为了避免这种情况,我们可以在连接尝试发生且没有可用的未完成接受请求时注册通知。当这种情况发生时,积压队列将已将连接请求排队,我们可以发布更多对 AcceptEx()
的调用,以便接受连接。我们使用 WSAEventSelect()
注册 FD_ACCEPT
事件——这些事件通过设置事件来报告。然后我们可以像这样构建我们的 accept
循环
WSAEventSelect(m_listeningSocket, m_acceptEvent.GetEvent(), FD_ACCEPT);
do
{
for (size_t i = 0; i < numAcceptsToPost; ++i)
{
Accept();
}
m_postMoreAcceptsEvent.Reset();
m_acceptEvent.Reset();
HANDLE handlesToWaitFor[2];
handlesToWaitFor[0] = m_postMoreAcceptsEvent.GetEvent();
handlesToWaitFor[1] = m_acceptEvent.GetEvent();
waitResult = ::WaitForMultipleObjects(2, handlesToWaitFor, false, INFINITE);
if (waitResult != WAIT_OBJECT_0 &&
waitResult != WAIT_OBJECT_0 + 1)
{
OnError(_T("CSocketServerEx::Run() - WaitForMultipleObjects: ")
+ GetLastErrorMessage(::GetLastError()));
}
if (waitResult == WAIT_OBJECT_0 + 1)
{
Output(_T("Accept..."));
}
}
while (waitResult == WAIT_OBJECT_0 || waitResult == WAIT_OBJECT_0 + 1);
我们只是将拒绝服务攻击从导致服务器拒绝连接转移到通过接受无限数量的恶意连接而导致服务器资源耗尽。为了解决这个问题,我们需要能够确定一个等待 accept
完成的套接字是否已经建立了连接,并且现在正在等待数据到达,如果是,它等待了多长时间... 为此,我们使用 getsockopt()
配合 SO_CONNECT_TIME
选项(从 Windows NT 4.0 开始可用)。如果套接字未连接,它会返回 -1
,或者返回已连接的秒数。如果我们被告知需要发布更多接受,我们发布接受,然后检查所有待接受的连接,查看它们已连接并等待数据多长时间,然后我们可以强制断开那些连接后“等待时间过长”(一个可配置参数)才发送数据的套接字...
我们现在有一个服务器,它在首次开始监听时会发布可配置数量的接受,并且在正常操作中,会随着连接完成而发布更多接受。如果我们达到没有待处理接受并且发生连接的地步,那么我们会收到通知,以便我们可以发布更多接受并检查是否有任何已连接的套接字等待数据的时间超过我们可配置的超时时间。
接受和读取数据
调用 AcceptEx()
时,您必须始终传递一个缓冲区来存储结果连接的本地和远程地址。对于在发送数据之前接收数据的服务器(例如 Web 服务器),您可以在此缓冲区中包含用于从连接读取的第一批数据的空间。如上所述,直到至少一个字节到达,接受才完成。代码可能如下所示
void CSocketServerEx::Accept()
{
Socket *pSocket = AllocateSocket();
{
CCriticalSection::Owner lock(m_listManipulationSection);
m_pendingList.PushNode(pSocket);
}
// allocate a buffer
CIOBuffer *pBuffer = Allocate();
pBuffer->SetOperation(IO_Accept_Completed);
pBuffer->SetUserPtr(pSocket);
const size_t sizeOfAddress = GetAddressSize() + 16;
const size_t sizeOfAddresses = 2 * sizeOfAddress;
DWORD bytesReceived = 0;
if (!CMSWinSock::AcceptEx(
m_listeningSocket,
pSocket->m_socket,
reinterpret_cast<void*>(const_cast<BYTE*>(pBuffer->GetBuffer())),
pBuffer->GetSize() - sizeOfAddresses,
sizeOfAddress,
sizeOfAddress,
&bytesReceived,
pBuffer->GetAsOverlapped()))
{
const DWORD lastError = ::WSAGetLastError();
if (ERROR_IO_PENDING != lastError)
{
Output(_T("CSocketServerEx::Accept() - AcceptEx: ")
+ GetLastErrorMessage(lastError));
pSocket->Close();
pSocket->Release();
pBuffer->Release();
}
}
else
{
// Accept completed synchronously. We need to marshal the data received
// over to the worker thread ourselves...
m_iocp.PostStatus((ULONG_PTR)m_listeningSocket, bytesReceived,
pBuffer->GetAsOverlapped());
}
}
请注意,我们调用派生类来提供我们需要为 sockaddr
预留的空间大小的详细信息,尽管默认实现只是返回 sizeof(SOCKADDR_IN)
。当 accept
完成时,由我们的工作线程从完成键中检索到的套接字是监听套接字,因为它是与 IO 完成端口关联并生成接受完成数据包的设备。我们还需要接受的套接字,所以我们将其存储在 IO 缓冲区的用户数据槽中。这在工作线程中有点粗糙,因为对于所有其他完成数据包,完成键是指向 Socket
的指针,但对于接受则不是——这是一个特殊情况,如果有时间可能会重构掉... 请注意,虽然预期的代码路径是 AcceptEx()
返回 false
并且 WSAGetLastError()
返回 ERROR_IO_PENDING
,但我们通过自己发布到完成端口来处理 accept
同步完成的情况,并且我们使用与异步生成的数据包相同的语义(即,监听套接字作为完成键)。我实际上从未能够让我的测试工具生成这种情况...
不读取数据的接受
对于在发送数据之前不接收数据的服务器,我们仍然可以使用 AcceptEx()
,我们只需将数据缓冲区大小指定为 0
,并且不进行读取,accept
在连接建立后立即完成。
接受完成
当 accept
完成,并且(如果适用)数据到达时,会发布一个 IO 完成数据包,我们的工作线程通过将接受的套接字选项设置为与监听套接字匹配来完成 accept
(通常 WSAAccept()
会为我们完成此操作,但 AcceptEx()
让我们使用 setsockopt()
和 SO_UPDATE_ACCEPT_CONTEXT
自己完成)。然后,我们将套接字从挂起列表移动到活动列表,从我们传递给 AcceptEx()
的数据缓冲区中提取本地和远程地址,并通知派生类新连接以及(如果适用)新数据。
派生类接口
派生类几乎与上一篇文章中的类一样简单,只不过您可以重写接受套接字的创建,并且可以重写您需要为本地和远程地址预留的空间量(以防您使用的是 TCP/IP 以外的协议)。
示例...
示例服务器是另一个简单的回显服务器,我知道我对回显服务器的看法,但在这种情况下,它是一个不错的示例 :)。服务器包含套接字服务器类的两个实例,并监听 5001 和 5002 端口。在 5001 上,它执行一个需要数据到达才能完成的 accept
,在 5002 上,它执行一个在连接建立后立即返回的 accept
。请注意,服务器展示了如何在同一个可执行文件中打包多个套接字服务器(也许有一天,我会优化该类,以便所有服务器都由单个 IO 线程池处理...)。
要测试服务器,请通过 telnet 连接到 localhost 5001/2 并输入一些数据。如果您多次 telnet 到 5001 且不输入任何数据,那么您应该能够看到 FD_ACCEPT
通知和连接超时检查在运行。一如既往,ServerShutdown
程序允许您暂停、恢复和关闭服务器。
修订历史
- 2002 年 6 月 3 日 - 初次修订
- 2002 年 6 月 26 日 - 删除了在创建监听套接字时调用
ReuseAddress
() 的操作,因为它不再需要 - 感谢 Alun Jones 指出这一点 - 2002 年 6 月 28 日 - 调整了套接字关闭的处理方式。现在我们发出异步断开连接。
- 2002 年 6 月 30 日 - 删除了用户需要子类化套接字服务器工作线程类的要求。现在所有工作都可以通过简单地子类化套接字服务器类来完成。
- 2002 年 7 月 15 日 - 当服务器在有活动连接的情况下关闭时,现在会发生套接字关闭通知。现在可以将
SocketServer
设置为确保读写数据包序列。 - 2002 年 7 月 19 日 - 与最新套接字服务器代码合并 - 仍需进行重构以消除重复。调整了
AcceptEx
的重新发布逻辑,使服务器运行更“平稳”。更新了文章以指出示例代码的未文档化性质。