ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • NodeJS - Event Loop
    Framework/NodeJS 2021. 12. 10. 00:46

    NodeJS 란?

    공식 사이트에서 확인해보면 "Node.js는 Chrome V8 JavaScript 엔진으로 빌드된 JavaScript 런타임입니다" 라고 되어있습니다.
    NodeJS 의 대표적인 특징은 비동기, 이벤트 기반, 싱글스레드, Non Blocking, 확장성 등이 있습니다.

    우선 Javascript 의 동작원리를 먼저 알아야 합니다.

    Javascript 동작원리

    Javascript 는 싱글스레드 기반 콜백 큐를 사용합니다.

    javascript 동작원리

    위의 그림처럼 Javascript 는 메모리 힙과 콜 스택으로 구성되어 있습니다.
    메모리 힙은 메모리 할당이 일어나는 곳이고 콜 스택은 코드가 실행됨에 따라 호출 스택이 쌓이는 곳입니다.

    Javascript 는 싱글 스레드기반이기 때문에 하나의 메인 스레드와 하나의 콜 스택을 가지고 있습니다. 즉, 한 번에 한 작업만 처리할 수 있는 것입니다.

    콜 스택(Call Stack)

    콜 스택은 기본적으로 우리가 프로그램 상에서 어디에 있는지를 기록하는 자료구조입니다.
    함수를 실행하게 되면 해당ㅇ 함수는 콜 스택의 가장 상단에 위치하게 되고, 함수의 실행이 끝날 때 콜 스택에서 제거합니다.

    Javascript 런타임

    Javascript 에서 자체적으로 비동기를 지원하지는 않습니다.
    비동기, 논블로킹 작업들은 Javascript 엔진을 구동하는 브라우저나 NodeJS 같은 런타임 환경에서 담당합니다.

    런타임이란 프로그램이 실행되고 있을 때 존재하는 곳을 의미합니다. 런타임 환경이란 프로그램들을 실행할 수 있는 환경을 뜻합니다.
    즉, NodeJS 는 기존 웹 상에서만 동작하는 Javascript 를 웹 상이 아닌 곳에도 실행시킬 수 있도록 도와주는 실행기 입니다.

    Javascript 런타임 환경에서의 비동기 처리

    javascript runtiime

    1. 이벤트 루프 
    이벤트 발생 시 호출되는 콜백 함수들을 관리하여 Callback Queue 에 전달해주고, Callback Queue 에 담겨 있는 콜백 함수들을 콜 스택에 전달해줍니다.
    이벤트 루프가 Call Stack 쌓여있는 함수가 없을 때만 Callback Queue 에서 넘겨주는 역할을 수행합니다.
    
    2. Callback Queue
    Web Apis 에서 비동기 작업들이 실행 된 후 호출되는 콜백함수들이 기다리는 공간입니다.
    
    3. Web Api
    브라우저에서 자체 지원하는 기능들입니다. dom 이벤트, Ajax, setTimeout 등의 비동기 작업들을 수행할 수 있도록 api 들을 지원합니다.

    어떻게 비동기 코드가 실행되지?

    1. Call Stack 에 쌓인 후 실행되면, Javascript 의 엔진은 비동기 작업을 Web Api 에게 위임합니다.
    2. Web Api 는 해당 비동기 작업을 수행하고 콜백 함수를 이벤트 루프를 통해 Callback Queue 에 넘겨준다.
    3. 이벤트 루프는 콜스택에 쌓여있는 함수가 없을 때, Callback Queue 에서 대기하고 있던 콜백함수를 콜스택으로 넘겨줍니다.
    4. 콜스택에 쌓인 콜백함수가 실행되고, 콜스택에서 제거됩니다.

    Javascript 의 런타임 환경에서는 비동기 작업들을 Web Api 에게 넘겨줌으로써, 해당 작업이 완료될 때 까지
    다른 코드들을 실행할 수 있게 됩니다. 그리고 이것이 바로 Non-blocking 입니다.

    NodeJS 구조

    구조를 살펴보기 전에 NodeJS 의 대표적인 특징들에 대해서 한 번 살펴봅시다.

    1. 싱글스레드

    싱글스레드는 프로세스 내에서 요청이 있을 경우 하나의 스레드가 하나의 요청만을 수행합니다.
    해당 요청이 수행될 때는 다른 요청을 함께 수행할 수 없습니다. 이를 싱글스레드 블로킹 모델이라고 합니다.

    NodeJS 는 싱글스레드 논블로킹 모델로 구성되어 있습니다. 하나의 스레드로 동작하지만, 비동기 처리를 통해 요청들을 서로 블로킹하지 않습니다.
    즉, 많은 요청들을 비동기로 수행함으로써 싱글스레드지만 논블로킹이 가능합니다.

    2. 확장성

    NodeJS 는 클러스터링이라는 기능을 통해 싱글스레드인 노드가 CPU 를 모두 사용해서 작업들을 병렬적으로 처리할 수 있게 해줍니다.
    즉, 싱글스레드이지만 멀티스레드처럼 사용하여 서버의 확장성이 용이하다는 장점을 갖게 됩니다.

    3. 이벤트 기반

    이벤트 기반이란 이벤트가 발생할 때 미리 지정해둔 작업을 수행하는 방식을 의미합니다.
    NodeJS 는 이벤트 리스너에 미리 등록해놓은 콜백함수를 실행하는 방식으로 동작합니다.
    이벤트 리스너란 이벤트가 발생했을 때 그 처리를 담당하는 함수입니다.

    router.get('/', (req, res, next) => {
        // router.get 이벤트에 대한 콜백함수 로직
    })

    우리가 흔히 사용하는 router 도 이벤트 기반으로 동작하고 있습니다.
    그렇다면 이벤트 루프란 무엇일까요?
    이벤트 루프란 이벤트에 따라 호출되는 콜백함수를 관리하는 것입니다.

     

    nodejs구조

     

    NodeJS 는 Javascript 와 C++ 언어로 구성되어 있습니다. V8 엔진도 70% 이상이 C++ 로 구성되어 있으며, libuv 는 100% C++ 언어로 구성된 라이브러리 입니다.

    libuv : NodeJS 에서 비동기 처리가 가능하도록 해주는 라이브러리
    V8 : Javascript 엔진으로써 자바스크립트를 실행하는 라이브러리

    NodeJS 에서 동작하는 이벤트 루프는 libuv 내에서 구현됩니다.

    libuv

    NodeJS 가 사용하는 비동기 I/O 라이브러리입니다.

    libuv 에게 파일 읽기와 같은 비동기 작업을 요청하면 libuv 는 이 작업을 커널에서 지원하는지 확인을 합니다.
    만약 지원한다면 libuv 가 대신 커널에게 작업을 비동기적으로 요청하고 응답이오면 전달해주는 역할을 합니다.

    커널

    • 커널: 컴퓨터 운영 체제의 핵심이 되는 컴퓨터 프로그램으로, 시스템의 모든 것을 완전히 통제합니다.

     

    만약 요청한 작업을 커널이 지원하지 않는 경우에는 자신만의 워커 스레드가 담긴 스레드 풀을 사용합니다.

    libuv 는 기본적으로 4 개의 스레드를 가지는 스레드 풀을 생성합니다. 4 개 이상의 스레드를 생성하고 싶을 경우에는 uv_threadpool 이라는 환경 변수를
    설정해 최대 128 개까지 스레드 개수를 늘릴 수가 있습니다.
    만약 우리가 요청한 작업을 커널이 지원하지 않는다면 libuv 는 커널을 호출하는 것이 아닌 스레드 풀에게 작업을 요청하고 그에 대한 응답을 해줍니다.

    스레드풀

    정리한다면 아래와 같습니다.

    1. libuv 는 운영체제의 커널을 추상화해서 비동기 API 를 지원한다.
    2. libuv 커널이 어떤 비동기 API 를 지원하고 있는지 알고 있다.
    3. 커널이 할 수 있는 작업은 커널에게 맡기고, 스레드 풀이 할 수 있는 작업은 스레드 풀에게 맡긴다.

    NodeJS 가 멀티스레드인가..??

    NodeJS 는 I/O 작업을 자신의 메인 스레드가 아닌 다른 스레드에 위임함으로써 싱글 스레드로 논 블로킹 I/O 를 지원합니다. 다른사람에게 일을 시키고 자신은 다른 일을 하러 가는 것입니다.
    즉, NodeJS 는 작업을 libuv 에게 위임함으로써 싱글 스레드기반 비동기처리가 가능할 수 있게 된 것입니다.
    그리고 그 기반에는 이벤트 루프가 있습니다.

    NodeJS Event Loop

    이벤트 루프는 NodeJS 가 여러 비동기 작업을 관리하기 위한 구현제입니다. 비동기 작업들을 모아서 관리하고 순서대로 실행할 수 있게 해주는 도구이며
    실행부터 종료까지 전체적인 흐름을 아래와 같습니다.

    이벤트루프

    Event Loop 안에 있는 6개의 박스들은 특정 작업을 수행하기 위한 페이즈(Phase) 를 의미합니다.
    즉, NodeJS 이벤트 루프는

    - Timer Phase
    - Pending Callbacks Phase
    - Idle, Prepare Phase
    - Poll Phase
    - Check Phase
    - Close Callbacks Phase

    로 구성되어 있습니다.
    그리고 한 페이즈에서 다음 페이즈로 넘어가는 것을 틱(Tick) 이라고 합니다.

    각각의 페이즈들은 자신만의 큐를 하나씩 가지고 있으며, 이 큐에는 이벤트 루프가 실행해야 하는 작업들이 순서대로 담겨있습니다.
    NodeJS가 페이즈에 진입을 하면 이 큐에서 작업을 처리하여 하나씩 실행합니다.
    만약 큐가 빈 상태(작업을 모두 처리)이거나, 시스템의 실행 한도에 다다르면 NodeJS 는 다음페이즈로 넘어갑니다.

    이벤트 루프가 NodeJS 비동기 실행을 도와주는 것과 별개로 싱글 스레드이므로 한 번에 하나의 페이즈에만 진입해 한 번에 하나의 작업만 수행할 수 있다는 점을 기억해야 합니다.
    즉, Poll Phase 작업을 처리하면서 Check Phase 의 작업을 동시에 처리하거나 페이즈에 여러 작업들을 동시에 처리하는 것은 불가능하다는 것입니다.

    Q. 만약 하나의 기능에서 어떤 페이즈의 큐에 여러 작업이 들어와서 수행된다면 영원히 그 페이즈에 갇히게 될까?
    A. NodeJS 는 큐에 계속 추가되는 작업들을 처리하느라 다른 페이즈로 이동하지 못할 수도 있지만, 페이즈는 시스템의 실행 한도의 영향을 받기 때문에
    쌓인 작업을 처리하다가 포기하고 다음 페이즈로 넘어가게 됩니다. 즉, NodeJS 가 한 페이즈에 영원히 갇히는 일은 발생하지 않습니다.

    Event Loop 페이즈

    각 페이즈마다 자신이 해야할 작업들이 정해져있습니다.
    예를 들면 Timer Phase 는 타이머에 관한 비동기 작업들을 관리하고 Close Callback Phase 는 Close Callback 과 관련된 비동기 작업들만을 관리합니다.

    1. Timer Phase

    Timer Phase 는 말 그래도 setTimeout 이나 setInterval 과 같은 함수가 만들어 내는 타이머들을 다룹니다.
    Timer Phase 의 큐에는 콜백을 추가하지 않습니다.

    관련 콜백 함수가 호출되었을 경우 콜백을 언제 실행할 지에 대한 정보가 담긴 타이머를 Timer Phase 가 관리하는 min-heap 에 넣습니다.
    만약 Poll Phase 에서 setTimeout 을 3 번 호출했다면 Timer Phase 는 타이머를 실행할 준비가 될 경우
    타이머가 가리키고 있는 콜백을 호출합니다.

    Q. 그렇다면 Timer Phase 는 콜백함수를 실행하는 역할은 하지 않나?
    A. Timer Phase 에서 타이머를 검사하고 실행도 합니다. 

    위에서도 언급한것처럼 NodeJS 는 페이즈 별로 역할이 정해져 있습니다. 따라서 NodeJS 는 Timer Phase 에서만 타이머 검사를 수행합니다.
    즉, Timer Phase 에 진입해야만 타이머들이 실행될 기회를 얻게 되는 것입니다.
    현재 시간을 now 라고 했을 때 setTimeout(fn, delay) 는 now + delay 에 fn 이 실행되는 것을 보장하는 것이 아닌,
    적어도 now + delay 이후에 fn 이 실행되는 것을 보장하는 것입니다.

    2. Pending Callbacks

    이 페이즈는 pending queue 에 담기는 콜백들을 관리합니다.
    해당 페이즈의 큐에 담기는 함수들은 이전 이벤트 루프 반복에서 수행되지 못했던 콜백함수들입니다.

    위에도 언급했듯이 대부분의 페이즈는 시스템의 실행 한도의 영향을 받습니다. 따라서 큐에 쌓인 모든 작업을 실행하지 못하고 다음 페이즈로 넘어가는 경우도 있습니다.
    이때 처리하지 못한 작업들을 저장하고 실행하는 페이즈입니다.

    에러 핸들러 콜백도 pending_queue 로 들어오게 됩니다.

    3. Idle, Prepare Phase

    해당 페이즈들은 NodeJS 의 내부적인 관리를 위한 페이즈로 자바스크립트를 실행하지 않습니다.
    공식문서에도 별다른 설명이 없고, 코드에 영향을 미치지 않습니다.

    4. Poll Phase

    Poll Phase 는 새로운 I/O 이벤트를 다루며, watcher_queue 의 콜백들을 실행합니다.
    watcher_queue 에는 I/O 에 대한 거의 모든 콜백들이 담깁니다. 흔히 우리가 아는 setTimeout, setImmediate, close 콜백 등을 제외한 모든 콜백이 여기서 실행된다고 볼 수 있습니다.
    예를 들면 아래와 같은 콜백들이 실행됩니다.

    • 데이터베이스에 쿼리를 보낸 후 결과가 왔을 때 실행되는 콜백
    • HTTP 요청을 보낸 후 응답이 왔을 때 실행하는 콜백
    • 파일을 비동기로 읽고 다 읽었을 때 실행되는 콜백

    Poll Phase 가 콜백을 관리하는 방법

    큐에 존재하는 I/O 작업들이 모두 순서대로 완료되었다고 해도, 콜백이 차례대로 실행된다는 보장이 없습니다.
    예를 들면 DB 에 A, B 쿼리를 순서대로 처리해도 응답이 B, A 순서로 올 수도 있습니다. 그렇다고 A 가 먼저라고 A 의 응답이 올 때까지 B 의 콜백처리를 미루는 것은 말도 안됩니다. 큐에 담긴 순서와는 무관하게 먼저 응답이 온 B 를 먼저 처리하는게 당연합니다.
    또한 I/O 이벤트는 Event Loop 혼자서는 완료 여부를 알 수가 없습니다. 이러한 문제를 해결하기 위해 Poll Phase 는 단순한 큐를 사용하지 않습니다.

    Event Loop 가 n 개의 열린 소켓을 가지고 있고, n 개의 완료되지 않은 요청이 있다고 가정해보자. n 개의 소켓에 대해 소켓과 메타 데이터를 가진 watcher 를 관리하는 큐가 바로 watcher_queue 입니다.
    그리고 각 watcher 는 File Descriptor(소켓, 메타데이터 등에 접근하기 위한 키) 를 가지고 있습니다.

    운영체제가 FD 가 준비되었다고 알려주면 Event Loop 는 이에 해당하는 watcher 를 찾을 수 있고 watcher 가 맡고 있던 콜백을 실행시키게 됩니다.

    Poll Phase Blocking

    Event Loop 의 Poll Phase 에 진입했을 때 기다리고 있는 I/O 요청이 없거나, 아직 응답이 오지 않았다면 어떻게 할까?
    일반적인 페이즈들은 다음 페이즈로 넘어갔지만 Poll Phase 는 조금 다릅니다.

    자신이 관리하는 큐만 검사하는 것이 아닌 다음 페이즈로 이동해 다시 Poll Phase 로 올 때까지 실행할 수 있는 작업이 있는가를 고려합니다.
    즉, watcher_queue 가 비어있을 경우 Poll Phase 에서 잠시 대기할 수 있다는 것입니다.

    • 이벤트 루프가 종료되었다면 바로 다음 페이즈로 넘어간다.
    • 다른 페이즈에서 실행할 작업이 남아있다면 바로 다음 페이즈로 넘어간다.
    • Timer Phase 에서 즉시 실행할 수 있는 타이머는 없지만 n 초 후에 실행할 수 있는 타이머가 있다면 n 초를 기다린 후 다음 페이즈로 넘어간다.

    5. Check Phase

    오직 setImmediate 의 콜백만을 위한 페이즈입니다. setImmediate 가 호출되면 Check Phase 의 큐에 담기고 NodeJS 가 Check Phase 에 진입하면 차례대로 실행됩니다.

    6. Close Callbacks Phase

    socket.on('close', () => {}) 와 같은 close 이벤트 타입의 핸들러를 처리하는 페이즈입니다.
    정확하게는 uv_close() 를 부르면서 종료된 핸들러의 콜백들을 처리하는 페이즈입니다.

    실행한도를 초과하지 않는다면 closing_handles 에 담긴 작업을 순서대로 진행합니다.

     

    nextTickQueue, microTaskQueue

    nextTickQueue, microTaskQueue 는 이벤트 루프의 일부는 아니다. 정확하게 libuv 에 포함되어 있지 않고 NodeJS 에 구현되어 있다.
    따라서 이벤트 루프의 페이즈와 상관없이 동작합니다.

    nextTickQueue 는 process.nextTick() 의 콜백을 관리하고, microTaskQueue 는 Resolve 된 Promise 콜백을 가지고 있습니다.

    nextTickQueue 가 microTaskQueue 보다 높은 우선 순위를 가지고 있습니다.

    nextTickQueue, microTaskQueue 는 다른 페이즈들과 다르게 시스템의 실행 한도에 영향을 받지 않습니다.
    즉, 큐가 비워질 때까지 콜백들을 실행하게 됩니다.

     

    출처

    https://www.korecmblog.com/node-js-event-loop/
    https://medium.com/@vdongbin/javascript-%EC%9E%91%EB%8F%99%EC%9B%90%EB%A6%AC-single-thread-event-loop-asynchronous-e47e07b24d1c
    https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

Designed by Tistory.