공부이야기/Network

최강의 poller를 찾아라! KQueue

coyotek 2008. 4. 2. 22:52
마이크로소프트웨어 9월호
최강의 poller를 찾아라! KQueue
장혜식 (연세대학교 기계전자공학부, maso@raff.to)
Fork, Thread 그리고 Asynchronous
하나의 서버에서 여러 클라이언트를 서비스하기 위해서 사용하는 방법은 여러 가지가 있다.
대표적으로 fork를 이용한 1:1 서비스 프로세스가 있고, fork대신 서비스 프로세스 부분을
thread로 처리하는 방법이 있고, select또는 poll을 이용해서 한 프로세스에서 빠르게(nonblocking)
처리해 주는 방법이 존재한다. 이 중 fork는 접속 처리가 안정적이고, 퍼미션이
나 chroot 등의 보안 처리가 쉬운 면이 있지만, 잦은 접속에 굉장히 많은 리소스를 낭비하
게 되고 접속 개수대로의 프로세스가 필요하므로 많은 양의 접속을 받기 힘들다. 그리고,
thread는 접속 처리도 비교적 쉽고 리소스를 크게 낭비하지 않지만 thread-safe한 코드를
쓰는 데에는 각별한 주의가 필요하며 현재 대표적인 오픈 소스 OS들이 대부분 thread 지원
이 미미하다. (FreeBSD 5와 Linux 2.5에서 강화될 예정이라고 한다.) 그리고, select나 poll
을 이용한 non-blocking 처리는 blocking되는 함수들을 사용하지 못하는 문제가 있지만,
fork나 thread처리에 비해 자원의 사용이 극도로 적으므로 서버의 자원을 최대로 활용할 수
있는 점이 좋다. 일반적인 통계 자료에 따르면 서버의 자원을 최대로 활용한 경우 fork나
thread방식 보다 non-blocking이 30~40% 정도 성능상 우위에 있다고 하며, 이는 커널이
여러 프로세스와 thread를 관리하는 루틴들로 인한 낭비를 줄인 결과이다.
일을 효과적으로 하는 사람과 그렇지 못한 사람
비동기 소켓(asynchronous socket)은 보내거나 받는 등의 작업이 다른 이벤트를 기다리고
있으면서 처리하면 안 된다. 예를 들자면, 바쁜 커피전문점에 1명의 아르바이트생이 있다고
하자.(이 때 아르바이트생의 수는 프로세스의 개수에 비유할 수 있다.) 손님이 비엔나 커피
를 주문했을 때, 비엔나 커피를 위해서는 휘핑 크림이 필요한데, 마침 가게에 없을 때 재료
상에 휘핑 크림을 주문하고 가져올 때까지 아무런 작업을 하지 않고 손님과 아르바이트생
모두 기다리고 있는 상황이 올 수 있는데, 바로 비동기 소켓에서 블러킹된 상황이 이와 같
아서 클라이언트의 접속을 처리하기 위해 필요한 다른 작업이 끝나지 않은 상황(블러킹)에
서는 아무런 처리도 못하게 된다. 따라서 비동기 소켓에서는 바로 처리가 불가능한 작업들
에 대해서는 그 일이 끝났을 때 이벤트를 보내주도록 poller에 등록하고 일단 넘어가게 된
다. 즉, 휘핑 크림이 도착할 때 까지는 아르바이트생은 다른 일을 하다가, 휘핑 크림이 도
착하면 비엔나 커피를 손님에게 서빙해 주는 것이 일반적인 비동기 소켓의 흐름인 것이다.
물론 손님이 1분 이상 걸리면 취소하고 싶다고 말하면 poller에 타임아웃을 요청하면 된다.
이렇게 여러 작업을 동시에 하기 위해서는 각 이벤트를 등록하고 이벤트가 발생했는지 여부
를 알려주는 역할을 수행하는 것이 바로 poller이며, POSIX환경에서는 select()와 poll()이
그 역할을 수행한다.
효과적인 polling을 위한 여러가지 방법
비동기 소켓에서는 앞에서 설명했던 대로 poller(multiplexer라고도 한다)이 이벤트를 전달
하고 이벤트를 기다리는 것에 핵심적인 역할을 맡게 된다. `1983년 4.2BSD에서 최초로 등장
했던 select()함수는 poller의 원형으로써 polling할 FD의 목록을 배열로 잡아서 넣어주고
리턴 역시 fd_set 포인터가 리턴된다. BSD에서의 fd_set과 select의 선언은 다음과 같다.
이렇게, 기본 FD_SETSIZE가 미리 정해져 있어서, 그 포인터의 크기 만큼은 늘 select를 호
출할 때마다 복사되고 다시 리턴할 때 또 복사된다. 또한, nfds를 제한해서 select에서 루프
를 돌도록 유도하므로 2,3번과 450번 fd를 select하고 싶을 때에는 nfds에서 450을 넣어주
게 되어서 0~450루프를 계속 돌게 되고, 리턴하고 나서도 변화를 체크하기 위해서는
0~450의 루프를 또 돌아야 하는 엄청난 낭비가 발생한다. 이후에 System V에서 poll()함수
가 선보이게 되었는데 select()의 가장 큰 약점이었던 fd_set 전체 복사와 멀리 떨어져 있는
fd를 위해 범위까지 넓어져야 하는 문제를 개선해서, pollfd 구조체의 포인터로 체크할 fd들
을 셋팅해주고 리턴 또한 pollfd 구조체 포인터로 반환을 하게 되었다. poll()과 pollfd 구조
체의 프로토 타입은 다음과 같다.
#define FD_SETSIZE 1024
typedef unsigned long fd_mask;
#define NFDBITS (sizeof(fd_mask) * NBBY) /* bits per mask */
typedef struct fd_set {
fd_mask fds_bits[howmany(FD_SETSIZE, NFDBITS)];
} fd_set;
int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout*);
이렇듯 poll()함수는 꼭 필요한 fd들만을 셋해서 보내주는 형태로 많이 개선되어, 현재 많은
POSIX OS들이 원활히 지원하고 있고 심지어 몇몇 OS는 select() 시스템 콜을 poll()로 에뮬
레이션하는 곳도 있다.
간단하게 생각하자 - KQueue
poll()도 충분히 뛰어난 poller였다. 하지만, 수 천개 이상의 클라이언트를 처리해야 하는
IRC서버나 HTTP 프락시, SOCKS 서버 등의 경우를 생각해 보자. 접속자 1명이 한 줄을 보
내면 서버측의 fd하나에 read이벤트가 발생한다. 그 이벤트는 pollfd중의 하나에 도착해서
수천개의 pollfd 중에 read이벤트가 발생한 것들을 일일이 찾아서 처리해주고 다시 write할
것들을 셋팅해서 넘겨준다. 이런 경우 한 명의 이벤트를 받기 위해 커널에서 라이브러리로
2번의 수천개의 pollfd 복사가 일어나고, 그 변한 fd를 찾기 위해 또 수천개의 fd를 뒤져야
한다. 비록 select()에 비하면 최적화된 것이지만, 많은 클라이언트가 있는 경우를 생각해
보면 역시 낭비가 있다는 느낌을 지울 수 없다. 결국 fd의 변화를 찾는 것이 궁극적인 목적
인데, 변화가 있는 fd만 알려주면 될 것이 아닌가 하는 생각에 태어난 것이 바로 FreeBSD
의 kqueue 시스템콜이다. 프로그램에서 모니터링할 fd들을 커널에서 관리하고 추가되는 fd
들을 커널에 등록한 후에, 그냥 kqueue()호출만 해 주면 바로 변화된 fd의 리스트가
queueing되어서 내려 온다. 실제 kqueue함수의 프로토타입은 다음과 같다.
이렇게 커널에 자기 KQueue에 변경을 가하는 것은 모두 kevent에 의해서 이루어지는데 변
화되지 않은 것은 전혀 커널에 알려줄 필요도 없고 알림 받을 필요도 없기 때문에 많은 커
널과 프로그램 사이의 트래픽이 절약된다. 결과적으로 poller 셋을 비교하면 다음과 같다.
struct pollfd {
int fd; /* file descriptor */
short events; /* events to look for */
short revents; /* events returned */
};
int poll(struct pollfd *fds, unsigned int nfds, int timeout);
int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges,
struct kevent *eventlist, int nevents,
const struct timespec *timeout);
Select poll kqueue
첫 등장 4.2BSD (1983년) System V R10 (1987년) FreeBSD 4.1.1 (2000년)
fd 추가 삭제 복잡도 O(1)*N
(N은 매우 큰 숫자로 고정)
O(N)
(N은 fd개수)
O(1)*N
(N은 변경 개수)
호출시 복잡도 O(1)*N - 위와 같음 O(N) - 위와 같음 O(1)
리턴에서 실제 변경된 fd
를 처리하기 위한 복잡도
O(N) - 위와 같음 O(N) - 위와 같음 O(1)
그 결과 C10K(Client 1만 접속을 동시에 처리하는 서버를 만들기 위한 여러 가지 프로젝트
들)의 여러 벤치마크에서 많은 접속에서 poll보다 배 이상 뛰어난 성능을 보여주었다. (그림
1 참조)
그림 1 poll과 kqueue의 클라이언트수에 따라 걸린 시간 (Jonathan Lemon의 보고서에서 인
용)
사용에서의 kqueue와 poll의 차이
간단한 소켓 서버로의 실제 사용에서 kqueue와 poll의 차이점을 알아보도록 하자. 둘의 하
는 일을 크게 두 가지로 나눠 보면, fd의 등록과 등록된 fd들의 이벤트 검출이다. 먼저 fd
를 등록하는 부분을 비교해 보자. 참고로 다음은 실제로 동작하는 소스가 아니며, 이해를
돕기 위해 각 poller에 관련된 부분 외에는 모두 생략되어 있다.
poll을 사용한 서버에서는 컨트롤할 fd들을 모두 프로그램이 관리해야 하지만 kqueue에서는
커널이 관리해 주므로 상태가 없다는 점에서 매우 유리하다. 그리고, 이제 실제 polling에
들어가면 다음과 같다.
/* poll */
n = poll(pfds, nfds, timeout);
/* kqueue */
n = kevent(kq, ch, nchanges, ev, nevents, &timeout);
poll에서는 미리 사용자가 poll을 호출하기 전 pollfd들에 추가, 삭제를 해 놓고 호출하지만,
kqueue는 추가/삭제 이벤트들까지도 모두 커널에 전달해 줘야 하므로 인자가 조금 더 많다.
또한, kqueue에서는 timeout 구조체를 복사하는 시간을 줄이기 위해서 포인터를 받는다.
이제, polling이 끝났으면 변경된 이벤트를 찾아서 그에 대한 처리를 해 주어야 한다. 그는
다음과 같다.
/* poll()의 경우 */
if (action == ADD) {
pfds[n_pfd] = malloc(sizeof(pollfd));
pfds[n_pfd].fd = fd; /* 새 fd를 추가하는 경우에 pfd들의 포인터 배열에 추가해준다. */
pfds[n_pfd].events = events;
n_pfd++;
} else {
free(pfds[fd]); /* 더 이상 필요하지 않은 fd는 제거해준다. */
pfds[fd] = pfds[n_pfd--];
}
/* kqueue에서 fd를 추가하려는 경우 */
EV_SET(&changes, fd, filter, action == ADD ? EV_ADD : EV_DELETE, 0, 0, 0);
/* kevent 구조체는 EV_SET 매크로로 한꺼번에 적용할 수도 있다. */
nchanges++;
/* changes와 nchanges에는 변경될 이벤트를 polling시에 적용하기 위해 전역변수로 stack이
구현되어 있다고 가정한다. */
/* poll에서 변화된 event체크하기 */
if (n <= 0)
goto error_timeout;
for (i=0; n != 0; i++) { /* 전체 fd를 모두 돌아본다. */
if (pfds[i].revents == 0) { /* 이벤트가 발생하지 않은 것은 모두 통과시킨다. */
continue;
n--;
if (pfds[i].revents & POLLIN)
readread(pfds[i].fd); /* read가 들어온 fd를 읽는다. */
if (pfds[i].revents & POLLOUT)
writewrite(pfds[i].fd); /* write이벤트가 들어온 fd에 쓴다. */
}
/* kqueue에서 변화된 event체크하기 */
if (n <= 0)
goto error_timeout;
for (i=0; i<n; i++) { /* 이벤트 발생 회수만큼 루프를 돈다. */
if (ev[i].filter == EVFILT_READ)
readread(ev[i].ident);
if (ev[i].filter == EVFILT_WRITE)
writewrite(ev[i].ident);
}
둘의 루틴은 아주 비슷하지만, 하나 주목할 곳이 있다. poll에서는 for (i=0; n!=0; i++) 이
지만 kqueue는 for (i=0; i<n; i++) 라는 사실이다. n개의 이벤트가 발생했을 때, poll은 전
체 fd개수로 루프를 돌면서 찾지만, kqueue는 단지 n으로 루프를 돌면 된다. 극단적인 경
우로 5000개의 fd를 감시하고 있을 때(IRC서버만 보더라도 자주 발생할 수 있는 일이다)
만약 3개의 이벤트가 발생했는데, 1번째, 4000번째, 5000번째 fd에서 발생했을 때 단 3개를
찾기 위해서 poll은 위의 for 루프가 끝나기 위해서는 i가 5000번까지 도달해야 찾을 수 있
다. kqueue는 단 3번만에 모든 이벤트를 처리할 수 있다.
kqueue more...
kqueue는 사실 소켓 외에도 많은 것을 polling할 수 있다. fd로 제어가 되는 파일, 콘솔,
디스크상의 inode들이나, 프로세스, VNODE, 시그널까지 이벤트 포착이 가능한 필터를 제공
해 주고 있다. 프로세스와 시그널을 kqueue를 통해 받게 되면 기존에 블러킹 시스템 콜이
아니면 알아내기 힘들었던 여러 가지 이벤트 변화들을 쉽게 비동기 프로그래밍의 영역에 끌
어들일 수 있다. 현재 kqueue는 OpenBSD에 포함되어 있고, NetBSD에서는 아직 몇 가지
문제로 인해 통합이 보류되어 있지만, 여러 개발자들이 그를 위해 노력하고 있다. Py-
KQueue의 등장(포트의 devel/py-kqueue)으로 파이썬에서도 쉽게 kqueue의 장점을 이용하
여 비동기 소켓을 프로그래밍할 수 있다. O(N)과 O(1)의 차이에서 오는 그 위력을 충분히
뽐낼 수 있는 멋있는 코드를 독자도 꼭 작성해 보기 바란다.
참고자료:
* Jonathan Lemon의 USENIX paper on kqueue
http://people.freebsd.org/~jlemon/kqueue.pdf
* C10K에 대한 여러 가지 생각들
http://www.kegel.com/c10k.html
* Ronald F. Guilmette의 간단한 echo 서버
http://www.monkeys.com/freeware/kqueue-echo.c
* Garret Rooney의 간단한 kqueue 채팅 서버
http://electricjellyfish.net/garrett/code/kq_chat_server.c
* Doug White의 py-kqueue
http://people.freebsd.org/~dwhite/PyKQueue/
* Rafferty H. Chang의 Asyncore-Kqueue
http://raff.to/MedusaKQueue