[Javascript] 이벤트 루프

zunzero·2022년 10월 20일
0

Node.js

목록 보기
1/4

오랜만...
오늘은 자바스크립트의 동작 원리를 파헤치기 위해 이벤트 루프에 대해 작성해보려고 한다.

동기 / 비동기

우선 자바스크립트는 싱글 스레드 기반의 언어이다.
스레드는 한 번에 하나의 요청에 대한 처리밖에 할 수 없다.
즉 동기적으로 동작한다.
그런데 우리가 Node.js를 공부할 때, 'Node.js는 비동기적 어쩌구 저쩌구...'하는 문구를 항상 만나게 된다.

위 사진은 동기 방식과 비동기 방식의 차이를 나타낸 사진이다.
동기 방식은 요청에 대한 처리가 끝나기 전에는 다른 요청을 처리할 수 없다.
즉, 순차적으로 요청을 처리한다.
만약 시간이 오래 걸리는 요청이 중간에 끼어있다면, 이후의 요청들은 실행되지 못한 채 대기할 것이다.

반면 비동기 처리는 업무들을 동시에 진행한다.
요청에 대해 순차적으로 처리하다가, 시간이 오래 걸리는 요청이 오면 누군가에게 위임하고 다음 요청을 처리한다.
앞의 요청이 끝날 때까지 무작정 기다리지 않는 것이다.

자바스크립트는 분명 싱글 스레드 언어이다.
그런데 어떻게 비동기적 처리가 가능한 것일까?

앞서 언급한 '요청을 누군가에게 위임한다.'가 핵심 포인트이다.
자바스크립트는 이벤트 루프라는 곳에 요청을 위임하여 비동기적 처리를 한다.

자바스크립트 런타임 구조

자바스크립트 런타임인 브라우저와 Node.js의 구조를 살펴보자.

첫 번째는 브라우저의 구조이고, 두 번째는 Node.js의 구조이다.
자바스크립트가 싱글 스레드 언어라 불리는 이유는 자바스크립트가 실행되는 자바스크립트 엔진이 하나의 호출 스택만을 갖고 있기 때문이다.
Node.js는 V8이라는 자바스크립트 엔진을 갖고 있어, 이 엔진에 있는 하나의 호출 스택에서 자바스크립트가 실행된다.
시간이 오래 걸리는 요청을 위임하는 곳이 이벤트 루프이며, 이벤트 루프는 자바스크립트 엔진과 독립적으로 존재한다.

자바스크립트 엔진을 살펴보면 하나의 Call Stack이 있다.
이 스택이 명령어(함수)들을 처리하는 것이다.
따라서 자바스크립트는 'Run-to-Completion'이라고 불린다.
스택이 비기 전까지, 즉 하나의 처리가 종료되기 전까지 도중에 다른 작업을 할 수 없다는 말이다.
이는 분명한 싱글 스레드 기반 언어의 특징이다.

자바스크립트 엔진의 바깥에 이벤트 루프가 존재한다.
우리는 Node.js에 대해 살펴볼 것이기 때문에 Node.js에 대해서만 집중적으로 살펴보도록 하겠다.
Node.js는 libuv라는 라이브러리를 통해 이벤트 루프를 구현한다.

이벤트 루프의 흐름과 libuv

우선 이벤트 루프의 대략적인 흐름은 아래와 같다.
node 명령어를 통해 test.js 파일을 실행하면 다음과 같은 동작으로 이벤트 루프가 실행된다.

1. 이벤트 루프를 생성한다.
2. 파일의 처음부터 끝까지 실행한다.
3. 동기적으로 실행될 수 있는 것들은 실행하고, 비동기 API의 콜백들은 이벤트 루프로 던진다.
4. 끝까지 실행된 이후, 이벤트 루프를 실행한다.
5. 이벤트 루프에 던져진 비동기 API의 콜백들이 순서대로 실행된다.
6. 이벤트 루프에 남아있는 작업이 없다면 이벤트 루프를 종료한다.

핵심은 소스코드를 처음부터 끝까지 실행하고, 비동기 API의 콜백들은 이벤트 루프로 던져져, 마지막에 실행된다는 것이다.

대표적인 비동기 API인 process.nextTick()을 예시로 하여 살펴보자.

let bar;

function someAsyncApiCall(callback) {
  	callback();
}

someAsyncApiCall(() => {
  consoel.log('bar', bar);
});

bar = 1;

과연 콘솔에는 bar의 값이 찍힐까?

bar undefined

bar 변수에는 값이 할당되지 않았을 때 발생하는 undefined가 찍힌다.

앞서 말한 흐름을 쭉 따라가보자.
코드는 위에서부터 아래로 끝까지 실행된다.
someAsyncApiCall() 함수가 콜되면 인자에 있는 함수가 callback이라는 변수에 담겨, 즉시 실행된다.
동기적으로 코드가 실행되는 것이다.
이 때에는 bar라는 변수에 아직 1이 할당되지 않았기 때문에 bar에 undefined가 찍히는 것이다.

let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar);
});

bar = 1;

process.nextTick은 대표적인 비동기 API 중 하나이다.
앞서 비동기 API의 콜백은 이벤트 루프로 던져진 뒤 나중에 실행된다고 했다.
흐름을 따라가보자.

코드는 마찬가지로 위에서부터 아래로 실행된다.
someAsyncApiCall() 함수가 호출되고 그 안의 인자로 담긴 console.log 함수가 callback으로 전달된다.
그 다음으로 process.nextTick(callback) 명령어가 실행된다.
process.nextTick은 nextTick Queue에 콜백을 담는 것으로 그 역할을 마친다.
본인의 역할을 다 한 것만으로 명령어는 처리된 것이다.
콜백은 이벤트 루프에 던져졌다.
함수는 이어서 위에서 아래로 실행된다.
bar 변수에 1이 할당된다.
다음은?
코드가 끝났으니, 이제는 이벤트 루프를 돌릴 차례이다.
이벤트 루프에 대해선 앞서 설명했듯이, 던져진 콜백이 담기고, 남은 콜백이 없을 때까지 이벤트 루프를 돌리는 것이다.
따라서 이벤트 루프는 던져진 콜백을 실행할 것이고, 이때는 bar에 1이라는 값이 찍힌 채로 콘솔에 찍힐 것이다.
남은 콜백은 없으니 이벤트 루프도 종료될 것이다.

bar 1

libuv와 비동기적 처리

그렇다면 libuv라이브러리는 어떻게 자바스크립트의 비동기적 처리를 도와주는 것일까?
우선 libuv는 C++로 작성된, Node.js가 사용하는 비동기 I/O 라이브러리이다.
이는 운영체제의 커널을 추상화한 Wrapping 라이브로리로, 커널이 어떤 비동기 API를 지원하는지 알고 있다.

위의 두 사진은 libuv가 자바스크립트의 비동기 작업을 도와주는 과정을 나타낸 것이다.
자바스크립트는 V8엔진에서 돌아가다가, 비동기적 요청을 libuv에게 위임한다.
libuv는 커널 혹은 워커 스레드 풀에 해당 요청에 대한 처리를 요구하고, 응답한다.
응답하는 과정에서 해당 비동기 API의 콜백을 이벤트 루프를 통해 알맞은 phase에 등록하고 이벤트 루프를 돌려 실행한다.
(자세한 실행과정은 아래에...)

이벤트 루프의 구조

Task Queue

브라우저의 구조를 살펴볼 때, 우리는 Task Queue라는 것을 발견할 수 있었다.

앞서 비동기 API의 콜백은 이벤트 루프로 던져진다고 했으나, 이벤트 루프로 콜백이 던져진다는 것은 사실이 아니다.
이벤트 루프는 그저 장치일 뿐이고, 비동기 API의 콜백을 보관하는 곳은 Task Queue이다.

자바스크립트는 싱글 스레드 기반의 언어이며, Run-to-Completion이다.
하나의 호출 스택을 갖고 있으며, 호출 스택의 작업이 완료되기 전에는 중간에 어떤 작업도 실행될 수 없다.
호출 스택의 작업이 완료된다는 것은 호출 스택이 빈다는 것을 의미한다.

이벤트 루프는 호출 스택을 계속 감시한다.
Task Queue에 쌓인 비동기 API의 콜백들은 호출 스택이 비었을 때, 이벤트 루프에 의해 호출 스택으로 호출되어 처리된다.
즉, 이벤트 루프는 호출 스택과 Task Queue를 계속 감시하면서, Task Queue에 남아있는 작업을 호출 스택이 비었을 때 처리하는 것이다. (호출 스택으로 옮기는 것)

Node.js의 이벤트 루프 구조 (libuv)

위의 사실을 인지한 채, Node.js가 사용하는 libuv에서 제공하는 이벤트 루프의 구조를 살펴보자.

위의 그림은 libuv의 이벤트 루프를 나타낸 것이다.
Timer, Pending I/O callbacks, Idle/prepare, Poll, Check, Close callbacks라고 이름 부여진 phase들이 존재한다.
각각의 phase는 앞서 언급한 Task Queue와 같은 것을 가지고 있다.
실제로는 큐로 구현되지 않은 것이 있지만 편의를 위해 큐로 설명하곘다.
(Timer phase의 경우 최소값을 찾기 유리하도록 최소힙으로 구현되어있다.)

비동기 API의 콜백들은 각각 알맞은 Phase의 큐에 보관되는 것이다.
그리고 이벤트 루프의 각 단계를 순회하면서, 각 큐에 있는 작업을 처리하는 과정을 반복한다.
브라우저의 Task Queue 같은 것들이 여러 개 있을 뿐이다.

nextTickQueue와 microTaskQueue라는 별도의 Queue를 발견할 수 있는데, 이는 이벤트 루프의 일부가 아니다.
이는 각각 process.nextTick()과 Promise 객체의 콜백을 보관하는데, 이 큐 또한 매 순회마다 조회가 된다.
우선 순위는 nextTickQueue > microTaskQueue > 각 phase의 큐 순서이다.
각 phase의 큐를 조회하기 전에, nextTickQueue와 microTaskQueue를 조회한다고 생각하면 된다.

Timer Phase

Timer phase가 가지고 있는 큐(실제로는 최소힙)에는 setTimeout이나 setInterval 같은 타이머들의 콜백을 저장하게 된다.

setTimeout(() => {
  console.log('after 3ms');
}, 3);

setTimeout 함수를 콜하게 되면 Timer phase에 타이머가 등록된다.
해당 타이머는 콜백함수가 언제 실행될지에 대한 정보를 담고 있으며, 해당 타이머가 실행될 때는 타이머가 가리키는 함수를 실행한다.

그렇다면 'after 3ms'라는 문구는 함수 콜 이후 정확히 3ms 뒤에 실행될까?
아니다.
만약 이벤트 루프가 timer가 아닌 다른 phase에 머물러 있는 중이었다면, 다시 timer phase까지 오는 시간을 고려해야 한다.

Pending I/O Phase

pending_queue는 이전 작업들의 콜백이 실행 한도 초과에 다다라서 실행되지 못한 경우, 그 작업들이 담긴다.
앞서 Task Queue의 큐가 모두 빌 때까지 Queue의 작업들을 처리한다고 했는데, 한 가지 조건이 더 있다.
실행 한도 초과에 다다르는 경우에는 큐에 작업이 남아있더라도 다음 phase로 넘어간다.

Idle, Prepare Phase

해당 phase는 내부적인 처리를 위한 것으로 구체적인 정보를 알 수 없다.

Poll Phase

Poll Phase의 큐에는 대부분의 콜백이 담긴다.
Poll Phase에는 중요한 특징이 있다.
Poll Phase가 큐가 비었거나 실행 한도에 다다르게 되어 종료되었을 때, check_queue/pending_queue/closing_callbacks_queue에 해야할 작업이 없다면 다음 단계로 넘어가지 않고 대기한다.
즉, 새로운 콜백이 생길 때까지 대기하는 것이다.

그럼 나머지 큐는 체크하지 않기 때문에 무한하게 poll phase에 머물러 있게 되는 일이 생기지 않을까?
그렇지는 않다.
Timer phase의 Timer 힙에서 첫 번째 타이머를 꺼내서 확인한 후, 만약 해당 타이머가 실행 가능한 상태라면 타이머의 딜레이시간만큼만 대기하고 다음 phase로 넘어간다.

Check Phase

check phase는 setImmediate() API의 콜백들이 담겨있다.

setImmediate과 setTimeout

fs.readFile('my-file-path.txt', () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
  });
});

콘솔에는 어떤 문구가 먼저 찍힐까?
Timer를 먼저 조회하고, 타이머의 대기시간이 0ms이니까 setTimeout이 먼저 찍히지 않을까?
그렇지 않다.

1. fs.readFile을 만나면 비동기 작업은 libuv에게 던져진다.
2. 파일 읽기는 OS 커널에서 비동기 API를 제공하지 않기 때문에 libuv는 본인의 워커 스레드 풀에 해당 작업을 던진다.
3. 워커 스레드 풀에 있는 스레드 중 하나가 해당 작업을 맡아 동기적으로 처리해서 반환한다.
4. 작업이 완료되면 이벤트 루프는 Pending I/O callback phase의 pending_queue에 작업의 콜백을 등록한다.
5. 이벤트 루프가 순회하면서 Pending I/O callback phase를 지날 때 해당 콜백을 실행한다.
6. setTimeout의 콜백이 Timer phase의 큐에 등록된다.
7. setImmediate의 콜백이 Check phase의 큐에 등록된다.
8. Poll phase는 check_queue에 작업이 있는 걸 확인하고 바로 check phase로 이동한다.
9. Check Phase의 큐에 등록된 콜백이 처리되면서 setImmediate 문구가 찍힌다.
10. 이벤트 루프는 순회하다가, Timer phase에 딜레이가 0인 타이머가 있으므로 바로 해당 콜백을 실행한다.
11. setTimeout 문구가 찍힌다.

setTimeout은 딜레이가 0ms인 경우에도 1ms라고 생각하면 된다.

정리하자면 이렇게 각각의 phase를 순회하면서 콜백을 처리하는 것이 이벤트 루프이다.

libuv 소스코드

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int r;
  r = uv__loop_alive(loop);
	// ...
  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    uv__run_timers(loop);
    ran_pending = uv__run_pending(loop);
    uv__run_idle(loop);
    uv__run_prepare(loop);
    // ...
    uv__io_poll(loop, timeout);
	  // ...
    uv__run_check(loop);
    uv__run_closing_handles(loop);
	  // ...
    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }
  return r;
}

위와 같은 소스코드를 확인할 수 있다. (https://github.com/libuv/libuv/blob/9e59aa1bc8c4d215ea3e05eafec7181747206f67/src/unix/core.c#L390)

살펴보면, 이벤트 루프는 while문으로 돌아가는데 조건으로 r값과 loop->stop_flag값을 확인한다.
r = uv__loop_alive(loop); 라는 문구에서 유추해보자면, 이는 루프의 생존을 확인하는 변수인 것 같다.
r != 0, 즉 루프가 살아있다면 while문을 계속 돌리는 것이다.
루프가 살아있다는 것은 모든 큐에 남아있는 작업이 없다는 것을 의미한다.

그럼 r == 0 일 때 while문을 종료하게 설정해야 하지 않나?
우리가 확인한 바에 따르면 이벤트 루프의 모든 큐가 비면 이벤트 루프는 종료되어야 한다.
하지만 이벤트 루프를 사용하는 곳이 브라우저라거나 서버라면, 이벤트 루프를 종료하면 안된다.
이벤트 루프를 종료하게 되면, 어떠한 요청이 발생했을 때 처리할 수 없기 때문이다.
서버는 계속 켜져있어야 한다.
따라서 loop->stop_flag라는 별도의 정지 명령이 있기 전까지는 계속 루프를 돌리는 것이라고 생각된다.

while문을 살펴보면 위에서 언급한 순서대로 phase가 돌아가는 것을 확인할 수 있다.

profile
나만 읽을 수 있는 블로그

0개의 댓글