일대일 네트워크 프로그램을 개발한다면 앞서 이야기했던 블로킹 소켓만 활용해도 별 문제가 없습니다. 하지만 네트워킹 대상이 많아진다면 블로킹 소켓으로는 원활한 통신을 할 수가 없을 것입니다.
가장 먼저 떠오르는 방법은 네트워킹 대상 개수만큼 스레드를 만드는 것입니다. 하지만 결국 네트워킹 대상의 개수가 수백수천 개라면 스레드가 차지하는 호출 스택, 스레드 간의 컨텍스트 스위치 등 엄청난 자원 낭비로 이어질 것입니다. 따라서, 이는 근본적인 해결책은 될 수 없을 것 같습니다.
이를 해결하기 위해 '논블록 소켓'이라는 개념이 등장합니다.
대부분의 운영체제에서는 소켓 함수가 블로킹되지 않게 하는 API를 제공합니다. 이를 논블록 소켓이라고 합니다.
- 소켓을 논블록 소켓으로 모드 전환
- 논블록 소켓에 대해 송신, 수신, 연결과 관련된 함수를 호출
- 논블록 소켓은 함수 호출에 대해 즉시 리턴. '성공' 혹은 'would block' 오류 둘 중에 하나.
여기서 'would block'이란 블로킹이 걸렸어야 할 상황인데 운 좋게 걸리지 않았다라는 의미입니다.
논블록 소켓으로 모드를 바꾼 후 송신 함수는 언제나 즉시 리턴합니다. 이 때, 리턴 값은 크게 세 종류로 볼 수 있습니다.
또한, 논블록 소켓을 이용하면 한 스레드에서 여러 개의 소켓을 활용할 수 있습니다. 반복문을 통해 소켓 100개에 대해 수신 함수를 호출한다고 생각해봅시다. 어떤 소켓은 수신할 데이터를 이미 받아 놓았을 수 있겠지만 아닌 경우도 많겠죠. 그렇게 되면 블로킹이 수없이 걸리게 될 것입니다. 이는 논블록 소켓을 활용함으로써 해결할 수 있습니다.
수신할 데이터가 있다면 그냥 꺼내서 처리합니다. 만약 없다면, would block 오류 코드를 리턴할 뿐입니다. 이 때에는 다시 논블록 수신 함수를 호출하면 됩니다.
TCP 접속 역할을 하는 연결 함수를 논블록으로 사용할 때에는 조금 다릅니다. 송수신 함수가 would block을 리턴할 때에는 소켓 안에서 아무 일도 일어나지 않습니다.
하지만 논블로킹 연결 함수가 would block을 리턴하였다면, 이미 소켓 안에서는 어떤 일이 일어난 후입니다. 소켓은 상대방의 TCP 연결 endpoint로 연결을 시도하다가 would block이 된 것이므로 '연결 과정이 진행중인 상태'가 됩니다. 이 때, 다시 연결을 시도하는 것은 찝찝할 수 있습니다. 안 되는 것은 아니지만 이런 상황에 리턴값은 운영체제마다 조금 다르다고 합니다.
하지만 논블로킹 연결 함수가 would block을 리턴하였을 때에는 연결이 되었는지 확인하는 과정을 거치는 것이 좋습니다. 이 때, '0바이트 송신'이라는 요령이 있습니다. TCP는 스트림 기반 프로토콜이기 때문에 0바이트를 보내는 것은 사실상 아무것도 하지 않는 것과 같습니다.
다만, 이를 코드로 구현하고 실제 구동하면 또 문제가 하나 발생합니다. 해당 스레드는 소켓이 would block인 상태에서는 앞서 말한 '0바이트 송신'과 TCP 연결을 확인하는 과정을 계속 반복합니다. 심지어 이 반복은 CPU 코어 하나의 사용량을 100%로 만듭니다.
게임 클라이언트를 만들 때에는 크게 상관없습니다. 어차피 CPU가 쉬지 않고, 게임 업데이트와 렌더링을 담당하는 메인 스레드가 있고, 그 메인 스레드에서 이 과정도 같이 수행하면 되기 때문입니다. 하지만 서버는 상황이 다릅니다. 서버는 이러한 기본 작업을 수행하지 않기 때문에 특별한 작업이 없는 한 서버의 CPU는 최대한 놀고 있는 상태여야 합니다.
논블로킹 모드를 활용하다보면 앞서 말한 CPU 사용량이 폭주하는 문제들이 종종 발생하곤 합니다. 너무 코드없이 설명하고 있는 것 같아 예시를 하나 들어보도록 하겠습니다.
List<Socket> sockets;
void NonBlockSocketOperation()
{
while(true)
{
foreach(s in sockets)
{
// 1 - Nonblock reception
(result, data) = s.receive();
if (data.length > 0)
{
print(data);
}
else if (result != EWOULDBLOCK)
{
// Socket Error
}
}
// 2
}
}
이 코드의 2에서는 CPU 사용량 폭주가 발생할 것입니다. 앞서 연결에서 설명했던 것처럼 소켓이 would block인 상태이거나 수신할 데이터가 없다면 계속 반복문을 돌게 되기 때문입니다.
이러한 기능을 제공하는 함수가 있다면 문제를 해결할 수 있을 것입니다. 다행히 select() 혹은 poll()이라는 함수가 있다고 합니다. 이 두 함수는 다음과 같은 작업을 수행합니다.
- 소켓 리스트를 입력
- 리스트에 있는 소켓 중에 하나라도 입출력 처리를 할 수 있는 것이 생길 때까지 블로킹
- 블로킹이 끝나면 어떤 소켓이 처리가 가능한지 통보
- 블로킹은 타임아웃 지정 가능. 무한대로 지정하여 처리가 가능할 때까지 기다리거나 0초로 지정하여 블로킹없이 리턴하는 것도 가능.
List<Socket> sockets;
void NonBlockSocketOperation()
{
while(true)
{
// 1
select(sockets, 100ms);
foreach(s in sockets)
{
// 2 - Nonblock reception
(result, data) = s.receive();
if (data.length > 0)
{
print(data);
}
else if (result != EWOULDBLOCK)
{
// Socket Error
}
}
}
}
1에서 sockets에 입출력 처리가 가능한 소켓이 하나라도 있다면 즉시 리턴합니다. 없다면 100밀리초까지 블로킹합니다. 그 안에 조건을 만족해도 즉시 리턴합니다.
입출력 처리가 가능하다는 것은 would block이 아닌 다른 결과가 나온다는 것입니다. 이러한 상태를 I/O 가능 이벤트가 왔단 또는 I/O 가능이라고 합니다. 송신 버퍼에 1바이트라도 공간이 남았거나 수신 버퍼에 1바이트라도 있다면 I/O 가능입니다.
select 함수가 리턴한 후에는 sockets의 각 소켓에 대한 논블록 I/O 처리 함수를 호춯하면 됩니다. 최소한 하나는 would block 아닌 다른 결과가 나올 것입니다.
마지막으로 논블록 accept 처리에 대해 알아보겠습니다. 블로킹 소켓의 경우 리스닝 소켓에 대해 accept()를 호출하면 블로킹이 걸립니다. TCP 연결이 들어오면 새 TCP 연결에 대한 소켓 핸들을 리턴합니다.
논블로킹 소켓의 경우 TCP 연결이 아직 들어오지 않았다면 would block 오류 코드를 리턴합니다. select()를 활용해서 리스닝 소켓에서 I/O 가능 이벤트가 감지되면 accept()를 호출하면 됩니다. 그러면 새 TCP 연결에 대한 소켓 핸들을 얻을 수 있습니다.