Reactor 패턴

2020. 2. 9. 18:34TypeScript&JavaScript/Node.js

운영체제에서도 나오지만, I/O 작업은 느리다. 그리고 사용자의 이벤트에 따라 발생하기 때문에 종료시점을 파악하기도 까다롭다. 전통적인 블로킹 I/O 방식은 동기적으로 동작한다. 블로킹 I/O를 사용하여 구현된 웹 서버는 동일한 스레드에서 여러 연결을 처리할 수 없게 된다. 각 소켓에서 모든 I/O 작업이 다른 연결 처리를 차단할 것이기 때문이다. 이러한 이유로 웹 서버에서 동시성을 처리하기 위한 전통적인 접근 방식은 처리해야 하는 각각의 동시 연결에 대해 새로운 스레드또는 프로세스를 시작하거나 풀에서 가져온 스레드를 재사용하는 것이다. 이렇게 하면 I/O 작업으로 스레드가 차단되어도 분리된 스레드에서 처리되므로 다른 요청의 가용성에는 영향을 미치지 않는다.

그러나 이 경우, '유휴상태'시점에서 유지되어야 하는 스레드의 유지는 자원을 효율적으로 쓰는 최고의 방법은 아니다. 스레드는 I/O 조작의 결과를 위해 자주 차단되며 시스템 리소스 측면에서 싸지 않은 비용을 지불한다. 메모리를 소비하고 컨텍스트 스위칭을 유발하기 때문에 각 연결에 대해 대부분의 시간을 사용하지 않으며 장시간 실행되는 스레드를 사용하는 것은 효율성 측면에서 최상의 절충안은 아니다.

따라서 최신 운영체제를 포함하여 Node.js 역시 논블로킹I/O라는 리소스를 엑세스 하는 또 다른 메커니즘을 지원한다. 이 운영 모드에서 시스템 호출은 데이터가 읽히거나 쓰여질 때까지 기다리지 않고 항상 즉시 반환된다. 호출하는 순간에 결과를 사용할 수 없는 경우, 이 함수는 단순히 미리 정의된 상수를 반환하여 그 순간에 반환할 수 있는 데이터가 없음을 나타낸다.

논블로킹 I/O에 엑세스하는 가장 기본적인 패턴은 루프 내에서 리소스를 적극적으로 polling하는 것이다. 

//pseudocode
const resources = [socketA, socketB, pipeA];
while(!resources.isEmpty()) {
    for(i = 0; i < resources.length; i++) {
        resource - resources[i];
        // 읽기 시도
        let data =resource.read();
        if (data === NO_DATA_AVAILABLE) {
            //당장 읽을 데이터가 없습니다.
            continue;
        }
        if (data === RESOURCE_CLOSED)
            //데이터 리소스가 닫혔기 떄문에, 리소스 목록에서 제가
            resources.remove(i);
        else
            // 데이터가 도착하여 이를 처리합니다
            consumData(data);
    }
}

위 코드는 동일한 스레드에서 서로 다른 리소스를 처리할 수 있지만 CPU에서 계속 데이터가 들어왔는지 리소스 객체에 대한 조회가 이뤄진다. 따라서 CPU자원을 효과적으로 사용한다고는 말하기 어렵다. 이러한 방식을 Busy-waiting이라고 한다.

최신 운영체제는 효율적인 논 블로킹 리소스 처리를 위한 기본적인 메커니즘을 제공하는데 이것을 동기 이벤트 디멀티플렉서 또는 이벤트 통지 인터페이스라고 한다. 이 구성 요소는 감시된 일련의 리소스들로부터 들어오는 I/O 이벤트를 수집하여 큐에 넣고 처리할 수 있는 새 이벤트가 있을 때까지 차단한다. 아래는 두 개의 서로 다른 자원에서 읽기 위해 일반 동기 이벤트 디멀티플렉서를 사용하는 알고리즘의 의사코드이다.

//pseudocode 
socketA, pipeB;
watchedList.add(socketA, FOR_READ); // [1]
watchedList.add(pipeB, FOR_READ); 
while(events = demultiplexer.watch(watchedList)) { //[2]
    //이벤트 루프
    forEach(event in events) { //[3]
        //여기서 reaad는 블록되지 않으며 비어 있을 지언정, 항상 데이터를 반환합니다.
        data = event.resource.read();
        if(data === RESOURCE_CLOSED)
            //리소스가 닫혔기 떄문에, 리소스 목록에서 제거합니다.
            demultiplexer.unwatch(event.resource);
        else
            //실제 데이터가 도착하여 이를 처리합니다.
            consumeData(data);
    }
}

1. 리소스를 List에 추가한다.

2. 이벤트 통지자에 감시할 리소스 그룹을 설정한다. 이 호출은 동기식이며 감시 대상 자원 중 하나라도 읽을 준비가 될 때까지 차단된다. 이 경우, 이벤트 디멀티플렉서는 호출로부터 복귀하여 새로운 일련의 이벤트를 처리할 수 있게 된다.

3. 이벤트 디멀티플렉서에 의해 반환된 각 이벤트가 처리된다. 이 시점에서 각 이벤트와 관련된 리소스는 읽기 작업을 위한 준비가 되어있으며, 차단되지 않는 상황이라는 것이 보증된다. 모든 이벤트가 처리되고 나면, 이 흐름은 다시 디멀티플렉스에서 처리 가능한 이벤트가 발생할 때까지 차단된다. 이를 이벤트 루프(event loop)라고 한다.

이러한 패턴을 사용하면 Busy-waiting 기술을 사용하지 않고도 단일 스레드 내에서 여러 I/O 작업을 처리할 수 있다. 

위처럼 하나의 스레드만 사용하더라도 다중 I/O 사용 작업을 동시에 실행할 수 있는 능력을 손상시키지 않는다. 즉, 시분할의 개념을 통해 분산된다. 이를 통해 스레드의 총 유휴시간을 최소화 하는 장점이 있다. 

nodejs에서 Reactor 패턴의 핵심 개념이란 각 I/O작업과 관련된 핸들러(callback 함수로 표시되는)를 갖는 것이다. 이 핸들러는 이벤트가 생성되어 이벤트 루프에 의해 처리되는 즉시 호출된다.  과정은 아래와 같다. (노드 디자인패턴 책 p41 그림 참고)

1. 어플리케이션은 이벤트 디멀티플렉서에 요청을 전달함으로써 새로운 I/O 작업을 생성한다. 또한 어플리케이션은 처리가 완료될 때 호출될 핸들러를 지정한다. 이벤트 디멀티플렉서에 새 요청을 전달하는 것은 논 블로킹 호출이며, 즉시 어플리케이션에 제어를 반환한다.

2. 일련의 I/O 작업들이 완료되면 이벤트 디멀티플렉서는 새 이벤트를 이벤트 큐에 집어넣는다.

3. 이 시점에서 이벤트 루프가 이벤트 큐의 항목들에 대해 반복한다.

4. 각 이벤트에 대해서 관련된 핸들러가 호출된다.

5. 어플리케이션 코드의 일부인 핸들러는 실행이 완료되면 이벤트 루프에 제어를 되돌린다. 그러나 핸들러의 실행 중에 새로운 비동기 동작이 요청이 발생하여 제어가 이벤트 루프로 돌아가기 전에 새로운 요청이 이벤트 디멀티플렉서에 삽입될 수도 있다.

6. 이벤트 큐 내의 모든 항목이 처리되면, 루프는 이벤트 디멀티플렉서에서 다시 블록되고 처리 가능한 새로운 이벤트가 있을 때 이 과정이 다시 트리거 될 것이다.

위와 같은 과정으로 어플리케이션은 특정 시점(블로킹 없이) 에서 리소스에 엑세스하려는 요청을 표시하고, 해당 처리가 완료되는 시점에 호출될 핸들러를 제공한다.

Reactor 패턴은 Nodejs에서 일련의 관찰 대상 리소스에서 새 이벤트를 사용할 수 있을 때까지 차단하여 I/O를 처리한 다음, 각 이벤트를 관련 핸들러로 전달함으로써 반응한다.

 

'TypeScript&JavaScript > Node.js' 카테고리의 다른 글

관찰자 패턴과 EventEmitter  (0) 2020.02.20
Map, Set Collection, WeakMap, WeakSet Collection  (0) 2020.02.09
Node.js의 철학  (0) 2020.02.09