作者:
Amin Gholiha
翻译:高庆余
前言:源代码使用比较高级的IOCP技术,它能够有效的为多个客户端服务,利用IOCP编程API,它也提供了一些实际问题的解决办法,并且提供了一个简单的带回复的文件传输的客户端/服务器。
1.1
要求:
l
文章要求读者熟悉
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.
l
有时,当一个异步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);
l
nPortt
:服务器将监听的端口号(在客户端模式设为
-1
)。
l
iMaxNumConnections
:最多允许连接数。
l
iMaxIOWorkers
:输入
/
输出工作线程数。
l
nOfWorkers
:逻辑工作者数(在运行时能被改变)。
l
iMaxNumberOfFreeBuffer
:保留的重复利用的内存的最大数量(
-1
:无
,
0
:无穷)。
l
iMaxNumberOfFreeContext
:保留的重复利用的客户端信息的最大数量(
-1
:无
,
0
:无穷)。
l
bOrderedRead
:用来进行顺序读。
l
bOrderedSend
:用来进行顺序发送。
l
iNumberOfPendlingReads
:等待数据的异步读循环的数量。在连接到一个远端的连接时调用下面的函数:
Connect(const CString &strIPAddr, int nPort)
l
strIPAddr
:远端服务器的
IP
地址。
l
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();