前段时间在搞win下面的IOCP服务器时发现了一些问题,有些也是折磨了好久才慢慢理解的,今天将这些踩过的坑记录下来,避免以后遇到同样的问题,又要折磨好久。
IOCP目前是性能最好的网络模型,他与其他网络模型的的区别和优缺点就不做赘述了,这些网上随便一搜就有大量的文章去解释,主要说下IOCP的优缺点,一个IOCP对象,在操作系统中可以关联多个socket和(或)文件控制端。它主要是在内部封装了LIFO原则的请求队列、FIFO原则的完成队列、多线程处理,同样IOCP也是唯一个不需要考虑安全属性的Windows对象,因为IO完成端口在设计时就只能在一个进程中运行。IOCP也是异步模型,我们只能将IO操作的事件投递到IOCP上,实际上socket的send和recv操作均是由IOCP完成的,存在的问题就是,我们没办法控制send和recv的成功。还有一个缺点就是只能在Windows环境使用,这样就会存在一点的局限性。
IOCP模型主要是利用了重叠I/O技术,最难的部分也是在重叠I/O的管理上面,因此我们将这块进行了简便处理,将结构体修改成类,这样会比较方便的进行线性管理。
首先我们创建一个重叠I/O结构的类:
#define MAX_BUFFER 8192 //buffer的最大长度,一般的HTTP POST数据用4个页字段完全够用,不够用的话按情况而定
class CCPerIOData
{
public:
CCPerIOData();
~CCPerIOData();
void ResetIO();
public:
//下面是重叠I/O的内部结构
WSAOVERLAPPED m_Overlappend;
SOCKET m_AcceptSocket;
WSABUF m_wsaBuf;
DWORD m_opType;
char m_szBuffer[MAX_BUFFER];
//定义重叠I/O的工作类型,方便操作
typedef enum
{
OP_ACCEPT,
OP_SEND,
OP_RECV,
OP_NULL
}OPERATION_TYPE;
};
为了线性管理重叠I/O结构,我们需要创建一个socket管理类。
#include "periodata.h"
using namespace std;
class SocketHandle
{
public:
SocketHandle();
~SocketHandle();
public:
SOCKET m_Socket; //客户端连接的socket
SOCKADDR_IN m_ClientAddr;
volatile time_t m_nTimer; //socket最后一次活跃的时间,设计这个字段的主要目的是socket的关闭可能不及时,HTTP请求短连接情况下可能会存在端口不够用的情况,我们需要及时的关闭这些超过时间无响应的socket
vector m_VSocketIoData;
public:
//从队列中删除一个重叠I/O
void RemoveIOData(CCPerIOData* pPerIoData);
CPerIOData* GetNewIOData();
};
SocketHandle::SocketHandle()
{
m_Socket = INVALID_SOCKET;
ZeroMemory(&m_ClientAddr, sizeof(m_ClientAddr));
m_nTimer = time(0);
}
SocketHandle::~SocketHandle()
{
//销毁创建的一些指针,注意,m_VSocketIoData这个vector里面存放的全部是CPerIOData类型的指针,因此需要全部清理,清理方法:
//遍历释放
std::vector::iterator iter = m_VSocketIoData.begin();
for(; iter != m_VSocketIoData.end(); iter++)
{
//delete ...
}
m_VSocketIoData.clear();
}
CPerIOData* SocketHandle::GetNewIOData()
{
CPerIOData* p = new CPerIOData;
m_VSocketIoData.push_back(p); //每次重新创建一个重叠I/O,加入vector
return p;
}
void SocketHandle::RemoveIOData(CPerIOData* pPerIoData)
{
if(pPerIoData == NULL || m_VSocketIoData.empty())
{
return;
}
vector::iterator iter = m_VSocketIoData.begin();
for(; iter != m_VSocketIoData.end(); iter++)
{
if(pPerIoData == *iter)
{
// do something ...
break;
}
}
}
接下来我们就要准备进入主题:
要使用完成端口,首先需要注意以下几项:
1、创建完成端口
2、根据系统性能创建工作线程
3、创建一个监听套接字,并将监听套接字和完成端口绑定
4、创建n个供客户端连接的socket
5、加载完成端口的函数
为什么需要加载完成端口的函数呢,因为IOCP在设计时就已经确定了他不能直接在WindowsAPI下使用,并且由于winsock的版本原因,需要加载他的函数才能够正常的使用。
先看一个简单的头文件定义:
上面第五点一定要注意,由于winsock的版本问题,完成端口的函数不能够直接使
#include "SocketHandle.h"
class CHttpSerVer
{
public:
CHttpSerVer();
virtual ~CHttpSerVer();
bool Start();
void Stop();
private:
static DWORD WINAPI WorkThread(LPVOID lpParam); //工作线程,用来不断的循环等待IOCP的状态返回,并处理IOCP的请求
static DWORD WINAPI CheckClientThread(LPVOID lpParam); //客户端检测线程,用来检测3秒内没有活跃的socket,并释放这些socket资源
//程序结束时的一些资源回收函数
void ClearClientList();
void DeInitialize();
void CheckClientList();
//接收的客户端管理函数
void AddToClientList(SocketHandle* pSocketHandle);
void RemoveSocketHandle(SocketHandle* pSocketHandle);
//初始化IOCP和一些错误的处理
bool AssociateWithIOCP(SocketHandle* pSocketHandle);
bool HandleError(SocketHandle* pSocketHandle, DWORD dwError);
//IOCP工作流程的体现
bool PostAcceptEx(CPerIOData* pAcceptIoContext);
bool DoAcceptEx(CPerIOData* pIoContext);
void PostSend(SocketHandle* pSocketHandle, CPerIOData* pPerIOData);
bool DoSend(SocketHandle* pSocketHandle, CPerIOData* pPerIOData);
bool PostRecv(SocketHandle* pSocketHandle, CPerIOData* pPerIOData);
bool DoRecv(SocketHandle* pSocketHandle, CPerIOData* pPerIOData);
protected:
static bool m_bIsRunning;
CMutex m_ClientMutex;
CMutex m_StartMutex;
private:
int m_nPort; //监听端口
int m_nThreadNum; //工作线程数目
int m_nSocketNum; //设置每次能够监听的最大的客户端数量
int m_nCheckThreadRunTimeOut; //检测是否无响应的超时时间
HANDLE* m_pWorkerThreads; //工作线程
HANDLE m_pCheckClientThread; //检测客户端是否在设置的时间内无响应的线程
HANDLE m_hIOCompletionPort; //IOCP完成端口
HANDLE m_hShutdownEvent; //程序结束的信号
SocketHandle* m_pSocketHandle; //定义的监听socket的管理
vector m_VClientSocket; //管理已经连接的HTTP请求的客户端
//定义的IOCP的函数指针
LPFN_ACCEPTEX m_pFnAcceptThread;
LPFN_GETACCEPTEXSOCKADDRS m_pFnGetAcceptExSocketAddr;
};
bool CHttpSerVer::Start()
{
//为了保证在有多线程调用时,只能启动一个监听线程
CSingleLock lock(&m_StartMutex);
if(!lock.Lock(1000))
return;
m_bIsRunning = true;
m_nPort = atol(readconfig("监听端口", "80"));
m_nSocketNum = atol(readconfig("最大连接数", "1000")); //设置同时能够连接的最大的客户端数
m_pSocketHandle = new SocketHandle();
m_hShutdownEvent = CreateEvent(NULL, TRUE, FALSE, NULL); //退出事件信号
/**创建完成端口**/
m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
/**根据系统的性能创建工作线程, 完成端口的最大性能的工作线程数为CPU核心数的2倍**/
SYSTEM_INFO sysTemInfo;
GetSystemInfo(&sysTemInfo);
m_nThreadNum = sysTemInfo.dwNumberOfProcessors * 2;
m_pWorkerThreads = new HANDLE[m_nThreadNum];
DWORD nThreadID;
for(int nIndex = 0; nIndex < m_nThreadNum; nIndex++)
{
m_pWorkerThreads[nIndex] = ::CreateThread(0, 0, WorkThread, (void *)this, 0, &nThreadID);
}
/**创建用于检测客户端连接的线程**/
m_pCheckClientThread = ::CreateThread(0, 0, CheckClientThread, (void *)this, 0, &nThreadID);
/**创建监听套接字**/
m_pSocketHandle->m_Socket = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
/**将监听套接字和完成端口绑定**/
CreateIoCompletionPort((HANDLE)m_pSocketHandle->m_Socket, m_hIOCompletionPort, (DWORD)m_pSocketHandle, 0);
struct sockaddr_in SerVerAddrs;
memset(&SerVerAddrs, 0, sizeof(SerVerAddrs));
SerVerAddrs.sin_addr.S_un.S_addr = INADDR_ANY;
SerVerAddrs.sin_family = AF_INET;
SerVerAddrs.sin_port = htons(m_nPort);
::bind(m_pSocketHandle->m_Socket, (sockaddr*)&SerVerAddrs, sizeof(SerVerAddrs));
::listen(m_pSocketHandle->m_Socket, SOMAXCONN);
//致此,监听socket已经在监听了
/**由于IOCP的一些特殊的原因,我们必须首先加载IOCP的函数才能够使用IOCP**/
DWORD dwBytes = 0;
GUID guidAcceptEx = WSAID_ACCEPTEX;
GUID guidGetAcceptExSocketAddr = WSAID_GETACCEPTEXSOCKADDRS;
WSAIoctl(m_pSocketHandle->m_Socket, SIO_GET_EXTENSION_FUNCTION_POINTER, &guidAcceptEx, sizeof(guidAcceptEx), &m_pFnAcceptThread, sizeof(m_pFnAcceptThread),&dwBytes,NULL,NULL);
WSAIoctl(m_pSocketHandle->m_Socket, SIO_GET_EXTENSION_FUNCTION_POINTER, &guidGetAcceptExSocketAddr, sizeof(guidGetAcceptExSocketAddr), &m_pFnGetAcceptExSocketAddr,sizeof(m_pFnGetAcceptExSocketAddr), &dwBytes, NULL, NULL)
/**IOCP最大的好处是我们能够提前准备n个socket供客户端连接,这样可减少每个socket连接时创建socket的内存消耗**/
for(int nIndex = 0; nIndex < m_nSocketNum; nIndex++)
{
CPerIOData* pListenIOData = m_pSocketHandle->GetNewIOData();
//创建完成之后将这些socket投递给IOCP的AcceptEx函数等到连接
if(PostAcceptEx(pListenIOData) == false)
{
//投递失败时则要对这些socket做资源回收
//do something ...
}
}
}
下面看看怎样提前将1000个套接字提前准备:
bool CHttpSerVer::PostAcceptEx(CPerIOData* pPerIOData)
{
ASSERT( INVALID_SOCKET! = pPerIOData->m_AcceptSocket);
pPerIOData->m_AcceptSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);
if( INVALID_SOCKET == pPerIOData->m_AcceptSocket)
{
//do something ...
return false;
}
//设置这个socket的属性,使其能够重复利用
int nReuseAddr = 1;
setsockopt(pPerIOData->m_AcceptSocket, SOL_SOCKET, SO_REUSEADDR, (char*)&nReuseAddr, sizeof(nReuseAddr));
pPerIOData->ResetIO(); //这一步主要是清理这个重叠I/O中的buffer缓存
DWORD dwBytes = 0;
pPerIOData->m_opType = CPerIOData::OP_ACCEPT;
if(FALSE == m_pFnAcceptThread(m_pSocketHandle->m_Socket, pPerIOData->m_AcceptSocket, pPerIOData->m_wsaBuf.buf, 0, sizeof(SOCKADDR_IN)+16, sizeof(SOCKADDR_IN)+16, &dwBytes, &pPerIOData->m_Overlappend))
{
if(WSA_IO_PENDING != WSAGetLastError())
{
//说明投递失败了
return false;
}
}
return true;
}
这样我们的端口监听就已经完成了,并且已经提前准备了1000socket套接字准备客户端连接,这样我们的效率会有很大的提升,接下来最重要的还是工作线程的处理:
DWORD WINAPI CHttpSerVer::WorkThread(LPVOID pParam)
{
CHttpSerVer* pHttpServer = (CHttpSerVer*)pParam;
SocketHandle* pSocketHandle = NULL;
DWORD dwIOSize = 0;
OVERLAPPED* pOverlapped =NULL;
//循环不断的等待退出线程事件的信号,WaitForSingleObject函数的功能是在等待信号的触发,如果在设定的超时时间内指定的信号有触发,则立即返回,如果在超时时间内内有触发,则等待时间超时后立即返回,具体的函数解释可以看下Windows的API。
while (WAIT_OBJECT_0 != WaitForSingleObject(pHttpServer->m_hShutdownEvent, 0))
{
/**获取IOCP的状态**/
BOOL bRet = GetQueuedCompletionStatus(pHttpServer->m_hIOCompletionPort, &dwIOSize, (PULONG_PTR)&pSocketHandle, &pOverlapped, WSA_INFINITE);
if ( EXIT_CODE == (DWORD)pSocketHandle)
{
break; //如果收到的是退出请求,则停止循环,退出完成端口的监听
}
if(bRet == FALSE) //返回false,则说明接收失败了,处理这个错误后继续循环
{
DWORD dwError = WSAGetLastError();
pHttpServer->HandleError(pSocketHandle, dwError);
continue;
}
else
{
/**下面这行代码是将完成端口接收到的数据读出来**/
CPerIOData* pPerIOData = CONTAINING_RECORD(pOverlapped, CPerIOData, m_Overlappend);
if((0 == dwIOSize) && CPerIOData::OP_ACCEPT != pPerIOData->m_opType)
{
//满足该条件的时,说明完成端口接收到的是客户端退出的请求,需要将维护的客户端列表里面的该套接字做资源回收
pHttpServer->RemoveSocketHandle(pSocketHandle);
continue;
}
else
{
pSocketHandle->m_nTimer = time(0); //每次有新数据来时,更新活跃时间
switch(pPerIOData->m_opType)
{
case CPerIOData::OP_ACCEPT:
{
pHttpServer->DoAcceptEx(pPerIOData); //说明有新的客户端连接
break;
}
case CPerIOData::OP_SEND:
{
//do something ...
//send
//recv //数据发送完后投递recv,这样就能保证下一次数据过来时能够直接读取到
break;
}
case CPerIOData::OP_RECV:
{
//do something ...
break;
}
default:
{
break;
}
}
}
}
}
return 0;
}
接下来我们看下有新客户端连接时怎么处理:
bool CHttpSerVer::DoAcceptEx(CPerIOData* pPerIOData)
{
CString strTemp;
SOCKADDR_IN *addrClient = NULL;
SOCKADDR_IN *addrLocal = NULL;
int nClientLen = sizeof(SOCKADDR_IN);
int nLocalLen = sizeof(SOCKADDR_IN);
//该函数能够将客户端、本地的相关信息获取到,同时也能够接收到客户端第一次发送的数据
//第二个参数设置为0, 则直接返回
//如果第二个参数设置为 pPerIOData->m_wsaBuf.buf - ((sizeof(SOCKADDR_IN) + 16) * 2), 则会在等到接收到客户端第一次发送的数据后才能返回
this->m_pFnGetAcceptExSocketAddr(pPerIOData->m_wsaBuf.buf,
0,
sizeof(SOCKADDR_IN) + 16,
sizeof(SOCKADDR_IN) + 16,
(LPSOCKADDR*)&addrLocal,
&nLocalLen,
(LPSOCKADDR*)&addrClient,
&nClientLen);
//将监听的socket的数据复制出来,然后把这个socket继续投递到下一个监听
SocketHandle* pSocketHandle = new SocketHandle();
pSocketHandle->m_Socket = pPerIOData->m_AcceptSocket;
int nReuseAddr = 1;
setsockopt(pSocketHandle->m_Socket, SOL_SOCKET, SO_REUSEADDR, (char*)&nReuseAddr, sizeof(nReuseAddr));
if(!AssociateWithIOCP(pSocketHandle))
{
DELETE_PT(pSocketHandle);
return false;
}
//对复制之后的socket投递recv,因为刚建立连接时没有数据传过来,需要投递给IOCP去接收数据
CPerIOData* pConIOData = pSocketHandle->GetNewIOData();
if(!PostRecv(pSocketHandle, pConIOData))
{
//投递失败,则需要更新客户端列表
//do something ...
return false;
}
//为了方便管理,将这个客户端的信息添加到列表
this->AddToClientList(pSocketHandle);
//然后将listenSocket的I/O数据清理一下,准备投递到下一次客户端的连接,这样做的好处是,我们一直会有1000个socket准备接收新的客户端连接
pPerIOData->ResetIO();
PostAcceptEx(pPerIOData);
return true;
}
下面看下客户端的检测是怎么实现的,为什么需要检测呢,是因为http请求是短连接,如果发现客户端列表里面有超过3秒钟时间没有活跃的客户端,我们默认这个客户端已经被关闭了,只是在关闭的过程中由于一些原因,导致没有关闭完成,因此,我们给IOCP投递一个recv事件,让IOCP去关闭这个链接。
DWORD WINAPI CHttpSerVer::CheckClientThread(LPVOID pParam)
{
CHttpSerVer* pHttpServer = (CHttpSerVer*)pParam;
//设置该线程1s执行一次,如果m_hShutdownEvent有信号,则立即结束线程循环 pHttpServer->m_nCheckThreadRunTimeOut = 1000
while (WAIT_OBJECT_0 != WaitForSingleObject(pHttpServer->m_hShutdownEvent, pHttpServer->m_nCheckThreadRunTimeOut))
{
pHttpServer->CheckClientList();
}
return 0;
}
void CHttpSerVer::CheckClientList()
{
CSingleLock lock(&m_ClientMutex);
if(!lock.Lock(1000))
return;
if(m_VClientSocket.empty())
return;
vector::iterator iter = m_VClientSocket.begin();
for(; iter != m_VClientSocket.end(); iter++)
{
if((time(0) - (*iter)->m_nTimer) > 3)
{
//如果某一个socket的活跃时间已经超过了我们设置的超时时间,则将该socket投递给IOCP,投递类型是RECV,这样做的好处是,我们并没有直接去关闭这些socket,而是让IOCP去释放
CPerIOData* pPerIOData = (*iter)->GetNewIOData();
pPerIOData->m_opType = CPerIOData::OP_RECV;
PostQueuedCompletionStatus(m_hIOCompletionPort, 0, (DWORD)(*iter), (LPOVERLAPPED)pPerIOData);
}
}
return;
}
下面是有客户端数据传过来时的操作,接收数据,然后投递一个异步的发送事件
bool CHttpSerVer::DoRecv(SocketHandle* pSocketHandle, CPerIOData* pPerIOData)
{
if(pSocketHandle == NULL || pPerIOData == NULL)
{
return false;
}
char szResponse[70] = {0};
//该函数执行,说明已经读到数据了,我们按照自己的要求去解析数据,如果发现接收到的数据为空,则将这个socket投递给IOCP,投递类型为RECV,目的是为了关闭这个socket
if(!ParseRequest(pPerIOData->m_wsaBuf.buf, szResponse))
{
pPerIOData->m_opType = CPerIOData::OP_RECV;
PostQueuedCompletionStatus(m_hIOCompletionPort, 0, (DWORD)pSocketHandle, (LPOVERLAPPED)pPerIOData);
return false;
}
//解析完数据后,投递一个WSASend,给客户端一个回应
pPerIOData->m_wsaBuf.buf = szResponse;
pPerIOData->m_opType = CPerIOData::OP_SEND;
pPerIOData->m_wsaBuf.len = strlen(szResponse) + 1;
this->PostSend(pSocketHandle, pPerIOData);
return true;
}
void CHttpSerVer::PostSend(SocketHandle* pSocketHandle, CPerIOData* pPerIOData)
{
DWORD dwFlags = 0;
DWORD dwIOSize = 0;
pPerIOData->ResetIO();
pPerIOData->m_opType = CPerIOData::OP_SEND;
if(SOCKET_ERROR == WSASend(pSocketHandle->m_Socket, &pPerIOData->m_wsaBuf, 1, &dwIOSize, dwFlags, &pPerIOData->m_Overlappend, NULL))
{
//说明投递WASSend失败了
//do something ...
}
}
下面看下数据的解析,只实现了POST数据的解析:
bool CHttpSerVer::ParseRequest(const char* szRequest, char* szResponse)
{
char szResponseHeader[70] = {0};
CString strRequest(szRequest);
CString strMethod = strRequest.Left(4);
if(strMethod != "POST")
{
sprintf(szResponseHeader, "HTTP/1.0 404 ERROR\r\nContent-Length: 0\r\nConnection:close\r\n\r\n");
strcpy(szResponse, szResponseHeader);
return false;
}
int nConLenStart = strRequest.Find("Content-Length: ");
int nConLenEnd = strRequest.Find("\r\n", nConLenStart);
int nConLenPos = nConLenStart + strlen("Content-Length: ");
CString strDataLen = strRequest.Mid(nConLenPos, nConLenEnd - nConLenPos);
long nDataLen = atoi(strDataLen);
int nDataPos = strRequest.Find("\r\n\r\n") + strlen("\r\n\r\n");
CString strData = strRequest.Mid(nDataPos, nDataLen);
if(strData.IsEmpty())
{
sprintf(szResponseHeader, "HTTP/1.0 200 OK\r\nContent-Length: 9\r\nConnection:close\r\n\r\nmsg empty");
strcpy(szResponse, szResponseHeader);
return true;
}
//下面是接收到的数据调用自己的业务函数,该业务函数目前没有实现
if(this is you function (strData.GetBuffer(0), nDataLen))
{
sprintf(szResponseHeader, "HTTP/1.0 200 OK\r\nContent-Length: 0\r\nConnection:close\r\n\r\n");
strcpy(szResponse, szResponseHeader);
}
else
{
sprintf(szResponseHeader, "HTTP/1.0 404 ERROR\r\nContent-Length: 0\r\nConnection:close\r\n\r\n");
strcpy(szResponse, szResponseHeader);
}
return true;
}
在做这个模型的是中间遇到了一些问题,主要是服务端产生大量的CLOSE_WAIT状态,这样会极大的影响性能,或者在这些socket没有在完全关闭的情况下,会造成系统的崩溃。下面我们分析下产生这个问题的原因,看下下面的这张图基本就能够明白这种状态是怎么产生的。
产生这个问题主要是因为客户端关闭连接,服务器被关闭连接,而服务端并没有发送FIN包给客户端(可能是有一些send或者recv的操作没有完成),这时候服务端就会处于CLOS_WAIT状态,而客户端处于FIN_WAIT_1状态。
问题产生的原因就是代码本身的问题:需要仔细检查代码,该关闭socket的时候就要主动去关闭,因此在本模块中才会有检测不活跃的socket线程出现。
在这个状态下时:系统会在等待时间超过2MSL(报文最大生存时间)后自动回收,但系统的设置这个时间一般是30min,因此,需要修改系统时间:
Linux系统下:
vim /etc/sysctl.conf
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_tw_recycle=0
net.ipv4.tcp_fin_timeout=30
net.ipv4.tcp_max_tw_buckets=80000
net.ipv4.tcp_timestamps=1
net.ipv4.ip_local_port_range=10000 65535
修改完后执行命令 sysctl -p 使其生效。
window系统下:
在HKEY_LOCAL_MACHINE/SYSTEM/CurrentControlSet/Services/Tcpip/Parameters,添加名为TcpTimedWaitDelay的
DWORD键,设置为30,以缩短TIME_WAIT的等待时间
同时在做这个时候出现了一个很奇怪的现象,在Windows下面资源管理器中可以发现,每当有一个HTTP请求结束后,程序的句柄数会增加2,只增不减,这样的后果是一定量的HTTP请求后,系统可用的句柄数会消耗完,程序将无法正常工作,后来使用windbg调试,发现在代码中使用了一个无用的变量指针,这个指针是继承的,并且每一个HTTP请求都会new一个这个指针,调用结束后句柄数增加2,将这个指针采用全局对象的方式,所有HTTP请求共用同一个对象,句柄增加的问题解决了。