| Linux RealTime Signal 기반의 온라인 게임 서버 엔진의 구현 문서 위치: http://chonga.pe.kr/computer/linux/linux-netengine/ 최초 작성일: 2002-11-05 최종 수정일: 2003-04-21
목차 0. 머리말 1. 개요 및 소개 2. 이론 및 배경 3. 구현 과정 4. 결론 및 검토 5. 참고 문헌 0. 머리말 본 문서의 저작자는 이홍기(orinmir _at_ gmail.com)이며, 이 곳에 사용된 자료의 내용과 자료의 그림에 대한 발췌 및 수정 후 재 배포시 저자에 대한 정보를 포함해야하며 그 때에는 저자의 허락을 받도록 한다. 1. 개요 및 소개 Linux는 OS 개발의 역사에 있어서 Open source project의 견인차 역할을 하고 있는 OS로 embedded system에서부터 고성능 서버의 OS에 이르기까지 주목 받고 있다. 온라인 게임은 현대 게임 산업의 화두다. 지역 네트웍이나 30명 미만의 동시 접속으로만 이루어진 형태의 초기 온라인 게임에서 지금은 동시접속 1만명 이상을 처리하는 대용량 온라인 게임의 등장으로 관련 분야에 대한 관심이 높다. 실제로 온라인 게임 서버는 일반적인 네트웍 기술을 기반으로 특화된 형태의 독립서버라 볼 수 있다. 온라인 게임 서버의 범주에서 볼 때, 고성능이면서 대용량의 처리를 하려면 패킷의 송수신 처리부와 데이터 처리부의 최적화가 핵심이며 이에 대한 여러가지 구현 방법이 존재한다. TCP/IP 기반의 네트웍 환경에서 일반적으로 사용되는 BSD socket의 고전적인 구현 방법으로는 select/poll을 이용한 io multiplexing(혹은 polling 방식)이 오래된 만큼 안정적이다. 하지만 이 방법은 말 그대로 kernel에서 처리되는 network io를 주기적인 polling에 의해 감시하므로 고성능 대용량 서버에서는 불필요한 CPU 부하를 유발하며 분명한 한계가 존재한다. 이에 대한 대안으로 여러 가지 vendor별 개선안이 나와있으며, linux에서는 RT Signal을 사용한 개선방법이 제공된다. 본 글에서는 이 RT Signal 방식을 이용한 네트웍 처리부를 통해 기존 방식에 비해 개선된 소켓 서버를 이용해 게임 서버의 기본 엔진을 구현에 대해 정리하고자 한다. 성능에 대한 평가는 추후 작업으로 남겨둔다. 2. 이론 및 배경 BSD socket의 태동과 함께 하나 이상의 소켓 접속을 처리하는 서버에서 사용된 방법은 io multiplexing 방식으로 select()와 poll() 시스템 콜을 통한 polling 방식이었다. 이 방법은 지금까지도 unix 서버 프로그램의 대부분에서 사용되는 방식 중의 하나이며 전통적인 unix 프로그램은 이 방식과 함께 프로그램의 구조 역시 single thread을 선호하였다. Unix kernel은 초기에 multi threaded application에 대한 지원이 없이 설계되었기 때문이기도 하다. 현재에 이르러서는 unix에서도 SMP(Symmetric Multiprocessing) 시스템의 범용화와 더불어 posix thread를 대부분 지원하고 있고, 이와 더불어 전통적인 polling 방식이 시스템 자원의 낭비를 초래하고 있어 개선점을 찾으려는 시도가 많아졌다. 이를 해결하기 위해 표 1)과 같이 여러가지 개선 방식을 vendor 별로 지원하고 있으며, 최근에 들어서 linux에서도 여러 가지 패치를 통해 새로운 socket 처리 방식을 지원하고 있다. 표 1) 소켓 서버의 vendor별 개선 방식 | 개선 방법 | 설명 | 안정성 | MS Windows | IOCP(io completion port)[iocp] | GetQueuedCompletionStatus() 호출을 통해 커널로부터 직접 socket event queue를 받음. | 대부분의 상용 게임서버에서 채용 | Solaris | /dev/poll [devpoll1] | Poll()방식의 완벽한 대체, 장치 개념으로 처리 | sun에서 지원 | FreeBSD | kqueue/kevent [kqueue] | event poll과 유사 | 안정적이라는 평 | Linux | Realtime signal[rtsig1] | Sigwaitinfo()호출을 통한 POLL_IN event 받음 | Signal overflow 단점 | Realtime signal/fd | Realtime signal 방식과 같으나 Signal overflow 문제 해결 | 몇가지 웹서버에서 테스트 됨 | /dev/poll | Solaris /dev/poll과 유사 | Sun의 /dev/poll 보다 성능 월등히 떨어짐[devpoll2] | /dev/epoll [rtsig2] | Solaris /dev/poll과 유사 | 현재 테스트 코드 나옴 /dev/poll보다 성능이 뛰어남. |
각 vendor별 지원 방식은 대동소이하며 방식은 커널로부터 관심 있는 socket 중, 이벤트가 있는 것만 받아오는 식이다. 기존의 polling 방식이 갖는 문제점은 single thread 기반이어서 timeout 시간을 두면서 looping을 하도록 되어 있다는 점이다. timeout 이후의 시간에 다른 processing을 할 수 있도록 하는 것이 일반적이다. 이와 더불어 최대 1000개의 소켓 접속을 받을 수 있다면, 1000개의 소켓에 대해 매 시간 polling을 하여 event의 발생 유무를 확인하도록 되어 있다. 그림 1)과 같이 polling 방식은 Kernel space에서 가진 1000개의 소켓 정보를 그대로 User space로 복사해 온 후, 전체 목록을 들면서 비교하는 방식을 취한다. 높은 성능을 내기 위해서는 timeout 시간을 적게하여 검사시간을 짧게하여 Packet 전송, 수신 효율을 높여야하는 문제점과 사용하지 않는 소켓 정보를 모두 검사해야하는 문제점이 있다. 따라서 그림 2)와 같이 일반적으로 이 구현을 고성능 서버에서 사용하는 경우 1개의 소켓 접속만 이루어져도 이 프로세스에 의해 CPU 사용률이 90% 이상으로 증가하며, 소켓 접속이 많아지면 많아질수록 응답 속도도 떨어질 가능성이 있다.
그림 1) polling 방식(select() )과 RTsignal 방식의 비교
그림 2) polling 방식의 구현 예 본 글에서 Linux의 RTSignal을 도입하게 된 것은 기존 방식의 대안 중 하나이면서 linux kernel 2.2부터 RealTime Signal(Posix RealTime Signal)을 지원하기 시작했기 때문이다. Linux의 RT signal방식은 과부하 상황에서 overflow가 일어나는 기존 signal 기반 프로그램들보다는 명확하게 polling 방식을 대체할 수 있는 방법이다. (poll()에서 사용하는 POLL_IN, POLL_OUT, POLL_ERR 등을 그대로 사용할 수 있다.) RT Signal의 장점이라면, signal을 등록해놓고 해당하는 이벤트가 발생하기 전까지는 sigwaitinfo system call을 통해 대기하고 있다가, 이벤트가 발생하면, 정해둔 signal handler를 통해 처리를 하며 timeout도 지원한다. 기존의 구현 방식에 비해 낮은 CPU점유율과 빠른 응답 속도, high load에서도 안정되고 일관된 성능을 유지하고 있다. 이러한 방식을 또한 event-dispatch 방식이라고도 한다. [rtsig3] 하지만 기본적으로 제공되는 RT Signal의 단점은 제한된 signal resource로 인해, 고부하에서 signal overflow가 되면 더이상 새로운 접속을 처리할 수 없게 된다. 이 때에는 기존의 poll()쪽으로 처리를 돌려 해결하는 혼합된 구현 방법을 함께 쓰기도 한다. 기본적으로 linux에서 지원하는 방법은 기존의 polling 방식과 병행해야하는 문제점이 있다.[phhttpd] 최근에 이르러 이를 극복한 방법으로 1개의 file descriptor당 하나씩 signal을 할당하고 그 개수 만큼 가지게 하여, 자원 만큼을 수용할 수 있는 방식의 패치가 Luban에 의해 제안되었다. 이 프로젝트에서는 이 방식을 사용하여 작성하기도 결정하였다. 타 OS의 개선 방법에 비해 linux에서 새로 도입된 이 방식은 EWOULDBLOCK 등에 대한 에러 처리를 지원하지 않기 때문에 이에 대한 것을 설계 시점에서 같이 해주어야 했다. 따라서 그림 3)과 같은 producer/consumer구조를 도입했다. 우선 IO Event Queue를 두어 같은 형태로 POLL_IN, POLL_OUT, POLL_REMOVE를 처리하도록 하였는데, 이 구조는 INPUT event는 sigwaitinfo()의 호출에서 POLL_IN에 해당하는 socket read event가 발생하였을 경우에 wake up이 되고 IO Event Queue에 push함으로써 thread의 기반 구조에서 producer의 역할을 하게 된다. 마찬가지로 게임 서버가 실행되면서 이벤트가 발생하는데 이때 POLL_OUT이라면 이에 해당하는 socket write event로 정의하고 객체의 object가 제거되면 POLL_REMOVE에 해당하는 socket close event를 발생시켜 IOEvent Queue에 producer thread의 역할을 하게 된다. Consumer thread는 Event Dispatching Thread를 통해 IO Event Queue를 pop을 하여 각각의 경우를 read(), write(), close()에 대응시켜 이벤트를 처리하며, 각각의 시스템 콜에서 EWOULDBLOCK과 같은 socket error를 만나면 또 다시 IO Event Queue에 그 상황에 맞는 Event를 만들어 다시 넣어주는 과정을 통해 소켓의 reading/ writing buffer 제어를 해준다.
그림 3) RealTime Signal I/O의 구현 결론적으로 RTSignal 방식을 사용함으로써 기존 방식에 비해, 시스템 자원을 효율적인 활용 할 수 있고, event dispatch방식으로 threaded application에 적합한 구조로 쓰여질 수 있다. 예상되는 문제점으로는 threaded application이 갖는 성능 공유 객체에 대한 thread간 경쟁에 대한 성능 배분을 어떻게 최적화하느냐에 따라 성능이 크게 좌우될 가능성이 높다.
3. 구현과정 앞서 설명한 RT signal을 이용한 소켓 서버의 개선 점을 토대로 polling 방식과 RealTime Signal을 이용한 I/O 부분을 각각 구현하여 기본 소켓 서버를 구현한다. 게임 서버의 기본이 되는 게임 객체 처리와 Packet 분석 및 처리 과정을 통한 게임 서버 엔진을 만들고 엔진 성능 테스트를 위한 더미 클라이언트를 만든다. 개발 환경 | Server | Client | 비 고 | OS | Linux Kernel 2.4.19 | Microsoft Windows | | Compiler | Gnu c/c++ 2.95.2 | Borland C++ Builder 5 | |
Server 의 설정 Server에서 linux kernel은 2002년 11월 25일 현재 최신 안정 버전인 2.4.19를 사용하였다.[liinuxknldn] 대부분의 network server는 socket을 기반으로 하고 있으며, kernel에서 설정하는 socket 개수은 file descriptor의 한계에 따라 제한된다. 따라서 이에 대한 kernel 설정 작업은 필수적이다. 또한 Realtime signal을 사용하기 위해서는 Realtime signal per File descriptor 에 대한 패치를 해야한다. Kernel의 설정과 함께 /dev/epoll 에 대한 세팅도 할 수 있으며 참고적으로 추가했다. | 내 용 | Max socket 설정 에 대한 Kernel 수정 | include/linux/fs.h: increase NR_FILE from 4096 to 65536 increase NR_RESERVED_FILES from 10 to 128 fs/inode.c: increase MAX_INODE from 16384 to 262144 참고: MAX_INODE는 NR_FILE의 3배정도 (2.4.19에서 max없음) Include/linux/limits.h NR_OPEN->15000 | Rtsignal per fd | Luban ‘s patch 적용 [rtsigdn] | dev/epoll | Eventpoll 적용 [epolldn]
Realtime Signal과 더불어 eventpoll도 함께 패치를
적용하고 아래와 같이 char 장치를 하나 만든다.
mknod /dev/epoll c 10 124
| Sysctl 설정 | echo 20 > /proc/sys/net/ipv4/tcp_fin_timeout
echo 8192 > /proc/sys/net/ipv4/tcp_max_syn_backlog
echo 1200 > /proc/sys/net/ipv4/tcp_keepalive_time
echo 1 > /proc/sys/net/ipv4/tcp_syncookies
| Shell 설정 | Shell에서 설정한 socket max에 대해 재설정을 해주어야한다
$ ulimit –n 15000
|
프로젝트는 크게 Server Engine과 ClientEngine 부분으로 나눌 수 있다. (표2) 참조.) 프로젝트의 이름은 물고기 등을 몰면서 논다는 의미의 몰이와 놀이의 합성어로 MoriNori(몰이놀이)로 지었다. 표 2) 프로젝트 모듈 설명 프로젝트 폴더 | 내용 | 파일 명 | ServerEngine | 자료구조 및 Packet 정보 등 의 공유 라이브러리 | Queue* , DLinkList*, BST* | MAP은 n x n 2차원 좌표계 구조로 지원 | Map* | Packet 처리부 | PacketParser* | 소켓과 소켓서버에 대한 라이브러리 | Socket* SocketServer* Network* | 게임 기본 객체의 처리 | ServerBasic* | ClientEngine | 서버와 네트웍 쪽의 기본라이브러리는 공유하며 더미 접속클라이언트 기능과 객체 동기 테스트 기능 | * |
그림 3)의 Realtime signal의 I/O를 Network 라이브러리에 구현을 한 상태로, 사용자 접속을 받아 소켓 버퍼를 제어하게 되면, 그 다음으로는 Game에서 쓰이는 Packet 형태로 가공을 하게 되는데, 그림 4)에서 Network Module에서 Input Packet과 Output Packet의 형태로 CPacket class를 통해 게임으로 들어오고 나가는 Packet이 관리된다. 이 때, 사용자로부터 입력된 Packet을 실제로 소비하는 부분이 Packet Parser이며 Game Object Loop에서 다시 사용자에게 보내지는 Output Packet을 발생시킨다. Packet Parser를 통해 유입된 Packet은 Game Object Loop를 통해 PC(Playerable Character, 사용자가 조종하는 객체) 객체의 행동을 담당하게 되며, 컴퓨터 인공지능으로 PC가 아닌 다른 MONSTER 객체를 처리하게 된다. 또한 Game Global Loop에서 게임내에서의 날씨 변화 등에 대해 처리하며, Output Packet에 영향을 미친다. 여기까지 2가지 Game Loop가 등장하는데 이 것에는 tick이라는 단위가 도입되는데, 이 tick은 한 Loop당 1/10 sec 와 같은 현실적인 Loop 수행에 관련된 응답 시간이 요구된다. 이 tick을 통해 게임내 객체는 tick단위의 동기화가 일어난다.
그림 4) 게임 서버의 구조 게임 클라이언트의 구조는 게임 서버의 구조와 어느 정도는 일치하지만, 대용량의 사용자를 처리하는 개념이 아닌 나 자신이 조종하는 PC 객체의 시야 범위 내에서의 객체 이동에만 관심이 있으므로 네트웍 부분과 Packet 처리에 있어서는 간소화 된다.
그림 5) 게임 클라이언트의 구조 서버 엔진의 구현 [기본 라이브러리] 프로젝트 초기에 기본적인 자료구조와 쓰레드 관련 함수들은 c++기반으로 작성하였다. 기본적으로 class기반의 SGI에서 나온 standard template library(stl)에서 자료구조를 제공하고 있지만 코드의 간명함과 threaded application에서의 저수준에서의 최적화, 실행 바이너리 사이즈를 줄일 수 있는 장점이 있어 대부분을 직접 구현 하였다. Thread에 관련된 라이브러리는 기존의 c thread library로 정의되어 있는 pthread_* 부분을 class로 감싸주었다.[pthread] 클래스 이름 | 설명 | 비고 | CMutex | 객체 상호배제 lock/unlock | | CRWLock | Read/Write Lock | | CCondition | Condition 동기 | | CThread | Posix thread의 create Join, schedule 등을 감싼 Thread 라이브러리 | | CThreadWrapper | Thread의 startup 동기 지원 | CThread를 감쌌다. | CQueue | Locked Queue | CMutex Condition | CQueueLockedPBuffer | Multiple pop queue를 지원한 Locked Queue | 기존 CQueue에 array로 여러개를 pop | CQueueBuffer | BYTE Buffer처리를 위한 Queue | Socket Buffer로 사용 | CDLinkedList | 기본 적인 Locked List | | BST | Locked Binary Searching Tree | |
[네트웍 엔진의 구현] 게임 서버에서의 네트웍 엔진은 일반적인 네트웍 서버의 형태를 따르며, 몇가지 측면에서 성능 개선 위한 방식을 사용한다. 기본적으로 본 프로젝트에서는 비교하고자하는 polling방식과 RTSignal 방식의 큰 차이가 있고, 의사코드에서 보는 바와 같이 client별로 socket을 Add/Delete해주는 Do_AddSocket(), Do_CloseSocket()과 buffering을 해주는 Do_Read(), Do_Write와 같은 부분은 서로 공유하게된다. socket을 accept하는 부분의 부하를 줄이기 위해 하나의 독립된 thread로 처리하였다. accept thread의 의사코드(pseudo-code) while(1) { s = accept ( serversocket) if (is_valid_socket(s) ) { Do_AddSocket(s); } } |
Polling 방식은 관심이 있는 Socket 정보를 서버에서 처리할 수 있는 MAXSOCKET의 FD list에 FD_SET 명령을 사용하여 세팅하고 select() 시스템호출을 통해 넘겨받은 결과 개수에 따라 다시 MAXSOCKET 만큼을 반복하면서 FD_ISSET으로 어떤 종류의 event인지 검사하는 구조다. select polling의 의사코드 while(1) { // socket fdset recompute FD_ZERO(INPUTFD); FD_ZERO(OUTPUTFD); FD_ZERO(EXCEPTFD) For ( s=0 ;s<= MAXSOCKET;s++) { If (is_valid_client_socket(s) ) { FD_SET(s,INPUTFD); FD_SET(s,OUTPUTFD); FD_SET(s,EXCEPTFD); } } resultcount = select (MAXSOCKET+1, INPUTFD, OUTPUTFD, EXCEPTFD, TIMEOUT{sec=0,usec=1000}); For ( s=0 ;s<= MAXSOCKET && resultcount>0 ;s++) { If (is_valid_client_socket(s) ) { If (FD_ISSET(s,INPUTFD)) { resultcount --; Do_read(s); // 실패시 socket 삭제 } If (FD_ISSET(s,OUTPUTFD)) { resultcount --; Do_write(s); // 실패시 socket 삭제 } If (FD_ISSET(s,EXCEPTFD)) { resultcount --; Do_ReadOOB(s); // Out of Bound 긴급 패킷(거의 쓰이지 않음) } } // for } // while |
RTSignal은 socket의 생성과 소멸시에 추가적인 작업이 필요하며 Signal 처리 thread에서Socket IO Event Dispatch에서 사용될 POLLIN/POLLOUT/POLLREMOVE에 해당하는 IOEvent를 생성하며, 게임 루프에서 클라이언트로 패킷을 만들어 내거나 강제로 삭제해야하는 경우 그때 마다 POLLOUT/POLLREMOVE IOEvent를 생성하게된다. 이렇게 생성된 IOEvent는 Event Dispatch Thread 의사코드와 같이 이를 통해 소비된다. 모든 IO Event Queue에서 pop 처리는 CQueueLockedPBuffer를 사용하며 array로 query하여 한꺼번에 처리해여 함수 호출에 대한 overhead를 줄이도록 하였다. RTSignal에서의 Do_AddSocket 호출시에 추가해야하는 정보 fcntl(SOCKETFD, F_SETFL, O_RDWR | O_NONBLOCK | O_ASYNC); fcntl(SOCKETFD, F_SETSIG, SIGRTMIN); fcntl(SOCKETFD, F_SETOWN, getpid()); // pid는 sigwaitinfo가 일어나는 thread pid fcntl(SOCKETFD, F_SETAUXFL, O_ONESIGFD); |
RTSignal에서의 Do_CloseSocket 호출시에 추가해야하는 정보 fcntl(SOCKETFD,F_SETFL,O_RDWR); |
RTSignal에서의 Signal 처리 Thread 의사코드 While (1) { sigemptyset(&signalset); sigaddset(&signalset,SIGRTMIN); pthread_sigmask(SIG_BLOCK, &signalset, NULL); // pthread signal block 무시하기 signum=sigwaitinfo(&signalset,&siginfo); if (signum==SIGRTMIN) { SOCKETFD=siginfo.si_fd; revents=siginfo.si_band; si_code=siginfo.si_code; if (si_code==POLL_IN) { if (revents & (POLLIN)) { insertIOEvent(SOCKETFD,RTSIG_EVENT_POLLIN) } else if (si_code==POLL_OUT) { if (revents & (POLLOUT)) { insertIOEvent(SOCKETFD,RTSIG_EVENT_POLLOUT) } } else { insertIOEvent(SOCKETFD,RTSIG_EVENT_POLLREMOVE) } } } // while |
RTSignal에서의 IO Event Dispatch Thread 의사코드 While (1) { IOEvent = POP_IOEVENT_FROM_QUEUE(); If (IOEvent.event == RTSIG_EVENT_POLLIN) { Do_Read(IOEvent.SOCKETFD); } else if (IOEvent.event==RTSIG_EVENT_POLLOUT) { Do_Write(IOEvent.SOCKETFD); } else { Do_CloseSocket(IOEvent.SOCKETFD); } } // while |
[서버의 객체 처리] 빠른 응답 속도를 위해 UserID를 기반으로하는 BST(Binary Searching Tree)와 SocketID와 UserID에 대응하는 array를 통해 searching 최적화를 시도하였다. 2진 트리로 구성하면 검색 시간이 2분 검색과 같은 0(log2n)이 되고 자료의 집합이 유동적일 때 행렬이나 리스트를 이용하는 것보다 효율적이므로 사용자의 접속이 순차적인 순서가 아닌 유동적인 접속이 이루어지므로 사용자 객체의 목록 관리에 BST를 사용하였다.[ds] 사용자의 객체를 찾기 위해서는 다음과 같은 과정을 거친다 |