Skynet 설계 설명

작자: cloudwu

번역: coolspeed

한달이란 시간에 걸쳐 드디어 skynet (https://github.com/cloudwu/skynet) 의 C 버전을 완성했다. 중간에 여러 모듈을 반복하여 리팩토링 한 결과 최종 남은 코드는 얼마 되지 않았다: 겨우 C 코드 6천여줄, 그리고 Lua 코드 천여줄이었다. 비록 일부 코드는 좀 급하게 짰지만 나 자신의 퀄리티 요구에 부합된다. 버그는 피면하기 어렵겠지만 이정도 작은 규모의 코드베이스는 충분히 명료하여 수정하기가 어렵지 않을 것이다.

Github 에 올린 이 프로젝트를 개발하는데 사용된 시간은 사실 한달도 훨씬 안된다. 나의 대부분 시간은 지난 반년 넘게 짜놓은 Erlang 버전 프레임워크와의 하위호환성과 호환 안되는 Erlang 모듈들을 포팅하는데 허비했다. 이 부분 코드들은 저희의 실 게임프로젝트와 관련 있는 것이어서 오픈하지 않았다. 또한 양이 이보다 몇배나 되는 관련코드들을 왕창 올려봤자 이 오픈소스프로젝트의 의미에 도움이 되지 않을 것이다. 프로젝트에 관심을 가지는 친구들은 그런 별로 중요하지도 않고, 많은 인터페이스들이 레거시 부담으로 추하게 설계된 구조 때문에 오히려 미궁에 빠질 것이다.

저희의 구 프로젝트와 연동시켜 보고 포팅도 다 잘 됐음이 확인된 후에 나는 또 skynet 의 일부 밑층 설계를 수정했다. 안전한 포팅을 보장하는 전제하에서 최대한 개선을 함으로써 히스토리컬 레거시를 최소한으로 걺어지도록 힘썼다. 이런 수정들은 쉽지 않았지만 의미가 있다고 생각한다. 최근 내가 자세하게 생각을 굴린 성과들이다. 오늘의 이 블로그글에서 나는 이번 픽스된 버전을 토대로 설계 설명을 좀 기록하려고 한다. 추후 찾아보기에도 좋게.


Skynet 코어는 어떤 문제를 해결하는가

나는 우리의 게임 서버가 ( 하지만 skynet 이 게임서버에만 사용될 수 있는게 아니다) 충분히 멀티코어의 파워를 이용할 수 있기를 바라며 서로 다른 비즈니스 로직들을 서로 독립적인 실행환경속에서 협동적으로 작동시키기를 원한다. 최초에 나는 이런 실행 환경이 OS 의 프로세스이기를 바랬다. 하지만 후에 발견한건데 만약 우리가 임베디드 언어를 필연적으로 사용하게 된다면, 예를 들어서 Lua, 독립적인 OS 프로세스의 의미가 크지 않을 것이다. Lua State 는 이미 충분히 양호한 샌드박스 환경을 마련해주어 서로 다른 실행환경을 격리해줄 수 있었다. 뿐만아니라 멀티스레딩 방식은 상태 공유를 통해 데이터 교환을 더욱 효율적으로 할 수 있게 해준다. 그러면서 멀티스레딩이 흔히 비평받는 단점들, 이를테면 복잡한 스레드 락, 스레드 스케쥴링 등은 밑단의 규모를 가능껏 제한하고 설계를 최대한 간소화함으로써 최소한의 범위안으로 가두어둘 수 있다. 이런 입장에서 skynet 은 최종적으로 3000 줄 안되는 C 코드로 코어층을 구현했는데 이것은 코딩 좀만 할줄 아는 C 프로그래머라면 빠른 기간내에 이해하고 유지보수 할 수 있는 규모이다.

핵심기능차원에서 skynet 은 오직 한가지 문제만 해결한다:

하나의 규약에 부합되는 C 모듈을 다이나믹 라이브러리 ( so 파일) 로부터 가동시켜 영원히 겹치지 않는 (모듈이 꺼지더라도) 하나의 숫자 id 에 바인딩하여 handle 로 사용하는 것이다. 모듈은 서비스 (Service) 라 불리고 서비스들은 서로 자유롭게 메세지를 보낼 수 있다. 모듈은 skynet 프레임워크에 하나의 callback함수를 등록하여 그것으로 자기에게 보내온 메세지를 받는다. 모듈들은 모두 하나하나의 메세지에 의해 드리븐 되고 메세지가 오지 않을 때에는 펜딩되여있으며 CPU 자원 소모가 0이도록 보장된다. 자발 실행적인 로직이 필요하다면 skynet 시스템에서 제공하는 timeout 메세지를 이용하여 일정 시간마다 트리그 시킬 수 있다.

Skynet 은 네임 서비스를 제공한다. 이걸 사용하여 서비스마다에 기억하기 쉬운 이름을 지어줄 수 있어 id 를 통해 사용하는 불편을 막을 수 있다. Id 는 실행상태와 관련되어 있으며 매번 실행때마다 같은 id 를 가질 수 있으리라는 보장이 없지만 이름은 이게 된다.


Skynet 은 어떤 문제를 해결하지 않는가

Skynet 의 메세지 전송은 모두 단방향성이며 데이터 패킷을 전달의 단위로 한다. TCP 연결과 비슷한 개념을 정의하지 않았다. RPC 의 프로토콜을 규정하지 않았다. 데이터 패킷의 인코딩을 규정하지도 않고 일치한 복잡 자료형의 직렬화(시리얼라이징) API 도 제공하지 않는다.

Skynet 은 원칙상 모든 서비스가 하나의 OS 프로세스내에서 협업하여 실행되기를 권장한다. 그러므로 코어 레이어에서는 기기간 통신 매카니즘을 고려하지 않으며 특정 서비스의 크래시, 재시작 등에 대한 지원도 제공하지 않는다. 일반 싱글스레드 포로그램과 마찬가지로 당신은 당신 코드중의 버그와 예외들에 대해 책임을 져야 한다. 당신의 프로그램에 문제가 생겨 크래시가 났다면 당신은 그 에러를 숨겨서 그것이 발생하지 않은 척 해서는 안된다. 적어도 그것은 코어층이 케어해야 할 일이 아니다. 게임서버는 OS 와 다르다. OS 는 모든 유저 프로세스가 믿을 수 없는 것이라고 가정을 함으로써 임의의 유저 프로세스가 다른 하나의 프로세스에 영향을 끼칠 수 없게 한다. 하지만 skynet 이 제공하는 프레임워크 내부에서는 모든 서비스가 하나의 목적을 둘러싸고 게임서버의 엔드 유저를 위해 협력해야 하기에 어느 특정 일환에서 에러 나든 모두 치명적인 것이므로 문제들로부터 격리되어야 할 필요가 없다.

물론 이것은 skynet 으로 구성한 시스템이 튼튼함 (robustness) 이 부족하다는 뜻이 아니라 보다 더 윗단 차원에서 해결하도록 접근한다는 얘기다. 예를 들어 Lua 의 샌드박스를 이용하면 다수의 윗층 로직상의 버그들을 격리시킬 수 있다.


간단하게 말해 skynet 은 오직 하나의 메세지 패킷을 하나의 서비스로부터 발송하여 동일 프로세스내의 다른 서비스에서 받아지도록 함으로써 등록된 콜백함수가 호출되여 그 메세지 패킷을 처리하도록 하는 것이다. Skynet 은 모듈의 초기화 과정, 하나하나의 독립된 callback 호출들이 모두 스레드 안전함을 보장한다. 서비스를 구현하는 사람은 멀티스레딩 환경을 위해 특별히 신경쓰지 않아도 된다. 오직 자기에게 보내진 하나하나의 메세지 패킷들을 잘 처리하기만 하면 된다.

Erlang에 익숙한 독자들은 한눈에 알아봤을 것이다. 이건 Erlang 의 Actor 모델이잖아! 다른 점은 나는 내가 더욱 익숙한 Lua 언어를 깃들였다는 것 뿐. 당신이 skynet 의 소스코드를 읽어보면 알 수 있겠지만 사실 Lua 언어는 필수가 아니다. 당신은 C 언어만로 서비스 모듈을 만들 수 있다. 그리고 Python 이나 다른 C 에 임베드 할 수 있는 스크립트언어로 바꾸는 것도 어렵지 않다. Lua 와 Python 이 병존하게 하는 것도 어렵지 않다 — 그렇게 하게 되면 제가 skynet 을 위해 만든 일부 Lua 로 구현된 인프라 서비스들을 사용할 수 있게 된다.

그렇다면 도대체 왜 Lua 를 선택했는가?

제일 주요한 원인은 개인취향이다. Lua 는 내가 제일 익숙하고 좋아하는 언어중의 하나이다.

Lua 는 아주 쉽게 C 언어 안에 임베드 하여 협동실행되게 할 수 있으며 성능도 꽤 괜찮게 나온다. 그리고 필요하면 LuaJIT 을 도입함으로 성능을 더 한층 끌어올릴 수도 있다.

Lua 의 런타임이 필요한 라이브러리도 매우 적은데 이는 대량의 독립적인 샌드박스들을 필요하는 시스템을 더욱 라이트하게 해줄 수 있다. 하나의 서비스를 생성하거나 소멸하는 것은 모두 매우 빠르다.


효율적인 서비스간 통신을 제공하기 위하여 skynet 은 아래와 같은 몇가지 설계를 취함으로써 멀티프로세싱 방식보다 높은 효율성을 이뤘다.

메세지 패킷은 보통 하나의 서비스 내에서 만들어지는데 skynet 은 그 데이터 패킷이 어떻게 만들어졌는지에 관심이 없다. 심지어 이 패킷내 데이터가 연속적일 것도 요구하지 않는다 (물론 이렇게 하는 것은 위험하다. 뒤에서 설명할 기기간 통신에서 에러를 내게 될 것이다. 당신의 패킷이 절대 현재 소재 프로세스 밖으로 발송 안됨을 보장할 수 있으면 괜찮겠지만). 그것은 단지 패킷의 포인터와 당신이 주장하는 패킷 사이즈 (실제 사이즈가 아닐 수도 있다) 를 보낸다. 같은 프로세스내에 있기 때문에 수신자는 이 포인터를 통해 그것이 가리키는 데이터를 사용할 수 있게 된다.

이 메커니즘은 필요시에 절대적인 zero copy 를 보장할 수 있으며 같은 스레드 내의 한번의 함수호출과 비용이 거의 같다.

하지만, 이것은 그냥 skynet 이 제공하는 성능상의 가능성이라는거. Skynet 이 추천하는 것은 보다 안정적이며 성능은 약간 손실하는 방식이다: 이 방식은 매번 패킷이 malloc 으로 할당한 연속적인 메모리에 복제될 것을 약속한다. 수신자는 이 패킷을 처리한 후에 (처리하는 callback 함수 호출이 완료된 후에) 디폴트로 free 함수를 이용하여 사용됐던 메모리를 제거한다.

skynet_send 함수와 callback 함수의 정의를 보도록 하자:

int skynet_send(
    struct skynet_context * context,
    uint32_t source,
    uint32_t destination,
    int type,
    int session,
    void * msg,
    size_t sz
);

typedef int (*skynet_cb)(
    struct skynet_context * context,
    void *ud,
    int type,
    int session,
    uint32_t source ,
    const void * msg,
    size_t sz
);

일단 type 와 session 이 두 파라미터에는 신경을 쓰지 말자. 여기서 source 와 destination 은 모두 32 bit 정수이며 주소를 나타낸다. 원칙상 source 주소는 디폴트로 자기 자신이기에 지정할 필요가 없다. 0 은 시스템 보류 handle 인데 자기 자신을 가리킨다. 여기서 source 값을 지정할 수 있게 한 것은 일부 특수상황에서 다른 사람이 보낸 패킷을 위조할 필요가 있을 수 있기 때문이다. 일단은 source 를 reply address 로 이해할 수 있다.

메세지 패킷을 보낸다는 것은 msg / sz 쌍을 보낸다는 것이다. 여기서 우리는 type 에 dontcopy 라는 tag ( PTYPE_TAG_DONTCOPY ) 를 올림으로써 프레임워크가 msg / sz 가 가리키는 패킷을 복제하지 않게 할 수 있다. 그렇게 하지 않으면 skynet 은 malloc 으로 메모리 한토막을 할당받아서 데이터를 복사해 넣는다. callback 함수는 이 토막의 데이터를 처리한 후에 free 를 사용해 메모리를 릴리즈 한다. 당신은 callback 함수가 1 를 리턴함으로 프레임워크가 메모리 릴리즈를 하지 않게 할 수도 있다. 이는 보통 send 시에 dontcopy tag 를 올리는 것과 결합하여 사용된다.


계속하여 session 과 type 이 두 파라미터를 봐보도록 하자.

초기 설계에는 session 과 type 이 없었다. 일견 얘들은 필요가 없어보인다. 왜냐면 우리는 모든 데이터를 모두 패킷 안에 쑤셔넣을 수 있는데 이런것들은 보통 밑단의 통신 프렘워크에서 관심하는 관심사가 아니니까. 그냥 윗단의 시설에서 일치한 통신방식만 약속하면 되는 것이다. 확실히 과거버전에서 우리는 실제로 이렇게 했었으며 사용된 통신 프로토콜은 google protocol buffer 이었다. 후에 우리는 이런 방식이 불필요한 성능 코스트를 일으킨다는 것을 발견하게 되였다. 그리고 서비스간 통신방식을 어떻게 정의하든 session 은 결코 꼭 필요한 것이며 type 도 거의 그렇다.

이 두 변수만 따로 뽑아내면 우리는 서로 다른 프로토콜들이 공존하며 작동하도록 할 수 있어 꼭 일치한 인코딩 방식을 강요할 필요가 없어진다 — 그것이야말로 개발자에 대한 구속이다.

한가지 더 첨언해야 할게, skynet 코어는 결코 프로세스간의 통신을 해결하지 않는다는 것이다. 데이터 교환 방식은 TCP 같은 데이터 스트림이 아니다. 따라서 서비스간의 통신방식을 하나의 데이터 블락으로 강제할 필요가 없다. 프로세스내 통신에 사용하기 가장 좋은 방식은 바로 C 언어 struct 이다. 발신자와 수신자가 모두 하나의 프로세스내에 있으므로 동일한 C struct 가 매핑된 메모리 구간을 식별할 수 있으며 메모리 레이아웃이나 바이트 오더 문제를 신경쓸 필요가 없다. 이 레이어에서 이러한 효율적인 데이터 교환 방식을 사용할 수 있다는 점은 성능을 상당히 향상시킬 수 있다.

session 은 무엇인가?

비록 ip 프로토콜이 하나의 ip 패킷을 인터넷의 하나의 ip 주소로부터 다른 하나의 ip 주소로 전송하는 일만 해결하는 것처럼 skynet 코어는 단방향 데이터 발송만 해결하지만, 우리의 애플리케이션들은 상당수가 요청/응답 방식을 취한다. 즉 하나의 서비스에서 다른 하나의 서비스에 요청 패킷을 보내면 상대방은 이 요청을 처리한 후에 다시 응답을 반환한다. 하나의 프로세스당 오직 하나의 callback 함수만 있기 때문에 이것은 ip 프로토콜에서 포트(port)란 설정을 빼버린 것과 비슷하다. 이 ip 주소에 보내진 모든 ip 패킷들은 서로 다른 프로세스들에 디스패치 할 수 없게 된다. 이럴 경우 우리는 다른 한가지 정보로 패킷들을 구분해줘야 한다. 이것이 바로 session 의 작용이다.

skynet_send 함수를 사용하여 하나의 패킷을 보냈을 때 type 에 alloc session 의 tag ( PTYPE_TAG_ALLOCSESSION ) 를 설정할 수가 있다. 이렇게 하면 send api 는 지정된 session 파라미터를 무시하고 현재 서비스에서 한번도 사용된 적 없는 session 넘버를 할당해서 발송을 한다. 동시에, 수신자가 이 패킷을 받은 후에 이 session 을 그대로 send back 할 것을 약속한다. 그럼으로 서비스를 구현하는 사람은 callback 함수에서 모든 리턴될 예정인 session 들의 테이블을 기록해 뒀다가 패킷들이 도착했을 때 해당 처리함수를 호출해주면 된다.

type 의 작용

윗소절에서 말한 것처럼 서비스간의 상호작용에서 다른곳에서 보낸 요청만 응답하면 되고 밖으로 요청을 하지 않아도 되는 서비스는 소수이다. 그래서 우리는 적어도 요청과 응답을 구별해야 할 필요가 있게 된다. 이 두가시 패킷은 당연히 서로 다른 처리방식이 있을 것인데 그들은 같은 callback 함수를 입구로 해야 한다. 그럼으로 여기에 별도의 파라미터를 넣어 구별해야 할 필요가 있게 된다.

최초에 나는 session 의 상위 비트로 이 두 종류의 패킷을 구별했었다. 양의 session 넘버는 리스폰스를 의미하고 마이너스인 session 넘버는 리퀫을 의미했다. 후에 나는 이 두가지 패킷타입만 구별하는게 부족하다는 것을 발견하게 됐다.

물론 우리는 전체 시스템 내에서 한가지 메세지 인코딩 프로토콜만 사용하도록 약속할 수 있다. 그렇게 하면 모든 서비스간의 커뮤니케이션에는 장애가 없을 것이다. 이러면 심지어 session 파라미터도 데이테 패킷내에 인코딩 해 넣을 수 있다. 성능 코스트를 조금 지불할 뿐.

하지만 리얼 프로젝트 개발에서 이것은 사실상 보장하기 쉽지 않다. 특히 서드파티 라이브러리 사용시 우리는 많은 비효율적인 래핑작업을 해야 상호작용 프로토콜을 모두 통일시킬 수 있을 것이다. 그리고, 이런 일치한 인코딩 프로토콜은 결국 시스템 밑단의 일종의 약속으로 되여버리고 모든 개발인원이 필수로 장악해야 하는 지식으로 되어버린디. Google protocol buffer 같은 프로토콜은 물론 니즈를 만족시킬 수 있다. 하지만 C 언어 차원에서의 개발이 아직 편하지 않다. Lua 에 대한 개발지원도 아직 효율성과 편이성이 충분히 좋지 않다. 물론 이걸 해결하기위해 나는 전문 라이브러리를 개발하여 성능과 편이성의 문제를 해결했었다. ( https://github.com/cloudwu/pbc )

만약 우리가 한발 물러선다면 보기 더 좋아질 것이다. 그것이 바로 한개의 서비스는 한가지 메세지 인코딩만 사용하지만, 모든 서비스들이 같은 인코딩 프로토콜을 사용할 것을 강요하지 않는 것이다. 그러면 당신은 어느 서비스를 호출하려면 상대방의 프로토콜만 맞춰주면 된다. session 이 독립적으로 패킷 밖에 존재하면 서로 다른 프로토콜의 대외 요청의 응답을 처리할 때 서로 다른 인코딩 포맷으로 디코딩 처리를 해줄 수 있다. 이것이 바로 최초의 C 버전 skynet 을 구현할 때 채택한 방식이었다.

레거시 코드 포팅작업을 하면서 이런 방식이 여전히 꽤 불편함을 느꼈다. 원래의 ptorobuffer 에 기반한 프로토콜 설계가 비교적 불편했다. 만약 새로 만든 서비스가 똑같은 기능을 제공하고자 한다면 구 프로토콜에 맞추어 구현해야만 했다. 한가지 서비스가 한가지 메세지 인코딩만 사용할 수 있다는 제한때문에 보다 더 효율적인 방식으로 새 기능을 구현할 수 없게 된다. 늘 일부 하위호환성 문제 때문에 callback 함수내에 여러겹 filter 를 넣어서 서로 다른 프로토콜을 같은 포맷으로 바꿔야만 하곤 했다. 이것은 레거시 코드를 커에하기 위함 만으로 별로 의미없는 코드를 많이 짜게 된 셈이다. 이런 문제들 때문에 우리 내부적으로는 점차 불합리적인 구코드를 버리기로 합의했었지만 과도시기에는 한개의 작동 가능한 버전을 유지해야만 했다.

결국 나는 type 파라미터를 넣기로 결정하게 된다.

사실 type 이 의미하는 것은 보통 의미에서의 메세지 타입 번호라기보다는 메세지 프로토콜이 소속하는 프로토콜 그룹이라 해야 할 것이다. 프로토콜 그룹은 그렇게 많지 않을 것이다. 그래서 나는 type 의 범위를 0부터 255사이로 제한하기로 했는데 이러면 한개의 바이트로 표현할 수 있게 된다. 구현할 때 나는 type 을 size 파라미터의 상위 8 bit 에 인코딩해 넣었다. 하나의 메세지 패킷의 제한 길이는 1600 만개 ( 24 bit ) 안이므로 합리적인 제한이라 본다. 그렇게 함으로 추가의 메모리 코스트 없이 모든 메세지에 type 필드를 넣을 수 있게 되었다.

skynet.h 파일을 열어보면 아래와 같은 메세지 타입들이 이미 존재함을 확인할 수 있을 것이다.

#define PTYPE_TEXT 0
#define PTYPE_RESPONSE 1
#define PTYPE_MULTICAST 2
#define PTYPE_CLIENT 3
#define PTYPE_SYSTEM 4
#define PTYPE_HARBOR 5
#define PTYPE_TAG_DONTCOPY 0x10000
#define PTYPE_TAG_ALLOCSESSION 0x20000

0 은 우리 내부적으로 가장 흔히 사용되는 텍스트 메세지 타입이다. 1 은 이것이 리스폰스 패킷임을 의미하는데 상대방의 규범에 따라 디코딩해야 한다. 우리가 정의한 기타 종종 타입은 일단 해석을 하지 않겠다. 더 많은 타입을 자기 마음대로 정의할 수 있다. 예를 들어서 우리는 Lua 레이어에서 일부 Lua State 간의 통신만을 위한 메세지 타입들을 정의했다.


클러스터 (Cluster) 간 통신

비록 설계상 싱글 프로세스, 멀티 프스레딩을 컨커런시 모델로 설계했지만 사실 skynet 은 싱글 프로세스로만 사용될 수 있는게 아니다. 사실 얘는 서로 다른 기기들에 배포하어 협동 실행될 수 있다. 이것이 코어층의 핵심 피처가 아니기는 하지만 코어층에서는 이걸 지원하기 위해 많은 협력을 했다.

한개의 skynet 프로세스내의 서비스의 수량은 handle 수량의 제한을 받는다. Handle 은 서비스의 주소이고 인터페이스 시점에서는 한개의 32 bit 정수이다. 하지만 실제로 하나의 프로세스 내 (역자주: 원문에서는 “하나의 서비스 내” 라고 했는데 역자는 이것은  기술 오류라 판단한다) handle 의 수량은 24 bit 내로, 즉 1600 만개 안으로 제한되어 있다. 상위 8 bit 는 클러스터간 통신을 위해 보류한 것이다.

결과적으로 255 개의 skynet 노드가 서로 다른 기기에 배포되어 실행될 수 있도록 지원하게 한다. Skynet 노드들은 서로 다른 id 를 갖고 있는데 우리 시스템에서는 harbor id 라고 부른다. 이건 독자적으로 지정되는 것이어서 인위적으로 배분하는 것이다 (중앙 서비스를 만들어 어렌지를 책임지게 할 수도 있다). 하나의 메세지 패킷이 생성될 때 skynet 프렘워크는 자신의 harbor id 를 source 주소의 상위 8 bit 에 인코딩해 넣는다. 이렇게 하면 시스템 내 모든 서비스 모듈들은 모두 서로 유니크한 주소를 갖게 되는 것이다. 그리고 숫자 주소만 보면 이것이 리모트 메세지인지 아니면 로컬(기기) 메세지인지 쉽게 판단해낼 수 있게 된다.

이것도 skynet 코어층에서 하는 일이다. 코어층은 원격 데이터 인터랙션을 해결하지 않는다.

클러스터간의 통신은 하나의 독릭적인 harbor 서비스가 책임진다. 모든 메세지는 발송될 때 skynet 은 그것이 원격 메세지인지를 판단해내고 원격메세지라면 harbor 서비스에 던진다. Harbor 서비스는 tcp 커넥션을 열어 자신이 알고있는 다른 모든 skynet 노드내의 harbor 서비스에 연결한다.

Harbor 들간에는 단방향 tcp 커넥션 파이프를 통하여 데이터를 전송함으로 skynet 노드들 간의 데이터 교환을 실현시킨다.

Skynet 은 현재 글로번 네이밍 서비스를 제공하는데 하나의 메세지 패킷을 특정 이름의 서비스에 발송할 수 있도록 한다. 목적지 서비스는 꼭 현재 skynet 노드 내에 있어야 할 필요는 없다. 그러므로 우리는 이런 글로벌 네임들을 동기화 할 수 있는 메커니즘이 필요하다.

이런 목적으로 우리는 master 라는 서비스를 만들었다. 얘의 작용은 브로드캐스팅으로 모든 글로벌 네임들을 동기화 하고 새로 가입된 skynet 노드의 주소를 동기화 하는 것이다. 본질상 이런 주소들도 일종의 네임이며 역시 key-value 형식으로 저장할 수 있다. 즉, 모든 skynet 노드는 하나의 문자열 주소를 갖게 된다.

멀티캐스팅 (Multicasting)

게임서버에 있어서 멀티캐스팅은 한가지 중요한 최적화이다. 멀티캐스팅이 없다면 우리는 하나의 반복문을 만들어 하나의 메세지 패킷을 서로 다른 주소에 하나하나 보낼 수 있다. 하지만 게임내에는 하나의 메세지를 많은 객체들에 보내야 하는 경우가 굉장히 많다. 일반적으로 이런 멀티캐스팅이 필요한 경우에 우리는 모든 객체를 하나의 스레드 안에 넣는다. 하지만 skynet 을 사용할 때에는 하나의 서비스가 하나의 작은 기능만 구현함으로 서비스간 통신을 하드하게 의존할 수 밖에 없다. 그럼으로 이런 컬티캐스팅을 최적화함이 더욱 필요해지게 된다.

멀티캐스팅은 skynet 코어층이 일부 협조를 해줘야만 된다. 전부 코어층 밖에서 할 수가 없었다.

왜냐면 멀티캐스팅 패킷의 (메모리) 할당과 릴리즈 정책은 일반 패킷과 다르기 때문이다. 일반 패킷은 발신자가 할당하고 수신자가 릴리즈하는데 얜 레퍼런스 카운팅이 필요하다. 물론 우리는 모든 메세지 패킷이 전부 레퍼런스 카운팅이 있도록 설계할 수 있는데 그럴 경우에 싱글 캐스팅 패킷은 레퍼런스 카운트가 1 인 특례이기만 하면 된다. 하지만 이러면 성능을 다소 희생해야 한다. 내가 원하는 바는 특정 메커니즘(일례로 멀티캐스팅)이 필요하지 않다면 그걸 위한 성능 코스트를 지불하지 않는 것이다. 그래서 나는 skynet 밑단에서 한정적인 지원을 했다.

Skynet 은 메세지의 타입이 PTYPE_MULTICAST 인지여부를 체크하고 그에 따른 다른 생명주기 관리정책을 취한다. 멀티캐스팅 패킷은 멀티캐스팅 서비스에 넘겨 처리하도록 한다. 이런 접근은 클러스터간 통신의 처리방식과 매우 흡사하다.

멀티캐스팅 서비스는 서로 다른 노드 기기에 배포되어 있는 서비스들의 그룹핑을 해결하지 않는다. 즉 하나의 캐스팅 그룹내의 구성원들은 반드시 동일 프로세스내에 있어야 한다. 이런 제한을 둠으로써 설계를 굉장히 간단화 할 수 있었다. 사용자는 여러 서비스의 handle 이 하나의 그룹 넘버에 소속되어 있게 할 수 있다. 그리고 나서 skynet 한테 이 그룹 넘버가 해당되는 handle 들을 전부 달라고 할 수 있다. 이 그룹의 handle 에게 메세지는 보내는 것은 그룹에 소속되어 있는 모든 handle 들에게 모두 메세지를 보내는 것과 같다.

그러면 크로스 클러스터 그룹핑은 어떻게 할 것인가? 여기서 우리는 윗단에서 Lua 로 한층 더 래핑을 했다.

우선 “tunnel” 이라 불리는 C 로 구현한 간단한 서비스를 제공했다. 이 물건은 자신에게 보내지는 메세지를 무조건 다른 하나의 handle 에 리다이렉팅 해준다. 이 리다이렉팅 handle 은 타 skynet 노드에 위치할 수 있다.

Lua 로 글로벌 그룹핑 매니저를 만들어 서로 다른 노드내에서 같은 이름의 그룹을 만들어낸 후에 tunnel 서비스로 서로 다른 노드들의 동일 그룹을 이어주면 끝! 자세한 구현 디테일은 여기에 장황하게 늘어놓지 않겠다.


Skynet 의 핵심 기능은 오직 메세지를 보내는 것과 메세지를 처리하는 것이다. 이 점은 skynet_send 와 skynet_callbak 두 api 에서 모두 체현이 된다. 원래 나는 네이밍 서비스를 밑단에 둘 생각이 없었다. 하지만 역사적인 레거시 원인으로 skynet_sendname 이란 api 를 만들었다. 숫자 주소도 문자열 이름을 갖고 있는데 “:” (콜론) 으로 시작되고 8 바이트의 16 진수 문자열(헥스 스트링)으로 표기된다. 동일 노드내 서비스의 주소는 “.” (점) 으로 시작되고 글로벌 네임은 기타 스트링이다. 코드와 프로토콜의 간단성을 위해 일부 작은 제한들을 두었다: 글로벌 네임은 16 바이트를 넘을 수 없다.

하지만 skynet 자체는 다른 일부 api 가 있어야만 작동할 수 있다. 이를테면 서비스를 켜기, 끄기, 그룹 관리, timer 관리 등등…

이 모든 것들은 전부 일종의 서비스 형식으로 제공할 수 있다. 하지만 이렇게 되면 서비스간의 통신 프로토콜을 정해야 된다 그리고 크로스 서비스 호울은 자기 나름의 단점들이 있는데 바로 한번의 크로스 서비스 리모트 호출을 하면 성능적으로 많은 손실이 없다고 하더라고 서비스내 상태변경에 대해 예언하기 어렵다는 문제에 봉착하게 된다. 즉, 리모트 리퀫을 하나 보낸뒤 그후 받은 메세지 패킷이 이번 리퀫에 대한 응답이라는 보장이 없다는 것이다. 이 패킷은 다른 서비스가 당신에 대한 리퀫일 수도 있으며 이 리퀫이 당신의 메모리 상태를 변경시킬 수 있다는 것이다.

하지만 이런 인프라 서비스들 (위에 나열한 서비스 런칭, 그룹 관리, timer 모듈 등) 은 잘 바뀌는 부분이기도 해서 하나하나 C API 로 만는 것도 좋지 않았다. 그래서 나는 절충 방안을 내놓았다:

Skynet 은 skynet_command 라는 C API 를 제공함으로 인프라 서비스들에 대한 통일된 입구로 사용한다. 이 함수는 하나의 문자열 파라미터를 받고 하나의 문자열 결과를 반환한다. 이것도 일종의 텍스트 프로토콜이라고 볼 수 있다. 다른 점은 skynet_command 는 호출 과정에 현재 서비스 스레드에서 스위칭해나감을 방지할 수 있어 상태가 변경되어버리는 불확실성을 방지할 수 있다. 특정 기능의 구현은 전부 skynet 소스 코드 내부에 들어가있는데 윗층 서비스로 구현하기 보다 훨씬 효율적일 것이다. (여러가지 메모리 api 를 직접 액세스 할 수 있어 메세지 통신으로 구현할 필요가 없기에)


Skynet 의 메세지 스케쥴링

Skynet 은 총 2급의 메세지 큐를 갖고있다.

서비스 객체들마다 자기 자신만의 메세지 큐를 갖고 있고 큐 안에는 하나하나의 자기에게 보내진 메세지 이다. 메세지는 네 부분으로 구성되였다:

struct skynet_message {
    uint32_t source;
    int session;
    void * data;
    size_t sz;
};

어떤 서비스에 메세지를 보낸다는 것은 바로 이런 메세지를 이 서비스의 사유 메세지 큐에 넣는 것이다. 이 struct 의 값이 메세지 큐에 들어가는 것이지 메세지의 내용 자체는 복제되지 않는다.

Skynet 은 하나의 글로벌 메세지 큐를 유지하고 있는데 안에는 여러 비지 않은 차급 메세지 큐가 들어있다.

Skynet 이 시작시 여러개의 워커 스레드를 만단다(수량은 설정 가능). 워커 스레드들은 끊임없이 메인 메세지 큐에서 한개의 차급 메세지 큐를 꺼낸 뒤 다시 그 차급 메세지 큐에서 한개의 메세지를 꺼내 해당 서비스의 callback 함수를 호출해준다. (역자주: 메세지 큐 샤딩?) 호출의 공평성을 위해 한번에 모든 메세지를 싸그리 소진시키는 것이 아니라 (비록 이렇게 구현하는 것이 국부적 효율을 최대화 할 수 있지만) 한번에 하나의 메세지만 처리한다. 이렇게 하면 굶어죽는 서비스가 없도록 보장할 수 있다.

유저가 정의한 callback 함수는 스레드 안전성을 보장할 필요가 없다. 왜냐하면 callback 함수가 호출되어 실행되는 기간동안 다른 워커 스레드는 모두 이 callback 함수가 소속되어있는 서비스의 차급 메세지 큐를 액세스할 수 없기 때문이다. 그래서 병렬성 문제가 없어지는 것이다. 어느 서비스의 메세지 큐가 비게 되면 그의 메세지 큐는 다시 글로벌 메세지 큐에 넣어지지 않는다. 그럼으로 대부분을 차지하는 일감 없는 서비스는 CPU자원을 쓸때없이 소모하지 않게 된다.

그나저나 이 부분 코드를 짤 때 나는 일부 병렬성에 관련된 버그들에 봉착하게 됬었다 ( http://blog.codingnow.com/2012/08/skynet_bug.html ). 다행히 결국 다 해결했다. 이게 아마 전체 시스템에서 병렬성 문제가 가장 복잡한 부분일 것이다. 하지만 뭐 그래봤자 이 작은 부분일 뿐 병렬성 관련 복잡성이 다른 부분으로 번지지 못하게 되어있다.


Gate 와 Connection

이상 얘기한건 모두 skynet 의 내부 작동방식이다. 하지만 하나의 완정한 게임서버는 불가피하게 외계와 통신해야 한다.

외계와의 통신은 두가지가 있다. 한가지는 게임 클라이언트가 TCP 를 사용하여 skynet 노드에 연결하는 것이다. 당신이 게임에 관심이 없다면 다른 시점에서 관찰해보도록 하자. 당신이 skynet 으로 하나의 web 서버를 만든다고 하자, 그러면 “게임 클라”는 하나의 브라우저에 해당하게 된다.

다른 한가지는 서드 파티 서비스이다. 예를 들어서 DB 서비스. 이런 서비스는 하나 또는 여러개의 TCP 커넥션을 받을 수 있다. 사용자는 skynet 내부에서 TCP 커넥션을 만들어서 접속해 들어가 사용해야 한다. 물론 완전히 skynet 인터페이스 규범으로 구현된 데이터 베이스를 사용할 수도 있고 그러면 효율도 더 좋겠지만 현실적으로 그건 어렵다. 할 수 있는 건 오직 메모리 cache 같은 거나 만드는 것 (일례로 내가 10 줄도 안되는 Lua 코드로 만든 하나의 간단한 key-value 메모리 DB 프로토타입)

전자를 나는 gate 서비스라고 부른다. 그 특징은 하나의 TCP 포트를 바인딩하고 외부에서 접속해들어오는 TCP 커넥션을 받으며 커넥션에서 받은 데이터를 skynet 내부로 전달하는 것이다. Gate 는 외부 데이터 패킷과 내부 메세지 패킷의 불일치성을 메꿔주는 작용을 한다. 외부 TCP 스트림의 패킷타이징 문제는 gate 의 구현상의 약속이다. 나는 이와 같은 gate 서비스를 구현했다: Big Endian 의 두 바이트로 패킷의 사이즈를 나타낸다. 이 모듈은 얼마전 나의 한 서버 프로젝트 ( http://blog.codingnow.com/2012/04/mread.html ) 에 기반한 것이다. 이론상 나는 보다 더 범용적으로 구현해 컨피그를 통하여 여러가지 패킷타이징 방안을 지원할 수 있게 구현할 수도 있었다 (이 방면에서 Erlang 이야말로 전면적인 지원을 하고 있다). 하지만 나는 코드의 간단성을 더욱 원했다. 물론 skynet 으로 범용 web server 를 만든다면 이 gate 는 다소 적용되지 않을 것이다. 하지만 커스터마징된 gate 서비스를 다시 하나 만드는게 어려운 일이 아니다. Web server 를 위한 커스터마이징 gate 를 하나 만드는건 심지어 더 쉽다. 패킷타이징이 더이상 필요하지 않기 때문에.

Gate 는 외부의 커넥션을 accept 하고 커넥션 관련 정보들을 다른 한 서비스에 넘겨 처리하도록 한다. Gate 자신이 데이터 처리를 하지 않는 것은 gate 구현의 간결성을 유지하고싶었기 때문이다. C 언어는 이 작업을 하는데 충분하다. 반면에 패킷 처리작업은 비즈니스 로직과 긴밀하게 관련되여있기 때문에 우리는 Lua 를 사용하여 구현할 수가 있다.

외부정보는 두가지가 있다. 한가지는 커넥션 자체의 연결과 끊김 메세지이고 다른 한가지는 커넥션상의 데이터 패킷이다. 처음에 gate 는 무조건적으로 이 두가지 메세지를 같은 서비스에 포워딩해 처리하도록 구현되었다. 하지만 연결 데이터 패킷에 패킷 헤더를 추가한다는건 성능상의 코스트가 있을게 뻔하다. 그래서 gate 는 다른 한가지 작동모드를 지원한다: 서로 다른 커넥션상의 데이터 패킷을 서로 다른 독릭된 서비스에 보내는 방식이다. 한개의 독립적인 서비스당 한개 커넥션상의 데이터 패킷만 처리한다.

또 한가지 방식이 있다. 우리는 서로 다른 커넥션상의 데이터 패킷들을 컨트롤 패킷 ( 커넥션 오픈 / 클로즈 ) 들로부터 분리시키되 서로 다른 커넥션 상의 데이터 패킷들을 구분없이 전부 동일 패킷처리서비스에 보낼 수 있다 (데이터 소스에는 민감하지 않고 오직 데이터 내용에만 민감한 경우에).

상술 세가지 모드를 나는 각각 watchdog 모드, agent 모드 와 broker 모드라고 이름한다.

  • Watchdog 모드: gate 가 패킷 헤더를 덮어씌워 컨트롤 정보와 데이터 정보를 통튼 모든 정보를 처리한다.
  • Agent 모드: agent 마다 독릭적인 커넥션을 처리한다.
  • Broker 모드: 하나의 broker 가 모든 커넥션들상의 모든 데이터 패킷을 처리한다.

어떤 모드이든 컨트롤 정보는 모두 watchdog 이 처리한다. 하지만 데이터 패킷들은 watchdog 을 거치지 않고 직접 agent 나 broker 에 보낸다면 별도의 패킷헤더를 피면할 수 있다 (데이터 복제도 줄어든다). 이러한 패킷들이 외부에서 보내온것인지를 판단하는 방법은 메세지 패킷의 타입이 PTYPE_CLIENT 인지여부를 판다하는 것이다. 물론 사용자는 자기 나름의 메세지 타입을 커스터마이징해 gate 가 사용자에게 통보하게 할 수 있다.

Skynet 의 인프라 서비스들중 클러스터간 통신 부분에서는 이미 gate 모듈을 구현의 일부로 사용했다. 하지만 gate 모듈은 단지 하나의 순수한 skynet (일반) 서비스이며 skynet 의 외부 공개 api 만 사용했으며 skynet 내부 구현의 그 어떤 정보도 의존하지 않았다. Harbor 모듈이 gate 를 사용할 때 채택한 모드는 broker 모드이고 PTYPE_HARBOR 란 메세지 패킷 타입을 커스터마이징 하여 사용했다.

오픈한 리포지토리의 예시 코드에서 우리는 하나의 간단한 gate 서비스를 가동했고 해당하는 watchdog 과 agent 도 가동했다. 첨부된 라이언트 프로그램으로 연결할 수 있으며 텍스트 프로토콜로 skynet 과 상호작용 할 수 있다. Agent 는 client 의 모든 입력을 skynet 내부의 simpledb 서비스에 전달한다. Simpledb 서비스는 하나의 간이한 key-value 메모리 데이터베이스이다. 클라이언트는 간단한 DB 쿼리와 업데이트를 할 수 있다.

주의할 점! Gate 는 외부 데이터를 읽어들이는 것만 책임지지 데이터를 되돌려 쓰기하는 것은 책임지지 않는다. 즉 이런 커넥션들에 데이터를 보내는 것은 gate 의 직책범위가 아니다. 예시로 skynet 오픈소스 프로젝트는 service_client 라는 하나의 간단한 write back 서비스를 구현했다. 이 서비스를 가동시키면서 하나의 fd 를 바인드 해주면 이 서비스에 전송되는 모든 메세지 패킷들은 모두 2바이트의 사이즈 패킷이 씌워져서 해당 fd 에 write back 된다. 서로 다른 패킷타이징 프로토콜에 근거해 자기 맘대로 client 서비스를 만들어 외부 커넥션에 데이터를 보내는 모듈을 만들 수 있다.


다른 하나의 중요한 모듈은 Connection 이라 한다. Gate 모듈과 달리 얘는 skynet 내부에서 socket 을 만들어 외부에 접속하는 일을 책임진다.

Connection 은 두 부분으로 구성되어 있다. 한 부분은 서로 다른 시스템 fd 의 읽기 가능성 상태를 감시하는데 이건 epoll 로 구현했다. epoll 이 없는 환경 (예를 들어 freebsd ) 에서는 쉽게 다른 대체품을 찾을 수 있을 것이다. 얘가 이 커넥션상의 데이터를 받았을 때는 모든 데이터를 아무런 패킷타이징 없이 다른 하나의 서비스에 보내서 처리하도록 한다. 이건 gate 와 작동방식이 좀 다른데 이것은 connection 이 주로 외부 서드 파티 디비에 사용되기 때문에 그 패킷타이징 포맷을 통일시키기 어렵기 때문이다.

다른 한 부분은 Lua 와 관련된 밑단 서포트 라이브러리이다. 커넥션 연결을 해줄 수 있고 커넥션상의 데이터들에 일부 자주 사용되는 패킷타이징 규칙들이 포함되어 있다.

나는 Redis 서포트 모듈을 구현했다. Redis 가 사용한 텍스트 프로토콜은 파싱하기가 비교적 쉬워서 이 물건의 사용 방법을 설명하는데 이롭기 때문이다. Redis 서포트 모듈에서 connection 서비스로 하나의 tcp socket 을 감시하고 그 어떤 데이터가 도착한 것을 발견하기만 하면 보내온다. 이렇게 함으로 서비스안에서 블락킹 방식으로 외부의 TCP 커넥션을 읽을 필요가 없게 된다. 모든 skynet 내부 서비스들은 모두 외부 IO 를 블락킹 사용하지 않을 것을 권장한다. 그렇게 하면 CPU 낭비가 발생하기 때문이다.

메세지 패킷의 type 으로부터 우리는 그 패킷들이 tcp 커넥션상의 데이터 블락임을 쉽게 판단할 수 있다. 제공된 Lua 모듈들로 우리는 쉽게 이 데이터들을 패킷타이징 할 수 있다 (지정된 바이트수만큼 데이터를 읽어들이거나 혹은 줄바꿈으로 끝나는 텍스트 한줄을 읽어들이기). Lua 의 코루틴 지원으로 우리는 어렵지 않게 데이터 패킷이 완정하지 않을 때 펜딩하되 실행 흐름을 끊기지 않게 할 수 있다.

Connection 모듈을 사용한 다른 한 예는 console 서비스이다. Github 에 올린 리포지토리에도 하나의 간단한 Console 모듈이 포함되어 있다. 이 모듈은 프로세스의 표준 입력을 줄바꿈 단위로 읽어들일 수 있으며 유저가 입력한 텍스트 라인을 통해 Lua 로 만든 서비스를 가동시킬 수 있다. 코드가 아주 짧아서 작동 방식을 이해하기가 아주 쉬울 것이다.


Lua 레이어의 설계

Lua 는 skynet 의 표준시설이다. 이는 필수가 아니지만 사실 아주 많은 부분에 사용되었다. 비록 다른 언어 예를 들어서 python 으로 lua 를 대체할 수 있지만 난 이럴 계획이 없다.

Lua 의 밑단에서 skynet 은 skynet 의 기본 C API 들을 래핑했다. 하지만 사용자는 이런 밑단 API 를 사용해 C 언어의 사고방식으로 서비스를 만들 필요가 없다.

Lua 의 코루틴 도움하에 우리는 C 레이어에선 서로 분리된 하나하나의 callback 호출들을 로직상 연속적인 실마리로 체이닝 (chaining) 할 수 있다. Lua 로 만든 서비스가 하나의 외부 리퀫을 받았을 때 해당 밑단 callback 함수가 호출되며 이어서 Lua VM 에 전달된다. Skynet 의 Lua 레이어는 매 리퀫마다 하나의 독립적인 코루틴을 만든다.

이 리퀫을 처리하는 코루틴에서 리모트 호출이 발생한다면, 즉 하나의 메세지 패킷을 발송한다면 이 코루틴은 펜딩된다. C 레이어에서 이번 콜백은 정상 리턴되었다. 하지만 Lua 에서는 이번 발송한 메세지의 session 을 기억하고 session 과 펜딩된 코루틴을 하나의 테이블에 기록한다. 그리고 나중에 수신한 응답 패킷에 같은 session 이 있으면 해당 코루틴을 깨워 resume 한다.

서비스마다 서로 다른 프로토콜 그룹을 사용할 수 있는데 밑단에서는 type 파라미터로 구분한다. Lua 레이어에서는 서로 다른 type 에게 서로 다른 dispatch 함수를 만들 수 있다. 디폴트로 RESPONSE 메세지에 대한 처리방법만 제공한다. Lua 서비스들은 모두 각자 지원할 프로토콜 타입의 처리함수를 만들어야 한다.

예를 들어 나는 이미 한가지의 RPC 지원 방법을 제공했다 ( http://blog.codingnow.com/2012/08/dev_note_25.html ). 사용자는 이걸 사용해도 되고 사용하지 않아도 된다. 사용하려 한다면 해당 Lua 모듈을 require 하기만 하면 그 처리함수는 자동적으로 메세지 디스패처에 주입된다.

다수의 lua 서비스들은 한가지 간이한 메세지 인코딩 프로토콜을 사용할 수도 있는데 나는 이것을 lua 프로토콜이라고 부른다. 왜냐면 이것은 간단히 lua 가 지원하는 타입을 직렬화시킨 것이다. 다른 lua 서비스는 이걸 쉽게 풀 수 있다. 이렇게 하면 (lua 로 만든) 원격 서비스를 요청하는 것을 로컬 함수를 호출하는 것만큼이나 쉬워진다.

만약 (프로그래밍)언어간 호환성을 염두에 둔다면 텍스트 프로토콜을 사용할 수도 있다. C 서비스에서 Lua 서비스를 요청하는게 처리하기가 더 쉬울 수 있다. 물론 더욱 많이 사용되는 것은 반대 케이스이다, 즉 lua 에서 C 로 만든 인프라 서비스를 요청하는 것이다. 만약 lua 규범이나 유사한 인코딩 방식을 사용한다면 C 서비스는 구현하기가 상대적으로 복잡해진다. 하지만 스페이스로 연결한 문자열 파라미터는 C 언어에서 sscanf 로 쉽게 파싱할 수 있다.

Written in September 3, 2012.

Translated in December 20, 2015.

원문링크:

http://blog.codingnow.com/2012/09/the_design_of_skynet.html

Advertisements

댓글 남기기

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s