개발 공부

손승열(Son Seungyeol)

[운영체제][OSTEP] 이벤트 기반의 병행성

쓰레드 없이 병행 서버를 개발하는 방법에 대해 공부해봅니다.

손승열(Son Seungyeol)
[운영체제][OSTEP] 이벤트 기반의 병행성

🚪 들어가며

GUI 기반의 프로그램이나 인터넷 서버에서는 다른 스타일의 병행 프로그래밍이 사용됨.
➡️ 이벤트 기반의 병행성(event-based concurrency)

node.js와 같은 서버 프레임워크에서 사용됨.
➡️ 시작점은 C와 유닉스 시스템

⚠️ 이벤트 기반의 병행성은 두 개의 문제를 갖고 있음.

  • 멀티 쓰레드 프로그램에서 올바르게 사용하는 것이 매우 어려움.

    ➡️ 락 누락, 교착 상태 등

  • 멀티 쓰레드 프로그램에서는 개발자가 쓰레드 스케줄링에 대한 제어권을 전혀 갖고 있지 않음.

    ➡️ 운영체제가 합리적으로 스케줄링하기만을 기대

따라서 쓰레드 없이 병행 서버를 구현할 때, 병행성을 유지하면서 각종 문제들을 피하는 방법에 대해서 알아보자.


1️⃣ 기본 개념: 이벤트 루프

이벤트 기반의 병행성: 특정 사건("이벤트")의 발생을 대기
➡️ 사건이 발생하면, 사건의 종류를 파악한 후 I/O을 요청하거나 추후 처리를 위한 다른 이벤트 발생 등의 작업을 수행

먼저, 고전적인 이벤트 기반의 서버가 어떻게 생겼는지 살펴보자.

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

이벤트 루프(event loop)라는 단순한 구조를 기반으로 짜여 있음.
➡️ 루프내에서 사건 발생을 대기하다가 이벤트가 발생하면 하나씩 처리

이벤트 핸들러(event handler): 이벤트를 처리하는 코드

⭐ 이벤트의 처리가 시스템의 유일한 작업
➡️ 다음에 처리할 이벤트를 결정하는 것이 스케줄링과 동일한 효과

✅ 스케줄링을 제어할 수 있는 기능이 이벤트 기반 방법의 큰 장점 중 하나

그런데 발생한 이벤트가 무슨 이벤트인지 어떻게 판단할까?
➡️ 네트워크나 디스크 I/O의 경우(어떤 디스크 요청이 완료?) 특히 쉽지 않음.


2️⃣ 중요 API: select() (또는 poll())

대부분의 시스템은 select() 또는 poll() 시스템 콜을 기본 API로서 제공
➡️ 도착한 I/O들 중 주목할 만한 것이 있는지를 검사
➡️ 예를 들면, 웹 서버 같은 네트워크 응용 프로그램이 자신이 처리할 패킷의 도착 여부를 검사

Mac OS X가 제공하는 메뉴얼은 select()를 다음과 같이 설명

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

인자들을 통해 전달된 I/O 디스크립터(descriptor) 집합들을 검사해서 각 디스크립터들에 해당하는 입출력 디바이스가 읽을 준비가 되었는지, 쓸 준비가 되었는지, 처리해야할 예외 조건이 발생했는지 등을 파악함.

다음과 같은 순서로 진행된다.

  1. 각 집합의 첫 번째 nfds 개의 디스크립터들(0~nfds-1)을 검사

  2. select는 집합을 가리키는 각 포인터들을 준비된 디스크립터들의 집합으로 교체

  3. select()는 전체집합에서 준비된 디스크립터들의 총 개수를 반환

select()에 대해 두 가지 알아두어야 할 사항이 있다.

  • select()를 이용하면 디스크립터에 대한 읽기 가능여부, 쓰기 가능여부를 검사할 수 있다.

    • 전자는 처리해야 할 패킷의 도착 여부를 파악할 수 있도록 함.

    • 후자는 서비스가 응답전송이 가능한 시점을 파악하도록 함.

  • timeout 인자의 존재

    • 일반적으로는 NULL로 설정

      ➡️ select()는 디스크립터가 준비될 때까지 무한정 대기

    • 오류에 대비하도록 설계된 서버들의 경우 timeout 값을 설정해 두기도 함.

      ➡️ 널리 사용되는 방법: 0으로 설정하여 즉시 리턴하도록 함.

poll() 시스템 콜도 이와 유사함.

이러한 기본 함수로 non-blocking event loop를 만들어, 패킷 도착을 확인하고, 소켓에서 메시지를 읽고 필요에 응답할 수 있도록 해줌.

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

  • 차단(또는 동기(synchronous)) 인터페이스

    • 호출자에게 리턴하기 전에 자신의 작업을 모두 처리

    • 차단 호출은 주로 I/O 때문에 발생

  • 비차단(또는 비동기(asynchronous)) 인터페이스

    • 작업을 시작하기는 하지만, 즉시 반환하기 때문에 처리되어야 하는 일이 백그라운드에서 완료가 됨.

    • 모든 프로그래밍(멀티 쓰레드 프로그래밍 등) 스타일에서 사용될 수 있음.

    • 이벤트 기반의 프로그래밍 방식에서는 필수적임.

      ➡️ 차단 방식의 시스템 콜(blocking call)이 전체 시스템을 멈출 수 있기 때문


3️⃣ select()의 사용

select()를 이용해 어떤 네트워크 디스크립터에 메시지가 도착했는지를 파악하는 경우를 살펴보자.

c
#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);
    
    // 선택을 함
    int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
    
    // FD_ISSET()를 사용하여 실제 데이터 사용 여부 검사
    int fd;
    for (fd = minFD; fd < maxFD; fd++)
      if (FD_ISSET(fd, &readFDs))
        processFD(fd);
  }
}

이 코드는 다음과 같은 방식으로 동작한다.

  1. 초기화 후 서버는 무한 루프에 진입

  2. 루프 내에서 FD_ZERO() 매크로를 사용하여 파일 디스크립터들을 초기화

  3. FD_SET()를 사용하여 minFD에서 maxFD까지의 파일 디스크립터 집합에 포함

    1. 이 집합은 서버가 보고 있는 모든 네트워크 소켓 같은 것들을 나타낼 수 있음.

  4. 마지막으로 서버는 select()를 호출하여 데이터가 도착한 소켓이 있는지 검사

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

🛸 물론, 실제 서버는 이보다 더 복잡함.
➡️ 디스크 작업이나 메시지를 보내는 시점, 그 외 세부 사항들을 결정하는 로직이 필요

💡 이벤트 기반의 서버 내에서는 블럭을 하지 말자

이벤트 기반 서버는 작업의 스케줄링을 정밀하게 제어할 수 있음.
➡️ 정밀한 제어를 위해서는 호출자가 실행한 것을 차단할 수 있는 어떠한 호출도 있어서는 안 됨.


4️⃣ 왜 간단한가? 락이 필요 없음

단일 CPU를 사용하는 이벤트 기반의 응용 프로그램에서는 병행 프로그램을 다룰 때 나타났던 문제들은 더 이상 보이지 않음.
➡️ 매 순간에 단 하나의 이벤트만 다루기 때문에 락을 획득하거나 해제해야 할 필요가 없기 때문

이벤트 기반의 서버는 단 하나의 쓰레드만 갖고 있으므로 다른 쓰레드에 의해서 인터럽트에 걸릴 수가 없음.
➡️ 쓰레드 프로그램에서 흔한 병행성 버그는 기본적인 이벤트 기반 접근법에서는 나타나지 않음.


5️⃣ 문제: 블로킹 시스템 콜(Blocking System Call)

그렇다면 차단될 수도 있는 시스템 콜을 불러야 하는 이벤트가 있다면 어떻게 할까?

예를 들어 디스크에서 데이터를 읽어서 그 내용을 사용자에게 전달하는 요청을 생각해보자.

서버는 다음과 같이 동작할 것이다.

  1. 이벤트 핸들러가 open() 시스템 콜을 사용하여 파일을 연다.

  2. read() 명령어를 사용하여 파일을 읽는다.

  3. 파일을 읽어서 메모리에 탑재한 후 서버는 그 결과를 사용자에게 전달한다.

open()과 read() 모두 저장 장치에 I/O 요청을 보내야 한다면, 이 요청을 처리하기 위해서 오랜 시간이 필요하다.

✅ 쓰레드 기반 서버는 이것이 문제가 되지 않는다.
➡️ 한 쓰레드가 I/O를 대기하면 다른 쓰레드가 실행되며 서버가 계속 동작할 수 있기 때문
➡️ I/O 처리와 다른 연산이 자연스럽게 겹쳐지는 현상(overlap)이 쓰레드 기반 프로그래밍의 장점

반면 이벤트 기반의 접근법에서는 쓰레드가 없고 단순히 이벤트 루프만 존재한다.
➡️ 이벤트 핸들러가 블로킹 콜을 호출하면 서버 전체가 오직 그 일을 처리하기 위해 명령어가 끝날 때까지 다른 것들을 차단한다.

이벤트 루프가 블록되면 시스템은 유휴 상태가 된다.
➡️ ⚠️ 심각한 자원 낭비 발생


6️⃣ 해법: 비동기 I/O

앞선 한계를 극복하기 위해 여러 현대의 운영체제들이 I/O 요청을 디스크로 내려보낼 수 있는 새로운 방법을 개발함.
➡️ 일반적으로 비동기 I/O(asynchronous I/O)라고 부름.

이 인터페이스를 다음을 가능하게 해주었다.

  • 프로그램이 I/O 요청 시, I/O 요청이 끝나기 전에 제어권을 즉시 다시 호출자에게 돌려줌.

  • 여러 종류의 I/O들이 완료 되었는지 판단

예를 들어 Mac OS X가 제공하는 인터페이스를 살펴보자.

이 API는 struct aiocb 또는 전문 용어로 AIO 제어 블럭(AIO control block)이라고 불리는 기본적인 구조를 사용하고 있다.

간단화한 구조는 다음과 같다.

c
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 */
};

파일에 대한 비동기 읽기 요청을 위해서 응용 프로그램은 먼저 이 자료 구조에

  1. 읽고자 하는 파일의 파일 디스크립터(aio_fildes)

  2. 파일 내에서 위치(aio_offset)

  3. 요청의 길이(aio_nbytes)

  4. 읽기 결과로 얻은 데이터를 저장할 대상 메모리의 위치(aio_buf)

와 같은 정보를 채워 넣어야 한다.

이렇게 자료 구조에 정보를 채운 후 응용 프로그램은 읽으려는 파일에 비동기 호출을 보낸다.
➡️ Mac OS X에서는 간단한 비동기 읽기(asynchronous read) API를 사용

c
int aio_read(struct aiocb *aiocbp);

✅ 이 명령어를 통해 I/O 호출을 성공하면, 즉시 리턴을 하며 응용 프로그램(이벤트 기반의 서버 류)은 하던 일을 계속 진행할 수 있음.

🤔 그렇다면 I/O가 종료되었다는 것을 어떻게 알 수 있을까?

🤔 또한 aio_buf가 가리키는 버퍼에 요청했던 데이터가 있다는 것을 어떻게 알 수 있을까?

이를 위해서는 API 하나가 필요하다.
➡️ Mac OS X에서는 이 API를 aio_error()라고 함.

c
int aio_error(const struct aiocb *aiocbp);

이 시스템 콜은 aiocbp에 의해 참조된 요청이 완료되었는지를 검사한다.

✅ 완료되었다면 성공했다고 0을 리턴한다.

🚫 실패했다면 EINPROGRESS을 반환한다.

💡 모든 대기 중인 비동기 I/O는 주기적으로 aio_error() 시스템 콜로 시스템에 폴링(poll)하여 해당 I/O가 완료되었는지 확인할 수 있다.

🤔 만약 어떤 시점에 수십/수백 개의 I/O 요청하는 프로그램이 있다면 어떻게 해야할까?
➡️ 이 문제의 해결을 위해서 어떤 시스템들은 인터럽트 기반의 접근법을 제공

✅ 유닉스의 시그널(signal)을 사용하여 비동기 I/O가 완료되었다는 것을 응용 프로그램에게 알려주기 때문에 시스템에 반복적으로 완료 여부를 확인할 필요가 없다.

⚠️ 비동기 I/O가 없는 시스템에서는 제대로 된 이벤트 기반의 접근법을 구현할 수 없다.
➡️ 대신 네트워크 패킷을 처리하기 위해 이벤트를 사용하고 대기 중인 I/O들을 처리하기 위해 쓰레드 풀을 사용하는 하이브리드 기법 등이 고안됨.


7️⃣ 또 다른 문제점: 상태 관리

⚠️ 이벤트 기반 접근법의 또 다른 문제점
➡️ 전통적인 쓰레드 기반 코드보다 일반적으로 더 작성하기 복잡함.

이벤트 핸들러가 비동기 I/O를 발생시킬 때, I/O 완료 시 사용할 프로그램 상태를 정리해 놓아야 함.
➡️ 수동 스택 관리(manual stack management)
➡️ 쓰레드 기반 프로그램에서는 이러한 정보들이 쓰레드 스택이 이미 들어있으므로 불필요

쓰레드 기반 서버에서 파일 디스크립터(fd)로 명시된 파일에서 데이터를 읽어들여, 해당 데이터들을 네트워크 소켓 디스크립터(sd)로 전송하는 예시를 살펴보자.

오류 처리를 제외한 코드는 다음과 같다.

c
int rc = read(fd, buffer, size);
rc = write(sd, buffer, size);

이러한 작업은 멀티 쓰레드 프로그램에서는 매우 간단한 일
➡️ read()가 리턴되면 전송할 네트워크 소켓에 관한 정보가 같은 스택에 존재하기 때문

이벤트 기반의 시스템의 경우 동일한 처리를 위하여 앞서 명시한 AIO 호출들을 사용하여 read()를 비동기로 요청해야 함.

aio_error()를 사용하여 주기적으로 읽기가 종료되었는지를 확인한다고 가정했을 때, 이벤트 기반 서버는 종료를 확인하고 다음으로 무슨 일을 해야할 지 판단해야 함.
➡️ Continuation 사용

✅ Continuation는 이벤트를 종료하는 데에 필요한 자료들을 한 곳에 저장해두고 이벤트가 발생하면(디스크 I/O가 완료되면), 저장해 놓은 정보들을 활용하여 이벤트를 처리한다.

💡 위의 예시에 대한 해법은 소켓 디스크립터(sd)를 파일 디스크립터(fd)가 사용하는 자료 구조(해시 테이블 등)에 저장해 놓는 것
➡️ 디스크 I/O가 완료되면 이벤트 핸들러가 파일 디스크립터에서 다음 할 일을 파악하여 호출자에게 소켓 디스크립터의 값을 반환하도록 함.
➡️ 이 시점에서 (최종적으로), 서버는 소켓에 데이터를 기록하는 마지막 동작을 할 수 있게 됨.


8️⃣ 이벤트 사용의 어려움

이벤트 기반 접근법에는 다른 어려운 점이 몇 개 존재

[1] 단일 CPU에서 멀티 CPU로 변경되면 이벤트 기반 접근법의 단순함이 없어짐.

하나 이상의 CPU를 활용하기 위해서는 다수의 이벤트 핸들러를 병렬적으로 실행해야 함.
➡️ 동기화 문제(임계 영역 등)가 발생하게 되며, 이를 해결하기 위한 락 등의 기능을 사용해야 함.
➡️ ⚠️ 따라서 현대 멀티코어 시스템에서 락이 없는 이벤트 처리 방식은 더 이상 사용할 수 없게 됨.

[2] 이벤트 기반의 접근법은 페이징(paging)과 같은 특정 종류의 시스템과 잘 맞지 않음.

예를 들어, 이벤트 핸들러에서 페이지 폴트가 발생하는 경우
➡️ 동작이 중단되므로 서버 페이지 폴트가 처리 완료되기 전까지는 진행을 할 수 없게 됨.
➡️ 서버가 비차단(non-blocking) 방식으로 설계되었다 할지라도, 페이지 폴트와 같은 내재적 원인으로 인한 차단은 피하기가 어려움.
➡️ ⚠️ 이런 상황이 자주 발생하는 경우에 심각한 성능 하락을 가져올 수 있음.

[3] 루틴의 작동 방식이 계속 변화하므로 이벤트 기반에서는 이들의 관리가 어려워짐.

예를 들어, 루틴 동작이 비차단 방식에서 차단 방식으로 변경되는 경우
➡️ 그 루틴을 호출하는 이벤트 핸들러 역시 새로운 성질에 적응하도록 변경해야 함.
➡️ 이에 적합하게 루틴을 두 버전으로 나눠야 함.

이벤트 기반 서버에서 차단(block)이라는 것은 치명적임.
➡️ 개발자는 각 이벤트가 사용하는 API의 문법이 변경되었는지를 늘 주의 깊게 살펴야 함.

[4] 비동기 디스크 I/O가 대부분의 플랫폼에서 사용 가능하기까지 상당히 오랜 시간이 걸렸음.

이에 더해 아직까지도 비동기 네트워크 I/O는 생각하는 것만큼 간단하고 일관성 있게 적용되어 있지 않음.

예를 들어, 모든 입출력 처리에 select()를 사용하여 일관성을 유지하는 것이 이상적이지만, 일반적으로 네트워크 요청의 처리에는 select()가, 디스크 I/O에는 AIO가 사용되고 있음.


📚 참고 문헌

Operating Systems: Three Easy Pieces ― 33: Event-based Concurrency (Advanced)

운영체제 아주 쉬운 세 가지 이야기 ― 36: 이벤트 기반의 병행성(고급)

관련있는 게시물


Made with React, Gatsby and DatoCMS by @smastrom

Contribute or star on GitHub

© 2022-2023 손승열 for 🅒🅞🅝🅣🅔🅝🅣🅢

모두 좋은 하루 보내세요! 😊