일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 디자인패턴
- codility
- 타입 객체
- DirectX 12
- DirectX12
- 그리디 알고리즘
- 렌더링 파이프라인
- 락
- 병행성
- 병행성 관련 오류
- OS
- 멀티쓰레드
- 멀티프로세서
- 동적계획법
- 스케줄링
- 운영체제
- 쓰레드
- 다이나믹 프로그래밍
- 그리디알고리즘
- 백준
- 다이나믹프로그래밍
- 파일시스템 구현
- 자료구조
- 알고리즘
- I/O장치
- 컨디션 변수
- directx
- 프로그래머스
- Direct12
- 영속성
- Today
- Total
기록공간
3-9장. 이벤트 기반의 병행성 (고급) 본문
GUI 기반 프로그램이나 인터넷 서버에서는 다른 스타일의 병행 프로그래밍이 사용된다. 이런 스타일을 이벤트 기반의 병행성(event-based concurrency)이라 한다. node.js와 같은 서버 프레임워크에서 사용되지만, 시작점은 지금부터 다룰 C와 유닉스 시스템이다.
이벤트 기반의 병행성은 두 개의 문제를 갖고 있다. 먼저 멀티 쓰레드 프로그램에서 이벤트 기반 병행성을 올바르게 사용하는 것이 매우 어렵다. 락을 누락시키거나, 교착 상태 또는 다른 골치 아픈 문제들이 발생할 수 있기 때문이다. 또 다른 문제는 멀티 쓰레드 프로그램에서는 개발자가 쓰레드 스케줄링에 대한 제어권을 전혀 갖고 있지 않다는 것이다. 개발자는 운영체제가 생성된 쓰레드를 CPU들 간에 합리적으로 스케줄링하기만을 기대할 수밖에 없다.
기본 개념 : 이벤트 루프
이벤트 기반의 병행성은 단순하다. 특정 사건의 발생(이벤트)을 대기한다. 사건이 발생하면, 사건의 종류를 파악한 후, I/O를 요청하거나 추후 처리를 위하여 다른 이벤트를 발생시키거나 하는 등의 작업을 한다. 이게 전부이다.
자세한 설명 전에 고전적인 이벤트 기반의 서버가 어떻게 생겼는지 살펴보자. 코드는 다음과 같다.
while(1)
{
events = getEvent();
for(e in events)
processEvent(e);
}
루프 내에서 사건 발생을 대기한다. 이벤트가 발생하면 하나씩 처리한다. 이때 각 이벤트를 처리하는 코드를 이벤트 핸들러(event handler)라 부른다. 중요한 것은 이벤트의 처리가 시스템의 유일한 작업이기 때문에, 다음에 처리할 이벤트를 결정하는 것이 스케줄링과 동일한 효과를 갖는다. 스케줄링을 제어할 수 있는 기능이 이벤트 기반 방법의 큰 장점 중 하나이다.
그러면 발생한 이벤트가 무슨 이벤트인지 어떻게 판단할까? 네트워크나 디스크 I/O 같은 경우는 특히 쉽지 않다. 디스크 I/O가 완료되었다는 이벤트가 도착했을 때 어떤 디스크 요청이 완료되었느냐 하는 것이다.
중요 API : select() (또는 poll())
대부분의 시스템은 select() 또는 poll() 시스템 콜을 기본 API로서 제공한다.
인터페이스의 기능은 간단하다. 도착한 I/O들 중 주목할 만한 것이 있는지를 검사하는 것이다. 예를 들면, 웹 서버 같은 네트워크 응용 프로그램 자신이 처리할 패킷의 도착 여부를 검사하는 것이다. 이 시스템 콜들이 정확하게 해당 역할을 하게 된다.
select()를 예로 살펴보자. Mac OS X가 제공하는 매뉴얼은 이 API를 다음과 같이 설명한다.
int select(int nfds,
fd_set * restrict readfds,
fd_set * restrict writefds,
fd_set * restrict errorfds,
struct timeval * restrict timeout);
select()는 readfds, writefds, 그리고 errorfds를 통해 전달된 I/O 디스크립터 집합들을 검사해서, 각 디스크립터들에 해당하는 입출력 디바이스가 읽을 준비가 되었는지, 쓸 준비가 되었는지, 처리해야 할 예외 조건이 발생했는지 등을 파악한다. 각 집합의 첫 번째 nfds 개의 디스크립터들, 즉 0부터 nfds-1까지 디스크립터를 검사한다. select는 집합을 가리키는 각 포인터들을 준비된 디스크립터들의 집합으로 교체한다. select()는 전체 집합에서 준비된 디스크립터들의 총개수를 반환한다.
select()에 대한 두 가지 알아두어야 할 사항이 있다. 첫 번째, select()를 이용하면 디스크립터에 대한 읽기 가능 여부, 쓰기 가능 여부를 검사할 수 있다. 전자는 처리해야 할 패킷의 도착 여부를 파악할 수 있도록 한다. 후자는 서비스가 응답 전송이 가능한 시점을 파악하도록 해준다.
두 번째는 timeout 인자의 존재이다. 일반적으로는 NULL로 설정한다. 그러면 select()는 디스크립터가 준비가 될 때까지 무한정 대기한다. 하지만, 오류에 대비하도록 설계된 서버들의 경우 timeout 값을 설정해 두기도 한다. 널리 사용하는 방법은 timeout값을 0으로 설정하여 select()가 즉시 리턴하도록 하는 것이다. poll() 시스템 콜도 위와 유사하다.
이런 기본 함수들로 non-blocking event loop를 만들어, 패킷 도착을 확인하고, 소켓에서 메시지를 읽고 필요에 응답할 수 있도록 해준다.
select()의 사용
확실한 이해를 위해, 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 + 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()를 사용하여 minFD에서 maxFD까지의 파일 디스크립터 집합에 포함시킨다. 이 집합은 서버가 보고 있는 모든 네트워크 소켓 같은 것들을 나타낼 수 있다. 마지막으로 서버는 select()를 호출하여 데이터가 도착한 소켓이 있는지를 검사한다. 반복문 내의 FD_ISSET()를 사용하여 이벤트 서버는 어떤 디스크립터들이 준비된 데이터를 갖고 있는지를 알 수 있으며 도착하는 데이터를 처리할 수 있게 된다. (물론 실제 서버는 이보다 훨씬 복잡하다.)
왜 간단한가? 락이 필요 없음
단일 CPU를 사용하는 이벤트 기반의 응용 프로그램에서는 병행 프로그램을 다룰 때 나타났던 문제들은 더 이상 보이지 않는다. 그 이유는 매 순간에 단 하나의 이벤트만 다루기 때문에 락을 획득하거나 해제해야 할 필요가 없기 때문이다. 이벤트 기반의 서버는 단 하나의 쓰레드만 갖고 있기 때문에 다른 쓰레드에 의해서 인터럽트에 걸릴 수가 없다. 그렇기 때문에 쓰레드 프로그램에서 흔한 병행성 버그는 기본적인 이벤트 기반 접근법에서는 나타나지 않는다.
문제 : 블로킹 시스템 콜(Blocking System Call)
차단될 수도 있는 시스템 콜을 불러야 하는 이벤트가 있다면 어떻게 해야 할까?
예를 들어 디스크에서 데이터를 읽어서 그 내용을 사용자에게 전달하는 요청을 생각해 보자. 이러한 요청을 처리하려면 이벤트 핸들러가 open() 시스템 콜을 사용하여 파일을 열어서 read() 명령어를 사용해 파일을 읽어야 한다. 파일을 읽어서 메모리에 탑재한 후에야 서버는 그 결과를 사용자에게 전달할 수 있게 된다.
open()과 read() 모두 저장 장치에 I/O 요청을 보내야 한다면, 이 요청을 처리하기 위해서 오랜 시간이 필요하다. 쓰레드 기반 서버는 이런 것이 문제 되지 않는다. 한 쓰레드가 I/O를 대기하면 다른 쓰레드가 실행이 되며 서버는 계속 동작할 수 있다. I/O 처리와 다른 연산이 자연스럽게 겹쳐지는 현상(overlap)이 쓰레드 기반 프로그래밍의 장점이다.
하지만 이벤트 기반의 접근법에서는 쓰레드가 없고 단순히 이벤트 루프만 존재한다. 이벤트 핸들러가 블로킹 콜을 호출하면 서버 전체가 오직 그 일을 위해 명령어가 끝날 때까지 다른 것들을 차단한다. 이벤트 루프가 블록 되면 시스템은 유휴상태가 되어 심각한 CPU 낭비와 딜레이가 발생한다. 이벤트 기반 시스템의 기본 원칙은 블로킹 호출을 허용하면 안 된다는 것이다.
해법 : 비동기 I/O
이러한 한계를 극복하기 위해 여러 현대 운영체제들이 I/O요청을 디스크로 내려 보낼 수 있는 비동기 I/O
(asynchronous I/O)라고 부르는 새로운 방법을 개발하였다. 이 인터페이스는 프로그램이 I/O 요청을 하면 I/O 요청이 끝나기 전에 제어권을 즉시 다시 호출자에게 돌려주는 것을 가능하게 했으며 추가적으로 여러 종류의 I/O들이 완료가 되었는지도 판단할 수 있도록 하였다.
예를 들어, Windows의 비동기 I/O API는 다음과 같다.
WSARecv 함수는 연결된 소켓으로부터 데이터를 읽는 역할을 한다. lpOverlapped 포인터가 NULL이면 동기식, 실제 포인터이면 비동기 방식으로 실행된다. lpCompletionRoutine에 함수를 적어주면 비동기 CallBack 방식으로 동작한다. CallBack 방식은 비동기 I/O의 완료 여부를 일일이 검사하지 않아도 자동적으로 처리해준다. 이는 곧 오버헤드 감소로 인한 성능 향상으로 이어진다. CallBack 함수는 운영체제가 호출한다. 하지만 실행 중인 프로세스를 멈추고 실행하지는 않는다. 프로세스가 운영체제를 호출했을 경우 운영체제에서 리턴 시 추가로 수행한다.
프로세스에서 비동기 I/O를 호출하면 운영체제는 어느 프로세스가 어떤 비동기 I/O를 요청했는지 내부 자료 구조에 저장한다. 그리고 I/O 완료 인터럽트 발생 시 내부 자료구조를 검색해서 해당 쓰레드에게 통보한다. 그러면 CallBack일 경우 CallBack 함수를 실행하고, 그렇지 않은 경우 대기 상태에서 깨우게 된다. 여러 쓰레드에서 동시의 비동기 I/O를 수행하면 그 프로세스는 더욱 성능이 좋아진다.
여담 : Unix Signal
시그널(signal)은 거의 모든 현대 UNIX에서 찾아볼 수 있는 매우 포괄적인 개념이다. 간단하게 시그널은 프로세스 간의 통신 방법이다. 응용 프로그램에게 시그널이 전달되면 해당 응용 프로그램은 하던 작업을 중지하고 시그널 핸들러(signal handler)를 실행한다. 시그널 처리가 완료되면 프로세스는 이전 작업을 재개한다.
시그널마다 이름이 있다. HUP(Hand UP, 끊다), INT(INTerrupt, 인터럽트), SEGV(SEGmentation Violation, 세그먼트 위반)등이 그것이다. 예를 들어 세그먼트 위반 오류가 발생하였을 때 운영체제는 SIGSEGV 시그널을 보낸다. 프로그램이 시그널을 접수할 수 있도록 설정되어 있다면, 프로그램이 오류를 발생시켰을 때, 특정 코드를 실행하도록 만들 수 있다. 대부분의 프로세스들은 각 시그널들에 대한 처리 코드가 정의되어 있다. 정의되지 않은 시그널을 받으면 프로세스는 기본 동작을 수행한다. 보통은 exit()를 호출하여 강제 종료된다. (우리가 흔히 사용하는 ALT + F4도 시그널을 보내는 것이다. 시그널 핸들러를 만들어 대부분의 시그널을 무시할 수도 있다. 이렇게 되면 좀비 프로세스가 탄생하게 된다.)
다음의 간단한 프로그램은 무한 루프를 수행한다. 무한 루프에 들어가기 전에 SIGHUP 시그널에 대한 핸들러를 먼저 설정하고 있다.
이 프로그램에 kill 명령어를 사용하여 시그널을 보낼 수 있다. 이 시그널이 도착하면 프로그램은 while 루프를 중단하고 handle() 함수를 실행한다.
prompt> ./main &
[3] 36705
prompt> kill -HUP 36705
stop wakin' me up...
prompt> kill -HUP 36705
stop wakin' me up...
prompt> kill -HUP 36705
stop wakin' me up...
'OS' 카테고리의 다른 글
4-2장. 하드 디스크 드라이브 (0) | 2020.05.22 |
---|---|
4-1장. 영속성 - I/O 장치 (0) | 2020.05.20 |
3-8장. 병행성 관련 오류 (0) | 2020.05.07 |
3-7장. 세마포어 (0) | 2020.05.04 |
3-6장. 컨디션 변수 (0) | 2020.05.01 |