OSTEP 33 Event-based Concurrency
node.js로 백엔드를 만들어 봤으면 node.js가 어떻게 작동하는지 알 수 있을 것이다. 이벤트 기반의 병행성은 서버 프레임워크에서 사용되지만, 그 시작점은 C와 유닉스 시스템이다.
이벤트 기반의 병행성은 두 개의 문제점을 가지고 있다.
우선 멀티 쓰레드 프로그램에서 이벤트 기반 병행성을 올바르게 사용하는 것이 매우 어렵다는 것이다. 락을 누락시키거나, 교착 상태 또는 다른 문제가 발생할 수 있기 때문이다.
또, 멀티 쓰레드 프로그램에서는 개발자가 쓰레드 스케줄링에 대한 제어권을 전혀 가지고 있지 않다는 것이다. 개발자는 운영체제가 합리적으로 스케줄링해주기를 기대할 수밖에 없다.
어떻게 쓰레드 없이 병행 서버를 개발하고, 각종 문제들을 피할 수 있을까?
Table of Contents
Open Table of Contents
1. 이벤트 루프
우리가 다룰 방법은 이벤트 기반의 병행성이다. 접근 방법은 다음과 같다.
특정 사건 (이벤트) 의 발생을 대기한다. 사건이 발생하면, 사건의 종류를 파악한 후, I/O를 요청하거나 추후 처리를 위하여 다른 이벤트를 발생시키거나 하는 등의 작업을 한다.
자세한 설명 전에 고전적인 이벤트 기반의 서버가 어떻게 생겼는지 살펴보자. 이 응용 프로그램은 이벤트 루프 (event loop) 라는 단순한 구조를 기반으로 짜여 있다.
while (1) {
events = getEvents();
for (e in events) {
processEvent(e);
}
}
매우 간단하다. 루프 내에서 사건 발생을 대기한다. 이벤트가 발생하면 하나씩 처리한다. 이 때 각 이벤트를 처리하는 코드를 이벤트 핸들러 (event handler) 라고 부른다.
중요한 것은 이벤트의 처리가 시스템의 유일한 작업이기 때문에, 다음에 처리할 이벤트를 결정하는 것이 스케줄링과 동일한 효과를 갖는다. 스케줄링을 제어할 수 있는 기능이 이벤트 기반 방법의 큰 장점 중 하나이다.
하지만 발생한 이벤트가 무슨 이벤트인지 어떻게 판단하지?
네트워크나 디스크 I/O의 경우는 더 어렵다. 디스크 I/O가 완료되었다는 이벤트가 도착했을 때 어떤 디스크 요청이 완료된걸까? 도착한 메시지가 자신을 위한 것인지 어떻게 알 수 있을까?
2. 중요 API: select()
(또는 poll()
)
이벤트를 어떻게 받을까? 대부분의 시스템은 select()
또는 poll()
시스템 콜을 기본 API로서 제공한다. 인터페이스의 기능은 도착한 I/O들 중 주목할 만한 것이 있는지를 검사하는 것이다.
예를 들어, 웹 서버같은 네트워크 응용 프로그램이 자신이 처리할 패킷의 도착 여부를 검사하는 것이다. 이 시스템 콜들이 정확히 해당 역할을 한다.
select()
를 예로 살펴보자. Mac OS X가 제공하는 메뉴얼은 다음과 같다.
int select(int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout);
select()
는 readfds
, writefds
, errorfds
를 통해 전달된 I/O 디스크립터 집합들을 검사해서 각 디스크립터들에 해당하는 입출력 디바이스가 읽을 준비가 되었는지, 쓸 준비가 되었는지, 처리해야 할 예외 조건이 발생했는지 등을 파악한다. 각 집합의 첫 번째 nfds
개의 디스크립터들을 검사한다. select()
는 집합을 가리키는 각 포인터들을 준비된 디스크립터들의 집합으로 교체한다. select()
는 전체 집합에서 준비된 디스크립터들의 총 개수를 반환한다.
select()
에 대해 알아두어야 할 사항이 두 가지 있다.
-
select()
를 이요하면 디스크립터에 대한 읽기 가능 여부, 쓰기 가능 여부를 검사할 수 있다. 전자(읽기 가능 여부)는 처리해야 할 패킷의 도착 여부를 알 수 있도록 한다. 후자(쓰기 가능 여부)는 서비스가 응답 전송이 가능한 시점을 파악할 수 있도록 한다. (예를 들어, outbound queue가 가득 차지 않은 상태) -
timeout
존재 일반적으로는NULL
로 설정하여 무한정 대기하지만 오류에 대비하도록 설계된 서버는 값을 설정하기도 한다. 널리 사용되는 방법으로는,timeout = 0
으로 설정하여select()
가 대기하지 않고 즉시 리턴하도록 하는 것이다.
poll()
시스템 콜도 유사하게 작동한다.
이런 기본 함수로 non blocking event loop를 만들어, 패킷 도착을 확인하고, 소켓에서 메시지를 읽고 필요에 응답할 수 있도록 해준다.
3. select()
의 사용
확실한 이해를 위해, 어떤 네트워크 디스크립터에 메시지가 도착했는지를 파악하는 경우를 살펴보자.
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
// 여러 개의 소켓을 열고 설정 (여기에는 안써있음)
// 주 반복문
while () {
// fd_set을 모두 0으로 초기화
fd_set readFDs;
FD_ZERO(&readFDs);
// 이제 이 서버가 관심을 가지는 디스크립터들의 bit 설정
// (min ~ max)
int fd;
for (fd = minFD; fd < maxFD; fd++)
FD_SET(fd, &readFDs);
// select
int rc = select(maxFD, &readFDs, NULL, NULL, NULL);
// FD_ISSET()을 사용하여 실제 데이터 사용 여부 검사
int fd;
for (fd = minFD; fd < maxFD; fd++)
if (FD_ISSET(fd, &readFDs))
processFD(fd);
}
}
맨 처음 초기화 후, 서버는 무한 루프에 들어간다. 그 루프 내에서 FD_ZERO()
매크로를 통해 파일 디스크립터들을 초기화한 후, FD_SET()
을 사용하여 minFD
~ maxFD
까지의 파일 디스크립터 집합에 포함시킨다. 이 집합은 서버가 보고 있는 모든 네트워크 소켓같은 것들을 나타낼 수 있다.
마지막으로 서버는 select()
를 호출하여 데이터가 도착한 소켓이 있는지를 검사한다. 반복문 내의 FD_ISSET()
을 사용하여 이벤트 서버는 어떤 디스크립터들이 준비된 데이터를 가지고 있는지를 알 수 있고, 도착하는 데이터를 처리할 수 있게 된다.
실제 서버는 디스크 작업, 메시지를 보내는 시점, 등 세부 사항들을 결정하는 로직이 필요하다.
팁: 이벤트 기반의 서버 내에서는 블럭을 하지 말자. 이벤트 기반 서버는 작업의 스케줄링을 정밀하게 제어할 수 있다. 하지만, 정밀한 제어를 위해서는 호출자가 실행한 것을 차단할 수 있는 어떠한 호출도 있어서는 안 된다. 이 디자인 팁을 지키지 않는다면 이벤트 기반 서버가 멈추게 될 것이고 사용자는 불만을 가질 것이다.
4. 왜 간단한가? -> 락이 필요 없음
단일 CPU를 사용하는 이벤트 기반의 응용 프로그램에서는, 병행 프로그램을 다룰 때 나타났던 문제들이 더 이상 보이지 않는다.
그 이유는 매 순간에 단 하나의 이벤트만 다루기 때문에 락을 획득하거나 해제할 필요가 없기 때문이다. 이벤트 기반의 서버는 단 하나의 쓰레드만 가지고 있기 때문에 다른 쓰레드에 의해 인터럽트에 걸릴 수가 없다. 그렇기 때문에 멀티쓰레드 프로그램의 병행성 버그는 기본적인 이벤트 기반 접근법에서는 나타나지 않는다.
5. 문제: 블로킹 시스템 콜 (Blocking System Call)
블로킹을 유발하는 시스템 콜이 호출되는 이벤트가 발생하면 어떡하지?
예를 들어, 디스크에서 데이터를 읽어서 그 내용을 사용자에게 전달하는 요청을 생각해 보자. 이 요청을 처리하려면 핸들러가 open()
시스템 콜을 호출하여 파일을 열고, read()
로 파일을 읽어야 한다. 파일을 읽어서 메모리에 탑재한 후에 서버는 그 결과를 사용자에게 전달할 수 있게 된다.
open()
, read()
둘 다 저장 장치에 I/O 요청을 보내야 한다면, 이 요청을 처리하기 위해 오랜 시간ㄴ이 필요하다. 쓰레드 기반 서버는 이런 것이 문제가 되지 않는다. 한 쓰레드가 I/O 대기를 하는 동안, 다른 쓰레드가 실행이 되며 서버는 계속 동작할 수 있다. I/O 처리와 다른 연산이 자연스럽게 겹쳐지는 현상이 쓰레드 기반 프로그래밍의 장점이다.
반면 이벤트 기반 접근법에서는 쓰레드가 없고 이벤트 루프만 존재한다. 즉, 이벤트 핸들러가 블로킹 시스템 콜을 호출하면 서버 전체가 오직 그 일을 처리하기 위해 명령어가 끝날 때까지 모든 것을 차단한다. 이벤트 기반 시스템의 기본 원칙은 블로킹 호출을 허용하면 안 된다는 것이다.
6. 해법: 비동기 I/O
여러 현대의 운영체제들이 I/O 요청을 디스크로 내려 보낼 수 있는, 일반적으로 비동기 I/O (asynchronous I/O) 라고 부르는 새로운 방법을 개발하였다.
이 인터페이스는 프로그램이 I/O 요청을 하면, I/O 요청이 끝나기 전에 제어권을 즉시 다시 호출자에게 돌려주는 것을 가능하게 했으며, 추가적으로 여러 종류의 I/O들이 완료되었는지 판단할 수 있도록 하였다.
Mac OS X가 제공하는 인터페이스를 살펴보자.
struct aiocb {
int aio_fildes; /* File descriptor */
off_t aio_offset; /* File offset */
volatile void *aio_buf; /* Location of buffer */
size_t aio_nbytes; /* Length of transfer */
};
이 API는 struct aiocb
, AIO 제어 블럭(AIO Control block) 이라고 불리는 구조를 사용하고 있다.
파일에 대한 비동기 읽기 요청을 하려면, 응용 프로그램은 먼저 이 자료 구조에 읽고자 하는 파일 디스크립터 (aio_fildes
), 파일 내에서의 위치 (aio_offset
), 요청의 길이 (aio_nbytes
), 읽기 결과로 얻는 데이터를 저장할 메모리의 위치 (aio_buf
)와 같은 정보가 필요하다.
Mac OS X에서는 간단한 비동기 읽기 (asynchronous read) API를 사용한다.
int aio_read(struct aiocb *aiocbp);
이 명령어를 통해 I/O 호출을 성공하면, 즉시 리턴을 하며 응용 프로그램 (이벤트 기반의 서버 등) 은 하던 일을 계속 진행할 수 있다.
그러면 I/O가 종료되었다는 것을 어떻게 알 수 있을까?, 그리고 aio_buf
가 가리키는 버퍼에 요청했던 데이터가 있다는 것을 어떻게 알 수 있을까?
API가 하나가 필요하다. Mac OS X 에서는 이 API를 aio_error()
라고 한다.
int aio_error(const struct aiocb *aiocbp);
이 시스템 콜은 aiocbp
에 의해 참조된 요청이 완료되었는지를 검사한다. 완료되었다면 성공했다고 리턴을 하고 (0으로 표시) 실패했다면 EINPROGRESS
를 반환한다. 모든 대기 중인 비동기 I/O는 주기적으로 aio_error()
시스템 콜로 시스템에 폴링(polling)하여 해당 I/O가 완료되었는지 확인할 수 있다.
어떤 I/O가 완료되었는지 확인하는 것이 귀찮을 수도 있다. 동시에 수백개의 I/O를 요청하는 프로그램의 경우, 이 모든 요청을 다 폴링하면서 검사해야 할까? 아니면 일정 시간 기다려야 할까?
이 문제를 해결하기 위해 인터럽트 기반의 접근법을 제공하는 시스템들이 있다. 유닉스의 시그널 (signal) 을 사용하여 비동기 I/O가 완료되었다는 것을 응용 프로그램에게 알려주기 때문에 반복적으로 완료 여부를 확인할 필요가 없다. 폴링 대 인터럽트 문제는 I/O 장치들을 다룰 때에도 나타난다.
비동기 I/O가 없는 시스템에서는 제대로 된 이벤트 기반 접근법을 구현할 수 없다. 대신 네트워크 패킷을 처리하기 위해 이벤트를 사용하고, 대기 중인 I/O들을 처리하기 위해 쓰레드 풀을 사용하는 등의 하이브리드 기법을 사용할 수 있다.
7. 또 다른 문제점: 상태 관리
이벤트 기반 접근법의 또 다른 문제점은 전통적인 쓰레드 기반 코드보다 일반적으로 더 작성하기 복잡하다는 것이다. 이벤트 핸들러가 비동기 I/O를 발생시킬 때, I/O 완료 시 사용할 프로그램 상태를 정리해 놓아야 한다. 이 작업은 쓰레드 기반 프로그램에서는 불필요하다. 쓰레드 스택에 그 정보들이 이미 들어 있기 때문이다. 이벤트 기반 접근법에서는 수동 스택 관리 (manual stack management) 가 필요하다.
쓰레드 기반 서버를 예로 들면, 이 서버는 파일 디스크립터 (fd
) 로 명시된 파일에서 데이터를 읽어들여, 해당 데이터들을 네트워크 소켓 디스크립터 (sd
)로 전송한다.
int rc = read(fd, buffer, size);
rc = write(sd, buffer, size);
멀티 쓰레드 프로그램에서는, read()
가 리턴되면 전송할 네트워크 소켓에 관한 정보가 같은 스택에 존재한다. 하지만 이벤트 기반의 시스템에서는 그렇지 않다.
이벤트 기반의 시스템에서 같은 일을 하려면, 앞서 명시한 AIO 호출들을 사용하여 read()
를 비동기로 요청해야 한다. aio_error()
를 사용하여 주기적으로 읽기가 종료되었는지 확인한다고 가정해보자. 읽기가 종료되었다고 알려주면 이벤트 기반 서버는 다음으로 무슨 일을 해야 하는지 어떻게 알 수 있을까?
그 해법은 continuation을 사용하는 것이다.
이벤트를 종료하는 데에 필요한 자료들을 한곳에 저장해 둔다. 이벤트가 발생하면 (디스크 I/O가 완료되면), 저장해 놓은 정보들을 활용하여 이벤트를 처리한다.
앞서 사용한 예시의 해법은 소켓 디스크립터 (sd
)를 파일 디스크립터 (fd
)가 사용하는 자료 구조 (예: 해시 테이블)에 저장해 놓는 것이다. 디스크 I/O가 완료되면 이벤트 핸들러가 파일 디스크립터에서 다음 할 일을 파악하여 호출자에게 소켓 디스크립터의 값을 반환하도록 한다. 이 시점에서 서버는 소켓에 데이터를 기록하는 마지막 동작을 할 수 있게 된다.
8. 이벤트 사용의 어려움
이벤트 기반 접근법에서는 다른 어려운 점이 몇 가지 존재한다.
단일 CPU에서 멀티 CPU로 변경되면, 이벤트 기반 접근법의 단순함이 없어진다. 하나 이상의 CPU를 활용하기 위해서는, 다수의 이벤트 핸들러를 병렬적으로 실행해야 한다. 그렇게 되면 동기화 문제 (임계 영역 등) 이 발생하게 되며, 이것을 해결하는데 필요한 락 등을 사용할 수밖에 없다. 때문에 최근의 멀티코어 시스템은 락이 없는 이벤트 처리 방식을 더 이상 사용할 수 없게 된다.
또 다른 문제는, 페이징 (paging) 과 같은 특정 종류의 시스템과 잘 맞지 않는다. 이벤트 핸들러에서 페이지 폴트가 발생하면 동작이 중단되기 때문에 서버는 페이지 폴트가 처리 완료되기 전까지는 진행을 할 수 없다. 서버가 논블로킹 방식으로 설계되었다 해도, 페이지 폴트로 인한 블로킹은 피하기 어렵다. 이런 상황이 자주 발생하면 심각한 성능 저하가 일어날 것이다.
세 번째 문제는 루틴의 작동 방식이 계속 변한다는 것이다. 루틴 동작이 논블로킹 방식에서 블로킹 방식으로 변경된다면, 그 루틴을 호출하는 이벤트 핸들러도 새로운 방식과 맞도록 변경해야 한다. 이에 적합하게 루틴을 두 버전으로 나누어야 한다. 이벤트 기반 서버에서 블로킹은 치명적이다. 개발자는 각 이벤트가 사용하는 API 명세가 바뀌는지 주의 깊게 살펴야 한다.
마지막으로, 비동기 디스크 I/O가 대부분의 플랫폼에서 사용되기까지는 매우 오래 걸렸으며, 아직까지도 비동기 네트워크 I/O는 일관성 있게 적용되어 있지 않다. 예를 들면, 모든 입출력 처리에 select()
를 사용하는 것이 이상적이지만, 일반적으로 네트워크 요청의 처리에는 select()
, 디스크 I/O에는 AIO가 사용되고 있다.