ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Javascript Engine & Event Loop 동작 원리
    개발 블로깅/Javascript 개념 2020. 8. 30. 03:50

     

     

    이번 기회에 내가 제일 좋아하는 언어인 자바스크립와 더욱 더 친해지기 위해서, 실제 자바스크립트을 동작시키는 엔진의 내부 구조와 동작 원리에 대해 파헤쳐보고 블로그로 정리해보려고 한다.

     

    자바스크립트 엔진

    자바스크립트 언어는 자바스크립트 엔진이라는 녀석을 통해 실행된다. 이 엔진은 웹 브라우저 내부 또는 Node.js 안에 구성되어있다.
    자바스크립트 엔진은 가능한 짧은 시간 내에 최적화된 코드를 생성하기 위해 전통적으로는 인터프리터 방식으로 구현되지만, 특정한 방식으로 바이트코드로 JIT(Just-In-Time: 번역한 기계어를 저장해놨다가 필요할 때 다시 꺼내 쓰는 방식) 컴파일을 할 수도 있다.

    💡인터프리터와 컴파일러의 차이
    사람이 작성한 소스코드를 동작시키기 위해서는, 컴퓨터 언어에 맞는 0과 1로 이루어진 기계어로 컴파일이 되어야 하고 이러한 작업을 컴파일러를 통해서 하게된다.

    컴파일은 한 번에 모두 기계어로 컴파일 후 실행되기 때문에 실행시간이 빠르지만 컴파일 양이 많으면, 실행하지 전까지 컴파일이 완료될 때까지의 시간이 걸린다는 단점이 있다.
    반면, 인터프리터는 컴파일을 하지 않고 바로 해석하고 실행하게 때문에 컴파일 시간이 따로 없지만, 실행시간이 컴파일 후 실행하는 시간보다 느리다는 단점이 있다.

    💡 자바스크립트 엔진과 브라우저 엔진
    브라우저 내부에 있는 자바스크립트 엔진과 브라우저 엔진(렌더링 엔진)은 엄연히 다른 것이다. 
    자바스크립트 엔진은 온전히 자바스크립트 언어만을 해석하는 인터프리터이고, 브라우저 엔진은 HTML, CSS로 작성된 마크업 언어를 파싱 하고 컴파일하여 화면에 그리는 작업을 하는 엔진이다.

     

    자바스크립트 엔진 종류

    • V8: 구글에서 개발한 오픈소스 엔진. C++로 작성되었으며, 구글 크롬과 Node.js에서 사용된다. 자바스크립트 엔진 중에 가장 유명하다.
    • SpiderMonkey : 최초의 자바스크립트 엔진이다. 넷스케이프 네비게이터 웹 브라우저를 위해 개발되었으나, 지금은 모질라 파이어폭스에 사용되고 있다.
    • Rhino : 전체적으로 자바로 개발되었으면 모질라 재단에서 운영하는 오픈소스이다.
    • JavaScriptCore : 애플에서 개발한 사파리 브라우저에서 사용중인 오픈소스 엔진이다.
    • Chakra(Jscript9) : 마이크로소프트의 익스플로러용 엔진이다.
    • Chakra(JavaScript) : 마이크로소프트 엣지용 엔진이다.
    • 이외 등등 ...

     

    그러면 이제 자바스크립트 엔진 내부 구조에 대해 알아보자.

     

    자바스크립트 엔진 구조

    자바스크립트 엔진은 크게 Call Stack과 Heap으로 나뉘며, 엔진 외부의 다른 요소들과 상호작용하여 자바스크립트 코드를 실행시킨다.

     

     

     

    Call Stack

    자바스크립트 엔진은 단 하나의 호출 스택을 사용한다. 한 번에 하나의 작업만 실행할 수 있으므로, 하나의 함수가 실행되면 해당 함수가 끝날 때까지 다른 어떤 Task도 수행될 수 없다. 각 Task는 순차적으로 호출 스택에 담아 처리된다.

     

    Heap

    자바스크립트에서 사용되는 넓은 메모리 공간이다. 
    C언어와 같은 로우 레벨 언어에서는 메모리 관리를 위해 malloc(), free()라는 함수를 이용해서 할당하고 제거하지만, 자바스크립트와 같은 하이레벨 언어에서는 자동으로 메모리를 할당하고 불필요하다고 판단되면 자동으로 제거한다. 이를 '가비지 컬렉션'이라고 한다.

     


    자바스크립트 엔진과 함께 동작하는 요소

    자바스크립트 엔진 밖에서 동작하고 자바스크립트 언어를 실행하는데 함께 사용되는 요소들로, 주로 자바스크립트를 비동기 처리 동작을 위해 사용된다.

    Web APIs

    자바스크립트 엔진 밖에서 동작하는, 웹 브라우저에 내장된 API이다.
    Web API 중 비동기 처리하는 함수는, 콜 스택에 쌓이지 않고 바로 자바스크립트 엔진 밖으로 나와서 이벤트 루프로 이동한다.

    setTimeout(()=>{console.log('hello~');},500);

     

    예를 들어, 위 코드처럼 setTimeout 함수는 WebAPI 함수인데, 해당 함수의 인자로 콜백 함수(console.log를 찍는 함수)를 넣는 행위 자체가 이벤트 큐로 이동시키는 것이다. 그러나 위 코드를 보면 500ms를 지정했으므로, 0.5초 뒤에 이벤트 큐로 들어가게 된다.


    Task Queue (Event Queue)

    비동기 콜백 함수들이 담기는 큐 리스트이다. SetTimeout, http요청 등 비동기 함수는 자바스크립트 엔진 밖으로 나와 바로 해당 이벤트 큐로 넘어오게 된다. 
    이벤트 루프를 통해 자바스크립트 엔진의 콜 스택이 비워졌는지 수시로 확인하다가, 비워진 순간 Task Queue에 있는 함수들을 콜 스택으로 하나씩 넘겨 실행시킨다. Task Queue는 FIFO(First in First Out) 방식으로, 큐에 들어온 순서대로 하나씩 처리된다.

     

    Event Loop

    Event Loop는 웹 브라우저와 Node.js에서 싱글 스레드인 자바스크립트를 비동기 처리 동작을 하도록 하는 핵심 요소이다.
    Event Loop는 어떠한 동작을 통해서 한 번에 하나의 처리밖에 하지 못하는 싱글 스레드 방식을 여러 비동기 처리가 가능하도록 하는 것일까?

    그럼 지금부터 Event Loop의 동작 원리를 한번 파헤쳐보도록 하자.

     

     

    Event Loop

    자바스크립트는 싱글 스레드의 특성을 가지고 있기 때문에, 한 번에 하나의 작업만 처리가 가능하다.
    그러나 웹 브라우저에는 여러 애니메이션 효과가 나타나면서도 마우스 이벤트를 받거나 키보드 입력을 받기도 하고, HTTP 요청을 동시에 여러 개 수행을 하기도 하면서 마치 여러 스레드가 동시에 동작하는 것을 알 수 있다. 이렇게 싱글 스레드인 자바스크립트가 동시성을 가질 수 있는 이유는 바로 이 'Event Loop'라는 요소 때문이다.

     

     

     

     

    Node.js는 비동기 IO를 지원하기 위해 libUV 라이브러리를 사용하며, 여기서 이벤트 루프가 제공된다. 자바스크립트 엔진은 비동기 작업을 위해 Node.js의 API를 호출하며, 이때 넘겨진 콜백은 libUV의 이벤트 루프를 통해 처리되는 것이다.

    💡livUV 라이브러리란?

    libUV는 Node.js에서 사용하는 비동기 I/O 라이브러리이다. 이 라이브러리는 C로 작성되었고 윈도우나 리눅스 커널을 추상화해서 Wrapping하고 있는 구조이다. Node.js에서 인스턴스(작업 내용)가 생성하여 동작하면 libuv에는 워커 스레드 풀이 생성된다. Node에서 API 콜이나 DB Read/Write 등 블로킹 작업이 들어오면 이벤트 루프가 uv_I/O에게 해당 메시지를 전달한다. 
    (다음에 시간 되면 livUV도 한번 봐바야겠다.)

     

    Event Loop는 아래 코드와 같이 동작한다. Queue 내에 대기 중인 메시지가 있는지 수시로 확인하면서, 메시지가 들어온 순간 해당 메시지의 작업을 실행하게 된다.

    while(queue.waitForMessage()){
      queue.processNextMessage();
    }

     

    이렇듯 Event Loop는 해당 인스턴스가 생성되면 계속해서 순환하면서 작업 내용이 있는지 확인하고 실행하도록 한다.

     

    Event Loop 구성 요소

    우선 이벤트 루프는 각 페이즈(단계) 별로 구성이 되어 동작한다.

     

     

     

    Timer Phase

    이벤트 루프의 시작을 알리는 페이즈이다. 이 페이즈가 가지고 있는 큐에는 SetTimeout이나 Setinterval 같은 타이머들의 콜백을 저장하게 된다. 타이머들을 min-heap으로 유지하고 있다가 실행할 때가 되면 콜백을 큐에 넣고 실행하는 것이다.

    큐에 콜백을 넣는다는 것은, 해당 콜백을 실행한다는 의미로, 타이머가 생성되자마자 큐에서 콜백을 넣는 것이 아니라, 별도의 힙에 타이머를 저장하고 나서 매 Timer Phase 때 어떤 타이머가 실행할 때가 되었는지 확인 후, 실행할 때가 된 콜백을 큐에 넣어서 실행된다.


    Pending I/O callback phase

    이벤트 루프의 Pending Queue에 들어있는 콜백들을 실행한다. 해당 큐의 콜백은 TCP 오류 같은 시스템 작업의 콜백을 반환한다. 

    타임 페이즈가 종료된 후 이벤트 루프는 Pending I/O phase에 진입하고, 가장 먼저 이전 작업들의 콜백이 실행 대기 중인지, 체크하게 된다. 만약 실행 대기 중이라면 해당 큐의 콜백들을 전부 비워질 때까지 실행한다. 이 과정이 종료되면 이벤트 루프는 Idle Handler Phase로 이동하게 된 후 내부 처리를 위한 Prepare phase를 거쳐 최종적으로 가장 중요한 단계인 Poll Phase에 도달하게 된다. 


    Poll phase

    이벤트 루프가 해당 페이즈로 진입하면, 내부의 watcher_queue(Poll Phase가 가지고 있는 큐)의 콜백 내용들이 있는지 확인 후 있는 콜백들을 실행한다.
    만약 더 이상 콜백들을 실행할 수 없는 상태가 된다면 check_queue, pending_queue, closing_callbacks_queue에 해야 할 작업이 있는지를 검사하고, 있다면 바로 Poll phase가 종료되고 다음 페이즈로 넘어가게 된다. 하지만 해야 할 작업이 없는 경우 Poll phase는 다음 페이즈로 넘어가지 않고 계속 대기하게 된다.

     

    Check phase

    Poll phase가 지나면 이벤트 루프는 바로 setImmediate() API의 콜백과 관련이 있는 Check phase에 들어서게 된다. 이 페이즈에서는 다른 페이즈와 마찬가지로 큐가 비거나 시스템 실행 한도 초과에 도달할 때까지 계속해서 setImmediate의 콜백들을 실행한다.

     

    Close callbacks

    Check Phase가 종료된 후에, 이벤트 루프의 다음 목적지는 close나 destroy 콜백 타입들을 관리하는 Close callback이다.

    이벤트 루프가 Close callback들과 함께 종료되고 나면 이벤트 루프는 다음에 돌아야 할 루프가 있는지 다시 체크하게 된다. 만약 아니라면 그대로 이벤트 루프는 종료된다. 하지만 만약 더 수행해야 할 작업들이 남아 있다면 이벤트 루프는 다음 순회를 돌기 시작하고 다시 Timer Phase부터 시작하게 된다.

     

    nextTickQueue & microTaskQueue

    이  두 개는 이벤트 루프의 일부가 아니라 Node.js 또는 브라우저 자체에 포함된 기술이다.
    이 두 개의 큐에 들어있는 콜백들은 어떤 페이즈에서든 실행될 수 있다. 페이즈에서 다음 페이즈로 넘어가기 전에 자신이 가지고 있는 콜백들을 최대한 빨리 실행해야 하는 역할을 맡고 있기 때문이다. 또한 nextTickQueue는 microTaskQueue보다는 높은 우선순위를 가지고 있다.

    nextTickQueue는 process.nextTick() API 콜백들을 가지고 있고, microTaskQueue는 Resolve 된 프로미스의 콜백을 가지고 있다.

     

    이벤트 루프 흐름 확인

    Timer Queue와 Check Queue

    setTimeout(() => {
        console.log('setTimeout');
    }, 0);
    setImmediate(() => {
      console.log('setImmediate');
    });

     

    위 코드에서 동작은 어떤 식으로 진행될까?
    둘 다 비동기 함수이고 setTimeout 함수부터 먼저 들어왔으므로, setTimeout이 먼저 실행될 것이라 생각할 수도 있다.

    그러나 실제로는 실행할 때마다 어떤 것이 먼저 실행이 될지는 알 수 없다.
    왜냐하면, setTimeout이 호출된 순간, Timer phase에 의해 메모리에 이 타이머를 저장하는데, 타이머는 시스템의 시간과 사용자가 제공한 시간을 사용하여 등록하기 때문에 컴퓨터의 성능이나 Node.js가 아닌 외부 작업 때문에 약간의 딜레이가 발생할 수 있기 때문이다.

    그러나 이 코드를  아래와 같이 비동기 I/O 콜백으로 옮겨진다면, 얘기가 달라진다.
    해당 코드는 무조건 setImmediate 함수부터 실행된다.

    fs.readFile('filePath.txt', () => {
      setTimeout(() => {
        console.log('setTimeout');
      }, 0);
      setImmediate(() => {
        console.log('setImmediate');
      });
    });

     

    먼저 fs.readFile을 만나면 이벤트 루프는 libUV에게 해당 작업을 던진다. (파일 읽기, 쓰기는 OS 커널에서 비동기 APi를 제공하지 않기 때문에 libUV가 별도의 스레드를 생성하여 해당 작업을 실행한다.)

    작업이 완료되면 이벤트 루프는 Pending I/O callback phase에 있는 pending_Queue에 작업의 콜백을 등록한다. 여기서 등록하는 콜백은 fs.readFile()을 실행 후 처리하는 콜백을 말한다. 그러면 이벤트 루프가 Pending I/O callback phase를 지날 때 해당 콜백이 실행된다.

    해당 콜백에 있던 setTimeout 함수에 의해 Timer phase 큐에 실행할 콜백이 등록된다. 이제 이 콜백은 이벤트 루프를 한 바퀴 돌다가 Timer phase 때 실행될 것이다.

    다음 setImmediate의 함수에 의해 Check phase의 check_queue에 콜백이 등록된다. 

    이제 Poll phase에서는 현재로서 바로 할 일이 없어 Check phase의 큐에 작업이 있는지 확인 후 바로 Check phase로 이동한다. 그리고 check_queue에 있던 setTimmediate()의 콜백 함수를 먼저 실행하게 된다. 이후에 Timer phase에서 타이머를 검사 후 시간이 된 콜백이 실행되게 된다.

     

    NextTickQueue

    이번엔 NextTickQueue의 동작에 대해 알아보자.
    NextTickQueue의 실행은 위에 말했던 것처럼 Next phase로 넘어갈 때마다 해당 Queue에 작업이 있는지 확인하고 먼저 실행시킨다고 했었다.

    var i = 0;
    function foo(){
      i++;
      if(i>20){
        return;
      }
      console.log("foo");
      setTimeout(()=>{
        console.log("setTimeout");
      },0);
      process.nextTick(foo);
    }   
    setTimeout(foo, 2);

     

    위 코드는 어떻게 실행될까?
    우선 맨 처음 foo() 함수를 Timer Queue에 넣은 뒤 실행을 할 것이다. 그러면 우선 "foo"가 먼저 출력이 되고 나서 다음 "setTimeout"을 출력하는 콜백 함수가 Timer Queue에 들어갈 것이다. 그 이후 nextTickQueue에 다시 foo() 함수를 넣게 된다.

    NextTickQueue에 들어간 콜백들은 한 페이즈에서 다음 페이즈로 넘어갈 때마다 무조건 콜백들을 동기적으로 실행해야 하기 때문에, 재귀  호출로 NextTickQueue에 들어간 모든 콜백들을 실행하고 나서야 Timer Phase에서 setTimeout 콜백을 처리하게 된다.

    그러니 "foo" 출력이 모두 되고 나서, 이후 Timer phase에 있던 "setTomeout" 출력이 모두 실행될 것이다.

     

    microTaskQueue

    자바스크립트에서 특정 함수를 비동기 처리를 하기 위해 사용하는 Promise.
    이때 실행되는 Promise 콜백 함수가 microTaskQueue로 넘어가서 처리된다.

    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
    
    Promise.resolve().then(function() {
      console.log('promise1');
    }).then(function() {
      console.log('promise2');
    });
    
    console.log('hello world')

     

    위 코드의 순서는 어떻게 될까?

    우선 비동기 처리가 아닌 "hello world"가 제일 먼저 출력되는 것은 알 수 있다.
    그리고 Timer Queue에 들어간 "setTimeout"을 호출하는 콜백과 microTaskQueue에 들어간 "promise1", "promise2"를 호출하는 콜백 함수가 있다.

    아까 위에서 설명한 것처럼 microTaskQueue는 NextTickQueue와 같이 다음 phase로 넘어갈 때마다 해당 작업이 있는지 확인 후 실행이 되기 때문에 Timer Queue의 작업보다 먼저 실행이 될 것이다. 

    그래서 결과는 promise1, promise2가 먼저 출력된 후 setTimeout이 출력되게 된다.

     

    NextTickQueue와 microTaskQueue

    setTimeout(function () {
      console.log("setTimeout");
    }, 0);
    
    Promise.resolve()
      .then(function () {
        console.log("promise1");
      })
      .then(function () {
        console.log("promise2");
      });
    
    process.nextTick(() => {
      console.log("nextTick");
    });
    
    console.log("hello world");

     

    바로 위 예시 코드에서 Promise 코드 밑에 NextTick() 함수를 추가했다. 
    여기서는 "hello world"가 출력되고, 그다음 출력은 promise가 아닌 "nextTick"이 될 것이다. 왜냐하면 위에서 설명한 것처럼 NextTickQueue가 microTaskQueue보다 더 높은 우선순위를 가지고 있기 때문이다.

     

     

    이렇게 자바스크립트 엔진과 싱글 스레드 처리를 비동기 처리가 가능하도록 동작하는 Event Loop의 내부 동작 원리까지 직접 확인해볼 수 있었다.


    예전에도 자바스크립트 동작 원리를 블로깅 하면서 이벤트 루프와 콜 스택, 이벤트 큐의 존재는 알고 있었으나, 시간이 지난 것도 있고 내부 구조를 깊게 파보는 것까지는 안 해봐서 정확하게 동작하는 원리는 몰랐었다.

    그러나 이번 브라우저 동작원리를 파헤쳐보면서 내가 좋아하는 자바스크립트 동작 원리도 깊게 한번 파보면 재밌겠다고 생각이 들었고, 하나하나 찾아보고 정리하면서 자바스크립트의 이벤트 루프가 어떤 식으로 동작하는지 제대로 알 수 있는 좋은 기회였다.

    결정적으로 비동기 처리 관련 기능 구현을 할 때 몰랐으면 많이 실수했을 법한 내용들, 기대했던 대로 동작하지 않았을 때 많이 헤매었을 법한 부분들을 제대로 잡게 된 것 같다.

     

    반응형

    댓글

Designed by Tistory.