온라인 게임 프로그래밍에서 소켓은 파일 핸들 방식과는 다르다.
1. 클라이언트 개수만큼 소켓이 있어야 한다.
2. 파일 처리를 하는동안 스레드가 대기하는 일이 없어야 한다.
이러한 이유 때문에 소켓은 보통 비동기 입출력 상태로 다룬다. 소켓을 비동기 입출력으로 다루는 방식에는 크게 두 가지 방식이 있다.
1. 논블로킹 소켓
2. Overlapped I/O 방식
발전된 버전
3. epoll
4. I/O Completion Port (IOCP)
3.1 블로킹 소켓
스레드에서 네트워크 수신을 하는 함수를 호출하면, 수신할 수 있는 데이터가 생길 때까지 스레드는 waitable 상태 (블로킹) 가 된다.
3.3 블로킹과 소켓 버퍼
소켓 각각은 송신 버퍼와 수신 버퍼를 하나씩 가지고 있다.
TCP 송신
소켓.send(data) 함수는 블로킹 없이 바로 리턴된다. (송신 버퍼가 가득 찬 경우가 아니라면)
TCP 수신
수신 버퍼가 가득 차면 사용자는 소켓에서 데이터를 수신하는 함수를 호출하여 수신된 데이터를 꺼낼 수 있다.
수신 버퍼가 완전히 비어 있으면 블로킹이 일어난다.
3.5 수신 버퍼가 가득 차면 발생하는 현상
TCP 수신 함수인 recv()는 1바이트라도 수신할 수 있으면 즉시 리턴한다.
수신 함수가 수신 버퍼에서 데이터를 꺼내는 속도가 운영체제가 수신 버퍼의 데이터를 채우는 속도보다 느리면 수신 버퍼가 꽉 차게 된다.
ㄴ 보내는 쪽의 송신 함수 send()가 블로킹된다. TCP 연결이 끊어지는 것은 아니다.
ㄴ 실제 송신 속도는 데이터를 느리게 처리하는 쪽에 맞추어진다.
ㄴ TCP는 초당 보내는 데이터양이 수신자가 초당 수신할 수 있는 데이터양보다 많을 때, 송신자 측 운영체제가 알아서 초당 송신량을 줄인다. (흐름 제어)
UDP의 경우
받는 쪽에서의 수신 함수는 수신 버퍼에 데이터그램이 하나라도 있으면 즉시 리턴한다.
수신 버퍼가 가득 차도 송신 함수에서 블로킹이 발생하지 않는다. (데이터그램은 버려진다.)
ㄴ 수신버퍼의 처리 속도가 느리면 데이터그램 유실이 발생한다.
ㄴ UDP에는 흐름 제어 기능이 없기 때문에 UDP를 속도 제한 없이 마구 송신하면 주변의 네트워킹이 경쟁이 밀려 혼잡 현상이 발생할 수 있다.
3.6 논블록 소켓
네트워킹을 해야하는 대상이 여럿인 경우에는 네트워킹 개수만큼 스레드를 만들고 각 스레드는 각각의 네트워킹 대상과 데이터를 주고받도록 하면 된다.
ㄴ 네트워크 대상이 많지 않을 때는 문제가 없다.
ㄴ 스레드 개수가 수백 개, 수천 개인 경우에는 컨텍스트 스위치 문제 때문에 자원 낭비로 이어진다.
논블록 소켓
ㄴ 송신 함수, 수신 함수에서 블로킹이 발생하지 않는다. (즉시 리턴한다, 리턴 값은 성공, 혹은 would block 둘 중에 하나)
ㄴ 대부분 운영체제에서 소켓 함수가 블로킹되지 않게 하는 API를 추가로 제공한다.
ㄴ 한 스레드에서 여러 소켓을 한꺼번에 다룰 수 있다.
ㄴ 플랫폼마다 다른 오류 처리 코드를 반환하기 때문에 잘 처리해야 한다.
논블록 소켓으로 연결을 시도하는 경우
ㄴ would block을 리턴한 경우 would block이 끝났는지 확인하기 위한 방법을 사용해야 한다.
ㄴ 클라이언트 입장에서는 게임 루프마다 길이가 0바이트인 데이터를 송신함수로 보내 성공 값을 받으면 연결이 성공한 것으로 간주하면 된다.
ㄴ 서버 입장에서는 스핀 락 방식으로 구현할 수 없다. poll 함수를 활용해야 한다. (CPU를 최대한 아껴야 하기 떄문에)
Poll 함수 (select 함수)
ㄴ 소켓 리스트와 블록 시간을 입력값으로 받는 함수
ㄴ 리스트 내 소켓중 하나라도 I/O 처리를 할 수 있게 되면 바로 리턴, 아니라면 지정된 타임아웃 시간 이후 리턴
논블록 accept
ㄴ 블로킹 모드인 경우 리스닝 소켓에 대해 accept()를 호출하면 accept()는 블로킹이 걸린다. 그리고 TCP 연결이 들어오면 리턴을 하는데, 이때 accept()의 리턴 값은 새 TCP 연결에 대한 소켓 핸들이다.
ㄴ 논블록 소켓을 사용한 I/O 함수는 연결이 안되었으면 블로킹 대신 would block 오류 코드를 주기 때문에 반복해서 재시도 호출을 해야 한다. Poll 함수를 사용하여 I/O 가능 이벤트가 감지될 때 accept()를 호출하도록 하여 CPU 사용량 폭주 문제를 해결해야 한다.
3.7 Overlapped I/O 혹은 비동기 I/O
논블록 소켓의 장점
ㄴ 스레드 블로킹이 없으므로 중도 취소 같은 통제가 가능하다.
ㄴ 스레드 개수가 1개여도 여러 소켓을 다룰 수 있다. (호출 스택 메모리 비용, 컨텍스트 스위치 비용이 작다)
논블록 소켓의 단점
ㄴ 소켓 I/O 함수가 리턴한 코드가 would block인 경우 재시도 호출 낭비가 발생한다.
ㄴ 소켓 I/O 함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산이 발생한다.
ㄴ connect() 함수는 재시도 호출을 하지 않지만, send() 함수나 receive() 함수는 재시도 호출해야 하는 API가 일관되지 않는다는 문제가 있다.
재시도 호출 낭비의 예시
UDP의 send()는 송신 버퍼가 1바이트라도 비어 있으면 I/O 가능인데 UDP는 데이터의 일부만 보낼 수 없으므로 넣을 수 있는 데이터 크기만큼의 공간이 비어있지 않다면 would block이 발생한다. I/O 가능인데 would block이므로 CPU로 낭비로 이어진다.
소켓 함수 내부의 데이터 복사 부하의 예시
ㄴ CPU 안에 있는 캐시 메모리에 내용이 복사되어 있으면 데이터 액세스는 매우 빠르지만, 캐시에 없는 데이터를 액세스할 때는 RAM을 액세스하는데 이것은 느리다.
Overlapped I/O는 이 문제들을 해결할 수 있다.
ㄴ Overlapped I/O 함수는 즉시 리턴되지만, 운영체제로 해당 I/O 실행이 별도로 동시간대에 진행되는 상태이기 때문에 I/O 함수가 비동기로 하는 일이 완료될 때까지 소켓 API에 인자로 넘긴 데이터 블록을 제거하거나 내용을 변경해서는 안된다.
ㄴ I/O 함수의 인자로 보내는 Overlapped status 구조체 또한 중간에 내용을 건드려서는 안된다.
Overlapped I/O 전용 송신 함수를 호출하면 운영체제는 송신할 데이터가 저장되어 있는 메모리 블록 자체를 송신 버퍼로 사용한다. 수신할 때는 수신 데이터 블록 자체를 수신 버퍼로 사용한다.
논블록 방식 대비 Overlapped I/O의 장단점
장점
소켓 I/O 함수 호출 후 would block 값인 경우 재시도 호출 낭비가 없다.
소켓 I/O 함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산을 없앨 수 있다.
send, receive, connect, accept 함수를 한 번 호출하면 이에 대한 완료 신호는 딱 한 번만 오기 때문에 프로그밍 결과물이 깔끔하다.
I/O completion port와 조합하면 최고 성능의 서버를 개발할 수 있다.
윈도우 플랫폼에서만 사용할 수 있다.
단점
완료되기 전까지 Overlapped status 객체나 데이터 블록을 중간에 훼손하지 말아야 한다.
accept, connect 함수 계열의 초기화가 복잡하다.
논블록 소켓은 리액터 패턴, Overlapped I/O는 프로액터 패턴이다.
3.8 epoll (논블록 방식)
epoll은 안드로이드와 리눅스에서만 사용 가능하다.
IOS, MacOs, FreeBSD에서는 epoll과 매우 유사한 kqueue를 사용하면 된다.
epoll에 논블록으로 설정된 소켓을
select를 쓰면 모든 소켓에서 루프를 돌지만 epoll을 사용하면 I/O 가능인 상태의 소켓에서만 루프를 돌면 된다.
소켓의 송신 버퍼가 빈 공간이 없는 순간을 유지하는 순간은 짧기 때문에 대부분의 소켓은 I/O 가능 상태이다. (비효율적이다)
이 문제를 해결하기 위해 레벨 트리거 대신 에지 트리거를 써야 한다.
레벨 트리거 : I/O 가능이면 epoll에서 항상 꺼내어진다.
에지 트리거 : I/O 가능이 아니었다가 가능으로 변하는 순간에만 epoll에서 꺼내어진다.
에지 트리거를 쓸 때 주의해야 할 점
1. I/O 호출을 한 번만 하지 말고 would block이 발생할 때까지 반복해야 한다.
2. 소켓은 논블록으로 미리 설정되어 있어야 한다.
3.9 IOCP
epoll은 논블록 소켓을 대량으로 갖고 있을 때 효율적으로 처리해 주는 API이다.
IOCP는 Overlapped I/O를 다루는 운영체제에서 사용하는 API이다.
IOCP는 소켓의 Overlapped I/O가 완료되면 이를 감지해서 사용자에게 알려 주는 역할을 한다.
ㄴ epoll과 비슷하지만 epoll은 I/O 가능인 것을 알려 주지만, IOCP는 I/O 완료인 것을 알려 준다.
epoll과 비교했을 때 사용법에 복잡한 기능이 몇 가지 있는데, 그중 하나가 Accept 처리이다.
1. IOCP에 listen socket L을 추가했다면, L에서 TCP 연결을 받을 경우 이에 대한 완료 신호가 IOCP에 추가됩니다.
2. 단 사전에 이미 AcceptEx로 Overlapped I/O를 건 상태여야 한다.
3. IOCP로 L에 대한 이벤트를 얻어 왔지만, 앞서 Overlapped accept처럼 SO_UPDATE_ACCEPT_CONTEXT와 관련된 처리를 해 주어야 새 TCP 소켓 핸들을 얻어 올 수 있다.
IOCP는 epoll에서 할 수 없는 성능상 유리한 기능이 있다. (스레드 풀링을 활용하여 여러 가지 일을 스레드 몇 개가 골고루 분담해서 처리할 수 있다)