1ilsang

Developer who will be a Legend

이벤트 디멀티플렉싱과 Reactor 패턴 그리고 Node.js의 구조

2019-09-23 1ilsangNode.js

node book image

본 내용은 Node.js 디자인패턴 책을 읽으면서 공부한 것들을 정리하고, 더 조사한 내용입니다.

전통적인 블로킹 I/O

전통적인 블로킹 I/O 프로그래밍에서는 I/O 요청에 해당하는 함수 호출시 작업이 완료될 때까지 해당 쓰레드의 실행이 차단된다. 따라서 블로킹 I/O를 사용한 웹 서버는 동일한 쓰레드에서 여러 연결을 처리할 수 없으므로 성능이 상당히 떨어지게 된다. 이러한 이유로 동시성을 처리하기 위해 각각의 동시 연결에 새로운 쓰레드를 할당하거나 쓰레드풀을 재사용 하는 등의 방법을 썻다. 하지만 이러한 방법은 상당한 리소스를 사용하게 된다.(컨텍스트 스위칭, 메모리 할당 등)

논 블로킹 I/O의 도래

 대부분의 최신 운영체제는 논 블로킹 I/O 라는 리소스를 액세스하는 또 다른 메커니즘을 지원한다. 논블로킹의 경우 시스템 호출시, 호출하는 순간에 결과를 사용할 수 없다면, 미리 정의된 상수를 반환하며 그 순간에 반환할 데이터가 없다고 알린다(e.x UNIX O_NONBLOCK > EAGAIN) 그렇다면 이 논 블로킹은 어떤식으로 구현되는 것일까? 가장 기본적인 방법으로 busy-waiting이 존재한다. 이는 while 을 돌리면서 데이터가 반환될 때까지 루프를 돌리면서 폴링(polling)한다. 대부분의 경우에서 의미없는 루프를 돌고 있으니 소중한 CPU만 낭비하게 된다.

이벤트 디멀티플렉싱

 Busy-waiting이 논 블로킹 리소스를 처리하기 위한 기본적인 방법이었다면, 이벤트 디멀티플렉싱은 효율적인 논 블로킹 리소스 처리를 위한 기본적인 메커니즘이다(동기 이벤트 디멀티플렉싱, 이벤트 통지 인터페이스 라고도 한다). 이 메커니즘은 감시된 일련의 리소스들로부터 들어오는 I/O 이벤트를 수집하여 큐에 넣고 처리할 수 있는 새 이벤트가 있을 때까지 차단한다. 아래는 동기 이벤트 디멀티플렉서를 사용하는 알고리즘의 의사코드이다(책 39p)

socketA, pipeB;
watchedList.add(socketA, FOR_READ); //[1]
while(events = demultiplexer.watch(watchedList)) { //[2]
    // Event Loop
    foreach(event in events) { //[3]
        // read 는 블록되지 않으며 비어 있을지언정, 항상 데이터를 반환함.
        data = event.resource.read();
        if(data === RESOURCE_CLOSED) demultiplexer.unwatch(event.resource);
        else resolve(data);
    }
}

 [2]에서 디멀티플렉서에 감시할 것들을 추가한다. 이 호출은 동기식이며, 감시 대상 중 하나라도 데이터를 리턴하기 전까지 차단된다. 이 때 이벤트 디멀티플렉서는 호출로부터 복귀하여 새로운 이벤트들을 처리할 수 있게 된다(비동기). [3]에서 디멀티플렉서가 반환한 이벤트가 처리된다. 여기를 Event Loop 라고 부르며, 이곳에 도달했다는 것은 역으로 읽기 작업이 완료되었다는 것이므로 차단되지 않는 상황이라는 것이 보증된다. 모든 이벤트가 처리되면 다시 차단되고 디멀티플렉서를 기다리게된다.

 조금 더 구체적으로 설명하자면, 바로 값을 가져올 수 없는 작업형 함수를 만날 경우(I/O) 일단 약속된 상수값을 리턴시키고 해당 함수를 디멀티플렉서에 추가한다. 이때 완료 후 호출될 핸들러(callback)와 이벤트가 디멀티플렉서에 들어가게 된다. 이벤트가 완료될 경우 디멀티플렉서가 이벤트를 반환한다. 반환된 이벤트는 이벤트 큐에 push 되고, 이벤트 루프는 이벤트 큐를 순회하며 각각의 이벤트들에 대한 핸들러를 실행한다.

event demultiplexer image

 이벤트 디멀티플렉싱 패턴을 사용하면 Busy-waiting 을 사용하지 않고도 단일 쓰레드 내에서 여러 I/O 작업을 처리할 수 있다. 기존의 동기식 블로킹 I/O가 쓰레드를 만들어 다중 I/O 작업을 처리했다면 이는 하나의 쓰레드만 사용하여 시간에 따라 작업을 분리해 처리한다. 따라서 위의 그림처럼 유휴시간(파란색)을 최소로 줄일 수 있다. 물론 이것만이 싱글쓰레드 처리의 장점은 아니다. 프로세스 간의 경쟁 혹은 여러 쓰레드들의 동기화 걱정이 없는 훨씬 간단한 동시성 전략을 사용할 수 있다는 것이 가장 큰 이점이다.

Reactor 패턴과 Node.js

 이제 Reactor 패턴을 보자. 리엑터 패턴의 핵심은 각 I/O 작업과 관련된 핸들러(callback)를 갖는 것이다. 이 핸들러는 이벤트 루프에 의해 처리되는 즉시 호출된다. 리엑터 패턴은 위에서 설명한 이벤트 디멀티플렉싱을 활용하는 패턴이다. 작업 처리 순서는 아래와 같다.

  1. 어플리케이션이 이벤트 디멀티플렉서에 요청을 전달해 새로운 I/O 작업을 생성한다.
    - 처리가 완료될 때 호출될 핸들러를 지정하고 즉시 제어를 반환한다.(논 블로킹)
  2. I/O 작업이 완료되면 이벤트 디멀티플렉서가 새 이벤트를 이벤트 큐에 집어넣는다.
    - 이벤트 루프가 큐를 순회하며 각 이벤트에 관련된 핸들러가 호출된다.
  3. 핸들러는 코드의 일부분이다(콜백). 핸들러 실행이 완료되면 이벤트 루프에 제어를 되돌린다.
    - 이때 새로운 비동기 동작이 발생하면 제어가 이벤트 루프로 돌아가기 전에 새로운 요청이 이벤트 디멀티플렉서에 삽입될 수도 있다.
  4. 이벤트 큐 내의 모든 항목이 처리되면, 루프는 다시 블록되고 처리 가능한 새로운 이벤트가 있을 때까지 기다린다.

console.log(1);
fs.readFile('1ilsang.txt', 'utf-8', () => console.log(2));
console.log(3);
fs.readFile('1ilsang2.txt', 'utf-8', () => console.log(4));
console.log(5);
console.log(6);

 즉, 리엑터 패턴은 관찰 대상 리소스가 반응(콜백)하면 해당 이벤트의 핸들러를 추적해 실행하는 디자인 패턴이다. 노드의 전신이라 할 수 있는 패턴이다.

node architecture

 Node.js는 Reactor 패턴을 사용하며 JS Core API, V8 및 libuv 에 의존하는 구조라고 할 수 있다.

  • Binding: libuv 외 로우레벨 기능들을 JS에 랩핑하고 사용 가능하게 만들어 줌.
  • V8: 구글에서 만든 크롬용 JS 엔진. V8 덕에 Node.js 가 매우 빠르고 효율적으로 작동한다.
  • JS Core API: Node.js API 를 구현하는 자바스크립트 코어

마무리하며

 간단하게 노드의 핵심 동작 패턴과 구조를 알아보는 시간이었다. 하지만 아직 이벤트 루프가 실제로 어떻게 구현되어 있는지, 코드단에서 어떻게 동작하는지 깊이있게 보지않았다. 또한 V8, libuv 등 다루어야 할 것들이 아직 많다. 더 자세한 내용을 보고 싶다면 아래의 링크를 참조하자.

피드백은 언제든 환영입니다!! 그럼 이만~