2012-02-26

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 프로세스 관리 도구 및 모니터링

comments powered by Disqus