Skip to content

OSTEP 33 Event-based Concurrency

Published: at 오후 04:40

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()에 대해 알아두어야 할 사항이 두 가지 있다.

  1. select()를 이요하면 디스크립터에 대한 읽기 가능 여부, 쓰기 가능 여부를 검사할 수 있다. 전자(읽기 가능 여부)는 처리해야 할 패킷의 도착 여부를 알 수 있도록 한다. 후자(쓰기 가능 여부)는 서비스가 응답 전송이 가능한 시점을 파악할 수 있도록 한다. (예를 들어, outbound queue가 가득 차지 않은 상태)

  2. 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가 사용되고 있다.

node.js - dont block event loop

동시성, 병렬, 비동기, 논블럭킹과 컨셉들