포스트

[네트워크] 논블로킹 소켓 사용하기(Select, Poll)

이 포스트는 “게임 서버 프로그래밍 교과서”를 참고하여 작성된 포스트입니다.


논블로킹 소켓이란 이름 그대로 블로킹하지 않는 소켓입니다.
이번 포스트에서는 논블로킹 소켓은 무엇이고 왜 필요한지에 대해 작성해 보겠습니다.

블로킹 소켓에 대한 내용은 다른 포스트에서 다룹니다.


막힘없는 소켓 통신

송신자와 수신자가 각각 1명뿐인 일대일 네트워킹 프로그램을 개발할 떄는 블로킹 소켓을 사용해도 문제 없습니다.
그러나 네트워킹의 대상이 늘어나 1만명 이상일 경우에도 블로킹 소켓을 사용한다면 어떻게 해야할까요?

가장 쉽게 할 수 있는 방법은 네트워킹 대상의 수만큼 스레드를 생성하는 것입니다.
그리고 각 스레드에서 네트워킹 대상과 각자 통신을 수행하면 됩니다.

이 방법은 네트워킹 대상 수가 적을 때는 상관 없지만 대상의 수가 수백, 수천 개 이상으로 증가한다면
각 스레드의 저장공간은 물론 스레드간 문맥전환으로 자원 낭비가 심각해질 수 있습니다.
또한 송신 버퍼, 수신 버퍼의 상태에 따라 언제 블로킹될지 모르기 때문에 문제가 될 수 있습니다.

따라서 이 문제를 개선한 방법 중 하나가 논블로킹 소켓(Non-blocking socket)입니다.
논블로킹 소켓은 크게 아래와 같은 방법으로 사용할 수 있습니다.

  1. 생성한 소켓을 논블로킹 소켓 모드로 전환
  2. 블로킹 소켓처럼 연결, 송신, 수신을 진행
  3. 논블로킹 함수는 연결, 송신, 수신 함수 호출에 대해 즉시 ‘성공’ 혹은 ‘would block’ 반환

여기서 would block 이라는 것은
“블로킹이 걸려야 할 상황이지만, 블로킹을 걸지 않았다.” 라는 의미입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
s = socket(TCP);
// ...
s.connect(...);
s.SetNonBlocking(true);

while(true) {
    ret = s.send(data);
    if(ret == EWOULDBLOCK) {
        // 블로킹이 걸려야할 상황, data를 송신하지 않음
        continue;
    } 
    
    if(ret == OK) {
        // 전송 성공
    } else {
        // 전송 실패, 오류 처리
    }

    {
        // ...
    }
}

이 의사 코드는 논블로킹 소켓을 사용해 데이터를 전송하는 코드입니다.

논블로킹 소켓을 사용했기 떄문에 s.send(data)를 호출하면 블로킹 없이 즉시 값이 반환됩니다.
여기서 값은 전송의 성공 여부를 담고 있습니다.

반환값(ret)이 would block(EWOULDBLOCK)인 경우, 송신을 하지 않은 상태로 다시 송신 함수를 호출해야 합니다.
송신이 성공적으로 됬다면 반환값(ret)은 송신한 데이터(data)의 크기를 반환합니다.
그 외에 would block도 송신한 데이터의 크기도 아니라면 또 다른 문제가 발생한 것으로
이에 대한 오류 처리가 필요합니다.


논블로킹 소켓으로 여러 소켓 다루기

블로킹 소켓 여러 개를 한 스레드에서 다룰 경우,
현재 접근중인 소켓에 블로킹 되어있으면 다른 소켓이 수신 혹은 송신을 완료해도
현재 접근중인 소켓의 작업이 완료될 때 까지 강제로 대기해야 합니다.
그로 인해서 상대의 프로그램도 같이 대기해야하는 최악의 상황까지 갈 수 있습니다.

그러나 논블로킹 소켓을 사용하면 한 스레드로 여러 소켓을 한 번에 다룰 수 있습니다.
블로킹 소켓과 다르게 송신, 수신 함수를 호출해도 즉시 반환되기 때문에
지연 시간 없이 비교적 빠르게 여러 소켓에 접근이 가능합니다.
반환값이 would block 이라도 나중에 다시 함수를 호출하면 되기 때문입니다.

1
2
3
4
5
6
7
8
9
for(s : sockets) {
    (ret, data) = s.receive();
    if(data.length > 0) {
        // 정상 수신 완료
    }
    else if(ret != EWOULDBLOCK) {
        // 오류 처리
    }
}


Select(), Poll()을 이용한 코어 사용량 폭주 막기

그렇다면 한 프로그램에서 여러 논블로킹 소켓을 관리하고 있을 때,
각 소켓이 수신한 데이터가 있는지 확인하기 위해서는 어떻게 해야 할까요?

가장 쉬운 구현 방법은 소켓들을 순회하며 수신 함수를 실행해 보는 것 입니다.
아래의 의사 코드는 이를 표현한 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
array<Socket, 100> sockets;

while(true) {
    for(auto socket : sockets) {
        auto result = socket.recv(data);
            
        if(result.length > 0) {
            // 데이터 수신 성공
        }
        else if(result != EWOULDBLOCK) {
            // 'would block' 외 오류 발생으로 오류 처리 필요.
        }
    }
}


이 코드를 실행한다면 데이터는 받을 수 있어도
실시간 처리가 중요한 게임 서버에서 사용하기에는 적합하지 않을 수 있습니다.

소켓이 ‘would block’인 상태여도 순회를 무한히 반복하기 때문에
CPU 코어 하나를 쉬지 못하는 바쁜 상태로 만듭니다.
즉, 이 반복문으로 CPU 코어 하나가 100% 사용량을 차지하게 됩니다.

코어 하나를 계속 독점하면 다른 프로세스 혹은 스레드는 코어 하나를 사용할 수 없기 때문에
그만큼 서버의 성능이 떨어질 수 있습니다.

정리하면 아래와 같은 방법이 필요합니다.

  • 여러 소켓 중 하나라도 ‘would block’ 상태에서 변화가 일어나면 그 상황을 알려주는 기능
  • 위의 변화가 일어나기 전까지는 블로킹으로 CPU 사용량 폭주를 막는 기능

위 방법을 제공하는 함수가 바로 Select() 혹은 Poll() 함수 입니다.
이 함수의 동작은 아래와 같습니다.

  1. 여러 소켓을 저장한 컨테이너 A를 입력합니다.
  2. A에 있는 소켓 중 하나라도 I/O 처리를 할 수 있는 소켓이 생기는 순간까지 블로킹
  3. I/O 처리가 가능한 소켓이 생겨 블로킹이 끝나면 어떤 소켓이 I/O 처리가 가능한지 알려 줍니다.
  4. 2번 과정에서 블로킹에 제한시간을 지정할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
while(true){
    // 최대 100ms까지 블로킹
    select(sockets, 100ms);

    for(auto socket : sockets) {
        result = s.receive();
        if(result.length > 0){
            // 수신 성공
        } 
        else if (result != EWOULDBLOCK){
            // 소켓 오류 처리
        }
    }
}

코드의 시작인 select(sockets, 100ms) 함수는
sockets 에 있는 소켓 중 하나라도 I/O 처리가 가능하면 반환하고
가능한 소켓이 없으면 최대 100ms까지 블로킹한 뒤 반환할 예정입니다.
만약, 100ms 이전에 I/O 가능이 된 소켓이 있다면 바로 반환하겠다는 의미입니다.


select() 함수는 fd_set 이라는 정적 배열을 사용하는데
이 배열의 크기가 \(2^10 = 1024\) 로 소켓의 최대 개수로 1024개까지만 가능합니다.

개수 제한을 개선한 함수가 바로 Poll() 함수입니다.
이 함수는 정적 배열을 사용하는 대신에 pollfd 라는 구조체를 동적으로 할당함으로서
Poll() 함수보다 더 많은 소켓을 관리할 수 있습니다.

그러나 Select(), Poll() 함수 모두 반복문을 사용한 전체 순회를 사용하기 때문에
게임 서버와 같은 실시간 처리가 매우 중요한 서버에서는 적합하지 않을 수 있습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.