概述
在网络编程中,IO(输入输出)操作是程序与外部世界交互的基础。非阻塞IO,是相对于阻塞IO而言的,两者在编程、表现和效果上均有显著的差别。
阻塞IO是最直接、且易于理解的IO模型。当一个线程执行读写函数时,如果数据还没有准备好,或者暂时无法完成写入,则线程会停留在该函数这里,无法继续往下执行,直到条件满足为止。阻塞IO的好处在于:实现简单,逻辑清晰。但缺点也很明显:在阻塞期间,整个线程无法执行其他代码,只能被动等待函数执行完,不利于构建高效的应用程序。
非阻塞IO允许程序在发起IO请求后,立即返回控制权给调用者,而不会因为数据未就绪等原因导致当前执行流程暂停。这意味着,即使IO操作尚未完成,程序也可以继续执行后续代码。这种方式提高了程序的响应速度和整体性能,特别是在需要处理大量并发连接的情况下。缺点是:代码的处理逻辑比阻塞IO要复杂,稍不注意可能会导致数据接收和发送出问题。
阻塞IO
在C++中,使用标准套接字API创建的socket默认是阻塞模式。这意味着,在进行IO操作时,如果数据未准备好或缓冲区满,调用将被挂起,直到条件满足为止。
在不同使用场景下,阻塞IO的行为如下。
连接操作:当客户端通过connect函数尝试与服务器建立TCP连接时,如果服务器暂时不可达或网络延迟较高,connect函数调用将被挂起,直到连接成功或者超时失败。
等待连接操作:服务器通过listen函数开始监听指定端口上的连接请求,随后的accept函数调用会阻塞,直到有新的客户端连接到达。一旦有新的连接,accept函数返回一个新的socket套接字,用于与该客户端通信。
读操作:当从socket中读取数据时,如果当前没有数据可读,read或recv函数调用将阻塞,直到有数据可用或发生其他网络错误。如果对端关闭了连接,read或recv函数会返回0,表示对端已经正常关闭连接。
写操作:当向socket中写入数据时,如果缓冲区已满或对端无法接收更多数据,write或send函数调用将阻塞,直到有足够的空间可以写入数据或发生其他网络错误。如果对端已经关闭连接,写操作可能会立即失败并返回错误。
非阻塞IO
如果想将socket设置为非阻塞模式,可以通过ioctlsocket函数(Windows系统)或fcntl函数(Linux系统)来实现。具体如何使用,可参考下面的示例代码。
int CHP_Socket::EnableNoBlock(HP_SOCKET sock, bool bEnable /* = true */)
{
if (sock == HP_SOCKET_INVALID_HANDLE)
{
return -1;
}
#ifdef _WIN32
DWORD dwNoBlock = bEnable ? 1 : 0;
return ioctlsocket(sock, FIONBIO, &dwNoBlock);
#else
int nOpt = fcntl(sock, F_GETFL);
if (bEnable)
{
nOpt = nOpt | O_NONBLOCK;
}
else
{
nOpt = nOpt & ~O_NONBLOCK;
}
fcntl(sock, F_SETFL, nOpt);
return 0;
#endif
}
当socket处于非阻塞模式时,相关操作可能会立即返回错误码(比如:EAGAIN、EWOULDBLOCK等),表示当前无法完成操作。我们需要正确处理这些错误情况,以保证连接和数据的完整性和有效性。
非阻塞IO连接
如果想要连接操作是非阻塞的,除了上面提到的将socket设置为非阻塞模式外,还需要封装两个函数。一个函数用于连接,这个连接是非阻塞的,可以立即返回。另一个函数是探测连接是否成功,需要应用层周期性来调用。具体如何封装,可以参考下面的示例代码。
int CHP_Socket::Connect(HP_SOCKET sock, const char *pszIP, unsigned short usPort)
{
unsigned int uiIP = IPStrToUint(pszIP);
return Connect(sock, uiIP, usPort);
}
int CHP_Socket::Connect(HP_SOCKET sock, unsigned int uiIP, unsigned short usPort)
{
if (sock == HP_SOCKET_INVALID_HANDLE)
{
return -1;
}
struct sockaddr_in serverAddr;
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = uiIP;
serverAddr.sin_port = htons(usPort);
int nRet = connect(sock, (struct sockaddr *)&serverAddr, sizeof(serverAddr));
if (nRet == SOCKET_ERROR)
{
nRet = LAST_SOCKET_ERROR;
if (nRet == WSAEWOULDBLOCK || nRet == WSAEINPROGRESS)
{
nRet = HP_SOCKET_E_WOULDBLOCK;
}
else
{
nRet = HP_SOCKET_E_GENERAL;
}
}
return nRet;
}
int CHP_Socket::ProbeConnect(HP_SOCKET sock)
{
if (sock == HP_SOCKET_INVALID_HANDLE)
{
return -1;
}
int nRet = 0;
#ifndef _WIN32
fd_set fdsRead;
#endif
fd_set fdsWrite;
fd_set fdsExcept;
struct timeval timeout;
#ifndef _WIN32
FD_ZERO(&fdsRead);
FD_SET(sock, &fdsRead);
#endif
FD_ZERO(&fdsWrite);
FD_SET(sock, &fdsWrite);
FD_ZERO(&fdsExcept);
FD_SET(sock, &fdsExcept);
timeout.tv_sec = 0;
timeout.tv_usec = 0;
#ifdef _WIN32
nRet = select((int)sock + 1, NULL, &fdsWrite, &fdsExcept, &timeout);
#else
nRet = select((int)sock + 1, &fdsRead, &fdsWrite, &fdsExcept, &timeout);
#endif
if (nRet == SOCKET_ERROR)
{
nRet = CHP_Socket::PasreErrorCode(LAST_SOCKET_ERROR);
}
else if (nRet > 0)
{
if (FD_ISSET(sock, &fdsExcept))
{
nRet = CHP_Socket::PasreErrorCode(LAST_SOCKET_ERROR);
}
#ifdef _WIN32
else if (FD_ISSET(sock, &fdsWrite))
{
nRet = 0;
}
#else
else if (FD_ISSET(sock, &fdsRead) || FD_ISSET(sock, &fdsWrite))
{
int nErrorCode = 0;
socklen_t nLen = (socklen_t)sizeof(nErrorCode);
if (getsockopt(sock, SOL_SOCKET, SO_ERROR, &nErrorCode, &nLen) != 0)
{
nRet = -1;
}
else
{
if (nErrorCode == 0)
{
nRet = 0;
}
else
{
nRet = CHP_Socket::PasreErrorCode(nErrorCode);
}
}
}
#endif
else
{
nRet = HP_SOCKET_E_GENERAL;
}
}
else
{
nRet = HP_SOCKET_E_WOULDBLOCK;
}
return nRet;
}
可以看到,在ProbeConnect函数中,Linux系统下检查了套接字的可读、可写和异常状态,但Windows系统下只检查了套接字的可写和异常状态,没有检查可读状态,为什么会这样呢?
在Linux系统下,select函数处理连接建立时的行为如下。
(1)可读状态:如果socket上有数据可读,或者对端已经关闭了连接。
(2)可写状态:如果socket可以写入数据,通常表示连接已经成功建立。
(3)异常状态:如果socket上发生了错误,或者有带外数据。
在Windows系统下,select函数处理连接建立时的行为如下。
(1)可读状态:即使连接还没有完全建立,socket也可能变得可读。
(2)可写状态:当连接成功建立后,socket会变得可写。
(3)异常状态:如果socket上发生了错误,或者有带外数据。
因此,我们在Windows系统下一般只检查可写和异常状态,避免因过早的可读状态导致误判。
非阻塞IO等待连接
非阻塞模式下调用accept函数时,如果成功,会返回一个新的socket套接字,用于与新连接的客户端通信。如果失败,accept函数会立即返回,并将errno设置为EAGAIN 或 EWOULDBLOCK(表示当前没有可用的连接请求),或者其他错误码。
具体如何封装,可以参考下面的示例代码。
int CHP_Socket::Accept(HP_SOCKET sock, HP_SOCKET &sockNew)
{
if (sock == HP_SOCKET_INVALID_HANDLE)
{
return -1;
}
int nRet = 0;
struct sockaddr_in addr;
socklen_t nAddrLen = (socklen_t)sizeof(addr);
HP_SOCKET sockTemp = accept(sock, (struct sockaddr *)&addr, &nAddrLen);
if (sockTemp != HP_SOCKET_INVALID_HANDLE)
{
sockNew = sockTemp;
}
else
{
nRet = LAST_SOCKET_ERROR;
if (nRet == WSAEWOULDBLOCK || nRet == WSAEINPROGRESS)
{
nRet = HP_SOCKET_E_WOULDBLOCK;
}
else
{
nRet = HP_SOCKET_E_GENERAL;
}
}
return nRet;
}
非阻塞IO读
在非阻塞IO模式下读取数据时,read或recv函数会立即返回,而不会阻塞等待数据。如果当前没有数据可读,这些函数会返回一个特定的错误码,比如:EAGAIN 或 EWOULDBLOCK。
如果想通过非阻塞IO读取一段完整的数据,可以参考下面的示例代码。
int CHP_Socket::TcpRecvWhole(HP_SOCKET sock, char *pBuf, int nTotalLen, int &nRecvedLen)
{
if (sock == HP_SOCKET_INVALID_HANDLE || pBuf == NULL || nTotalLen <= 0)
{
return -1;
}
int nRecvedLenSingle = nTotalLen - nRecvedLen;
int nRet = CHP_Socket::TcpRecvSingle(sock, pBuf + nRecvedLen, nRecvedLenSingle);
if (nRet == 0)
{
nRecvedLen += nRecvedLenSingle;
if (nRecvedLen == nTotalLen)
{
nRecvedLen = 0;
}
else
{
nRet = HP_SOCKET_E_ATLEAST_ONE_BYTE;
}
}
return nRet;
}
int CHP_Socket::TcpRecvSingle(HP_SOCKET sock, char *pBuf, int &nBufLen)
{
if (sock == HP_SOCKET_INVALID_HANDLE || pBuf == NULL || nBufLen <= 0)
{
return -1;
}
int nRet = recv(sock, pBuf, nBufLen, 0);
if (nRet == SOCKET_ERROR)
{
nRet = LAST_SOCKET_ERROR;
if (nRet == WSAEWOULDBLOCK)
{
nRet = HP_SOCKET_E_WOULDBLOCK;
}
else
{
nRet = HP_SOCKET_E_GENERAL;
}
}
else if (nRet == 0)
{
nRet = HP_SOCKET_E_GENERAL;
}
else
{
nBufLen = nRet;
nRet = 0;
}
return nRet;
}
上面的TcpRecvWhole函数,用于接收一段完整的数据。它可以从上次已接收的位置继续接收数据,直到接收完所有数据。各个参数和返回值的含义如下。
sock:需要接收数据的套接字。
pBuf:接收数据的缓存。
nTotalLen:接收数据的总长度。
nRecvedLen:传入缓存中已经接收的数据长度,返回最新已经接收的数据长度。
返回值:0表示成功,HP_SOCKET_E_WOULDBLOCK表示需要等会继续接收,HP_SOCKET_E_ATLEAST_ONE_BYTE表示收到了至少一个字节的数据,也需要继续接收,其他为错误码,表示连接已不可用。
非阻塞IO写
在非阻塞IO模式下写入数据时,write或send函数会立即返回,而不会阻塞等待缓冲区有空间。如果当前没有足够的缓冲区空间来写入数据,这些函数会返回一个特定的错误码,比如:EAGAIN 或 EWOULDBLOCK。
如果想通过非阻塞IO写入一段完整的数据,可以参考下面的示例代码。
int CHP_Socket::TcpSendWhole(HP_SOCKET sock, char *pBuf, int nTotalLen, int &nSendedLen)
{
if (sock == HP_SOCKET_INVALID_HANDLE || pBuf == NULL || nTotalLen < 0)
{
return -1;
}
if (nTotalLen == 0)
{
return 0;
}
bool bSendOneByte = false;
while (true)
{
int nLenTemp = nTotalLen - nSendedLen;
int nRet = send(sock, pBuf + nSendedLen, nLenTemp, 0);
if (nRet == SOCKET_ERROR)
{
nRet = LAST_SOCKET_ERROR;
if (nRet == WSAEWOULDBLOCK || nRet == WSAENOBUFS)
{
if (!bSendOneByte)
{
return HP_SOCKET_E_WOULDBLOCK;
}
else
{
return HP_SOCKET_E_ATLEAST_ONE_BYTE;
}
}
else
{
return HP_SOCKET_E_GENERAL;
}
}
if (nRet != 0)
{
bSendOneByte = true;
}
nSendedLen += nRet;
if (nSendedLen == nTotalLen)
{
nSendedLen = 0;
break;
}
}
return 0;
}
上面的TcpSendWhole函数,用于发送一段完整的数据。它可以从上次已发送的位置继续发送数据,直到发送完所有数据。各个参数和返回值的含义如下。
sock:需要发送数据的套接字。
pBuf:需要发送的数据。
nTotalLen:需要发送数据的总长度。
nRecvedLen:传入已经发送数据的长度,返回最新已经发送的数据长度。
返回值:0表示成功,HP_SOCKET_E_WOULDBLOCK表示需要等会继续发送,HP_SOCKET_E_ATLEAST_ONE_BYTE表示发送了至少一个字节的数据,也需要继续发送,其他为错误码,表示连接已不可用。