NodeJS Event Loop

기운찬곰·2020년 9월 16일
5

NodeJS 이론

목록 보기
2/4
post-thumbnail

본 글은 Udemy에서 Node JS: Advanced Concepts 강의를 듣고 정리한 글입니다.

Hits


The Basics of Threads

Thread 란 무엇인가

이벤트 루프(Event Loop)는 노드에서 비동기 코드를 처리하는데 사용된다. 하지만 이것을 알기전에 Thread 가 무엇인지부터 이해하는 것이 중요하다.

프로세스에는 한개 이상의 Thread (Main Thread)가 존재하는데 멀티 프로세스와는 다르게 Multi-Thread는 Thread 간 공유가 쉽고 전환하는게 효율적이다. OS는 Thread 단위의 스케쥴링을 해서 여러 프로그램을 실행시켜도 문제 없이, 지연없이 동작할 수 있도록 해준다.

참고로 윈도우 작업관리자를 열어보면 다음과 같이 현재 실행 중인 프로세스 개수와 Thread 개수를 알 수 있다.

OS Scheduler

CPU는 1초당 많은 명령을 처리할 수 있다. 하지만 컴퓨터가 사용할 수 있는 리소스 양은 제한적이다. 이를 해결하기 위해 OS Scheduler가 필요하다. 만약 마우스를 움직이면 마우스 움직임을 처리하는 쓰레드를 처리할 것이고, 키보드를 누르면 키보드 입력을 받는 쓰레드를 처리할 것이다. 이것은 사용자 입장에서는 동시에 일어나는 것처럼 보이지만, 사실 OS Scheduler가 빠르게 스위칭하기 때문에 그렇게 보이는 것일 뿐이다.

더 빠르게 처리하는 방법

그렇다면 이렇게 많은 처리를 더 빠르게 처리할 수 있는 방법은 뭐가 있을까?

1. 다중 코어

첫번째는 CPU Core를 여러개 두어서 Thread를 멀티하게 처리하는 방법이다.

2. 비동기적 처리

다음 그림에서 보는것과 같이 Thread 1, 2가 있고 1은 하드디스크로부터 파일을 읽고 몇 글자가 있는지 카운트해야한다. 2는 3 X 3 연산을 수행해야 한다.

만약 OS가 Thread 1에서 하디드스크로부터 파일을 읽는데 시간이 좀 걸린다면 Thread 2의 동작결과도 늦게 나오게 된다. 따라서 이런 경우 해결방법은 하드디스크로부터 파일을 읽는 그 시간 동안 딴 일을 하고, 파일 읽기가 끝나면 알림을 받아 그 이후 작업을 처리하는 것이다.

✅ NodeJS는 이 중에서 2번째 비동기적 처리를 이용한다.


The Node Event Loop

NodeJS는 한개의 쓰레드로 동작하는 싱글 쓰레드 기반이다. 그 안에는 이벤트 루프가 존재하며 이벤트 루프는 우리의 하나의 쓰레드가 무엇을 해야하는지 제어하는 역할을 한다.

우리는 이벤트루프를 이해하는게 매우 중요한데, 좀 어려울 수 있다. 이해를 돕기 위해 다음 수도 코드를 보면서 설명을 진행하겠다.

// node myFile.js

const pendingTimers = [];
const pendingOSTasks = [];
const pendingOperations = [];

//New timers. tasks, operations are recorded from myFile running
myFile.runContents();

function shouldContinue() {
    // Check one: Any pending setTimeout, setInterval, setImmediate?
    // Check two: Any pending OS tasks? (Like sever listening to port)
    // Check three: Any pending long running operation(Like fs module)
    return pendingTimers.length || pendingOSTasks.length || pendingOperations;
}

// Entire body executes in one 'tick'
while(shouldContinue()) {
    // 1) Node looks at pendingTimers and sees 
    // if any functions are ready to be called. setTimeout, setInterval

    // 2) Node looks at pendingOSTasks and pendingOperations and calls relevant callbacks

    // 3) Pause execution. Continue when... 
    //   - a new pendingOSTask is done
    //   - a new pendingOperation is done
    //   - a timer is about to complete

    // 4) Handle any 'close' event
}

// exit back to terminal

가장 먼저 우리가 작성한 Javascript 파일이 실행될 것이다. 이 파일 안에는 Timers, OS Tasks, Operations가 있으며 이들을 수행한다. 이벤트 루프는 while문을 계속 돌면서 이들이 끝났는지 체크를 하고, 만약 정말 다 끝났으면 실행을 종료한다.

순서대로 정리해보면 다음과 같다.

  1. Node JS는 pendingTimers 함수가 불려졌는지, 콜백되었는지를 본다.
  2. 마찬가지로 Node JS는 pendingOSTaskspendingOpeations이 호출되었는지, 콜백되었는지를 본다.
  3. 만약 콜백되었다면 기존에 수행하던거는 중지하고 콜백된것을 수행한다. 콜백되기 전까지는 다른 수행을 계속 한다. (이게 바로 비동기 처리 작업)
  4. 모든 작업이 끝났다면 close 이벤트를 발생시킨다. close 이벤트가 발생하면 모든 코드가 cleanup 된다.

Is Node Single Threaded?

우리가 봤던 이벤트 루프만 보자면 싱글 쓰레드가 맞다. 그것은 우리 프로그램은 오직 한개의 CPU Core만으로 수행할 수 밖에 없다는 것을 의미한다. 만약 컴퓨터의 CPU Core가 많다면 NodeJS는 그것들의 장점을 활용하지 못하는 것이다.

그러나 노드의 표준라이브러리 안에 포함된 몇가지 함수는 사실 싱글쓰레드가 아니다. 다시말해 이런 함수는 이벤트루프와 싱글스레드 외부에서 실행된다. 즉, NodeJS는 싱글스레드이기는 하지만 절대적 사실은 아니다. 이벤트 루프는 하나의 스레드를 사용하지만 내가 쓰는 많은 코드는 전부 그 안에서만 실행되지는 않는다는 것이다.

실습

🚨 주의사항
실습을 진행하기에 앞서 자신의 컴퓨터 CPU 개수와 Core 수에 따라 결과가 크게 달라지니 참고바란다. 여기서는 Core 수가 4개이다. Core 수가 4개라는 것은 1개를 실행하던 4개를 동시에 실행하던 결과 차이는 별반 없다. 하지만 Core수가 1개라면 1개를 실행하는 것과 2개를 실행하는 것 차이가 2배가량 날 것이다.

예제1. 하나만 실행

저번시간에 봤던 crypto 라이브러리에 pbkdf2 함수를 이용해 어떤식으로 동작하는지 알아보겠다. 먼저 한개만 실행했을때 걸리는 시간은 664ms 이다.

const crypto = require('crypto');

const start = Date.now();
crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {
    console.log('1:', Date.now() - start);
});

예제2. 두개 실행

이번에는 2개를 실행했을 경우이다. (💻 참고로 Core 수가 1개라면 예제 1 결과의 2배만큼 증가해서 나오게 된다. 1: 1300, 2: 1320 정도로 나옴. 이는 Core 한개가 병렬처리를 하기 때문)

const crypto = require('crypto');

const start = Date.now();
crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {
    console.log('1:', Date.now() - start);
});

crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {
    console.log('2:', Date.now() - start);
});

어? 뭔가 이상하다. 예제1은 수행시간은 664ms가 나왔다. 예제2는 동일한 함수를 2번 실행하는데 걸리는 시간이 예제1과 비슷하게 672ms와 679ms가 나왔다. NodeJS가 싱글스레드라면 첫번째 함수는 예제 1과 비슷하게 나올거고 두번째 함수는 2배만큼 오래걸릴텐데...? 🤔

그렇다면 NodeJS는 싱글쓰레드가 아닌가?

우리의 저번시간에 Javasript 코드는 결국 C++로 수행된다는 사실과 C++에서 V8과 libuv를 사용한다는 것을 배웠었다.

pbkdf2와 같은 stardard 라이브러리 함수 중 대부분은 libuv안에 쓰레드 풀(Thread Pool) 을 사용하고 있다. 쓰레드 풀에는 4개의 쓰레드가 존재하는데 주로 이벤트 루프가 수행하기에는 좀 오래걸리는 계산복합적인 작업을 대신 수행해준다.

만약 이벤트 루프 하나가 그런 작업을 한다면 다른 작업을 전혀 수행할 수 없을 것이다. 이런 쓰레드 풀이 있기 때문에 우리는 기다릴 필요없이 다른 작업이 수행가능한 것이다. 다시말해 비동기 작업이 가능해지는 이유가 되는 셈이다.

✍ 결론. 노드는 싱글스레드 이지만, libuv에 있는 쓰레드 풀을 사용한다는 점에서는 싱글스레드가 아닐수도 있겠다. 이는 해석의 차이.

예제3. 쓰레드 풀(4) 보다 많이 실행

이번에는 쓰레드 풀(4) 보다 많이 실행해보자. 여기서는 5개를 수행하도록 하겠다.

const start = Date.now();
crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {
    console.log('1:', Date.now() - start);
});

crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {
    console.log('2:', Date.now() - start);
});

crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {
    console.log('3:', Date.now() - start);
});

crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {
    console.log('4:', Date.now() - start);
});

crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {
    console.log('5:', Date.now() - start);
});

1번, 2번, 3번, 4번 함수는 뭐 그럭저럭 비슷하게 나왔고 5번 함수만 거의 2배만큼 더 걸렸다. 쓰레드 풀에 있는 쓰레드는 결국 4개이므로 마지막 5번 함수는 자리가 날때까지 대기해야하기 때문에 더 걸릴 수 밖에 없었던 것이다.


Changing Threadpool Size

package.json에서 npm start를 했을때 set UV_THREADPOOL_SIZE=2를 통해 스레드 풀 개수를 2개로 설정하였다. 그리고 나서 node threads.js가 실행되도록 했다.

// package.json
"scripts": {
    "start": "set UV_THREADPOOL_SIZE=2 & node threads.js"
},

그랬더니 결과가 (1, 2), (3, 4), (5) 이런식으로 나눠서 출력되었다.

그림을 통해 보자면 다음과 같을 것이다.


Common ThreadPool Questions

질문 1. Javascript 코드에서 쓰레드 풀을 사용할 수 있는가 혹은 NodeJS 함수에서만 사용할 수 있는가?

우리는 Custom Javascript에서도 쓰레드 풀을 사용할 수 있다. 우리는 어떻게 자바스크립트 코드에서 별도의 쓰레드를 쓸 수 있는지 알아볼 것이다.

질문2. node 표준 라이브러리 함수중 쓰레드풀을 사용하는 함수는?

정확하게 답할 수 없지만, 파일 관련 fs 모듈 함수는 전부다 라고는 말할 수 있다. 그 외에 몇가지 crypto 같은 모듈도 존재한다.

질문3. 이벤트 루프안에 어떻게 쓰레드풀이 작동되는지?

쓰레드 풀이 실행되는 task는 pendingOperations 이다. 따라서 이벤트 루프안에 쓰레드 풀이 호출되고 콜백되는것을 관리하고 있다.


Libuv OS Delegation(위임)

이번에는 구글 페이지 요청을 보내보고 얼마나 걸리는지를 알아보자. 그것을 6번 반복한다. 위에서 쓰레드 풀을 공부해봤으니 4개는 비슷하게 나올 것이고, 나머지 2개는 좀 더 걸릴것으로 예상된다.

const https = require('https');

const start = Date.now();
const options = {
  hostname: 'google.com',
  port: 443,
  path: '/',
  method: 'GET',
};

function doRequest() {
  const req = https.request(options, (res) => {
    res.on('data', (d) => {});
    res.on('end', () => {
      console.log(Date.now() - start);
    });
  });
  req.on('error', (e) => {
    console.log('problem with request: ' + e.message);
  });

  req.end();
}

doRequest();
doRequest();
doRequest();
doRequest();
doRequest();
doRequest();

보면 결과가 좀 이상하다. 쓰레드 풀은 4개가 최대라서 분명 4개가 비슷하게 나오면 나머지 2개는 좀 더 걸려야 한다. 하지만 결과를 보면 순차적으로 약간씩 증가해서 출력되는 것을 볼 수 있다. 이게 무슨 경우일까? 🤔😵

사실 몇몇 함수는 libuv를 통해서 OS에 내장된 코드를 사용한다. 이게 무슨소리냐면 libuv도 C++코드도 어느코드도 super low level operation을 다루지는 않는다. 특히 이것은 네트워크 요청과 관련되어 있어서 libuv는 OS에 요청을 위임해야 한다.

OS가 실제로 HTTP 요청을 하는것이고, libuv는 요청을 발행하고 OS가 일을 끝날때까지 비동기적으로 기다린다. 따라서 이벤트 루프에서 쓰레드풀을 사용하지 않고도 blocking 되지 않고 다음 일을 처리할 수 있는 것이다.

질문 1. node 표준 라이브러리에서 OS 시스템 async feature을 갖는 함수는 무엇인가?

거의 네트워크 라이브러리에 해당하는 모든것. 일부 포트 리스너 등

질문 2. 어떻게 이벤트 루프안에서 os async가 들어맞게 작동되는가?

pendingOSTasks ayrray이 관리해주기 때문.

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

1개의 댓글

comment-user-thumbnail
2021년 3월 21일

글 잘 읽었습니다. 그러면 실제 코드들 (ex, console, function) 이러한 것들은 다 이벤트 루프에서 실행하는 건가요?

답글 달기