[Network] Blocking & Non-Blocking I/O Concept

녹차·2026년 1월 7일

Network

목록 보기
7/15
post-thumbnail

Introduction

C++ 게임 서버를 개발하기 위해서는 소켓 프로그래밍(Socket Programming)에 대한 이해가 필수적입니다. 그리고 소켓 프로그래밍을 제대로 다루기 위해서는, 그 기반이 되는 네트워크(Network)와 운영체제(OS)에 대한 기본적인 지식이 선행되어야 합니다.

두번 째 글의 목표는 Socket Model 중 IOCP에 대한 정확한 이해입니다. IOCP는 GameServer에 있어서 중요한 역할을 하기에 정확한 이해를 위해 사전지식으로 Socket Mode를 알 필요성이 있습니다.

논외로 글을 원활하게 이해하기 위해서는 TCP / UDP의 기본 개념과 주요 Socket Option에 대한 사전 지식이 필요합니다. 해당 내용이 아직 익숙하지 않다면, 먼저 제가 작성한 Network 관련 글을 참고하신 후 읽어주시기 바랍니다.

(이 글은 여러 기술 문서와 교재 등 다양한 자료를 참고하였습니다)

Body

Socket은 Socket Function 호출 시 동작하는 방식에 따라 Blocking과 Non-Blocking Socket으로 구분합니다. 이를 Socket Mode라고 부르며, 각각의 특징에 대해 정리해볼려 합니다.

What is "Blocking"

Blocking이란? 프로세스나 스레드가 어떤 이벤트를 기다리며 실행이 중단되는 상태를 말합니다. 아래는 참고한 내용에 따른 원문입니다.

Blcoking Concept(위키백과st)
In computing, a process (that is blocked) is waiting for some event, such as a resource becoming available or the completion of an I/O operation.

Blocking Concept(게임 서버 프로그래밍 교과서st)
디바이스에 처리 요청을 걸어 놓고 응답을 대기하는 함수를 호출할 때 스레드에서 발생하는 대기 현상을 Blocking이라고 합니다. Socket뿐만 아니라 file handle에 대한 함수를 호출했을 때도 이렇나 대기 현상이 발생하는 것을 모두 Blocking이라고 합니다.

즉, Blocking Socket이란? 소켓 함수 호출 시 조건을 만족하지 않으면 함수가 리턴하지 않고 스레드 실행을 정지하는 것을 말합니다. 만일 조건을 만족하면 소켓 함수를 리턴하고 정지된 스레드가 실행을 재개합니다.

주요 소켓 함수에 따른 조건입니다.

참고로 지금까지 TCP, UDP Server & Client 모두 accept(), connect(), send() & sendto() 등등 모두 Blocking Socket + Sync 방식으로 작성했습니다.

Q1. 그렇다면 무엇이 문제이길래 Non-Blocking을 배워요?

MMORPG 게임 도중 아이템을 획득하는 순간 캐릭터가 움직이지 않는다면, 플레이어 입장에서는 상당히 불편하게 느껴질 것입니다. 이러한 현상은 서버가 블로킹 I/O 구조로 동작할 경우 발생할 수 있습니다.

예를 들어, 특정 클라이언트의 아이템 획득 패킷을 처리하기 위해 서버가 recv()와 같은 블로킹 소켓 함수에서 대기 상태에 들어가면, 해당 스레드는 패킷 수신이 완료될 때까지 다른 게임 로직을 수행하지 못하게 됩니다.

만약 이 스레드가 캐릭터 이동이나 전투 판정과 같은 핵심 로직을 함께 담당하고 있다면, 아이템 처리 중에는 캐릭터가 일시적으로 멈춘 것처럼 보이게 됩니다.

이를 해결하기 위해 클라이언트마다 스레드를 생성하는 방식도 생각해볼 수 있지만, 수많은 동시 접속자를 처리해야 하는 MMORPG 서버에서 이는 스레드 생성 비용자원 소모 측면에서 현실적인 해결책이 되기 어렵습니다.

이러한 문제로 인해 운영체제는 소켓 함수가 스레드를 멈추지 않도록 하는 논블로킹(Non-blocking) I/O비동기 I/O를 위한 API를 제공하게 되었습니다.

Aync & Sync I/O에대한 자세한 내용은 아래 링크를 참고해주시면 됩니다.

What is "Non-Blocking"

Non-Blocking Socket이란? 소켓 함수 호출 시 조건을 만족하지 않더라도 함수가 즉시 리턴하여 스레드가 중단 없이 다음 코드를 수행하는 것을 말합니다. 예를 들어 접속한 클라이언트가 없어도 accept() 함수가 리턴하고, 소켓 수신 버퍼에 도착한 데이터 없어도 recv() 함수가 리턴합니다.

Non-Blocking Socket에 대해 소켓 함수 호출 시 리턴 값은 '성공' 혹은 'would block'입니다.

would block이란? 오류가 아니라 현재 시점에서는 해당 I/O 작업을 처리할 수 없음을 알리는 정상적인 상태 값입니다. 쉅자 표현하면, "Blocking 걸렸어야 할 상황인데 말이지, 어쨌건 자네는 운이 좋아. Blocking 안걸렸잖아?"라는 의미입니다.

Non-Blocking Code

NonBlockingTCPServer.cpp
생략...

SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, 0);

u_long on = 1;
retval = ioctlsocket(listen_sock, FIONBIO, &on);
if (retval == SOCKET_ERROR)
{
	//TODD : 애러구현
}

ACCEPT_AGAIN: //C & C++ label
addrlen = sizeof(clientaddr);
client_sock = accept(listen_sock, (struct sockaddr*)&clientaddr, &addrlen);

if (client_sock == INVALID_SOCKET)
{
	if (WSAGetLastError() == WSAEWOULDBLOCK)
		goto ACCEPT_AGAIN;

	//TODD : 애러구현
	break;
}


생략...

while (true)
{
RECEIVE_AGAIN: //C & C++ label
	retval = recv(client_sock, buf, BUFSIZE, 0);

	if (retval == SOCKET_ERROR)
	{
		if (WSAGetLastError() == WSAEWOULDBLOCK)
			goto RECEIVE_AGAIN;

		//TODD : 애러구현
		break;
	}
	else if (retval == 0)
		break;
	
	//받은 데이터 출력
	buf[retval] = '\0'; //문자열 종료 문자 추가
	cout << "[TCP/" << addr << ":" << ntohs(clientaddr.sin_port) << "] " << buf;

SEND_AGAIN: //C & C++ label
	retval = send(client_sock, buf, retval, 0);
	if (retval == SOCKET_ERROR)
	{
		if (WSAGetLastError() == WSAEWOULDBLOCK)
			goto SEND_AGAIN;

		//TODD : 애러구현
		break;
	}
	else if (retval == 0)
		break;

	closesocket(client_sock);
	cout << "[TCP Server] Client disconnected : IP주소 = " << addr << ", 포트 번호 = " << ntohs(clientaddr.sin_port) << endl;
}

socket() 함수는 기본적으로 Blocking Socket을 생성합니다. Non-Blocking Socket이 필요하면 아래와 같이 작성해야 합니다.

retval = ioctlsocket(listen_sock, FIONBIO, &on);
  • FIONBIO : File I/O Non-Blocking I/O로 말 그대로 Non-Blocking I/O를 사용하기 위한 가장 기본적인 설정입니다.

먼저, 기존 TCPServer.cpp의 accept(), send(), recv() 함수 오류처리 부분에 변화가 있음을 알 수 있습니다. 하지만 여기서 다뤄보고 싶은 부분은 TCPClient.cpp의 connect() 함수입니다.

송수신 함수를 Non-Blocking 모드로 호출했을 때 would block가 반환되면, 해당 시점에서는 소켓 내부 상태에 아무런 변화도 발생하지 않습니다. 단순히 “지금 당장 처리할 수 있는 데이터가 없다”는 의미일 뿐입니다.

하지만 connect() 함수는 다릅니다. Non-Blocking 모드에서 connect()를 호출했을 때 would block가 반환되었다면, 이는 연결 시도가 이미 시작되었음을 의미합니다.

즉, 소켓은 이미 상대방의 TCP 연결 끝점으로 연결을 시도하고 있으며, 이 과정이 즉시 완료되지 않아 would block가 반환된 것입니다. 이 시점에서 소켓은 ‘연결 진행 중(in-progress)’ 상태로 전이됩니다.

이러한 상태 변화 이후에 connect()를 다시 호출하는 것은 바람직하지 않습니다. 이미 진행 중인 연결 과정에 대해 중복으로 연결을 요청하는 셈이 되기 때문입니다.

따라서 Non-Blocking connect()would block를 반환한 이후에는, 연결이 완료되었는지를 다른 방법으로 확인하는 것이 좋습니다. 그 중 하나로 흔히 사용되는 요령이 바로 0바이트 송신(Zero-byte send) 입니다.

TCP는 스트림 기반 프로토콜이므로, 0바이트를 전송하는 것은 실제로 데이터를 보내지 않으면서도 소켓의 현재 연결 상태를 확인할 수 있는 수단이 됩니다. 아래는 TCPClient.cpp의 조각코드입니다.

0바이트 송신 시 반환 값에 따른 의미는 다음과 같습니다.

TCPClient.cpp

생략...

retval = connect(sock, (struct sockaddr*)&serveraddr, sizeof(serveraddr));

if (retval == SOCKET_ERROR)
{
    if (WSAGetLastError() == WSAEWOULDBLOCK)
    {
        while (true) //예제 단순화를 위한 루프
        {
            int ret = send(sock, nullptr, 0, 0);

            if (ret >= 0)
            {
                // TCP 연결이 완료된 상태입니다.
                break;
            }
            else
            {
                if (WSAGetLastError() == WSAENOTCONN)
                {
                    // 아직 TCP 연결 과정 중입니다.
                    continue;
                }
                else
                {
                    // TCP 연결 실패
                    break;
                }
            }
        }
    }
}

위 방식은 0바이트 송신 요령을 설명하기 위한 예제로, 구현 단순화를 위해 무한 루프 형태로 작성되었습니다.

그러나 이러한 방식은 Busy Waiting으로 인한 CPU 자원 낭비를 유발하므로 이를 피하기 위해 일반적으로 select와 같은 I/O 이벤트 기반 모델을 사용하며, Windows 환경의 고성능 서버에서는 IOCP(I/O Completion Port) 기반 비동기 I/O 모델을 사용합니다.

Select, IOCP 모델에 대한 자세한 내용은 아래 링크를 참고해주시면 됩니다.

Non-Blocking Code's Result

코드를 실행한 상태에서 작업 관리자를 확인해 보면, CPU 사용률이 상당히 높게 나타날 것입니다. 싱글 코어 CPU 환경에서는 하나의 코어를 거의 100% 점유하는 모습이 관찰될 수 있으며, 멀티 코어 환경에서도 특정 코어가 지속적으로 사용되는 것을 확인할 수 있습니다.

이는 접속한 클라이언트가 없음에도 불구하고, Non-Blocking 모드의 소켓 함수가 대기하지 않고 즉시 리턴하면서 루프가 계속해서 반복 실행되기 때문입니다.

Q1. 그러면 CPU가 계속 낭비되는 건가요?

결론부터 말하면 그렇습니다. 이러한 방식은 Busy Waiting으로 인해 CPU 자원을 불필요하게 소모합니다.

다만 게임 클라이언트 프로그램(Unity, Unreal)의 경우, 이미 매 프레임마다 실행되는 메인 루프가 존재하며 게임 로직과 렌더링을 위해 CPU가 지속적으로 사용되기 때문에 이 문제가 상대적으로 크게 드러나지 않을 수 있습니다.

반면 서버 프로그램은 다릅니다. 서버는 렌더링 루프가 없으며, 특별한 이벤트가 발생하지 않는 동안에는 CPU를 최대한 사용하지 않고 대기 상태에 머무는 것이 이상적인 구조입니다.

이러한 이유로 서버 구현에서는 무한 루프 기반의 Non-Blocking 처리 대신, 소켓이 준비되었을 때만 CPU를 사용하도록 해주는 이벤트 기반 I/O 모델(select, epoll, IOCP 등)을 사용합니다.

Conclusion

이번 글에서는 소켓 프로그래밍에서 가장 기본이 되는 Blocking / Non-Blocking Socket의 개념과, Non-Blocking 환경에서 발생하는 would block의 의미, 그리고 그로 인해 발생할 수 있는 Busy Waiting 문제를 살펴보았습니다.

Non-Blocking 소켓은 스레드를 멈추지 않는다는 점에서 분명 장점이 있지만, 이를 단순한 반복 호출 방식으로 구현할 경우 CPU 자원을 과도하게 소모하게 되며, 특히 서버 프로그램에서는 이러한 구조가 심각한 성능 저하로 이어질 수 있음을 확인했습니다.

다음 글에서는 Non-Blocking Sokcet에서 발생하는 불필요한 CPU낭비(Busy Waiting)를 없애기 위해 Select 모델을 다루겠습니다.

Q1. 바로 IODP Model을 배우고싶은데요...

충분히 좋은 열정입니다. 다만 IOCP 모델은 select와 같은 이벤트 기반 I/O 모델의 개념을 커널 레벨로 확장한 구조이기 때문에, 먼저 Select 모델을 이해하고 넘어가는 것이 IOCP의 동작 과정을 훨씬 수월하게 이해하는 데 도움이 됩니다.

참고문헌

[위키백과]
https://en.wikipedia.org/wiki/Blocking_%28computing%29?utm_source=chatgpt.com

[교보문구]
https://product.kyobobook.co.kr/detail/S000061352062
https://product.kyobobook.co.kr/detail/S000001792817

profile
CK23 Game programmer

0개의 댓글