Table of contents
Open Table of contents
들어가며
이제까지 쓰레드를 병행 프로그램을 제작하는 유일한 도구인 것처럼 말했다.
하지만 그렇지 않다.
특히, GUI 기반 프로그램이나 인터넷 서버에서는 다른 스타일의 병행 프로그래밍이 사용된다.
이런 스타일을 event-based concurrency이라 한다.
node.js
와 같은 서버 프레임워크에서 사용되지만, 그 시작점은 지금부터 다룰 C와 유닉스 시스템이다.
이벤트 기반의 병행성은 두 개의 문제를 갖고 있다.
- 멀티 쓰레드 프로그램에서 이벤트 기반 병행성을 올바르게 사용하는 것이 매우 어렵다. (락을 누락시키거나, 교착 상태 또는 다른 골치 아픈 문제들이 발생할 수 있기 때문이다.)
- 멀티 쓰레드 프로그램에서는 개발자가 쓰레드 스케줄링에 대한 제어권을 전혀 갖고 있지 않다는 것이다. 개발자는 OS가 생성된 쓰레드를 CPU들 간에 합리적으로 스케줄링하기만을 기대할 수밖에 없다.
핵심 질문은 다음과 같다.
어떻게 쓰레드 없이 병행 서버를 개발할까? 쓰레드 없이 병행 서버를 구현할 때, 어떻게 병행성을 유지하면서 각종 문제들을 피할 수 있을까?
기본 개념: 이벤트 루프
우리가 다룰 방법은 언급한 이벤트 기반 병행성이다. 이 접근 방법은 단순하다. 특정 사건 (즉, “이벤트”) 의 발생을 대기한다. 사건이 발생하면, 사건 (즉, “이벤트”) 의 종류를 파악한 후, I/O를 요청하거나, 추후 처리를 위하면 다른 이벤트를 발생시키거나 하는 등의 작업을 한다. 고전적인 이벤트 기반의 서버가 어떻게 생겼는지 살펴보자. 이 응용 프로그램은 event loop라는 단순한 구조를 기반으로 짜여 있다. 코드는 하기와 같다.
매우 간단하다. 루프 안에서 사건 발생을 대기한다. 이벤트가 발생하면 하나씩 처리한다. 이때 각 이벤트를 처리하는 코드를 이벤트 핸들러(event handler)라 부른다. 중요한 건 이벤트의 처리가 시스템의 유일한 작업이기 때문에, 다음에 처리할 이벤트를 결정하는 것이 스케줄링과 동일한 효과를 갖는다. 스케줄링을 제어할 수 있는 기능이 이벤트 기반 방법의 큰 장점이다. 큰 질문이 생긴다.
발생한 이벤트가 무슨 이벤트인지 어떻게 판단할까?
네트워크나 디스크 I/O의 경우 특히 쉽지 않다. 즉, 디스크 I/O가 완료됐다는 이벤트가 도착했을 때 어떤 디스크 요청이 완료됐느냐 하는 것이다. 좀 더 구체적으로 도착한 msg가 자신을 위한 것인지 어떻게 알까?
중요 API: select()
(또는, poll()
)
기본질문: 이벤트를 어떻게 받을까?
대부분의 시스템은 select()
혹은 poll()
시스템 콜을 기본 API로 제공한다.
interface의 기능은 간단하다.
도착한 I/O들 중 주목할 만한 것이 있는지를 검사한다.
예를 들어, 웹 서버 같은 네트워크 응용 프로그램이 자신이 처리할 패킷의 도착 여부를 검사하는 것이다.
이 시스템 콜들이 정확히 해당 역할을 한다.
select()
를 예로 살펴보자.
Mac OS X가 제공하는 메뉴얼은 이 API를 다음과 같이 설명한다.
메뉴얼의 내용은 다음과 같다.
select()
는readfds
,writefds
, 그리고errorfds
를 통해 전달된 I/O 디스크립터(descriptor) 집합들을 검사해서, 각 디스크립터 들에 해당하는 입출력 디바이스가 읽을 준비가 되었는지, 쓸 준비가 되었는지, 처리해야 할 예외 조건이 발생했는지 등을 파악한다. 각 집합의 첫 번째nfds
개의 디스크립터들, 즉0
부터nfds-1
까지의 디스크립터를 검사한다.select
는 집합을 가리키는 각 포인터들을 준비된 디스크립터들의 집합으로 교체한다.select()
는 전체집합에서 준비된 디스크립터들의 총 개수를 반환한다.
(무슨 말이지?)
select()
에 대한 두 가지 알아두어야 할 것이 있다.
첫 번째, select()
를 이용하면 디스크립터에 대한 읽기 가능여부, 쓰기 가능여부를 검사할 수 있다.
전자는 처리해야 할 패킷의 도착 여부를 파악할 수 있도록 한다.
후자는 서비스가 응답전송이 가능한 시점 (예를 들어, 아웃바운드 큐가 가득 차지 않았다.) 을 파악토록 해준다.
두 번째는 timeout
인자의 존재이다.
일반적으로 NULL로 설정한다.
그러면 select()
디스크립터가 준비될 때까지 무한정 대기한다.
하지만, 오류에 대비토록 설계된 서버들의 경우 timeout
값을 설정해 두기도 한다.
널리 사용되는 방법으로는 timeout
값을 0으로 설정해, select()
가 즉시 리턴토록 하는 것이다.
poll()
시스템 콜도 유사하다.
이런 기본 함수로 non-blocking event loop를 만들어, 패킷 도착을 확인하고, 소켓에서 메시지를 읽고 필요에 응답할 수 있도록 해준다.
여담: 차단 (blocking) 과 비차단 (non-blocking) 인터페이스
차단 (또는 synchronous) 인터페이스는 호출자에게 리턴하기 전에 자신의 작업을 모두 처리하는 반면 비차단 (또는 asynchronous) 인터페이스는 작업을 시작하기는 하지만, 즉시 반환하기 때문에 처리되어야 하는 일이 background에서 완료가 된다. 차단 호출은 주로 I/O 때문에 발생한다. 예를 들어, 작업 완료를 위해서 disk에서 읽어야 하는 자료가 있다면, disk에 요청한 I/O 요청을 대기해야 한다. non-blocking interface는 모든 프로그래밍 (예, 멀티 쓰레드 프로그래밍) 스타일에서 사용될 수 있다. 하지만, event-based programming에서는 필수적이다. 왜냐면, 차단 방식의 시스템 콜 (blocking call)이 전체 시스템을 멈출 수 있기 때문이다.
select()
의 사용
확실한 이해를 위해, select()
를 이용해 어떤 네트워크 디스크립터에 msg가 도착했는지를 파악하는 경우를 살펴보자.
하기 코드를 살펴 보자.
상기 코드는 이해하기 쉽다.
초기화 후에 서버는 무한 루프에 들어간다.
그 루프 내에서 FD_ZERO()
매크로를 이용해, 파일 디스크립터를 초기화한 후, FD_SET()
을 사용하여,minFD
에서 maxFD
까지의 파일 디스크립터 집합에 포함시킨다.
이 집합은 서버가 보고 있는 모든 네트워크 소켓 같은 것들을 나타낼 수 있다.
마지막으로, 서버는 select()
를 호출하여 데이터가 도착한 소켓이 있는지 검사한다.
반복문 내의 FD_ISSET()
을 사용해, 이벤트 서버는 어떤 디스크립터들이 준비된 데이터를 갖고 있는지를 알 수 있으며, 도착하는 데이터를 처리할 수 있게 된다.
팁: 이벤트 기반의 서버 내에서는 블럭을 하지 말자
이벤트 기반 서버는 작업의 스케줄링을 정밀하게 제어할 수 있다. 하지만, 정밀한 제어를 위해서는 호출자가 실행한 것을 차단할 수 있는 어떤 호출도 있어서는 안된다.
왜 간단하가? 락이 필요 없음
단일 CPU를 사용하는 이벤트 기반의 응용 프로그램에서는 병행 프로그램을 다룰 때 나타났던 문제들은 더 이상 보이지 않는다. 그 이유는 매순간에 단 하나의 이벤트만 다루기 때문에 락을 획득하거나 해제해야 할 필요가 없다. 이벤트 기반의 서버는 단 하나의 쓰레드만 갖고 있기 때문에, 다른 쓰레드에 의해서 인터럽트가 걸릴 수가 없다. 그렇기 때문에 쓰레드 프로그램에서 흔한 병행성 버그는 기본적인 이벤트 기반 접근법에서는 나타나지 않는다.
문제: 블로킹 시스템 콜 (Blocking System Call)
이런 이벤트 기반 프로그래밍에도 문제가 있다.
차단될 수도 있는 시스템 콜을 불러야 하는 이벤트가 있다면 어떻게 해야 할까?
예를 들어, disk에서 data를 read하여, 그 내용을 사용자에게 전달하는 요청을 생각해 보자. (간단한 HTTP 요청과 비슷한 경우이다.)
이런 요청을 처리하려면, 이벤트 핸들러가 open()
시스템 콜을 사용하여, 파일을 열어서 read()
명령어를 사용하여, 파일을 읽어야 한다.
파일을 읽어서 메모리에 탑재한 후에야 서버는 그 결과를 사용자에게 전달할 수 있다.
open()
과 read()
모두 저장 장치에 I/O 요청을 보내야 한다면, 이 요청을 처리하기 위해 오랜 시간이 필요하다.
쓰레드 기반 서버는 이런 게 문제가 되지 않는다.
한 쓰레드가 I/O를 대기하면, 딴 쓰레드가 실행이 되며 서버는 계속 동작할 수 있다.
I/O 처리와 딴 연산이 자연스레 overlap되는 현상이 쓰레드 기반 프로그래밍의 장점이다.
반면에 이벤트 기반의 접근법에서는 쓰레드가 없고 단순히 이벤트 루프만 존재한다.
즉, 이벤트 핸들러가 블로킹 콜을 호출하면, 서버 전체가 오직 그 일 처리만을 위해, 명렁어가 끝날 때까지 다른 것들을 차단 (block) 한다.
이벤트 루프가 블록되면, 시스템은 유휴상태가 된다.
심각한 자원 낭비다.
이벤트 기반 시스템의 기본 원칙은 블로킹 호출을 허용하면 안 된다는 것이다.
해법: 비동기 I/O
언급된 한계를 극복하기 위해 여러 현대 OS들이 I/O 요청을 디스크로 내려 보낼 수 있는 일반적으로 asynchrounous I/O라 부르는 새로운 방법을 개발했다.
이 interace는 프로그램이 I/O 요청을 하면 I/O 요청이 끝나기 전에 제어권을 즉시 다시 호출자에게 돌려주는 것을 가능케 했으며, 추가적으로 여러 종류의 I/O들이 완료됐느지도 판단할 수 있게했다.
예를 들어, Mac OS X가 제공하는 interface를 살펴 보자.
이 API는 struct aiocb
또는 전문 용어로 AIO 제어 블럭 (AIO control block)이라고 불리는 기본적인 구조를 사용하고 있다.
상기 코드가 간단화한 구조다.
파일에 대한 비동기 읽기 요청을 하려면, 응용 프로그램은 먼저 이 자료 구조에 읽고자 하는 파일의 파일 디스크립터 (aio_fildes
), 파일 내에서 위치(aio_offset
)와 더불어 요청 길이 (aio_nbytes
), 그리고 마지막으로 읽기 결과로 얻은 데이터를 저장할 대상 메모리의 위치(aio_buf
)와 같으 관련 정보를 채워 넣어야 한다.
이 자료 구조에 정보가 다 채워지면 응용 프로그램은 읽으려는 파일에 비동기 호출을 보낸다.
Mac OS X에서는 간단한 asynchronous read API를 사용한다.
이 명령어를 통해 I/O 호출을 성공하면, 즉시 리턴을 하며 응용 프로그램 (이벤트 기반의 서버 류)은 하던 일을 계속 진행할 수 있다.
풀어야 하는 퍼즐 한 조작이 있다.
I/O가 종료됐다는 것을 어떻게 알 수 있을까? 그리고
aio_buf
가 가리키는 버퍼에 요청했던 데이터가 있다는 것을 어떻게 알 수 있을까?
마지막으로 API 하나가 필요하다. Mac OS X에서는 이 API를 aio_error()
라 한다.
이 시스템 콜은 aiocbp에 의해 참조된 요청이 완료됐는지를 검사한다.
완료됐으면, 성공했다고 리턴을 하고 (0으로) 실패했다면 EINPROGRESS를 반환한다.
모든 대기 중인 비동기 I/O는 주기적으로 aio_error()
시스템 콜로 시스템에 폴링(poll)하여 해당 I/O가 완료됐는지 확인할 수 있다.
어떤 I/O가 완료됐는지 확인하는 것이 귀찮게 느껴질 수 있다.
만약 어떤 시점에 수십 또는 수백 개의 I/O를 요청하는 프로그램이 있다면, 그 많은 요청들을 일일이 다 검사해야 할 것인가 아니면 먼저 일정 시간 동안을 대기해야 할까, 그것도 아니라면?
이 문제의 해결을 위해서 어떤 시스템들은 interrupt 기반의 접근법을 제공한다.
유닉스의 signal을 사용하여, 비동기 I/O가 완료됐다는 것을 응용 프로그램에게 알려주기 때문에 시스템에 반복적으로 완료 여부를 확인할 필요가 없다.
폴링 대 인터럽트 문제는 I/O 장치들을 다룰 때에도 나타난다.
비동기 I/O가 없는 시스템에서는 제대로 된 이벤트 기반의 접근법을 구현할 수 없다.
하지만, 현명한 연구자들이 그 대신 꽤 괜찮게 동작할 수 있는 방법들을 고안했다.
예를 들어, 네트워크 패킷을 처리하기 위해 이벤트를 사용하고, 대기 중인 I/O들을 처리하기 위해 쓰레드 풀을 사용하는 하이브리드 기법이다.
또 다른 문제점: 상태 관리
이벤트 기반 접근법의 또 다른 전통적인 쓰레드 기반 코드보다 일반적으로 더 작성하기 어렵다는 것이다.
그 이유는 다음과 같다.
이벤트 핸들러가 비동기 I/O를 발생시킬 때, I/O 완료 시 사용할 프로그램 상태를 정리해 놓아야 한다.
이 작업은 쓰레드 기반 프로그램에서는 불필요하다.
왜냐면, 쓰레드 스택에 그 정보들이 이미 들어 있기 때문이다.
이것은 수동 스택 관리 (manual stack management) 라 부르며, 이벤트 기반 프로그래밍에서 기본이다.
쓰레드 기반 서버를 예로 들자.
이 서버는 파일 디스크립터 (fd
) 로 명시된 파일에서 데이터를 read해, 해당 데이터를 네트워크 소켓 디스크립터 (sd
) 로 전송한다.
코드는 하기와 같다.
이 작업은 멀티 쓰레드 프로그램에서는 매우 간단하다.
read()
가 리턴되면 전송할 네트워크 소켓 정보가 같은 스택에 존재하기 때문이다.
근데, 이벤트 기반 시스템에서는 그렇게 간단하지가 않다.
같은 일을 하려면, 앞서 명시한 AIO 호출들을 사용하여, read()
를 비동기로 요청한다.
aio_error()
를 사용해, 주기적으로 읽기가 종료 됐는지를 확인하다고 하자.
이 호출이 읽기가 종료됐다고 알려주면, 이벤트 기반 서버는 다음으로 무슨 일을 해야 할지 어떻게 알 것인가?
continuation 을 사용한다.
이 개념은 단순하다.
이벤트를 종료하는 데에 필요한 자료들을 한곳에 저장한다.
이벤트가 발생하면 (디스크 I/O가 완료되면), 저장해 놓은 정보들을 활용해 이벤트를 처리한다.
앞서 사용한 예의 해법은 소켓 디스크립터 (sd
) 를 파일 디스크립터 (fd
) 가 사용하는 자료구조 (예, 해시 테이블)에 저장해 놓는 것이다.
자료 구조 (예 : 해시 테이블) 에 저장해 놓는 것이다.
I/O가 완료되면 이벤트 핸들러가 파일 디스크립터에서 다음 할 일을 파악하여 호출자에게 소켓 디스크립터의 값을 반환하도록 한다.
이 시점에서 (최종적으로), 서버는 소켓에 데이터를 기록하는 마지막 동작을 할 수 있게 된다.
이벤트 사용의 어려움
이 단락도 그냥 그대로 인용했습니둥.
이벤트 기반 접근법에는 다른 어려운 점이 몇 개 존재한다. 예를 들어 단일 CPU에서 멀티 CPU로 변경되면, 이벤트 기반 접근법의 단순함이 없어진다. 구체적으로 말하자면, 하나 이상의 CPU를 활용하기 위해서는 다수의 이벤트 핸들러를 병렬적으로 실행해야. 그렇게 되면 동기화 문제(예, 임계 영역) 가 발생하게 되며 이의 해결에 필요한 기능 (예, 락) 을 사용할 수밖에 없다. 때문에 근례의 멀티코어 시스템은 락이 없는 이벤트 처리 방식은 더 이상 사용할 수 없게 된다. 또 다른 문제는 이벤트 기반의 접근법은 페이징(paging) 과 같은 특정 종류의 시 스템과 잘 맞지 않는다. 예를 들면 이벤트 핸들러에서 페이지 폴트가 발생하면 동작이 중단되기 때문에 서버는 페이지 폴트가 처리 완료되기 전까지는 진행을 할 수 없게 된다. 서버가 비차단(non-blocking) 방식으로 설계되었다 할지라도, 페이지 폴트와 같은 내재적 원인으로 인한 차단은 피하기가 어렵다. 이런 상황이 자주 발생하는 경우에 심각한 성능 하락을 가져 올 수 있다. 세 번째 문제는 루틴의 작동 방식이 계속 변화하기 때문에, 이벤트 기반에서는 이들의 관리가 어려워진다. 예를 들어 루틴 동작이 비차단 방식에서 차단방식으로 변경된다면 그 루틴을 호출하는 이벤트 핸들러 역시 새로운 성질에 적응하도록 변경해야 한다. 이에 적합하게 루틴을 두 버전으로 나눠야 한다. 이벤트 기반 서버에서 차단(block) 이라는 것은 치명적이기 때문에 개발자는 각 이벤트가 사용하는 API의 문법이 변경이 되었는지를 늘 주의 깊게 살펴야 한다. 마지막으로 비동기 디스크 I/O가 대부분의 플랫폼에서 사용 가능하지만, 그렇게 되기까지 상당히 오랜 시간이 걸렸다. 더구나, 아직까지도 비동기 네트워크 I/O는 생각하는 것만큼 간단하고 일관성 있게 적용되어 있지 않다. 예를 들면, 모든 입 출력 처리에
select()
를 사용하여 일관성을 유지하는 것이 이상적이지만, 일반적으로 네트워크 요청의 처리에는select()
가, 디스크 I/O 에는 AIO가 사용되고 있다.