[JS] Event Loop

steven semyung oh·2023년 6월 2일
1

비동기여행

목록 보기
2/4

자바스크립트는 싱글스레드 언어다.

싱글 스레드 언어(Single threaded Language)는 언어 자체의 스펙이라기보다는 이 언어로 만든 프로그램을 돌리는 환경에서 한 개의 스레드를 가지고 언어로 만든 프로그램을 돌린다는 표현에 가깝다. 자바스크립트는 웹사이트의 일부 기능, 이를 테면 유저의 인터렉션 처리와 같은 기능을 위해 만들어진 언어다. 당시 브라우저가 주로 자바스크립트 프로그램을 실행시켰다. 브라우저는 페이지를 만들고 프론트엔드 액션을 다루는 것을 하나의 메인 스레드에서 관리하였다. 2023년 지금은 어떨까? 여전히 웹페이지의 핵심 기능은 메인 스레드에서 관할하지만 네트워킹 요청이나 이미지 디코딩, 그리고 워커와 같은 작업등은 별개의 스레드에서 관리하고 있다.

싱글 스레드로 돌아가기 때문에..

스레드란 실행 흐름의 최소 단위를 말한다. 자바스크립트 프로그램은 단 한 개의 실행 흐름에서 실행된다. 한 작업이 끝나기 전까지 다른 작업은 시작되지 않는다. 다시말해 자바스크립트는 작업 단위에 대하여 완전 실행(Run to Complete)을 보장한다.

그러나 다른 작업을 막는다는 건 큰 문제를 야기할 수도 있다. 왜냐하면 자바스크립트가 돌아가는 메인 스레드에서는 브라우저의 UI 메커니즘도 실행하기 때문이다. 개발자가 짠 코드가 유저 인터렉션 흐름을 막을(Blocking) 가능성이 존재한다.

<main>
  <img src="./expensive-image.gif" alt="nemyung's profile image">
  <button class="fancy-button">블로킹을 해보자.</button>
  <span class="fancy-text"></span>
  <input class="fancy-input">
</main>

<script type="text/javascript">
  function sleep(milliseconds) {
    const start = Date.now();
    while ((Date.now() - start) < milliseconds) {}
  }
                                                    
  function changeStatus(status) {
    document.querySelector(".fancy-text").textContent = status;
  }      
                                               
  function blocking() {
    changeStatus("블로킹 시작");
    sleep(5000);
    changeStatus("블로킹 종료");
  }
                                                    
  document
    .querySelector(".fancy-button")
    .addEventListener("click", blocking);
</script>

blocking() 함수 내부에서 sleep() 을 호출하였고, sleep() 함수 내부의 반복문으로 인해 5초동안 다음 처리를 막았다. 5초라는 시간동안 유저는 인풋 태그를 선택하지 못한다. 위의 코드는 제품으로 내놓기에는 형편없는 프로그램이지만 싱글 스레드로 작동하는 프로그램이 생길 수 있는 위험을 설명하기에는 충분하다고 본다.

지금 자바스크립트를 둘러싸는 호스팅 환경은 블로킹 문제를 해결하기 위한 노력의 일환으로 아주 다양한 API를 제공한다. 우리가 잘 알고 있는 Timer 부터 시작하여 Worker 처럼 멀티 스레딩으로 프로그래밍을 할 수 있게 도와주기도 한다. 이러한 API들의 공통적인 특징은 자바스크립트 프로그램이 '지금' 호스팅 환경에 행동을 요청하여 '나중에' 결과를 받는다는 것이다. API의 실행 종료시점과 실행 완료시점은 비동기적으로 처리된다.

'나중의 결과'를 받고 프로그램의 실행을 이어나가는 패턴은 1. 이벤트 핸들러 / 콜백함수를 이용한 패턴과, 2. 프로미스를 이용한 패턴이 있다. 그러면 어떻게 나중의 일을 실행하는 것일까? 호스팅 환경은 나중에 메인 스레드에서 우리가 작성한 프로그램을 다루는 메커니즘을 가지고 있다. 이 메커니즘을 이벤트 루프라고 한다.

이벤트 루프

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. [html spec]

대표적인 구성 요소

Task, Task Queue

Task

Task는 브라우저에서 발생하는 작업(Works)에 대한 내용을 캡슐화한 값이다. 스펙에 있는 작업을 보면 다음과 같다.

  1. Events: EventTarget에서 Event 객체를 보내는(Dispatching)하는 작업이다. 모든 EventDispatch가 TaskQueue의 요소로 들어가는 것은 아니다.
  2. Parsing: 브라우저의 HTMLParser가 토크나이징 하는 작업이다.
  3. Callbacks: 콜백함수를 호출하는 작업이다.
  4. Using a resource: 자원을 불러올 때 쓰는 작업이다
  5. Reacting to DOM manipulation: DOM 조작에 대해 반응하는 작업이다

Task의 구조 일부를 표현하자면 다음과 같다

interface Task {          
  // 이 값을 이용하여 작업(Work)을 지시한다
  Steps: ECMAScriptCode | Implementation_Details
  // Task와 관련되어 있는 Document 객체를 바인딩한다.
  Document: Document | null
  Source: TaskSources
}
  1. TaskSource에 관하여
    TaskSource는 태스크가 발생한 원천이다.
    스펙에서는 Generic Task sources 라는 내용으로 Task source의 종류 일부를 설명하고 있다.
  2. Document 객체
    이 객체는 특정 조건에 따라서 Fully Active한지 그렇지 않은지의 상태를 가지고 있다.
    만약 Fully Active한 Document를 어떤 Task가 참조하고 있다면 그 Task는 Runnable 상태이다.

비동기로 콜백함수가 실행되는 로직이 모두 Task인 것은 아니다. 이벤트 루프는 특정 조건이 발생하였을 때 UpdateRendering 단계를 거치는데, 이 단계에서 reqeustAnimationFrame()에 전달된 콜백함수가 실행되기 때문이다.

Task Queue

A task queue is a set of tasks.

TaskQueueTasks 를 관리하고 조작하는 Set 자료구조이다. 이벤트 루프는 Runnable한 가장 첫 번째 Task를 가지고 온 다음, 그 TaskTaskQueue 에서 지운 후, 해당 Task를 실행하는 방식으로 tick의 일부를 구성하고 있기 때문이다.

이벤트 루프는 한 개 이상의 TaskQueue를 가질 수 있다.

  • An event loop has one or more task queues.
  • Per its source field, each task is defined as coming from a specific task source.
  • For each event loop, every task source must be associated with a specific task queue.

스펙에서는 TaskQueue의 정의에서는 이벤트루프가 반드시 한 개의 TaskQueue를 가져야 한다고 명시하고 있다. 다만 TaskSource의 정의에서는 각 Source별로 특정한 TaskQueue를 가져야 한다고 명시하고 있다.

이를 종합하면 이벤트루프는 여러개의 TaskQueue 를 통해 Task를 관리한다고 볼 수 있다.

그래서.. 아래의 사진이 이벤트 루프 메커니즘을 잘 반영하고 있다고 보기는 어려운 것 같다.

이 사진이 설명하려는 이벤트 루프의 메커니즘을 잘 반영하는 것 같다.

그러면 TaskQueues 중 어떤게 매 틱마다 선택되는걸까? 이 질문에 대한 스펙의 대답은 구현체에 따라 다르는 것이다. 😇
...Let taskQueue be one such task queue, chosen in an implementation-defined manner.
The particulars of what is said to be implementation-defined are up to the implementation.

Micro Task, Micro Task Queue

Micro Task

MicroTaskTask 와 같은 구조를 지닌 값이지만, Runnable의 개념이 없으며 몇몇 속성이 정해져있다는 점에서 차이가 있다.

interface MicroTask {          
  Steps: ECMAScriptCode | Implementation_Details
  Document: Document
  Source: MicroTaskSource
}

MicroTaskSource 속성은 MicroTaskSource 만을 참조할 수 있으며, 스펙에 어떤 것이 MicroTaskSource인지는 명시하지 않았다.

Micro Task Queue

MicroTaskQueueMicroTaak를 관리하는 Queue 형태의 자료구조다. TaskQueue 와 다르게 큐를 쓴 이유는 무엇일까? 이벤트 루프는 MicroTaskQueue를 맞이할 시점에서 이 큐가 비워질 때 까지 맨 앞의 요소(작업)를 Dequeue하고 그 요소를 실행한다. Runnable의 개념이 없기 때문에 Set으로 만들지 않아도 되는 것이다.
JobQueue라는 말을 들어본 적이 있을 것이다. 이는 ECMAScript에서 MicroTaskQueue를 부르는 용어이다.

스펙에 따르면 이벤트 루프는 한 개의 MicroTaskQueue 만을 갖는다.

우리가 조작하는 자바스크립트 월드에서는..

  • (Micro)Tasksteps함수이다. 그리고 (Micro)TaskQueue 에서는 해당 (Micro)Tasksteps실행시킨다. 나중에 다시(back) 호출되는(call) 함수. 이름이 아주 이쁜 것 같네!

  • 프로그램의 한 스텝을 Task로 생각하게 도와주는 API들은 setTimeout, setInterval, EventHandler 정도가 대표적이라고 볼 수 있겠다.

    • TaskQueue에 들어오는 Task의 순서는 우리가 생각하는 것만큼 보장하질 않는다.
  • Promise.prototype 메서드나 MutationObserver, queueMicroTask()와 같은 API들은 프로그램의 한 스텝을 MicroTask 로 생각하게 도와준다.

  • MicroTaskQueue 가 비워질 때 까지 DequeuedMicroTask는 계~속 실행된다.

이벤트 루프는 어떻게 작동하는가?

이벤트 루프의 순회를 tick이라고 하는데, 한 번의 tick에 다음의 연산들을 수행한다. 실제 tick의 과정을 굉장히 얕게 요약하였다.

  1. TaskQueue를 (브라우저의 우선순위에 맞게) 고르고 OldestRunnableTask를 선택한다.
    1.1. OldestRunnableTask를 TaskQueue에서 제거한다
    1.2. OldestRunnable.steps을 실행한다.
  2. MicroTaskCheckpoint 연산을 수행한다.
    2.1. 가장 먼저 큐에 들어온 요소를 Dequeue하여 선택한다.
    2.1. 해당 MicroTask.steps을 실행한다.
  3. hasARenderingOpportunityfalse 으로 초기화한다.
  4. UpdateRendering 연산을 수행한다
    4.1. 이벤트루프와 관련있는 Documentsdocs 으로 초기화한다
    4.2. docs 의 요소 중 렌더링할 기회(RenderingOpportunity)를 가지고 있는 Document 만 필터링한다.
    4.3. docs 에 요소가 아무것도 들어있지 않다면 연산을 종료한다. 그렇지 않으면 hasARenderingOpportunity 의 값을 true 으로 재할당한다.
    4.4. 렌더링을 쓸데없이 할만한 가능성을 제거한다 (스펙의 7-4, 7-5)
    4.5. 렌더링을 업데이트한다. (참고자료 1) (참고자료 2)

MicroTaskQueue가 더 우선순위가 높은 것이 아니다.

스택오버플로우, 블로그, 그 밖의 많은 아티클을 보면서 MicroTaskTask(또는 MacroTask)보다 더 우선순위가 높다고 이야기한다. 이 논리에 대한 대표적인 예시를 보자.

// nemyung.js
setTimeout(() => console.log("timeout"));
Promise.resolve().then(() => console.log("resolve"));

이 파일을 실행하면 resolve 다음 timeout 이 로그에 찍힌다. 다시말해 MicroTaskQueue에 담긴 작업이 TaskQueue에 담긴 작업보다 더 먼저 실행이 된다는 것이다.

파일을 실행하는 작업Task일까 아닐까? 몇몇 스택오버플로우의 답변은 nemyung.js 의 코드를 초기화하고 실행하는 작업 역시 Task 라고 간주하였다. 어떤 Task.Steps가 실행될 당시에는 콜스택에 관련 실행컨텍스트가 띄워져 있고 전역 코드를 실행할 때 역시 전역 컨텍스트가 콜스택에 담기므로 Task라고 봐도 무방하다는 것이 그들의 논리이다. 그런데 이 논리가 당위성을 얻으려면 실제 구현체를 까봐야 알텐데, 나는 그만한 내공이 없기 때문에 확실하게 다가오지는 않았다.

웹 스펙에서는 스크립트를 실행하는 작업에 대한 명세가 있다. 명세에는 cleanup 이라는 연산이 포함되어있는데 이 연산의 마지막 부분이 조금 흥미롭다

If the JavaScript execution context stack is now empty, perform a microtask checkpoint.

nemyung.js 을 전부 실행한 완료 시점에서 콜스택은 비워져있을테니 MicroTaskCheckpoint 작업을 수행하라는 것이다. 이는 이벤트 루프 작동 메커니즘이 실행을 마치고 뭔가를 업데이트하는 컨텍스트에서, 전역코드가 최초로 실행 중이기 때문에 Task가 아니라는 위 논리를 반박하는 것을 수용하기는 한다. 그런데 그렇게 따지면 cleanup 연산의 MicroTaskCheckpoint 역시 뭔가를 업데이트하는 컨텍스트에서 발생한 것은 아니다. 프로그램이 초기화 된 다음의 부수효과로 이루어진 것이기 때문이다. 따라서 해당 컨텍스트 안에서 이야기를 한다면 setTimeout 만을 두고 이벤트 루프의 작동원리를 설명해야 한다.

profile
네명입니다

0개의 댓글