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

    NDB Tips

    구글 앱엔진의 새로운 데이터베이스 라이브러리인 ndb의 사용법과 팁을 소개한다.

    @tasklets.tasklet

    이 데코레이터를 사용하면, 함수를 내부에 yield 구문을 사용할 수 있는, generator 함수로 바꿀 수 있다. 대신 맨 끝에는 raise tasklets.Return(x,y,z) 의 형태로 리턴을 해야 한다. (그냥 리턴을 하면 에러로 간주된다)

    이 데코레이터로 둘러싼 함수를 호출하려면

    x, y, z = my_func_async().get_result()
    

    또는

    x, y, z = yield my_func_async()
    

    와 같이 사용할 수 있다. 다만 yield 를 사용하려면 호출하는 쪽도 tasklet 이 되어야 한다는 단점이 있다.

    여기까지는 매뉴얼에 설명된 부분이고, 기본적인 단어의 의미를 살펴보자.

    • tasklet : 비동기적으로 실행되는 단위 함수(coroutine)
    • yield : 비동기 함수의 결과를 받을 때까지 대기하면서, 다른 tasklet 에게 스케줄링을 양보한다. 결과가 도착하면 get_result() 와 동일하다.
    • future : 비동기 함수의 실행 상황을 저장해두는 객체. jQuery 의 promise 와 같다.
    • eventloop : 내부 큐에 쌓여있는 tasklet 들을 잘 실행해주는 스케줄러다. @context.top_level 의 소스를 보면 큐에 쌓여있는 모든 작업들을 끝낼 때까지 무한루프(eventloop.run())를 돌리는 걸 볼 수 있다.

    이제 실제 사용예를 살펴보자.

    여러 개의 비동기 함수를 기다리기

    futures = []
    f1 = A.get_async()
    futures.append(f1)
    f2 = B.get_async()
    futures.append(f2)
    x, y = yield futures
    # or
    x, y = yield A.get_async(), B.get_async()
    

    이 경우 2개의 쿼리는 동시에 실행되며, 둘 다 끝날 때까지 대기하는 배리어(Barrier) 패턴을 사용할 수 있다. 주의할 점은 동시에 실행하는 함수들은 서로 서로 관계가 없어야 된다는 점이다.

    @tasklets.tasklet
    def load_async(v):
        x = yield X.get_async()
        v.x = x
        raise taskets.Return(x)
    
    yield load_async(A), A.put_async()
    

    위 예제처럼 하나의 tasklet에서 객체를 변형하는데, 동시에 그 객체를 저장하는 함수를 실행할 경우, 완료 순서가 다름으로 인해서 A의 값이 저장될 수도 있고 되지 않을 수도 있다. 예제가 너무 극단적이지만 실제 프로젝트에서 비동기 함수들이 간접적으로 재귀 호출이 되는 상황에 처하다 보면, 헷갈릴 때가 많으니 주의 바란다.

    Map Query

    쿼리로 읽어온 객체의 참조 속성(reference property)을 다시 읽어와야 할 경우라든지, 읽어온 값을 이용해서 또다른 쿼리를 해야 할 때 사용한다.

    @tasklets.tasklet
    def prefetch(x):
        ref = yield x.ref_prop
        raise tasklets.tasklet(x, ref)
    
    values = X.query().filter().map(prefetch)
    values = yield X.query().filter().map_async(prefetch)
    

    map/map_async는 파이썬 기본 함수인 map()처럼, 개별 쿼리 결과에 대해서 동기/비동기로 콜백을 실행해주는 함수다. 참고로, 위 예제를 아무리 병렬로 실행해본들, 첫번째 쿼리의 결과가 끝나야 그다음 결과를 읽어올 수 있다는 점에 주의할 것.

    어쨌거나 내부적으로 AutoBatcher 가 2개의 쿼리로 만들어준다고 하니 믿고 써보자. 운좋게 컨텍스트 캐시에 적중하면 좀 더 빠르겠지…

    위 예제를 yield 없이 구현하면 명백한 2개의 쿼리로 만들 수는 있다. 아주 직관적이지만 코드는 좀 더럽다.

    futures = []
    v1 = []
    for x in X.query().filter().fetch():
       futuers.append(x.ref.get_async())
       v1.append(x)
    values = zip(v1, yield futures)
    

    여기까지는 그럭저럭 손코딩이 빠를 수 있지만, 여기에 서브 쿼리가 들어가고 리턴되는 양도 많아지면, 오히려 AutoBatcher가 더 빠르다고 한다.

    @tasklets.tasklet
    def prefetch_subquery(x):
        ref, subquery = yield x.ref_prop, Y.query().fetch_async()
        raise tasklets.tasklet(x, ref, subquery)
    values = X.query().filter().map(prefetch)
    

    잘 생각해보면, 이걸 아무리 손으로 최적화해본들 쿼리의 절대 숫자(1+첫번째 쿼리 결과 갯수)는 줄일 수가 없다. 따라서 첫번째 쿼리 결과가 많아지면 손으로 최적화하기 보다는 자동으로 처리하고 Guido에게 비는게 가장 좋은 방법이다.

    @tasklets.synctasklet

    이 데코레이터는 generator 함수를 일반 함수로 바꿔준다. 예를 들면

    @tasklets.synctasklet
    def func_async(k):
        v = yield some_async(k)
        raise tasklets.Return(v)
    
    v = func_async(k)
    

    위와 같이 평범한 함수 호출처럼 사용할 수 있다. 그냥 일반 tasklet 에서 get_result() 를 한번 더 호출해주는 걸로 구현되었다.

    @context.top_level

    이 데코레이터는 내부적인 모든 비동기 루틴들이 완료될 때까지 기다린다. 소스 코드를 살펴보면 eventloop 로 무한루프를 돌리는 걸 확인할 수 있다. django view 라든지 webapp view 에 사용하면 된다.

    최적화

    ndb 최적화는 결국 RPC 호출을 얼마나 줄이느냐에 달려 있다. 다음 두 문장을 살펴보자.

    x = MyModel.get_by_id_async('a')
    y = MyModel.get_by_id_async('b')
    x.get_result()
    y.get_result()
    
    x, y = MyModel.get_by_id_async('a'), MyModel.get_by_id_async('b')
    x.get_result()
    y.get_result()
    
    x, y = yield MyModel.get_by_id_async('a'), MyModel.get_by_id_async('b')
    
  • 2011-09-21

    Wordpress To Octopress

    해커 전용 블로깅 프레임워크?

    Octopress는 정적 사이트 생성기인 Jekyll 을 이용해서 블로그를 손쉽게 구성하도록 해주는 루비 프레임워크다. 말 그대로 정적 HTML 파일들을 미리 만들어서 어딘가로 올려서 서비스하는 거라, GitHub에서 블로그를 서비스할 수 있음은 물론, Amason S3나 구글 앱엔진에서도 블로그 서비스가 가능하다.

    큼직큼직한 글씨와 미려한 테마, 코드 문법 하일라이팅, blockquote, gist 코드 포함 등의 다양한 플러그인이 지원되며, 생성, 배포, 최신 소스 업데이트, 글쓰기 등의 작업들이 rake 콘솔 명령으로 간편하게 포장(?)되어 있다. DB를 쓰지 않기 때문에 코멘트는 Disqus 같은 외부 사이트를 이용하게 된다. 워낙 매뉴얼이 잘 되어 있어서 설치나 사용법은 그냥 따라해도 충분하다. 레이옷은 DropBox에 origin 레파지토리를 두고 사용중이다.

    중요한 건 기존 블로그를 얼마나 손쉽게 이전하느냐인데, 워드프레스.com에서 이사오는 데 거의 3일이 걸렸다. 워낙 블로그 이사를 많이 다녀서 메타 정보가 개판이었고 본문에 이런 저런 비표준 태그를 많이 써서 문제는 더욱 심각했다. 또 워드프레스에서 내보내기로 받아온 XML 자체에도 문제가 많았다. (내 경우에는 글들이 중간에 짤려서 태그가 제대로 완결이 안되었다든지, ^Z가 본문 중에 있어서 파싱이 안되기도 했다.) Jekyll에서 제공하는 스크립트는 마크다운 변환 기능이 빠져 있고, 유니코드 에러를 자주 뱉기 때문에 직접 파이썬으로 만들어야만 했다. (루비 책 몇 권 사놓고도 문법을 보니 머리가 아파서 포기했다.)

    어쨌든 그 삽질의 결과물을 아래와 같이 공개한다. 자유롭게 이용하되, 아래 스크립트의 사용시 책임은 사용자 본인에게 있다는 걸 미리 밝힌다. :P

    참고로, 변환을 하다 보면 다양한 에러들을 만나게 된다. -_-+

    • invalid byte sequence in UTF-8 : 이건 본문 중에 애매한 문자열이 있다는 건데, 강제로 유니코드로 변환했음에도 발생한다. 알아서 찾아서 고치는 수 밖에 없다. 단 octopress 소스를 아래와 같이 고쳐야 한다.
    #ruby octopress/plugins/post_filters.rb
        def do_layout(payload, layouts)
          pre_render if respond_to?(:pre_render)
          begin
            old_do_layout(payload, layouts)
          rescue StandardError => e
              puts "Post Filter Error: " + e.message + self.url
          end
        end
    
    • Liquid Exception: 혹시 django 나 jquery template 코드가 있을 경우, 이게 Jekyll 의 템플릿 엔진인 liquid의 태그와 동일하기 때문에 에러가 발생한다. 이런 문법에 따라 수동으로 잘 제거하는 수 밖에 없다.
    • 핵심적으로 한글로 된 제목 때문에 가장 고생이 심했다. 아주 오래전에 쓴 글들은 제목은 한글, slug는 한글을 URL로 바꾼 %xx%yy와 같은 형식이었다. 이걸 다시 제대로된 유니코드로 역변환(unquote)해서 파일을 만들었는데 rake preview로 살펴보면 한글 URL 포스트에 접근이 안되는 것이었다. 몇 시간동안 삽질을 해본 결과 로컬 서버인 Webrick의 문제인지, GitHub에 올리니까 잘 되는 것이었다. 여기서의 교훈: slug는 영문을 애용할 것!!
    Category:blogging
    Tags:jekylloctopress
  • 2011-07-09

    jQuery Proven Performance Tips & Tricks

    jQuery Proven Performance Tips & Tricks 간단 요약

    • 최신 버전을 사용해라. (1.4 보다 1.6이 2배 가까이 빠르다)
    • id 선택자가 class 선택자보다 5-10배 이상 빠르다.
    • 가상 선택자는 느리니까 최대한 쓰지 말 것. 검색 영역의 모든 요소를 다 뒤진다!!
    • 부모에서 자식을 찾는 방법은 $parent.find('.child') 가 제일 빠르다. 다른 것은 잊어라. (근데 왜 children() 이 더 안빠르지..)
    • jQuery 객체는 꼭 필요할 때에만 만들 것.
    • 항상 캐싱할 것
    • 체인 방식을 애용할 것. 이미 필터링된 집합을 이용하므로 빠르다. 코드도 간단해진다.
    • bind, live 보다 delegate가 좋다!
    • DOM insert/append 는 가급적 한번에 끝낼 것. 무거운 연산을 해야 할 경우 일단 detach 했다가 다시 넣으면 좋다.
    • $.each() 는 느리니까 그냥 for 를 쓸 수 있으면 써라.
    • $.method 보다 로레벨 함수인 $.fn.method 가 빠르다.

    대체로 아는 이야기라는 점이 슬프다. 그걸 아는 놈이 짠 코드가 이 모양이라니.. ㅠㅠ

    그나저나 jsperf.com 같은 걸로 브라우저별로 검증해보지 않으면 다 공허한 이야기인 듯. 재미있는 건 IE 6/7 클래스 선택자의 속도가 다른 브라우저보다 1000-2000배 느리다는 점과, 크롬 13.x 의 성능이 갑자기 앞뒤버전보다 확 떨어졌다는 것. :)

  • 2011-06-18

    jQuery Mobile Tip

    jQuery Mobile(이후 jQM)으로 아이패드용 클리앙 뷰어를 만든 경험을 토대로 팁을 정리했습니다. 이후, 편의상 경어를 사용합니다.

    data-xxx

    jQM의 위젯들은 다른 jQuery 위젯이나 플러그인들과는 달리 자바스크립트로 옵션을 지정하는 대신, HTML 의 data- 속성을 이용해서 모양새와 동작을 지정한다.

    가장 중요한 것은 data-role 속성인데, HTML 태그에 page, header, content, footer 에서부터 listview, navibar, button 등의 "역할"을 지정하면, jQM이 알아서 적당한 렌더링 해준다는 거다. 이를 통해서 자바스크립트 코드는 거의 손대지도 않고 순수 HTML 만으로 깔끔한 모바일 뷰를 만들어낼 수 있게 된다.

    예를 들어 버튼을 만들어야 된다고 하자. 단순한 anchor 에 data-role="button" 속성을 넣는 것만으로 버튼을 만들 수 있다. 버튼 아이콘은 data-icon 으로 바꾸고, 페이지 전환 애니메이션이 필요할 경우 data-transition= slide | slideup | slidedown | pop | flip | fade 을 사용하면 되고, 또 data-direction="reverse" 으로 애니메이션 방향을 반대로 바꿀 수 있다. 만약 페이지 전환이 아니라 다이얼로그를 띄워야 할 때 anchor.rel 속성처럼 data-rel="dialog" 속성을 지정하면 된다.

    이 모든 것이 JS 코드 한줄 없이도 자동적으로 이루어지게 만든, jQM 개발팀에게 박수를 보낸다.

    페이지와 캐싱

    페이지는 보통 header - content - footer 로 나뉘는, jQM의 가장 핵심적인 구성 요소다. 그냥 모바일 화면의 하나의 뷰라고 생각하면 된다. HTML 파일 안에 여러 개의 페이지들이 존재할 수 있다. 보통은 각 페이지들 마다 #id 를 붙여두면 되긴 한데, AJAX 로 읽어오는 페이지들의 하위 요소(예를 들면 listview)에 #id를 붙여서 jQuery로 접근하는 것은 가급적 피해야 한다.

    왜냐하면 모바일 환경의 특성상 이미 방문했던 페이지에 대해서 다시 서버에 요청을 하지 않기 위해서, jQM은 이미 한번 방문했던 페이지들은 URL 을 해쉬한 키값으로 DOM 에 저장한 후 숨겨버린다. 그러므로, 동일한 #id 를 가진 페이지가 캐싱될 경우 자바스크립트에서 검색하는 게 불가능하므로, 현재 화면에 보이는 페이지를 기준으로 CSS 클래스로 찾는 것을 권한다.

    그냥 페이지 전환이 발생하면 항상 div.data-role="page" 라는 게 무조건 추가된다고 생각하면 이해가 빠를 듯하다. 참고로 로컬 캐싱된 이런 페이지들에 대해 히스토리(Back-Forward) 이동을 적용하기 위해서 yourdomain.com/#/some/where 등의 로컬 주소로 변환된다는 점에 유의할 것.(트위터에서 쓰는 방식이랑 비슷한건데, 뭔가 이걸 가리키는 용어가 있었던 듯... 가물가물..)

    ul-li-thumb 문제

    리스트 아이템(li) 바로 아래에 이미지 태그를 넣어두면, jQM은 자동적으로 트위터와 같은 2단 레이아웃으로 바꾼다. 내부적으로 이미지에 ul-li-icon 또는 ul-li-thumb 클래스를 붙이고 li 에다가도 ul-li-has-thumb 같은 클래스를 붙여서 크기와 너비, 마진을 설정해버린다.

    한편으로는 좋은 기능이지만, 원치않는 경우라고 해도 이미지 크기가 강제로 줄어들게 된다. 해결책은 리스트 바로 아래 자식 이미지를 다른 태그로 둘러싸서 jQuery 검색에 걸리지 않게 만들면 된다.

    see also: http://forum.jquery.com/topic/latest-release-list-thumbnail-issue

    터치 이벤트 사용하기

    데스크탑에서 개발하다가 아이패드에서 테스트해보니 유독 내가 만든 버튼만 클릭이 잘 안먹는 경우가 있어서 뭔가 했는데, 가만히 생각해보니 click 이벤트와 touch 이벤트는 별개라는 사실을 잠시 잊어서 생긴 문제였다. 항상 tap (tab 이 아니다) 이벤트를 click 과 함께 등록해야 된다 :)

    fixed header & footer

    data-position="fixed" 를 이용하면 헤더나 푸터를 스크롤에 관계없이 화면에 고정할 수 있다. 스크롤 할 때에는 사라져서 편한 거 같은데, 막상 아이패드에서는 이게 번쩍거리거나 프레임을 떨어뜨리는 문제가 있으므로 적당히 사용해야 할 거 같다. 나도 처음에는 긴 글이 있을 때 넣으면 좋겠거니 했는데, 워낙 깜빡임이 심해서 빼버렸다. 페이지 전환 애니메이션도 너무 과하면 곤란한 듯하다.

    $.mobile.ajaxEnabled = false

    기본적으로, 링크를 클릭하면 jqm은 이동하려는 페이지가 DOM에 없을 때에만, ajax()를 호출해서 페이지를 자동으로 만든다.  그런데 backbone 같은 MVC 라이브러리를 사용할 경우, 이런 흐름은 달라져야 된다. 사용자가 링크를 클릭하면 관련된 모델을 fetch 하고 컬렉션을 적당히 변경한 후, 페이지는 backbone 뷰가 자동으로 만들어주는 느낌이 가장 어울릴 것이다. 즉 ajaxEnabled 옵션을 끄고 관련된 이벤트들을 직접 바인딩해서 처리하면 된다.

    소소한 팁들

    • 버튼을 헤더의 오른 편으로 정렬하기 : 버튼의 class 속성에 ui-btn-right 를 추가하면 된다.
    • data-backbtn="false": jQM 1.0a4.1 까지는 페이지 전환시 자동적으로 헤더에 Back 버튼이 추가되므로, 돌아가기 버튼이 필요없는 메인 페이지 등에서는 이 속성을 지정하면 된다.
    • 원치않는 nested list : ul-li-thumb 와 마찬가지로 li 바로 아래에 ul 이나 ol 이 있으면 자동적으로 페이지가 만들어지므로, 다른 태그로 둘러싸면 된다.
    • 사용자가 추가한 버튼에 live("click tap") 을 지정하면 이벤트가 2개 발생되는 모양이다. 그냥 tap 만 넣어도 컴퓨터에서는 잘 동작한다.
    • 자바스크립트로 리스트 아이템을 추가한 후 listview("refresh")를 해도 그 안에 있는 inset listview 는 별도의 쿼리를 통해서 listview 위젯으로 만들어야 한다. 샘플 코드는 여기 http://jsfiddle.net/Reiot/CpudD/