비동기 시스템은 대부분 이벤트 루프 기반으로 구현되어 있다. 대표적인 사례가 node.js라고 할 수 있고, node.js는 단일 이벤트 루프(single event loop)를 사용한다. 그래서 node.js가 blocking IO를 수행해야하는 요청을 받게 된다면 이 작업이 끝나기 전까지는 다른 요청도 모두 대기해야 하는 상황이 온다. 요청을 받을 때마다 blocking IO를 하게 되고, 이런 요청이 계속 쌓이다보면 node.js 애플리케이션은 아무런 처리를 하지 못하고 결국은 hang될 수 밖에 없다. 이론적으로는 이렇다.
원래 node.js의 단일 이벤트 루프 모델은 단일 쓰레드 기반이다. 그러나 실제로는 멀티 쓰레드로 동작하도록 구현되어 있다. 요즘은 대부분의 컴퓨터가 멀티코어 하드웨어를 갖추고 있고 커널이 멀티 쓰레드로 작업을 처리할 수 있게 되어 있어서 node.js는 커널에 작업을 넘길 때 멀티 쓰레딩을 약간 활용한다고 할 수 있다.
공식 문서에는 이렇게 적혀 있다.
Node.js uses a small number of threads to handle many clients. In Node.js there are two types of threads: one Event Loop (aka the main loop, main thread, event thread, etc.), and a pool of k Workers in a Worker Pool (aka the threadpool).
Node.js는 많은 클라이언트를 다루기 위해 적은 수의 쓰레드를 사용한다. Node.js에는 두 가지 타입의 쓰레드가 있다. 한개의 이벤트 루프이고 k개 워커들의 풀(pool)이다.
멀티 쓰레드를 사용하긴 하지만, Apache처럼 클라이언트마다 쓰레드를 하나씩 할당하는 멀티쓰레드 풀링 방식이 아니다.
아래 그림은 libuv를 사용하여 구현된 node.js의 구조를 보여주고 있다. libuv는 비동기 처리를 OS와 커널에 의존하기 위해 사용되는 라이브러리이다.
그러므로 단일 이벤트 루프가 단일 쓰레드 모델이라는 것의 의미는
공식 문서에는 이벤트 루프 또는 워커 쓰레드에서 blocking 작업을 피해야 하는 두 가지 목적이 있다고 한다.
Performance: If you regularly perform heavyweight activity on either type of thread, the throughput (requests/second) of your server will suffer.
Security: If it is possible that for certain input one of your threads might block, a malicious client could submit this "evil input", make your threads block, and keep them from working on other clients. This would be a Denial of Service attack.
성능: 어느 타입의 쓰레드에서든 정기적으로 무거운 활동을 수행한다면, 서버의 throughput(처리속도)는 고생할 것이다.
보안: 어떤 입력에 대해서 쓰레드 중의 하나가 block될지도 모른다면, 악성 클라이언트가 이런 "악한 입력"을 제출할 수도 있고 당신의 쓰레드를 block시킬 수도 있고, 다른 클라이언트에 대해 동작하는 것을 막을 수도 있다. 이것은 서비스 거부 공격이 될 것이다.
Apache와 달리 node.js는 공평한 처리를 직접 제공하지 않고 애플리케이션의 책임으로 넘긴다. 무거운 작업을 하는 애플리케이션은 결국 감당하지 못하고 hang될 수 밖에 없다. 그러나 반면에 경량 작업만을 수행하는 데 최적화되어 있으므로 성능이 높고, 수평 확장이 용이한 것이 장점이 될 수 있다.
애플리케이션 개발자가 작성한 callback은 block되면 안 된다. 다음의 작업들은 block될 가능성이 높으므로 신중하게 도입해야 한다.
무거운 연산은 파티셔닝(분할)이나 오프로딩(워커 풀에서 수행)을 통해서 처리하는 게 좋다. libuv가 무거운 연산(특히 C++로 개발된 암호화 등의 연산)을 오프로딩하는 역할을 담당한다. 오프로딩을 위해 Node API를 이용하여 C++ 애드온을 개발하거나 "Child Process"나 "Cluster" 기능을 이용하여 별도의 워커 풀을 만드는 방법을 택할 수 있다.
기본 워커 풀을 사용하든 분리된 워커 풀을 사용하든 워커 풀의 작업 처리량을 최적화하는 것은 중요하다.
그렇다면, 다중 이벤트 루프를 도입하는 아이디어를 떠올릴 수도 있다. 실제 구현체는 거의 없지만 이론적으로는 가능하며, 특수한 경우에는 도입 가능하다. 보통 이렇게 사용되는 두 번째 이벤트 루프를 "커스텀 이벤트 루프(custom event loop)"라고 부르기도 한다. 커스텀 이벤트 루프는 별도의 쓰레드에서 별도의 blocking IO를 수행해야 하는 경우에는 도입 가능하다. 이벤트 루프 당 하나의 쓰레드가 사용되므로 커스텀 이벤트 루프가 필요하다면 별도의 쓰레드를 생성해야 한다는 점에 유의해야 한다.
단일 이벤트 루프를 동일한 용도로 확장하여 n개의 다중 이벤트 루프를 만든다고 치면, 이건 일종의 이벤트 루프 풀링(pooling)이라고 할 수 있는데, 일반적으로 이런 구조를 도입하는 시스템은 거의 없다. 멀티쓰레드 풀링과 이벤트 루프의 두 가지 방식이 혼합되기 때문에 실제로 얻는 이익이 별로 없는데 반해 구조만 복잡해지기 때문이다.
다중 이벤트 루프는 근본적으로 멀티 쓰레드 기반이 될 수 밖에 없는데, 이럴 경우 여러 이벤트 루프가 시스템 자원을 나눠 써야 하는 상황이 되므로 deadlock이나 race condition 등의 문제가 발생할 수 있다.