[OS] 이벤트 기반의 병행성

장선규·2023년 9월 26일
0

[OS] OSTEP Study

목록 보기
23/28

이벤트 기반의 병행성

쓰레드를 사용하는 것이 병행 프로그램을 제작하는 유일한 방법은 아니다. 이벤트 기반의 병행성(Event-based Concurrency) 스타일로 프로그래밍을 하여 병행 프로그램을 작성할 수 있다.

이벤트 기반의 병행성(Event-based Concurrency)

  • 대표적으로 node.js 프레임워크가 이벤트 기반의 병행성 스타일을 사용하여 만들어졌다.
  • 문제점
    1. 멀티 쓰레드 프로그램에서 이벤트 기반 병행성을 올바르게 사용하는 것이 매우 어렵다.
      • 락 누락, 교착상태 등 여러 문제 다루기 쉽지 않음
    2. 멀티 쓰레드 프로그램에서는 개발자가 쓰레드 스케줄링에 대한 제어권을 전혀 갖고 있지 않다.
      • 개발자는 운영체제가 생성된 쓰레드를 CPU들 간에 합리적으로 스케줄링 하기만을 기대할 수 밖에 없음

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

  • 쓰레드 없이 병행 서버를 구현할 때, 어떻게 병행성을 유지하면서 각종 문제들을 피할 수 있을까?

1. 기본 개념: 이벤트 루프

이벤트 기반의 병행성(Event-based Concurrency)

  • 이벤트의 발생을 대기하다가 이벤트가 발생하면, 이벤트의 종류를 파악한 후 I/O 요청을 하거나 추후 처리를 위해 다른 이벤트 발생하는 등의 작업을 한다.
  • 이벤트 루프(event loop)
    while (1) {
        events = getEvents();
            for (e in events)
                processEvent(e);
    }
    • 루프 내에서 이벤트 발생을 대기한다.
    • 이벤트가 발생하면 하나씩 처리한다.
    • 이때 각 이벤트를 처리하는 코드를 이벤트 핸들러(event handler)라 부른다.
  • 스케줄링을 제어할 수 있다는 큰 장점이 있다.
    • 이벤트의 처리가 시스템의 유일한 작업이기 때문에, 다음에 처리할 이벤트를 결정하는 것이 스케줄링과 동일한 효과를 갖는다.

그렇다면 발생한 이벤트가 무슨 이벤트인지 어떻게 판단할까?

  • 네트워크나 디스크 I/O의 경우 쉽지 않음
  • 이벤트 메세지가 자신을 위한 것인지 어떻게 판단?

2. 중요 API: select() (or poll())

기본질문: 이벤트를 어떻게 받을까?

  • 대부분의 시스템은 select() 또는 poll() 시스템 콜을 기본 API로서 제공한다.

select() API

  • 기능: 도착한 I/O들 중 주목할 만한 것이 있는지 검사
    int select(int nfds ,
                fd_set *restrict readfds,
                fd_set *restrict writefds,
                fd_set *restrict errorfds,
                struct timeval *restrict timeout);
    • nfds: 파일 디스크립터들의 개수 (0~nfds-1)
    • readfds,writefds,errorfds: 읽기/쓰기/에러 파일 디스크립터 집합
      • 해당 I/O 디스크립터들을 검사하여 각 디스크립터들에 해당하는 입출력 디바이스가 읽고 쓰고 예외조건이 발생했는지 파악한다.
    • timeout: 일반적으론 NULL(준비 완료까지 대기) 또는 0(즉시 리턴)으로 설정
    • 전체집합에서 준비된 디스크립터들의 총 개수를 반환한다.
  • select는 집합을 가리키는 각 포인터들을 준비된 디스크립터들의 집합으로 교체한다.
  • select 를 이용하면 디스크립터에 대한 읽기/쓰기 여부를 검사할 수 있다.
    • 디스크립터 읽기 여부 = 처리해야할 패킷의 도착 여부 파악
    • 디스크립터 쓰기 여부 = 서비스 응답 전송 가능 시점 파악

3. select()의 사용

다음은 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);
            
        // 선택을 함
        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);
    }
}
  • 초기화 후 서버는 무한루프에 들어감
  • 주 반복문 내에서 디스크립터 초기화 및 세팅 (FD_ZERO, FD_SET)
  • select()를 호출하여 데이터가 도착한 소켓이 있는지 검사
  • FD_ISSET()를 사용하여 이벤트 서버는 어떤 디스크립터들이 준비된 데이터를 갖고 있는지를 알 수 있다.
  • 이후 도착하는 데이터를 처리(processFD(fd))한다.

팁: 이벤트 기반의 서버 내에서는 block을 하지 말자

  • 호출자가 실행한 것을 차단할 수 있는 호출이 있어선 안 된다.
  • 만약 block을 한다면, 이벤트 기반 서버가 멈추게 될 것

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

단일 CPU를 사용하는 이벤트 기반의 응용 프로그램에서는 병행 프로그램을 다룰 때 나타났던 문제들은 더 이상 보이지 않는다.

  • 매번 단 하나의 이벤트만 다루기 때문이다.
  • 락을 획득하거나 해제해야 할 필요가 없다.

이벤트 기반의 서버는 단 하나의 쓰레드만 갖고 있기 때문에 다른 쓰레드에 의해서 인터럽트에 걸릴 수가 없다.
-> 병행성 버그는 기본적인 이벤트 기반 접근법에서는 나타나지 않는다.

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

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

  • 예제) 간단한 HTTP GET 요청
    • 동작 과정
      • 이벤트 핸들러가 open() 시스템 콜을 사용하여 파일 열기
      • read() 시스템 콜을 사용하여 파일 읽기
      • 파일 읽고 메모리 탑재 후 사용자에게 결과 전달
    • 만약 open/read가 모두 저장장치에 I/O 요청을 보내야 한다면?
      • 쓰레드 기반 서버: 한 쓰레드가 I/O를 대기하면 다른 쓰레드에서 서버 계속 동작 가능 (연산이 겹쳐지는 overlap 현상)
      • 이벤트 기반 서버: 이벤트 루프만 존재하므로, 이벤트 핸들러가 블로킹 콜을 호출하면 서버 전체가 그 일을 처리하기 위해 다른 것들을 차단...
    • 이벤트 기반 시스템의 기본 원칙은 블로킹 호출을 허용하면 안된다는 것

6. 해법: 비동기 I/O

비동기 I/O (asynchronuous I/O)

  • 프로그램이 I/O 요청을 하면 I/O 요청이 끝나기 전에 제어권을 즉시 다시 호출자에게 돌려줌
  • 추가적으로 여러 종류의 I/O 들이 완료되었는지 판단 가능
  • 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_buf: 읽기 결과로 얻은 데이터를 저장할 대상 메모리의 위치
    • aio_nbytes: 요청의 길이
    int aio_read(struct aiocb *aiocbp);
    int aio_error(const struct aiocb *aiocbp);
    • aio_read: 비동기 읽기 API
      • I/O 호출 성공시 즉시 리턴을 하며, 응용 프로그램(이벤트 기반의 서버 류)은 하던 일을 계속 진행할 수 있다.
    • aio_error: I/O가 종료되었는지, aio_buf에 요청했던 데이터가 있는지 알 수 있는 API
      • aiocbp에 의해 참조된 요청이 완료되었는지 검사
      • 완료시 0 반환, 실패시 EINPROGRESS 반환
      • 모든 대기 중인 비동기 I/O는 주기적으로 aio_error() 시스템 콜로 시스템에 폴링(poll) 하여 해당 I/O가 완료되었는지 확인할 수 있다.

문제점: 만약 어떤 시점에 수십 또는 수백 개의 I/O를 요청하는 프로그램이 있다면, 그 많은 요청들을 일일이 다 검사해야 할 것인가 아니면 먼저 일정 시간 동안을 대기해야 할까, 그것도 아니라면?

  • 이 문제를 해결하기 위해 어떤 시스템들은 인터럽트 기반의 접근법을 제공한다.
  • 유닉스의 시그널(signal)을 사용하여 비동기 I/O가 완료되었다는 것을 응용 프로그램 에게 알려주기 때문에 시스템에 반복적으로 완료 여부를 확인할 필요가 없다.

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

상태관리

  • 이벤트 기반 접근법의 또 다른 문제점은 전통적인 쓰레드 기반 코드보다 일반적으로 더 작성하기 복잡하다는 것이다.
  • 수동 스택 관리(manual stack management)
    • 이벤트 핸들러가 비동기 I/O를 발행시킬 때, I/O 완료 시 사용할 프로그램의 상태를 정리해 놓아야 한다.
    • 쓰레드 기반 프로그램에선 쓰레드 스택에 이미 그 정보들이 들어있다.
    • 예제)
      • 파일 디스크립터(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. 이벤트 사용의 어려움

  1. 단일 CPU에서 멀티 CPU로 변경되면, 이벤트 기반 접근법의 단순함이 없어진다.
    • 멀티 CPU 상황에선 다수의 이벤트 핸들러를 병렬적으로 실행하야 한다.
    • 동기화 문제(ex. 임계영역) 발생 -> 락 사용
  2. 페이징(paging)과 같은 특정 종류의 시스템과 잘 맞지 않는다.
    • 이벤트 핸들러에서 페이지 폴트가 발생하면 동작이 중단된다.
    • 때문에 서버는 페이지 폴트가 처리 완료되기 전까지는 진행을 할 수 없게 된다.
    • 서버가 비차단(non-blocking) 방식으로 설계되었다 할지라도, 페이지 폴트와 같은 내재적 원인으로 인한 차단은 피하기가 어렵다.
  3. 루틴의 작동 방식이 계속 변화하기 때문에, 이벤트 기반에서는 이들의 관리가 어려워진다.
    • ex) 루틴 동작 방식이 비차단에서 차단방식으로 바뀐다면, 그 루틴을 호출하는 이벤트 핸들러 역시 변경되어야 한다.
  4. 비동기 디스크 I/O가 대부분의 플랫폼에서 사용 가능하지만, 아직까지 일관성 있게 적용되어 있지 않다.
    • 네트워크 요청의 처리에는 select()가, 디스크 I/O 에는 AIO가 사용되고 있다.
profile
코딩연습

0개의 댓글