KoreanFoodie's Study

[C++ 게임 서버] 3-7. 논블로킹 소켓 본문

Game Dev/Game Server

[C++ 게임 서버] 3-7. 논블로킹 소켓

GoldGiver 2023. 9. 25. 11:07

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 3-7. 논블로킹 소켓

핵심 :

1. 논블로킹 소켓은, 블로킹 소켓과 다르게 연결 및 송수신이 일시적으로 불가능한 경우, SpinLock 처럼 while 문을 돌며 다시 시도를 한다.

2. 논블로킹 소켓을 만드는 것은 간단하다. ioctlsocket 을 사용하면 된다.

3. 논블로킹 소켓 사용시, WSAEWOULDBLOCK 에러가 나올 경우, 다시 시도한다. 이는 송수신 버퍼의 상태가 비어 있거나 가득 차 있어 송수신이 불가능한 임시적인 상태이기 때문이다.

우리가 이전 글에서 활용했던 것은 소켓은 블로킹 소켓이다. 따로 옵션을 설정한 것 같지는 않은데? 그럼에도 불구하고, 우리가 만든 소켓이 기본적으로 블로킹 소켓으로 잡힌 것인데.. 논블로킹 소켓을 이야기하기 전에 먼저 블로킹 소켓에 대해 이야기해야 한다.

사실 핵심은 간단하다. 클라든 서버든, 데이터를 주고 받을 수 없는 상황(연결이 정상적으로 되지 않았다거나, 송수신 버퍼가 가득 찼다거나)이 되면, 해당 소켓은 '블록' 된다. 뭐... 소켓을 단말기라고 가정하면, 데이터가 끊겼을 때 웹 페이지가 먹통이 되었다가, 데이터 연결이 되면 다시 로드 되는 방식이라고 생각해도 된다.

 

그런데 논블로킹 소켓은 조금, 아주 약간 다르다. 즉, 데이터를 주고 받을 수 없는 상황에서도 보내려는 시도를 계속 한다. 그게 논블로킹 소켓이다. 이름처럼, 작은 논블로킹 소켓은 아무도 막을 수 없다. 🤣

 

기존의 블로킹 소켓을 논블로킹 소켓처럼 사용하려면, 사실 아래 옵션 정도만 주면 된다(GameServer.cpp 에서 정의) :

// listenSocket 생성. 기존과 동일하다
SOCKET listenSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (listenSocket == INVALID_SOCKET)
    return 0;

// 논블로킹 옵션을 준다
u_long on = 1;
if (::ioctlsocket(listenSocket, FIONBIO, &on) == INVALID_SOCKET)
    return 0;

::ioctlsocket 이 바로 그것인데, MS 문서에는 다음과 같이 나와 있다 :

 

즉, 기존에는 accept 를 위해서는 접속한 클라가 있어야 했고, connect 를 위해서는 접속이 성공해야 했으며, send/sendto 를 위해서는 요청한 데이터가 송신 버퍼에 복사되어야 했고, recv/recvfrom 이 되려면 수신 버퍼에 도착한 데이터가 있고, 그것이 유저 레벨 버퍼에 복사되어야 했다.

하지만 논블로킹 소켓은 just doesn't care. 그냥 될 때까지 계속 시도를 한다. 서버쪽 코드를 보자.

일단 server 주소를 만들고, bind 및 listen 을 하는 부분까지는 동일하다.

SOCKADDR_IN serverAddr;
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = ::htonl(INADDR_ANY);
serverAddr.sin_port = ::htons(7777);

if (::bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
    return 0;

if (::listen(listenSocket, SOMAXCONN) == SOCKET_ERROR)
    return 0;

cout << "Accept" << endl;

SOCKADDR_IN clientAddr;
int32 addrLen = sizeof(clientAddr);

 

그럼 이제 connect, recv, send 를 하는 부분을 보자.

// Accept
while (true)
{
    SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
    if (clientSocket == INVALID_SOCKET)
    {
        // 원래 블록했어야 했는데... 너가 논블로킹으로 하라며?
        if (::WSAGetLastError() == WSAEWOULDBLOCK)
            continue;

        // Error
        break;
    }

    cout << "Client Connected!" << endl;

    // Recv
    while (true)
    {
        char recvBuffer[1000];
        int32 recvLen = ::recv(clientSocket, recvBuffer, sizeof(recvBuffer), 0);
        if (recvLen == SOCKET_ERROR)
        {
            // 원래 블록했어야 했는데... 너가 논블로킹으로 하라며?
            if (::WSAGetLastError() == WSAEWOULDBLOCK)
                continue;

            // Error
            break;
        }
        else if (recvLen == 0)
        {
            // 연결 끊김
            break;
        }

        cout << "Recv Data Len = " << recvLen << endl;

        // Send
        while (true)
        {
            if (::send(clientSocket, recvBuffer, recvLen, 0) == SOCKET_ERROR)
            {
                // 원래 블록했어야 했는데... 너가 논블로킹으로 하라며?
                if (::WSAGetLastError() == WSAEWOULDBLOCK)
                    continue;
                // Error
                break;
            }

            cout << "Send Data ! Len = " << recvLen << endl;
            break;
        }
    }
}

음.. 뭔가 while 문이 많은 것 같은데, 사실 핵심은 이 부분이다 :

// 원래 블록했어야 했는데... 너가 논블로킹으로 하라며?
if (::WSAGetLastError() == WSAEWOULDBLOCK)
    continue;

사실 이전까지는 socket 이 INVALID_SOCKET 이거나, recvLen 이 SOCKET_ERROR 거나, send 가 SOCKET_ERROR 면 바로 return 을 해줬었다. 블로킹 소켓의 경우에는.

그런데 논블로킹 소켓의 경우, 작은 논블로킹 소켓을 건드리면 안되기 때문에, 작은 논블로킹 소켓은 될때까지 계속 츄라이를 한다. 그러다 보니 에러가 WSAEWOULDBLOCK 일 경우에는, 계속 시도를 한다. 참고로 WSAEWOULDBLOCK 이란...

단순히 소켓 버퍼에 데이터가 없거나 가득 차 있는 일시적인 상황을 의미한다.

따라서 각 단계마다 while loop 이 추가되어 각 단계를 계속 시도하는 것이다.

 

참고로 클라이언트 쪽도 별반 다르지 않다. 각각 Connect, Send/Recv 부분으로 쪼개 보자.

먼저 Connect 쪽은 다음과 같다.

// Connect
while (true)
{
    if (::connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
    {
        // 원래 블록했어야 했는데... 너가 논블로킹으로 하라며?
        if (::WSAGetLastError() == WSAEWOULDBLOCK)
            continue;
        // 이미 연결된 상태라면 break
        if (::WSAGetLastError() == WSAEISCONN)
            break;
        // Error
        break;
    }
}

cout << "Connected to Server!" << endl;

위 코드처럼, 연결될때까지 시도를 한다.

송수신 쪽은 다음과 같다 :

char sendBuffer[100] = "Hello World";

// Send
while (true)
{
    if (::send(clientSocket, sendBuffer, sizeof(sendBuffer), 0) == SOCKET_ERROR)
    {
        // 원래 블록했어야 했는데... 너가 논블로킹으로 하라며?
        if (::WSAGetLastError() == WSAEWOULDBLOCK)
            continue;
        // Error
        break;
    }

    cout << "Send Data ! Len = " << sizeof(sendBuffer) << endl;

    // Recv
    while (true)
    {
        char recvBuffer[1000];
        int32 recvLen = ::recv(clientSocket, recvBuffer, sizeof(recvBuffer), 0);
        if (recvLen == SOCKET_ERROR)
        {
            // 원래 블록했어야 했는데... 너가 논블로킹으로 하라며?
            if (::WSAGetLastError() == WSAEWOULDBLOCK)
                continue;

            // Error
            break;
        }
        else if (recvLen == 0)
        {
            // 연결 끊김
            break;
        }

        cout << "Recv Data Len = " << recvLen << endl;
        break;
    }

    this_thread::sleep_for(1s);
}

1초 간격으로 송수신을 시도하는 것을 알 수 있다 😁

Comments