概述

        在网络编程中,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表示发送了至少一个字节的数据,也需要继续发送,其他为错误码,表示连接已不可用。

11-01 11:43