libUV 분석
개요
node.js의 플랫폼 레이어로 여러 플랫폼에서의 비동기 입출력을 추상화하고, 완료될 때 콜백을 실행할 수 있게 해준다.
see also GitHub
입출력 타입
include/uv.h 에 보면 다음과 같은 입출력 타입이 지원된다.
- TCP, UDP 소켓
- IPC, named Pipe(부모-자식 프로세스간 통신)
- 타이머, 고해상도 타이머, idle 이벤트
- 쓰레드풀링(QueueUserWorkItem)
이 정도면 능히 게임 서버를 만들 수 있는 수준이다?
입출력 핸들과 처리 함수들
일단, 비동기 입출력 타입들은 전용 핸들과 초기화/소멸 함수를 가진다.
- uv_tcp_t: uv_tcp_init(), uv_tcp_endgame()
- uv_udp_t: uv_udp_init(), uv_udp_endgame()
또한 각종 이벤트에 대해서 요청/완료 함수가 존재한다. 예를 들어 TCP 소켓 입출력에 대해서는 Accept/Connect/Read/Write 이벤트가 존재하는데 아래와 같은 함수들이 대응되어 있다.
- uv_tcp_accept => uv_process_tcp_accept_req
- uv_tcp_read_start => uv_process_tcp_read_req
- uv_tcp_write => uv_process_tcp_write_req
- uv_tcp_connect => uv_process_tcp_connect_req
특이한 것은 Loop Watcher 로 분류되는, idle/prepare/check 이벤트들이다. idle은 서버에 아무런 입출력이 없을 때 호출된다. prepare/check 는 이벤트 루프를 커스터마이징하기 위한 함수인데 거의 쓸 일은 없을 듯.
메인 이벤트 루프
// src/core.cc
do {
// 현재 시간 설정
uv_update_time((loop));
// 완료된 타이머 이벤트 실행
uv_process_timers((loop));
// 요청도 없고 타이머도 없으면 idle 이벤트 실행
if ((loop)->pending_reqs_tail == NULL && (loop)->endgame_handles == NULL){
uv_idle_invoke((loop));
}
// 완료된 입출력을 정리하고 지정된 콜백을 실행
uv_process_reqs((loop));
// 끊긴 연결 등의 핸들을 정리
uv_process_endgames((loop));
// pre-poll 콜백들을 호출
uv_prepare_invoke((loop));
// GetQueuedCompletionStatus 를 실행해서 입출력 완료를 대기
// 현재 처리할 이벤트가 없으면 계속 대기
poll((loop), (loop)->idle_handles == NULL &&
(loop)->pending_reqs_tail == NULL &&
(loop)->endgame_handles == NULL &&
(loop)->refs > 0);
// post-poll 콜백들을 호출
uv_check_invoke((loop));
} while (0);
메모리 관리
- TCP read : zero byte receive라는 기법을 이용한다. WSARecv 에 길이가 0인 빈 버퍼를 넘기고, 실제로 뭔가 오면 WSARecv 로 다시 읽는 방식이다. locked page 를 줄일 수 있어서 I/O 가 많은 서버들이 자주 쓴다고 한다. 참고로 WSARecv/WSASend 에 넘긴 메모리는 커널 드라이버가 접근해야 하므로 lock 이 걸리게 되는데 물리적인 최대값이 있다.(램의 1/8??)
전통적인 게임 서버들은 하나에 프로세스가 여러 개의 쓰레드를 가지고, 세션당 1개의 입력 버퍼를 두는 1-recv 기법을 사용한다. 반면 node.js 는 클러스터링을 염두에 둔 탓인지, 메모리를 최소하는 전략을 채택한 듯하다.
- TCP write: N-send 를 사용한다. 즉 보낼 게 있을 때마다 버퍼가 만들어지거나 이미 존재하는 객체의 포인터를 이용한다는 뜻이다. 구조적으로는 gather write 가 가능할 거 같은데 코드에서는 없는 듯하다.
대신 브로드캐스팅을 해도 복사는 없으므로 한편으론 괜찮을지도.
평가
게임 서버 프로그래머로서 발견할 수 있는 문제는,
- CPU 연산이 많은 경우 모든 실행이 멈춘다. 해결책은
- process.nextTick() 으로 잘게 자른다. (또는 코루틴이 도입될 때까지 기다린다 ㅋㅋ) yield 같은 게 있으면 좋을텐데...
- 그래도 크다면 pipe 나 소켓을 통해 다른 프로세스로 task 를 넘기고 받을 것. 게임의 핵심 로직은 별도 프로세스로 분리하는 게 더 좋을 듯.
- 캐릭터 이동이나 대규모 전투 같이 크기는 작지만 I/O의 절대 갯수가 많을 경우, 성능 저하가 좀 있을 것 같다.
- 왜냐하면 I/O 갯수만큼 GQCS를 호출해야 하니까. 이건 nv_run 코드를 패치하는 방법도 있지만..
- 어쨌거나 기본적으로 작게 분산해서 하나하나를 가볍게 가져가는 형태가 될 것이다.
만약 node.js 만으로 리얼타임 MMO 게임 서버군을 구성한다면…
- frontend servers: 클라이언트와의 인증, 입출력을 전담. 메시지가 도달하면 backend 의 적당한 서버로 IPC(pipe or socket)를 통해 보낸다.
- backend servers: game server(sharding), AI, chat, guild, shop 등 역할별로 분산해서 지연을 최소화한다.
- CPU 를 많이 사용해야 한다면 C++ native 로 전환해가는 방법도 좋다.
그 외에 고민해야 될 것들이라면
- 적당한 데이터베이스/캐시 미들웨어: membase?
- node 프로세스 관리 도구 및 모니터링