Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.
지금까지는 병행 프로그램을 만드는 유일한 방법이 스레드를 이용하는 것 뿐인 것처럼 이야기 해왔지만, 사실 그렇지는 않다. 구체적으로, GUI 기반 애플리케이션이나 몇몇 인터넷 서버들에서 쓰이는 다른 스타일의 병행성 프로그래밍들도 있다. 이벤트 기반 병행성(event-based concurrency)라 불리는 이 스타일은, node.js와 같은 서버 사이브 프레임워크를 포함한 여러 현대 시스템에서 널리 쓰이고 있는데, 그 뿌리는 아래에서 다룰 C/UNIX 시스템에 기반을 두고 있다.
이벤트 기반 병행성이 다루는 문제에는 두 가지가 있다. 첫 번째는 멀티 스레드 애플리케이션에서 병행성을 올바르게 관리하는 일이 상당히 어렵다는 것이고, 두 번째는 멀티 스레드 애플리케이션에서 개발자는 특정 시점에서 무엇이 스케줄링 되는지에 대한 제어를 거의 가지지 못한다는 것이다. 프로그래머는 단순히 스레드를 만들고, 그 기저의 OS가 이를 사용가능한 CPU들 사이에서 합리적인 방식으로 스케줄링해주기를 기대할 뿐이다. 모든 작업량들에 대해, 그리고 모든 경우에서 잘 작동하는 범용 스케줄러를 만드는 것은 어렵기 때문에, OS는 종종 최적보다 못한 방법으로 작업을 스케줄링한다.
어떻게 스레드를 이용하지 않고 병행성 서버를 만들고, 멀티스레드 애플리케이션에서 나타나는 문제들을 회피하며, 병행성에 대한 제어를 얻을 수 있을까?
우리가 사용할 기본 방식은 이벤트-기반 병행성이라 불리다. 이 방식은 상당히 간단하다. 어떤 일(이벤트)이 일어나기를 기다리는 것이다. 만약 어떤 이벤트가 일어나면, 이 이벤트의 타입이 무엇인지를 확인하고 이에 필요한 작업들을 수행한다.
세부 내용을 다루기에 앞서, 우선 표준적인 이벤트 기반이 어떤 것인지를 보자. 이런 애플리케이션은 이벤트 루프(event loop)라고 하는 단순한 구조를 기반으로 만들어진다. 이벤트 루프의 의사 코드는 다음과 같다.
while (1) {
events = getEvents();
for (e in events)
processEvent(e);
}
정말 간단하다. 메인 루프는 단순히 어떤 일을 할지를 대기하고, 반환되는 각 이벤트를 한 번에 하나씩 처리한다. 이 각 이벤트를 처리하는 코드를 가리켜 이벤트 핸들러(event handler)라 부른다. 중요한 것은 핸들러의 이벤트 처리가 시스템에서 일어나는 유일한 작업이라는 점이다. 따라서 어떤 이벤트를 다음으로 처리할지를 결정하는 것은 스케줄링과 같다. 이러한 스케줄링에 대한 명시적인 제어가 이벤트 기반 방식의 근본적인 장점들 중 하나다.
하지만 이와 관련한 논의는 다음의 큰 의문점을 남긴다. 이벤트 기반 서버는 어떤 이벤트가 일어나고 있는지를 어떻게 판단할 수 있을까? 구체적으로, 이벤트 서버는 도착한 메시지가 자신을 위한 것인지를 어떻게 알 수 있을까?
select()
(or poll()
)기본적인 이벤트 루프를 염두에 두고, 어떻게 이벤트를 받느냐는 질문을 다루도록 해보자. 대부분의 시스템에서, 기본 API는 select()
나 poll()
시스템 콜을 통해 사용 가능하다.
이 인터페이스가 프로그램에게 제공하는 기능은 단순하다. 들어오는 I/O 중 주목해야 할 것이 있는지를 확인하는 것이다. 예를 들어, 네트워크 애플리케이션이 어떤 네트워크 패킷이 도착했는지를 확인하고자 한다고 생각해보자. 위의 시스템 콜들이 바로 그것을 가능하게 한다.
예를 들어 select()
를 사용해보자. Mac의 매뉴얼 페이지는 이 API를 다음과 같이 설명한다.
int select(int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout);
매뉴얼 페이지의 실제 내용은 다음과 같다.
select()는 readfds, writefds, errorfd로 전달된 주소들을 가지는 I/O 디스크립터 집합을 검사해, 디스크럽터들이 각각 읽기에 준비되어있는지, 쓰기에 준비되어있는지, 처리되지 않은 예외 조건을 가지고 있는지를 확인한다. 첫 번째 nfds 개 디스크럽터들은 각 집합에서 확인된다. 반환 시, select()는 주어진 디스크립터 집합을 요청된 연산을 위해 준비된 디스크립터들을 포함하는 부분 집합으로 교체한다. select()는 전체 집합에 준비되어있는 디스크립터의 총 개수를 반환한다.
select()
에 대해 짚고 넘어가야 할 점이 두 가지 있다. 첫 번째로, 이는 디스크립터가 쓰일 수 있는지와 더불어, 읽을 수 있는지도 확인할 수 있게 한다. 읽을 수 있는지를 확인하는 것은 서버가 새 패킷이 도달했는지를 판정할 수 있게 하고, 처리할 수 있게 한다. 후자의 경우 서비스가 언제 응답을 해도 되는지를 알 수 있게 한다.
두 번째로, timeout
인자 사용에 주의하자. 일반적으로는 timeout
을 NULL로 설정함으로써, 디스크립터가 준비될 때까지 select()
를 무한정 대기시킨다. 하지만 좀 더 견고한 서버들은 timeout
을 구체적으로 정한다. 흔히 쓰이는 테크닉에는 이를 0으로 설정해 select()
호출이 바로 리턴하게 만드는 것이다.
poll()
도 이와 상당히 비슷하다. 자세한 내용은 매뉴얼 페이지를 보자.
어느 쪽이든, 이 기본적인 함수들은 간단하게 입력 패킷을 확인하고, 소켓에서 메시지를 읽고, 응답하는, 논-블로킹 이벤트 루프를 만들 방법을 제공한다.
select()
어떤 네트워크 디스크립터에 메시지가 도착했는지를 보기 위해 select()
를 사용하는 예제를 살펴보자. 예시 코드는 아래와 같다.
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
// open and set up a bunch of sockets (not shown)
// main loop
while (1) {
// initialize the fd_set to all zero
fd_set readFDs;
FD_ZERO(&readFDs);
// now set the bits for the descriptors
// this server is interested in
// (for simplicity, all of them from min to max)
int fd;
for (fd = minFD; fd < maxFD; fd++)
FD_SET(fd, &readFDs);
// do the select
int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
// check which actually have data using 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()
을 사용함으로써, 이벤트 서버는 어떤 디스크립터가 준비된 데이터를 가지고 있는지 확인하고, 들어오는 데이터를 처리한다.
물론 실제 서버는 이것보다 복잡할 것이고, 언제 메시지를 보낼지, 디스크 I/O를 할지 등을 결정하기 위한 로직을 필요로 한다.
단일 CPU와 이벤트 기반 애플리케이션을 이용하면, 병행성 프로그램에서 찾을 수 있던 문제들은 더 이상 일어나지 않는다. 구체적으로, 한 번에 한 이벤트만이 처리되므로 락을 얻고 해제할 필요가 없다. 이벤트 기반 서버는 하나의 스레드에서만 돌아가기 때문에 다른 스레드에 의해 인터럽트 당할 일도 없다. 따라서 스레드 프로그램에서 흔히 일어나던 병행성 버그들은 기본적인 이벤트 기반 방식에서는 나타나지 않는다.
지금까지만 보면 이벤트 기반 프로그래밍은 훌륭해보인다. 간단한 루프를 만들고 이벤트가 일어날 때만 처리해주면 되기 때문이다. 심지어는 락에 대해서도 생각할 필요가 없다. 하지만 다른 이슈가 하나 있다. 만약 이벤트가 블럭을 일으키는 시스템 콜을 호출해야 하는 경우에는 어떨까?
예를 들어, 클라이언트에서 서버로, 디스크의 파일을 읽고 그 내용을 요청한 클라이언트에게 반환하는 요청이 들어왔다고 생각해보자. 이러한 요청을 서비스하려면, 어떤 이벤트 핸들러는 결국 open()
시스템 콜을 이용해 파일을 열고, read()
로 파일을 읽어야 한다. 파일이 메모리로 읽혀오면, 서버는 그 결과를 클라이언트에게 보내게 될 것이다.
open()
과 read()
콜은 모두 저장 시스템에 I/O 요청을 만들 것이고, 따라서 서비스 되려면 오랜 시간이 걸린다. 스레드 기반의 서버에서야 이것이 문제가 되지 않는다. I/O 요청을 보내는 스레드는 해당 작업이 마칠 때까지 대기하고, 다른 스레드들이 실행되어 서버가 진행되게 할 수 있기 때문이다. 실제로 이와 같은 I/O와 다른 계산들 사이의 자연스러운 오버랩이 스레드 기반 프로그래밍은 자연스럽고 직관적으로 만드는 것이다.
하지만 이벤트 기반 방식에서는 실행시킬 다른 스레드가 없고, 메인 이벤트 루프 밖에 없다. 이는 이벤트 핸들러가 블럭을 일으키는 시스템 콜을 호출하는 경우 전체 서버가 멈춰버릴 수 밖에 없다는 것이다. 이벤트 루프가 블럭하면 시스템은 가만히 멈추게 되고, 따라서 자원 낭비도 커진다. 따라서 이벤트 기반 시스템에서는 블로킹 콜이 허용되어서는 안된다는 규칙이 필요해진다.
이러한 한계를 극복하기 위해, 많은 현대 OS는 디스크 시스템에 I/O 요청을 보낼 수 있는 다른 새 방법을 도입했다. 이는 보통 비동기 I/O(asynchronous I/O)라 불린다. 이 인터페이스들은 애플리케이션이 I/O 리퀘스트를 만들고나서 I/O 작업이 마치기 전에 즉시 호출자로 리턴할 수 있도록 한다. 추가적인 인터페이스들은 애플리케이션이 여러 I/O가 완료되었는지의 여부를 결정할 수 있게 하기도 한다.
예를 들어, Mac에서 제공되는 인터페이스를 살펴보자. 이 API는 struct aiocb
, 또는 AIO 제어 블럭(AIO control block)이라 불리는 기본 구조체를 사용한다. 단순화된 버전은 다음과 같다.
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
};
파일에 대한 비동기 읽기 요청을 만드려면, 애플리케이션은 우선 이 구조체를 관련된 정보들로 채워야 한다. 읽을 파일의 파일 디스크립터(aio_fildes
), 파일 내에서의 오프셋(aio_offset
), 요청할 길이(aio_nbytes
), 마지막으로 읽기의 결과가 복사될 메모리 위치(aio_buf
)다.
이 구조가 채워지면 애플리케이션은 파일에 대한 비동기 읽기 요청을 만든다. Mac에서는 다음의 간단한 비동기 읽기 API를 사용한다.
int aio_read(struct aiocb *aiocbp);
이 콜은 I/O 요청을 만든다. 성공하는 경우에는 바로 리턴하고, 애플리케이션은 작업을 계속한다.
하지만 아직 풀어야되는 문제가 하나 더 남아있다. 어떻게 I/O 작업이 완료되고 버퍼에 요청된 데이터가 있음을 알 수 있을까?
다른 API가 마지막으로 하나 더 필요하다. Mac에서는 aio_error()
로, 다음과 같다.
int aio_error(const struct aiocb *aiocbp);
이 시스템 콜은 aiocbp
가 가리키는 요청이 완료됐는지를 확인한다. 만약 그렇다면 루틴은 성공(0)을 반환하고, 그렇지 않은 경우는 EINPROGRESS가 리턴된다. 모든 비동기 I/O에 대해, 애플리케이션은 주기적으로 aio_error()
호출로 시스템에 폴링(poll)함으로써 해당 I/O가 완료됐는지의 여부를 결정한다.
문제는 I/O 작업이 완료됐는지를 확인하는 일이 상당히 어려울 수도 있다는 것이다. 만약 프로그램이 특정 시점에 수 십, 수 백의 I/O 요청을 가지고 있다면, 이것들을 모두 각각 확인해야할까, 아니면 우선 잠시동안 대기해야할까?
이 문제를 해결하기 위해, 몇몇 시스템들은 인터럽트에 기반한 방식을 제공한다. 이 방법은 UNIX 시그널을 통해 비동기 I/O가 완료되었음을 애플리케이션에 알림으로써 시스템을 반복해서 확인해야할 필요를 없앤다. 이 폴링 vs 인터럽트 이슈는 이후 I/O 장치를 다루는 장에서도 나타나게 될 문제다.
비동기 I/O가 없는 시스템에서, 순수한 이벤트 기반 방식은 구현될 수 없다. 하지만 똑똑한 연구자들은 꽤 잘 작동하는 방식들을 만들어냈다. 예를 들어 Pai 등은 네트워크 패킷을 처리하기 위해 이벤트를 사용하고, 대기중인 I/O 요청을 관리하기 위해서는 스레드 풀을 사용하는 혼합 방식을 만들어냈다.
이벤트 기반 방식에서의 다른 이슈는, 해당 방식의 코드가 전통적인 스레드 기반 코드보다 일반적으로 작성하기 더 어렵다는 것이다. 그 이유는 다음과 같다. 이벤트 핸들러가 비동기 I/O 요청을 만들 때, 이는 I/O가 최종적으로 완료됐을 때, 다음 이벤트 핸들러가 사용할 수 있도록 프로그램의 상태를 저장해야한다. 이 추가 작업은 스레드 기반 프로그램에서는 불필요한데, 프로그램이 필요로 하는 상태는 스레드의 스택에 있기 때문이다. Adya 등은 이 작업을 수동 스택 관리(manual stack management)라 부르는데, 이는 이벤트 기반 프로그래밍에서는 반드시 필요하다.
이에 대해 자세하게 알기 위해, 스레드 기반 서버가 파일 디스크립터(fd
)로부터 읽고, 작업이 완료되면 파일로부터 읽은 데이터를 네트워크 소켓 디스크립터(sd
)에 쓰는 예를 살펴보자. 에러 확인을 제외한 코드는 다음과 같다.
int rc = read(fd, buffer, size);
rc = write(sd, buffer, size);
멀티 스레드 프로그램에서 이러한 작업은 사소한 일이다. read()
가 최종적으로 리턴할 때, 코드는 스레드 스택 내에 있는 정보로 즉시 어떤 소켓에 쓸지를 알 수 있기 때문이다.
하지만 이벤트 기반 시스템에서는 쉽지 않다. 같은 작업을 수행하기 위해서는 우선 위의 AIO 콜을 통해 비동기 읽기 요청을 보내야 한다. aio_error()
콜을 통해 읽기 작업이 완료됐는지를 주기적으로 확인한다고 하자. 만약 이 콜이 읽기 작업이 완료됐다고 알릴 때, 이벤트 기반 서버는 무엇을 할지 어떻게 알 수 있을까?
해결법은 프로그래밍 언어에서 오랫동안 사용된 continuation 개념을 사용하는 것이다. 복잡하게 들리지만 아이디어는 간단하다. 기본적으로 이 이벤트 완료를 위해 필요한 정보들을 어떤 자료 구조에다가 기록하고, 이벤트가 발생하면 필요한 정보를 찾아 이벤트를 처리한다.
구체적인 예로, 소켓 디스크립터(sd
)를 어떤 자료 구조(예를 들면 해시 테이블)에 저장하고, 파일 디스크립터(fd
)로 인덱싱한다. 디스크 I/O가 완료되면 이벤트 핸들러는 fd
를 이용해 continuation을 찾는데, 이는 소켓 디스크립터의 값을 호출자에게 반환한다.이 시점에서 서버는 데이터를 소켓에 쓰는 마지막 작업을 하게 된다.
이벤트 기반 방식에서 언급해야 할 몇 가지 다른 어려운 점들이 있다. 예를 들어 시스템이 단일 CPU에서 여러 CPU를 쓰게 바뀌었을 때, 이벤트 기반 방식의 단순성 일부는 사라진다. 구체적으로 하나보다 많은 CPU를 활용하기 위해, 이벤트 서버는 여러 이벤트 핸들러를 병렬적으로 실행해야 한다. 이렇게 하면 통상적인 동기화 문제가 발생하고, 이에 대한 통상적인 해법들도 필요해진다. 따라서 현대의 멀티코어 시스템에서, 락을 사용하지 않는 간단한 이벤트 처리는 더 이상 가능하지 않다.
이벤트 기반 방식의 다른 문제는 이것이 페이징과 같은 몇몇 시스템 활동과 잘 맞지 않는다는 것이다. 예를 들어 만약 이벤트 핸들러 페이지가 폴트를 내면, 이는 블락을 할 것이고, 따라서 서버는 페이지 폴트가 해결될 때까지 진행되지 못한다. 비록 서버가 명시적인 블로킹은 피하도록 구성되어있지만, 페이지 폴트로 인한 이러한 암묵적인 블로킹은 피하기 어렵고, 이러한 상황이 만하지면 큰 성능 저하로 이어질 수 있다.
세 번째 이슈는 여러 루틴들의 의미가 시간에 지남에 따라 달라지게 되므로, 이벤트 기반 코드도 관리하기 어려워진다는 것이다. 예를 들어 만약 루틴이 논-블로킹에서 블로킹으로 변하면, 루틴을 호출하는 이벤트 핸들러도 이 새 특성을 수용할 수 있게, 두 갈래로 나뉘며 변해야한다. 블로킹은 이벤트 기반 서버에 있어 치명적이므로, 프로그래머는 항상 각 이벤트가 사용하는 API의 의미 변화를 잘 살펴야 한다
마지막으로, 비동기 디스크 I/O는 이제 대부분의 플랫폼에서 가능해지기는 했지만, 여기까지 도달하는 데에는 오랜 시간이 걸렸고, 생각만큼 간단하고 균일한 방식으로 비동기 네트워크 I/O가 통합되어 있지도 않다. 예를 들어 대기 중의 I/O를 관리하기 위해 간단하게 select()
인터페이스를 쓰고 싶어할 수도 있지만, 일반적으로는 네트워킹을 위한 select()
와 디스크 I/O를 위한 AIO의 조합이 필요하다.
이벤트에 기반한, 다른 스타일의 병행성에 대해 간단히 알아봤다. 이벤트 기반 서버는 스케줄링 제어를 애플리케이션 자체에 제공하지만, 이렇게 하는 데에는 복잡함과 통합의 어려움이라는 대가가 있다. 이러한 어려움들로 인해, 단 하나의 최고의 방식이 나타날 수는 없게 됐다. 그러므로 스레드와 이벤트는 같은 병행성 문제에 대한 두 다른 접근법으로 오래 남게 될 것이다.