본 내용은 OSTEP 의 내용을 정리 및 요약한 내용입니다. 전문은 이 곳을 방문하시면 보실 수 있습니다.

33 이벤트 기반의 병행성(고급)

동시성을 가진 프로그램이라고 하는 것은, 쓰레드 만이 방법론으로 존재하는 것이 아니다. GUI 기반의 프로그램이나, 인터넷 서버에서는 다른 스타일의 병행 프로그래밍이 사용된다. 이 방법이 바로 이벤트 기반의 동시성(event-based concurrency) 의 방법이다.

이 방법의 핵심은 동시 프로그래밍, 병렬 프로그래밍이라는 어려운 방법을 사용한 것이 아니다. 이러한 방법은 자료 구조를 락으로 보호 해야하거나, CPU의 스케쥴링에 기대서 잘 처리되기를 바라는 등의복잡한 작업들이 필요하다. 시미어 개발자는 운영체제가 합리적으로 쓰레드가 처리되길 기도하는 수밖에 없다(!)

따라서 이번 과에서는 다음과 같은 것을 배울 것이다.

🚩 핵심 질문: 어떻게 쓰레드 없이 병행 서버를 개발할까?

쓰레드 없이 병행 서버를 구현하는데 필요한 것, 동시성 유지와 각종 문제들을 피하는 방법은?

33.1 기본 개념 : 이벤트 루프

결국, 위에서 언급한 내용처럼 핵심은 이벤트 기반의 동시성(병행성) 이다. 이 접근의 핵심은 특정 사건을 기다린다는 것이다.

사건이 발생하면, 이 종류를 파악하고, I/O 요청하거나 추후의 처리를 위해 다른 이벤트를 발생시키거나 하는 등의 작업을 한다. 이러한 간단한 구조가 이벤트 루프(event loop) 라는 것인데, 다음과 같은 구조를 가진다.

while(1) {
	events = getEvents();
	for (e in events) {
		processEvent(e);
	}
}

간략화된 구조를 보면 어떤 식으로 동작하는지를 알 수 있다. 루프 내에서 이베트 발생을 대기하며, 이벤트가 발생해서 튀어나오면, 이 이벤트를 처리하는 코드로 들어가고 이러한 코드를 이벤트 핸들러(event handler)라고 부른다.

중요한 포인트는 이벤트의 처리가 시스템의 유일한 작업이므로, 다음에 처리할 이벤트를 결정하는 것이 스케쥴링과 동일한 효과를 갖는다. 이러한 특징은 이벤트기반 처리의 가장 큰 장점이라고 볼 수 있다.

여기서 핵심은 발생한 이벤트가 구체적으로 어떤 이벤트인가를 판별하는 것이 간단하지 않다. 네트워크나 디스크 IO의 경우 특히 구분하기 어렵다. 이벤트가 도착했을 때 어떤 디스크의 요청이 완료 되었느냐, 그리고 이 메시지가 어떤 의미와 누구를 위한것인가를 판단하는 것은 중요한 포인트가 된다.

33.2 중요 API: select() (또는 poll())

  • 기본질문 : 이벤트 발생을 감지 방법은? select() / poll()

해당 인터페이스는 간단하다. I/O 이벤트가 발생 시 처리를 필요로 하는 것이 있는지 확인한다.

int select(int nfds,
		  fd_set *restrict readfds,
		  fd_set *restrict writefds,
		  fd_set *restrict errorfds,
		  struct timeval *restrict timeout);

매뉴얼을 참고하면 각 fd_set이라는 fd의 집합을 나타내는 구조체에서, 각 디스크립터들에 해당하는 입출력 디바이스가 읽을 준비가 되었는지, 쓸 준비가 되었는지, 처리할 예외 조건이 발생했는지 등을 파악한다.

기본적으로 0번부터, nfds개의 디스크립터를 감시하며, select는 집합을 가리키는 각 포인터들을 준비된 디스크립터들의 집합으로 교체한다. select() 는 전체 집합에서 준비된 디스크립터의 총 개수를 return 값으로 삼는다.

😁 차단(blocking)과 비차단(non-blocking) 인터페이스

차단(또는 동기(synchronous)) 인터페이스는 호출자에게 리턴하기 전에 자신의 작업을 모두 처리하는 반면, 비 차단(또는 비동기(asynchronous)) 인터페이스는 작업을 시작하기는 하지만 즉시 반환하기 때문에 처리되어야 하는 일이 백그라운드에서 완료가 된다.
차단 콜(blocking call)은 주로 I/O 때문에 발생한다. 디스크에서 자료를 가져오는 동안 프로그램은 이를 기다릴 수 밖에 없는 것이다.
비차단 인터페이스(non-blocking interface) 는 모든 프로그래밍 스타일에서 사용될 수 있다. 하지만 이벤트 기반 프로그래밍 방식에서는 필수적이다. 차단 방식의 시스템콜은 전체 시스템을 멈추기 때문이다.

33.3 select() 의 사용

//33.1 select()를 사용한 간단한 코드 
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int main(void) {
	// 여러 개의 소켓을 열고 설정(생략)
	while(1) {
		// fd_set을 모두 0으로 초기화함 
		fd_set readFDs;
		FD_ZERO(&readFDs);

		// 이 서버가 감시할 디스크립터들의 bit를 설정 
		// 단순함을 위해 min, max 까지 
		int fd;
		for (fd = minFD; fd < maxFD; fd++) {
			FD_SET(fd, &readFDs);
		}
		// 선택을 함 
		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);
			}
		}
	}
}

위의 예시를 보면 전형적인 로직을 보여준다. maxFD까지의 파일 디스크립터 집합에 포함하고, 이 집합은 서버가 보고 있는 모든 네트워크 소켓 같은 것들을 나타낼 수 있다. 서버는 select()를 호출하여 데이터가 도착한 소켓이 있는지를 검사한다.

반복문 내의 FD_ISSET()을 사용하여 이벤트 서버는 어떤 디스크립터 들이 준비된 데이터를 갖고 있는지 알고, 도착하는 데이터를 처리할 수 있게 된다.

33.4 왜 간단한가? 락이 필요 없음

😡이벤트 기반의 서버 내에서는 대기하지 말자

이벤트 기반 서버는 작업의 스케쥴링을 정밀하게 제어할 수 있다. 하지만 그렇기에 중간에 시스템이 대기하는 상황에 빠진다면 선형으로 진행되는 작업들이 모두 그 순간 멈추는 꼴이 난다. 따라서 반드시 서버가 블락 되지 않도록 설정해야 하며, 이벤트 기반 서버가 멈춘다면 이는 이벤트 기반 방식이 가지는 이점을 버리는 꼴이다.

단일 CPU에서 이벤트 기반 응용 프로그램이 되면, 쓰레드 기반 동시성 프로그램을 다룰 때 등장하던 모든 문제가 발생하지 않게 된다. 락의 획득이나 해제, 락에서 오는 교착 상태등도 발생하지 않는다.

33.5 문제점: 블로킹 시스템 콜

기존의 입출력의 방식에선 저장장치의 I/O 요청과 답신까지 대기한다. 쓰레드 기반은 이러한 문제를 해결코자 여러 쓰레드를 통해 다중으로 입출력 처리를 만들어 연산을 중첩한다. 이를 통해 쓰레드 기반 프로그래밍은 동시성을 확보한다.

이벤트 기반의 접근법은 이와는 다르게, 오로지 이벤트 루프만이 존재한다. 이벤트 핸들러가 블로킹 콜을 호출하면 서버 전체가 오직 그 일을 처리하기 위해 정지된다. 이는 다른 요청들에게 심각한 자원 낭비가 될 것이며, 그렇기에 원칙적으로 블로킹 호출이 이벤트 기반의 프로그램에선 허용되지 못한다.

33.6 해법: 비동기 I/O

이런 점 때문에 현대의 운영체제들은 디스크 입출력 요청의 방식에 새로운 방식으로 비동기 I/O(asynchronous I/O) 라는 방법을 개발하였다. 맥의 경우 AIO 제어 블록(AIO Control Block) API같은 것이 이러한 비동기 입출력을 구현하는 인터페이스이다.

이 작업을 하는 API를 비동기 읽기(asynchronous read) 라 칭하며,

`int aio_read(struct aiocb * aiocbp);`

라는 인터페이스를 통해 읽기 작업을 완료하지 않고 즉시 리턴한다. 응용 프로그램은 하던 일을 계속 진행한다.

그렇다면 여기서 우리는 그런 생각이 들 것이다. 어떻게 I/O의 완료를 보장할까?

여기서 MAC의 다른 API가 등장하고 그것이 바로 int aio_error(const struct aiocb *aiocbp) 이다. 이 시스템 콜은 참조된 변수의 요청이 완료되도 요청이 완료 된 것을 검사해야 한다. 비동기 I/O는 주기적 aio_error() 시스템 콜로 시스템에 폴링(polling)해당 IO가 완료 여부를 파악하는 역할을 맡는다.

여기서 폴링이란 I/O 완료 여부를 계속적으로 검사하는 것을 말한다. 즉 비동기 IO 구조에서 핵심은 IO 대기가 아님에도 읽기 내용을 정상적으로 처리해주는 것을 말한다.

폴링 과 다르지만 IO의 이걸 구현하는 것은 다양한 방법이 있지만, 어떤 시스템들은 인터럽트 기반의 접근법을 사용한다. 시그널을 사용해, 비동기 I/O를 구현할 수 있다. 유닉스의 시그널을 사용해, 비동기 IO 완료를 응용프로그램에게 알려주기 때문에 시스템에 반복적으로 완료여부 확인을 할 필요가 없다.

비동기 I/O가 없는 시스템에서는 제대로된 이벤트 기반을 구현할 수 없다. 그러나 이에 대한대안을 연구자들이 고안을 해냏고, 네트워크 패킷을 처리하기 위해 이벤트를 사용하고 대기중인 IO 들을 처리하기 위해 쓰레드 풀을 사용하는 하이브리드 기법을 제안하였다.

33.7 또 다른 문제점: 상태 관리

이벤트 기반의 접근 방식이 가지는 문제는 전통적인 쓰레드 기반 코드보다 일반적으로 복잡하다는 것이다. 이벤트 핸들러가 비동기 입출력을 발생 시킬 때, 입출력 완료시 사용할 프로그램 상태를 정리해 놓아야 한다. 이 작업은 쓰레드 기반 프로그램에서는 불필요하다. 왜냐면 쓰레드 스택에 그 정보가 이미 들어있고, 굳이 이를 기록하며 상황이 다를 것을 예상할 필요가 없다.

이러한 특징을 Ayda 등은 수동 스택 관리(mannual stack management) 라고 부르고, 이벤트 기반 프로그래밍에서는 이를 기본으로 본다고 보면 된다.

이벤트 기반에서 read를 한다고 하면, read()가 리턴한 후 read() 요청이 완료된다. 이때 aio_error()를 주기적으로 호출하여 읽기 완료 여부를 판단한다. 읽기가 완료되었다고 가정해보고, 이때 이벤트 기반 서버는 다음 작업을 어떻게 파악할까?

여기서 해법 중 하나로 continuation의 개념을 사용하여 이를 확인할 수 있다. 이 방법은 이벤트 종료 시, 필요한 자료를 한 곳에 저장해두고, 저장해 놓은 자료를 이벤트 발생 시 활용하여 이벤트를 처리하도록 만들 수 있다.

33.8 이벤트 사용의 어려움

이벤트 기반 접근법에는 또 다른 어려움들이 있다. 예를 들어 단일 멀티 CPU의 경우 이벤트 기반의 접근법의 단순함은 사라진다. 다수의 CPU를 활용하기 위해 이벤트 서버는 다수의 이벤트 핸들러를 병렬적으로 실행한다. 그렇게 되면 임계영역 보호 등과 같은 동기화 문제가 발생하며, 락과 같은 기법을 사용할 수 밖에 없다.

이를 좀더 쉽게 풀어보면 이렇게 정리될 것이다. 이벤트 기반은 단일 프로세스 단일 스레드 기반이다(하이브리드가 아니라면) 이때 멀티 프로세스의 스케쥴링 상에 올라가게 되면, 각 코어에서 컨텍스트 스위칭이 발생하메 따라 생기는 데이터의 읽기 쓰기의 오버헤드가 커지게 되고, 그만큼의 성능 하락이나, 오히려 내부에서 작업이 복잡해지는 문제를 일으키게 된다. 오히려 이런 경우 더더욱 스레드와 멀티 프로세싱을 활용하는 것이, 초반에 생길 수있는 오버헤드를 넘어서 더 효과적인 서버 프로그래밍이 될수도 있는 상황인 것이다.

또한 이벤트 기반의 접근법과 운영체제 동작 간의 상관관계도 발생한다. 이벤트 기반 서버에서 페이징과 같은 시스템의 구조는 조화롭지 않을 수 있다. 서버는 페이지 폴트 처리 완료 전까지 실행이 되지 못하게 되고, 설령 서버가 비봉쇄(non-blocking) 방식으로 설계가 되었다고 해도, 운영체제 내부적으로 발생하는 페이징 구조로 인한 서버 성능 저하 사건에까지 대비할 수는 없는 것이고, 이는 상당한 성능저하를 초래하는 것이다.

세 번째 문제로는 루틴들의 작동 방식의 변화이다. 소프트웨어가 개선되고 갱신되면서 각 루틴들의 특성이 변경될 수 있다. 이벤트 기반 서버에서는 변경되는 특성에 적합하게 코드를 다시 작성해야 하는데, 이것이 상당히 복잡하다. 예시로 루틴 동작이 비봉쇄가 아닌 봉쇄 방식인 경우가 있을 수 있다. 데이터가 들어올 때까지 기다려야 하는 경우를 생각해보자. 예를 들어 사용자의 키 입력이 대표적인 예일 것이고, 이런 경우 결국 이에 적합하게 루틴을 두 버전으로 나누지 않으면 하나의 이벤트로 모든 서버의 작업이 멈추게 되어 버린다.

마지막으로 비동기 디스크 I/O 사용 가능 여부의 문제다. 현재는 대부분의 OS가 비동기 입출력을 지원한다. 하지만 그렇다고 해서 아직까지도 비동기 네트워크 I/O는 생각 만큼 간단하고 일관성 있게 적용되지는 않는다. 일반적으로 네트워크 요청은 select()를 통해 진행하며, 디스크 I/O에는 AIO가 사용된다. 심지어 맥은 kevent(), 리눅스 계열의 epoll() 까지 생각한다면 아직 정립되고 통합화된 상태의 비동기 디스크 입출력은 아니라는 소리다.

33.9 요약

이벤트 기반 서버는 프로그램 자체에 스케줄링에 대한 제어권을 부여하지만 복잡도가 높고, 현대 시스템의 다른 부분들로 인해 적용이 어렵다는 문제를 가진다. 이러한 문제점은 그렇기에 여전히 기존의 방식 서로서로의 장단점을 가지게 만들고 우열을 가리기 어렵다는 점을 보여준다.

병렬형 프로그램의 개발은 이벤트 기반이냐 쓰레드 기반이냐, 이는 시스템의 특성, 목적에 맞게 결정되어야 하며, 이를 위한 논문들을 참고하는게 중요하며, 이벤트기반의 코드 작성 경험을 가지는 것도 좋은 방법이다.