IOCP 实现一个简单高并发服务器程序

前言:源代码使用比较高级的IOCP技术,它能够有效的为多个客户端服务,利用IOCP编程API,它也提供了一些实际问题的解决办法,并且提供了一个简单的带回复的文件传输的客户端/服务器。
1.1 要求:
文章要求读者熟悉C++, TCP/IP, 套接字(socket)编程, MFC, 和多线程。
l 源代码使用Winsock 2.0和IOCP技术,并且要求:
Ø Windows NT/2000 or later: Requires Windows NT 3.5 or later.
Ø Windows 95/98/ME: 不支持
Ø Visual C++ .NET, or a fully updated Visual C++ 6.0.
1.2 摘要:
在你开发不同类型的软件,不久之后或者更晚,你必须得面对客户端/服务器端的发展。对程序员来说,写一个全面的客户端/服务器的代码是很困难的。这篇文章提供了一个简单的,但却强大的客户端/服务器源代码,它能够被扩展到许多客户端/服务器的应用程序中。源代码使用高级的IOCP技术,这种技术能高效的为多个客户端提供服务。IOCP技术提供了一种对 一个线程—一个客户端(one-thread-one client)这种瓶颈问题(很多中问题的一个)的有效解决方案。它使用很少的一直运行的线程和异步输入/输出,发送/接收。IOCP技术被广泛应用于各自高性能的服务器,像Apache等。源代码也提供了一系列的函数,在处理通信、客户端/服务器接收/发送文件函数、还有线程池处理等方面都会经常用到。文章主要关注利用IOCP应用API函数的实际解决方案,也提供了一个全面的代码文档。此外,也为你呈现了一个能处理多个连接、同时能够进行文件传输的简单回复客户端/服务器。
2.1. 介绍:
这片文章提供了一个类,它是一个应用于客户端和服务器的源代码,这个类使用IOCP和异步函数,我们稍后会进行介绍。这个源代码是根据很多代码和文章得到的。
利用这些简单的源代码,你能够:
l 服务/连接多个客户端和服务器。
l 异步发送和接收文件。
l 为了处理沉重的客户端/服务器请求,创建并管理一个逻辑工作者线程池。(logical worker thread pool)。
我们很难找到充分的,但简单的能够应对客户端/服务器通信的源代码。在网上发现的源代码即复杂(超过20个类),又不能提供足够的功能。本问的代码尽量简单,也有好的文档。我们将简要介绍Winsock API 2.0提供的IOCP技术,编码时遇到的疑难问题,以及这些问题的应对方案。
2.2. 异步输入/输出完成端口(IOCP)简介
一个服务器应用程序,假如不能够同时为多个客户端提供服务,那它就没有什么意义。通常的异步I/O调用,还有多线程都是这个目的。准确的说,一个异步I/O调用能够立即返回,尽管有阻塞的I/O调用。同时,I/O异步调用的结果必须和主线程同步。这可以用很多种方法实现,同步可以通过下面方法实现:
l 利用事件——当异步调用完成时设定的信号。这种方法的优点是线程必须检查和等待这个信号被设定。
l 使用GetOverlappedResult函数——这个方法和上面方法有相同的优点。
l 使用异步程序调用(APC)——这种方法有些缺点。第一,APC总是在正被调用的线程的上下文中被调用;第二,调用线程必须暂停,等待状态的改变。
l 使用IOCP——这种方法的缺点是有些疑难问题必须解决。使用IOCP编码多少有些挑战。
2.2.1 为什么使用IOCP
使用IOCP,我们能够克服 一个线程 —— 一个客户端 问题。我们知道,假如软件不是运行在一个真实的多处理器机器上,它的性能会严重下降。线程是系统的资源,它们即不是无限的,也不便宜。
IOCP提供了一种利用有限的(I/O工作线程)公平的处理多客户端的输入/输出问题的解决办法。线程并不被阻塞,在无事可作的情况下也不使CPU循环。
2.3. 什么是IOCP
我们已经知道,IOCP仅仅是一个线程同步对象,有点像信号量(semaphore),因此IOCP并不是一个难懂的概念。一个IOCP对象和很多支持异步I/O调用的I/O对象相联系。线程有权阻塞IOCP对象,直到异步I/O调用完成。
3 IOCP如何工作
为了得到更多信息,建议你参考其它的文章(1, 2, 3, see References)。
使用IOCP,你必须处理3件事情。将一个套接字绑定到一个完成端口,使用异步I/O调用,和使线程同步。为了从异步I/O调用得到结果,并知道一些事情,像哪个客户端进行的调用,我们必须传递两个参数:CompletionKey参数还有OVERLAPPED结构体
3.1. CompletionKey参数
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调用
通过调用WSASend, WSARecv函数,进行实际的异步调用。这些函数也需要包含将要被用到的内存指针的参数WSABUF。通常情况下,当服务器/客户端想要执行一个I/O调用操作,它们并不直接去做,而是发送到完成端口,这些操作被I/O工作线程执行。这是因为,要公平的分配CPU。通过给完成端口传递一个状态,进行I/O调用。象下面这样:
BOOL bSuccess = PostQueuedCompletionStatus(m_hCompletionPort,
pOverlapBuff->GetUsed(),
(DWORD) pContext, &pOverlapBuff->m_ol);
3.5. 线程的同步
通过调用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开发客户端/服务器程序时会遇到它们。它们是:
l WSAENOBUFS出错问题。
l 数据包的重排序问题。
l 访问紊乱(access violation)问题。
3.6.1 WSAENOBUFS出错问题。
这个问题并不直观,并且很难检查。因为,乍一看,它很像普通的死锁,或者内存泄露。假设你已经弄好了你的服务器并且能够很好的运行。当你对服务器进行承受力测试的时候,它突然挂机了。如果你幸运,你会发现这和WSAENOBUFS出错有关。
伴随着每一次的重叠发送和接收操作,有数据的内存提交可能会被加锁。当内存被锁定时,它不能越过物理内存页。操作系统会强行为能够被锁定的内存的大小设定一个上限。当达到上限时,重叠操作将失败,并发送WSAENOBUFS错误。
假如一个服务器在在每个连接上提供了很多重叠接收,随着连接数量的增长,很快就会达到这个极限。如果服务器能够预计到要处理相当多的并发客户端的话,服务器可以在每个连接上仅仅回复一个0字节的接收。这是因为没有接收操作和内存无关,内存不需要被锁定。利用这个方法,每一个套接字的接收内存都应该被完整的保留,这是因为,一旦0字节的接收操作完成,服务器仅仅为套接字的接收内存的所以数据内存返回一个非阻塞的接收。利用WSAEWOULDBLOCK当非阻塞接收失败时,也没有数据被阻塞。这种设计的目的是,在牺牲数据吞吐量的情况下,能够处理最大量的并发连接。当然,对于客户端如何和服务器交互,你知道的越多越好。在以前的例子中,每当0字节的接收完成,返回存储了的数据,马上执行非阻塞接收。假如服务器知道客户端突然发送数据,当0字节接收一旦完成,为防止客户端发送一定数量的数据(大于每个套接字默认的8K内存大小),它可以投递一个或多个重叠接收。
源代码提供了一个简单的解决WSAENOBUFS错误的可行方案。对于0字节内存,我们采用WSARead()函数(见OnZeroByteRead())。当调用完成,我们知道数据在TCP/IP栈中,通过采用几个异步WSARead()函数读取MAXIMUMPACKAGESIZE的内存。这个方法在数据达到时仅仅锁定物理内存,解决了WSAENOBUFS问题。但是这个方案降低了服务器的吞吐量(见第9部分的Q6和A6例子)。
3.6.2 数据包的重排序问题
在参考文献3中也讨论了这个问题。尽管使用IOCP,可以使数据按照它们被发送的顺序被可靠的处理,但是线程表的结果是实际工作线程的完成顺序是不确定的。例如,假如你有两个I/O工作线程,并且你应该接收“字节数据块1、字节数据块2 、字节数据块3”,你可以按照错误的顺序处理它们,也就是“字节数据块2、字节数据块1 、字节数据块3”。这也意味着,当你通过把发送请求投递到IO完成端口来发送数据时,数据实际上是被重新排序后发送的。
这个问题的一个实际解决办法是,为我们的内存类增加顺序号,并按照顺序号处理内存。意思是,具有不正确号的内存被保存备用,并且因为性能原因,我们将内存保存在希哈表中(例如m_SendBufferMap和m_ReadBufferMap)。
要想得到更多这个方案的信息,请查看源代码,并在IOCPS类中查看下面的函数:
l GetNextSendBuffer (..) 和GetNextReadBuffer(..), 为了得到排序的发送或接收内存。
l IncreaseReadSequenceNumber(..)和IncreaseSendSequenceNumber(..), 为了增加顺序号。
3.6.3 异步阻塞读和字节块包处理问题
大多数服务器协议是一个包,这个包的基础是第一个X位的描述头,它包含了完整包的长度等详细信息。服务器可以解读这个头,可以算出还需要多少数据,并一直解读,直到得到一个完整的包。在一个时间段内,服务器通过异步读取调用是很好的。但是,假若我们想全部利用IOCP服务器的潜力,我们应该有很多的异步读操作等待数据的到达。意思是很多异步读无顺序完成(像在3.6.2讨论的),通过异步读操作无序的返回字节块流。还有,一个字节块流(byte chunk streams)能包含一个或多个包,或者包的一部分,如图1所示:
图1
这个图表明部分包(绿色)和完整的包(黄色)在字节块流中是如何异步到达的。
这意味着我们要想成功解读一个完整包,必须处理字节流数据块(byte stream chunks)。还有,我们必须处理部分包,这使得字节块包的处理更加困难。完整的方案可以在IOCP类里的ProcessPackage(..)函数中找到。
3.6.4 访问紊乱(access violation)问题。
这是一个次要问题,是编码设计的结果,而不是IOCP的特有问题。倘若客户端连接丢失,并且一个I/O调用返回了一个错误标识,这样我们知道客户端已经不在了。在CompletionKey参数中,我们为它传递一个包含了客户端特定数据的结构体的指针。假如我们释放被ClientContext结构体占用的内存,被同一个客户端执行I/O调用所返回的错误码,我们为ClientContext指针传递双字节的CompletionKey变量,试图访问或删除CompletionKey参数,这些情况下会发生什么?一个访问紊乱发生了。
这个问题的解决办法是为ClientContext结构体增加一个阻塞I/O调用的计数(m_nNumberOfPendlingIO)当我们知道没有阻塞I/O调用时我们删除这个结构体。EnterIoLoop(..) 函数和 ReleaseClientContext(..).函数就是这样做的。
3.7 源代码总揽
源代码的目标是提供一些能处理与IOCP有关的问题的代码。源代码也提供了一些函数,它们在处理通信、客户端/服务器接收/发送文件函数、还有线程池处理等方面会经常用到。
图2 源代码IOCPS类函数总揽
我们有很多I/O工作线程,它们通过完成端口(IOCP)处理异步I/O调用,这些工作线程调用一些能把需要大量计算的请求放到一个工作队列着中的虚函数。逻辑工作线程从队列中渠道任务,进行处理,并通过使用一些类提供的函数将结果返回。图形用户界面(GUI)通常使用Windows消息,通过函数调用,或者使用共享的变量,和主要类进行通信。
图3
图3显示了类的总揽。
图3中的类归纳如下:
l CIOCPBuffer:管理被异步I/O调用使用的内存的类。
l IOCPS:处理所有通信的主要类。
l JobItem:包含被逻辑工作线程所执行工作的结构体。
l ClientContext:保存客户端特定信息的结构体(例如:状态、数据 )。
3.7.1 内存设计——CIOCPBuffer类
当使用异步I/O调用时,我们必须为I/O操作提供一个私有内存空间。当我们分配内存时要考虑下面一些情况:
l 分配和释放内存是很费时间的,因此我们要反复利用分配好的内存。所以,我们像下面所示将内存保存在一个连接表中。
· // 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()可以实现这一目标。我们有时也需要在两个内存间复制信息, CIOCPS类中的AddAndFlush()函数可以实现。
l 我们知道,我们为我们的内存增加序列号和状态变量(IOZeroReadCompleted())。
l 我们也需要字节流和数据相互转换的方法,在CIOCPBuffer类中提供了这些函数。
在我们的CIOCPBuffer类中,有上面所有问题的解决办法。
3.8 如何使用源代码
从IOCP中派生你自己的类,使用虚函数,使用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)。
l iMaxNumConnections:最多允许连接数。
iMaxIOWorkers :输入/输出工作线程数。
l nOfWorkers:逻辑工作者数(在运行时能被改变)。
iMaxNumberOfFreeBuffer :保留的重复利用的内存的最大数量(-1:无 ,0:无穷)。
iMaxNumberOfFreeContext :保留的重复利用的客户端信息的最大数量(-1:无 ,0:无穷)。
bOrderedRead :用来进行顺序读。
bOrderedSend :用来进行顺序发送。
iNumberOfPendlingReads :等待数据的异步读循环的数量。在连接到一个远端的连接时调用下面的函数:
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();

5.1 文件传输

使用Winsock 2.0的TransmitFile 函数传输文件。TransmitFile 函数在连接的套接字句柄上传输文件数据。此函数使用操作系统的缓冲管理机制接收文件数据,在套接字上提供高性能的文件传输。在异步文件传输上有以下几个重要方面:
l 除非TransmitFile 函数返回,否则不能再对套接字执行 发送 或 写入 操作,不然会破坏文件的传输。在执行PrepareSendFile(..) 函数后,所有对ASend函数的调用都是不允许的。
l 由于系统是连续读文件数据,打开文件句柄的FILE_FLAG_SEQUENTIAL_SCAN特性可以提高缓存性能。
l 在发送文件(TF_USE_KERNEL_APC)时,我们使用内核的异步程序调用。TF_USE_KERNEL_APC的使用可以带来明显的性能提升。很可能(尽管不一定),带有TransmitFile 的线程的上下文环境的初始化会有沉重的计算负担;这种情况下可以防止反复执行APC(异步程序调用)。
文件传输的顺序如下:服务器通过调用PrepareSendFile(..)函数初始化文件传输。客户端接收到文件信息时,通过调用PrepareReceiveFile(..)函数准备接收,并且给服务器发送一个包来开始文件传输。在服务器收到包后,它调用使用高性能的TransmitFile函数的StartSendFile(..)函数传输指定的文件。

6 源代码例子

提供的源代码是一个模拟客户端/服务器的例子,它也提供了文件传输功能。在源码中,从类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 或者 .NT时,在处理类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.
展开阅读全文

没有更多推荐了,返回首页