采⽤完成端⼝(IOCP)实现⾼性能⽹络服务器
(Windowsc++版)
前⾔
TCP\IP已成为业界通讯标准。现在越来越多的程序需要联⽹。⽹络系统分为服务端和客户端,也就是c\s模式(client \ rver)。client⼀般有⼀个或少数⼏个连接;rver则需要处理⼤量连接。⼤部分情况下,只有服务端才特别考虑性能问题。本⽂主要介绍服务端处理⽅法,当然也可以⽤于客户端。
我也发表过c#版⽹络库。其实,我最早是从事c++开发,多年前就实现了对完成端⼝的封装。最近⼜把以前的代码整理⼀下,做了测试,也和c#版⽹络库做了粗略对⽐。总体上,还是c++性能要好⼀些。c#⽹络库见⽂章自满的近义词
Windows平台下处理socket通讯有多种⽅式;⼤体可以分为阻塞模式和⾮阻塞模式。阻塞模式下nd和recv都是阻塞的。简单讲⼀下这两种模式处理思路。
阻塞模式:⽐如调⽤nd时,把要发送的数据放到⽹络发送缓冲区才返回。如果这时,⽹络发送缓冲区满了,则需要等待更久的时间。socket的收发其实也是⼀种IO,和读写硬盘数据有些类似。⼀般来讲,IO处理速度总是慢的,不要和内存处理并列。对于调⽤recv,⾄少读取⼀个字节数据,函数才会返
回。所以对于recv,⼀般⽤⼀个单独的线程处理。
⾮阻塞模式:nd和recv都是⾮阻塞的;⽐如调⽤nd,函数会⽴马返回。真正的发送结果,需要等待操作系统的再次通知。阻塞模式下⼀步可以完成的处理,在⾮阻塞模式下需要两步。就是多出的这⼀步,导致开发难度⼤⼤增加。⾼性能⼤并发⽹络服务器必须采⽤⾮阻塞模式。完成端⼝(IOCP)是⾮阻塞模式中性能最好的⼀种。
作者多年以前,就开始从事winsocket开发,最开始是采⽤c++、后来采⽤c#。对⾼性能服务器设计的体会逐步加深。⼈要在⼀定的压⼒下才能有所成就。最开始的⼀个项⽬是移动信令分析,所处理的消息量⾮常⼤;⾼峰期,每秒要处理30万条信令,占⽤带宽500M。⽆论是socket通讯还是后⾯的数据处理,都必须⾮常优化。所以从项⽬的开始,我就谨⼩慎微,对性能特别在意。项⽬实施后,程序的处理性能出乎意料。⼀台服务器可以轻松处理⼀个省的信令数据(项⽬是08年开始部署,现在的硬件性能远超当时)。程序界⾯如下:
题外话通过这个项⽬我也有些体会:1)不要怀疑Windows的性能,不要怀疑微软的实⼒。有些⼈遇到性能问题,或是遇到奇怪的bug,总是把责任推给操作系统;这是不负责任的表现。应该反思⾃⼰的开发⽔平、设计思路。2)开发过程中,需要把业务吃透;业务是开发的基⽯。不了解业务,不可能开发出⾼性能的程序。所有的处理都有取舍,每个函数都有他的适应场合。有时候需要拿来主义,有时候需要从头开发⼀个函数。
⽬标
开发出⼀个完善的IOCP程序是⾮常困难的。怎么才能化繁为简?需要把IOCP封装;同时这个封装库要有很好的适应性,能满⾜各种应⽤场景。⼀个好的思路就能事半功倍。我就是围绕这两个⽬标展开设计。
1 程序开发接⼝
socket处理本质上可以分为:读、写、accept、socket关闭等事件。把这些事件分为两类:a)读、accept、socket关闭 b)写;a类是从库中获取消息,b类是程序主动调⽤函数。对于a类消息可以调⽤如下函数:
//消息事件
enum Enum_MessageType :char
{
EN_Accept = 0,
EN_Read,
EN_Clo,
EN_Connect
};
//返回的数据结构
class SocketMessage
{
public:
SOCKET Socket;
Enum_MessageType MessageType;
//当MessageType为EN_Connect时,BufferLen为EasyIocpLib_Connect函数的tag参数
INT32 BufferLen;
char *Buffer;
};
//不停的调⽤此函数,返回数据
SocketMessage* EasyIocpLib_GetMessage(UINT64 handle);
对于b类,就是发送数据。当调⽤发送时,数据被放到库的发送缓冲中,函数⾥⾯返回。接⼝如下:
enum EN_SEND_BUFFER_RESULT
{
en_nd_buffer_ok = 0, //放⼊到发送缓冲
en_not_validate_socket, //⽆效的socket句柄
en_nd_buffer_full //发送缓冲区满
};
EN_SEND_BUFFER_RESULT EasyIocpLib_SendMessage(UINT64 handle, SOCKET socket,
char* buffer, int offt, int len, BOOL mustSend = FALSE);
总的思路是接收时,放到接收缓冲;发送时,放到发送缓冲。外部接⼝只对内存中数据操作,没有任何阻塞。
2)具有⼴泛的适应性
如果⽹络库可以⽤到各种场景,所处理的逻辑必须与业务⽆关。所以本库接收和发送的都是字节流。包协议⼀般有长度指⽰或有开始结束符。需要把字节流分成⼀个个完整的数据包。这就与业务逻辑有关了。所以要有分层处理思想:
库性能测试
⾸先对库的性能做测试,使⼤家对库的性能有初步印象。这些测试都不是很严格,⼤体能反映程序的性能。IOCP是可扩展的,就是同时处理10个连接与同时处理1000个连接,性能上没有差别。
我的机器配置不⾼,cup为酷睿2 双核 E7500,相当于i3低端。
1)两台机器测试,⼀个发送,⼀个接收:带宽占⽤40M,整体cpu占⽤10%,程序占⽤cpu不超过3%。
2)单台机器,两个程序互发:收发数据达到30M字节,相当于300M带宽,cpu占⽤⼤概25%。
3)采⽤更⾼性能机器测试,两个程序对发数据:cpu为:i5-7500 CPU @ 3.40GHz
收发数据总和80M字节每秒,接近1G带宽。cpu占⽤25%。
测试程序下载地址:。只有exe程序,不包括代码。
⽹络库设计思路
服务器要启动监听,当有客户端连接时,⽣成新的socket句柄;该socket句柄与完成端⼝关联,后续读写都通过完成端⼝完成。
1 socket监听(Accept处理)
关于监听处理,参考我另⼀篇⽂章。
2 数据接收
收发数据要⽤到类型OVERLAPPED。需要对该类型进⼀步扩充,这样当从完成端⼝返回时,可以获取具体的数据和操作类型。这是处理完成端⼝⼀个⾮常重要的技巧。
//完成端⼝操作类型
typedef enum
{开机电脑黑屏
POST_READ_PKG, //读
POST_SEND_PKG, //写
POST_CONNECT_PKG,
POST_CONNECT_RESULT
}OPERATION_TYPE;
struct PER_IO_OPERATION_DATA
{
WSAOVERLAPPED overlap; //第⼀个变量,必须是操作系统定义的结构
OPERATION_TYPE opType;
SOCKET socket;
WSABUF buf; //要读取或发送的数据
};
发送处理:overlap包含要发送的数据。调⽤此函数会⽴马返回;当有数据到达时,会有通知。
BOOL NetServer::PostRcvBuffer(SOCKET socket, PER_IO_OPERATION_DATA *overlap)
{
DWORD flags = MSG_PARTIAL;
DWORD numToRecvd = 0;
overlap->opType = OPERATION_TYPE::POST_READ_PKG;
overlap->socket = socket;
int ret = WSARecv(socket,
&overlap->buf,
&numToRecvd,
&flags,
&(overlap->overlap),
NULL);
if (ret != 0)
{
if (WSAGetLastError() == WSA_IO_PENDING)
{
ret = NO_ERROR;
}
el
{
ret = SOCKET_ERROR;
}
}
return (ret == NO_ERROR);
}
从完成端⼝获取读数据事件通知:
DWORD NetServer::Deal_CompletionRoutine()
{
DWORD dwBytesTransferred;
PER_IO_OPERATION_DATA *lpPerIOData = NULL;
ULONG_PTR Key;
养狗注意事项
BOOL rc;
int error;
龙腾虎跃的意思
while (m_bServerStart)
{
error = NO_ERROR;
//从完成端⼝获取事件
rc = GetQueuedCompletionStatus(
m_hIocp,
&dwBytesTransferred,
&Key,
(LPOVERLAPPED *)&lpPerIOData,
INFINITE);
if (rc == FALSE)
{
error = 123;
if (lpPerIOData == NULL)
民心{
DWORD lastError = GetLastError();
健康促进
if (lastError == WAIT_TIMEOUT)
{
continue;
}
el
{
//continue;
//程序结束
asrt(fal);
return lastError;
}
}
el
{
if (GetNetResult(lpPerIOData, dwBytesTransferred) == FALSE) {
error = WSAGetLastError();
}
}
}
if (lpPerIOData != NULL)
{
switch (lpPerIOData->opType)
{
ca POST_READ_PKG: //读函数返回
{
OnIocpReadOver(*lpPerIOData, dwBytesTransferred, error); }
break;
ca POST_SEND_PKG:
{
OnIocpWriteOver(*lpPerIOData, dwBytesTransferred, error); }
break;
}
}
return0;
}
void NetServer::OnIocpReadOver(PER_IO_OPERATION_DATA& opData,
DWORD nBytesTransfered, DWORD error)
{
if (error != NO_ERROR || nBytesTransfered == 0)//socket出错
{
Net_CloSocket(opData.socket);
NetPool::PutIocpData(&opData);//数据缓冲处理
}
el
{
OnRcvBuffer(opData, nBytesTransfered);//处理接收到的数据
BOOL post = PostRcvBuffer(opData.socket, &opData); //再次读数据
if (!post)
{
Net_CloSocket(opData.socket);
NetPool::PutIocpData(&opData);
}
}
}
3 数据发送
数据发送时,先放到发送缓冲,再发送。向完成端⼝投递时,每个连接同时只能有⼀个正在投递的操作。
BOOL NetServer::PostSendBuffer(SOCKET socket)
{
if (m_clientManage.IsPostSendBuffer(socket)) //如果有正在执⾏的投递,不能再次投递
return FALSE;
//获取要发送的数据
PER_IO_OPERATION_DATA *overlap = NetPool::GetIocpData(FALSE);
int ndCount = m_clientManage.GetSendBuf(socket, overlap->buf);
if (ndCount == 0)
{
NetPool::PutIocpData(overlap);
小学生座右铭
return FALSE;
}
overlap->socket = socket;
overlap->opType = POST_SEND_PKG;
BOOL post = PostSendBuffer(socket, overlap);
if (!post)
{
Net_CloSocket(socket);
NetPool::PutIocpData(overlap);
return FALSE;
}
el
{
m_clientManage.SetPostSendBuffer(socket, TRUE);
return TRUE;
转账凭证}
}
总结:开发⼀个好的封装库必须有的好的思路。对复杂问题要学会分解,每个模块功能合理,适应性要强;要有模块化、层次化处理思路。如果⽹络库也处理业务逻辑,处理具体包协议,它就⽆法做到通⽤性。⼀个通⽤性好的库,才值得我们花费⼤⽓⼒去做好。我设计的这个库,⽤在了公司多个系统上;以后⽆论遇到任何⽹络协议,这个库都可以⽤得上,⼀劳永逸的解决⽹络库封装问题。