이벤트 루프란 무엇이며, 어떻게 진행되는 건가?

RookieAND·2023년 2월 7일
0

Solve My Question

목록 보기
13/27
post-thumbnail

✒️ JS Engine use Single Thread

✏️ JS는 단일 스레드 기반의 언어

  • JS는 단일 스레드 기반의 언어이다. 여러 개의 작업이 있더라도 하나의 작업만을 처리할 수 있다. 하지만 JS가 사용되는 환경에서는 여러 개의 작업이 동시에 처리되는 모습을 쉽게 볼 수 있다.
  • 하지만 동기적 작업은 선행 작업의 시간이 오래 걸릴 경우 그 사이에 사용자는 작업이 완료될 때까지 기다려야 한다는 단점을 내포한다. 이를 타파하기 위해 비동기 처리 방식을 사용한다.
  • 브라우저나 Node.js 에서는 이벤트 루프 기반의 비동기 방식으로 Non-Blocking I/O를 지원 한다고 하는데, 이때 나오는 이벤트 루프 를 활용한 비동기 방식으로 JS는 동시성을 지원한다.

✏️ 단일 스레드는 오직 JS 엔진에만 국한된다.

  • JS 엔진 자체는 단일 스레드지만 이를 구동하는 환경 (브라우저, Node.js) 의 경우에는 여러 개의 스레드를 사용 한다.
  • 이러한 구동 환경과 단일 호출 스택을 사용하는 JS 엔진 간의 상호 연동을 위해 쓰이는 장치가 바로 이벤트 루프 인 것이다.

✏️ 비동기 관련 로직은 JS 엔진 외부에 있다.

  • JS 엔진인 V8 의 경우 단일 호출 스택 (Call Stack) 을 통해 순차적으로 들어온 요청을 스택에 담아 하나씩 처리한다.
  • 허나 비동기 호출을 위해 쓰이는 setTimeout 이나 XMLHttpRequest 같은 Web API는 JS 엔진이 아닌 외부에 정의되어 있다. 태스크 큐 같은 장치 또한 JS 엔진 외부에 구현되어 있다.
  • Node.js는 비동기 I/O를 지원하기 위해 C++ 기반의 libuv 라이브러리를 사용하며, 여기서 이벤트 루프를 제공한다.

✏️ Web API

  • Web API란 브라우저에게 제공하는 API 이며, 비동기 작업을 처리하기 위한 setTimeout, Promise 등과 같은 기능을 제공한다.
  • Call Stack 에서 실행된 비동기 함수들은 모두 Web API를 호출하며, 이후 Web API는 인계 받은 콜백 함수를 Task Queue 에 넣는다.
console.log(1);
setTimeout(() => console.log(2));
console.log(3);
// 실행 결과 : 1, 3, 2 순으로 출력
  • 상단의 코드는 아래와 같은 과정을 통해 최종적으로 출력된다.

    • console.log(1) 함수가 Call Stack에 추가되고 이후 실행된다.
    • setTimeout 함수가 Call Stack에 추가되어 실행되고, 이는 Web API 를 호출시킨다.
    • 호출된 Web API는 인계 받은 콜백 함수를 Task Queue에 넣는다.
    • console.log(3) 함수가 Call Stack에 추가되고 이후 실행된다.
    • 이벤트 루프에서 Call Stack이 비워졌음을 인지하고, Task Queue에 있던 Task를 가져온다.
    • 이후 Call Stack에 console.log(2) 함수가 추가되고 실행된다.

✏️ Run-to-Completion

  • JS의 함수가 실행되는 방식을 보통 Run to Completion 방식이라 하는데, 이는 하나의 함수가 실행되면 해당 함수가 종료되기 전까지 다른 작업이 중간에 실행되지 않음을 의미한다.
  • JS 엔진은 단일 호출 스택을 사용하며, 현재 스택에 쌓인 함수들이 모두 실행되어 스택에서 제거되기 전까지는 다른 함수가 실행될 수 없다.
  • JS 내 함수의 호출들은 프레임 스택을 형성한다고 말한다. 이는 함수가 실행될 시 Call Stack에 새로운 프레임이 추가되고 처리가 완료되면 없어지는 원리를 의미한다.
function delay() {
    for (var i = 0; i < 100000; i++);
}

function foo() {
    delay();
    bar();
    console.log('foo!');
}

function bar() {
    delay();
    console.log('bar!');
}

function baz() {
    console.log('baz!');
}

// 결과 : bar!, foo!, baz! 순으로 출력된다.
setTimeout(baz, 10);
foo();
  1. setTimeout 함수가 실행된다면 브라우저에게 타이머 이벤트를 요청한 후 콜 스택에서 제거된다.
  2. 이후 foo 함수가 실행되고, 내부의 bar 함수가 실행되며 차례로 스택에 추가되었다가 제거된다.
  3. 마지막으로 콜 스택이 비워졌다면 baz 함수가 스택에 즉시 추가되어 실행된다.

믿음성 문제 란?

  • 결과적으로 상단의 코드에서 baz 는 우리가 설정했던 딜레이인 10ms 보다 더 늦게 실행될 것이다.
  • setTimeout 함수가 호출된 이후 실행된 foo 함수가 처리되기까지 오랜 시간이 걸린다면 콜 스택이 비워지지 않아 baz 함수가 실행될 수 없기 때문이다.
  • 따라서 setTimeout 의 타이머가 항상 올바르게 작동한다는 보장이 없다. 이를 믿음성 문제 라고 한다.

✒️ Event Loop

✏️ What is Event Loop?

  • 브라우저는 변경된 화면을 렌더링하거나 DOM의 이벤트를 관리하고, Promise와 setTimeout 같은 비동기 함수를 적절히 처리하는 일들을 관리한다.
  • 이런 작업들은 모두 Task라고 하며, 이는 순서대로 Task Queue에 담겨서 가장 오래된 순으로, 실행 가능한 Task 부터 순차적으로 꺼내진다.
  • 즉, 브라우저에서의 이벤트 루프란 Task Queue와 Call Stack을 모니터링하고 지속적으로 Task를 실행하는 하나의 프로세스이다.
  • Call Stack 이 차있다면 이벤트 루프는 스택이 빌 때까지 대기하다 Task Queue에서 Task를 꺼내와 Call Stack에 넣어 이를 실행시켜준다.

✏️ Sequence of Event Loop?

  1. Task Queue 에서 실행 가능하며 (runnable) 가장 오래된 Task를 하나 꺼내 실행한다.
  2. Micro Task Queue 가 전부 비워질 때까지 안의 Micro Task 들을 모두 꺼내 실행한다.
  3. 브라우저의 렌더링이 필요한 요소가 있는지를 체크하고, 만약 그러하다면 렌더링을 진행한다.
    • resize, scroll 등의 이벤트 실행
    • Animation Frame Callbacks 실행 (requestAnimationFrame으로 추가된 callback을 의미)
    • Update Intersection Observation Steps 실행
    • 렌더링을 통한 화면 업데이트
  4. Task Queue 에 새로운 Task 가 나타날 때까지 대기한다.

✏️ (Macro) Task Queue

Task queues are sets, not queues, because the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.

  • Task Queue란 setTimeout 이나 setInterval 같은 비동기 함수의 콜백 함수나 이벤트 핸들러 같은 Task가 보관되는 곳이다.
  • 최종적으로 Call Stack에서 실행될 Task들이 모인 하나의 집합 (Set) 이며, 스택이 빌 경우 가장 먼저 들어온 실행 가능한 Task를 추가한다.

Task Queue 에서 Task란 어떤 것들일까?

  • 외부 스크립트 (<script src="...">)가 로딩될 때 이를 실행하는 작업.
  • setTimeout 이나 setInterval 같은 비동기 함수로부터 인계 받은 콜백 함수를 실행하는 작업.
  • 사용자가 마우스를 움직일 때 mousemove 이벤트와 이벤트 핸들러를 실행하는 작업
  • HTML 문서를 parser로 파싱하여 토큰화 시키는 작업
  • Fetch, Ajax 같이 비동기적으로 외부로부터 리소스를 요청하는 작업
  • document 에 새로운 element 를 삽입하는 등의 DOM 조작 작업.

✏️ Micro Task Queue (수정 예정)

해당 섹션에는 잘못된 정보가 다량 포함되어 있습니다. 개인적인 조사를 통해 빠르게 수정하겠습니다.

  • Micro Task 란, 기존의 Task에 영향을 받지 않고 비동기적으로 빠르게 수행되는 Task를 의미한다.
    • Promise, Object.observe, MutationObserver, process.nextTick 이 이에 속한다.
  • 일반적인 구현으로는 각 Task가 끝나거나 Event Loop의 시작과 끝에서 체크된다. 이 과정을 표준 문서에서는 Micro Task Checkpoint 라고 정의하였다.

Micro Task Check Point 란?

  • Micro Task Queue가 빌 때까지, Micro Task Queue 에서 가장 오래된 Micro Task를 꺼내 실행하는 과정이다.
  • 해당 과정은 각 Task의 종료와 Micro Task 를 모두 실행시키는 반복 과정의 실행과 끝에서 발생한다.
  • 따라서 Micro Task의 경우 다음 반복에서 실행될 Task 보다 실행에 대한 우선권을 가진다. (그 전에 싹 다 실행되어야 하기 때문)
  • 또한 Micro Task 외의 어떤 Task (렌더링, Callback 등) 도 실행되지 않음을 해당 작업을 통해 보장한다.
  1. perfoming a microtask checkpoint 플래그가 false인지를 확인한다. (현재 Micro Task Queue 를 비우는 중인지 체크)
  2. 만약 false 라면, perfoming a microtask checkpoint를 true 으로 한다.
  3. 이벤트 루프의 Micro Task Queue가 빌 때까지 Micro Task Queue의 가장 오래된 Micro Task 를 실행한다.
    • 만약 실행 중인 Micro Task가 콜백을 실행하는 경우 Microtask Checkpoint 가 실행된다.
    • 이 때에는 perfoming a microtask checkpoint 플래그가 true 이기 때문에, 해당 작업이 이중으로 실행되는 것을 막는다. (false 일 경우에만 체크하기에)

✏️ Async / Await 관련 처리 순서의 차이

function a() {
    console.log('a1');
    b();
    console.log('a2');
}

function b() {
    console.log('b1');
    c();
    console.log('b2');
}
  
async function c() {
    console.log('c1');
    await d();
    console.log('c2'); // 이후의 작업은 Micro Task Queue로 이전
}
  
function d() {
    return new Promise(resolve => {
      console.log('d1');
      resolve();
      console.log('d2');
    }).then(() => console.log('then!')); // then Callback 은 Micro Task Queue로 이전

a(); 

// a1
// b1
// c1
// d1
// d2
// b2
// a2
// then! 
// c2
  1. a 함수 호출, console.log(a1) 실행 및 출력
  2. b 함수 호출, console.log(b1) 실행 및 출력
  3. c 함수 호출, console.log(c1) 실행 및 출력
  4. d 함수 호출, 첫 번째 console.log(d1) 실행 및 출력
  5. 이후 두 번째 console.log(d2) 실행 및 출력
  6. then 콜백 함수는 Micro Task Queue 에 쌓임. Call Stack이 빌 때까지 대기
  7. d 함수 호출 완료 후 await에 의해 async 함수 c는 중단됨. 나머지 Task (console.log(c2)) 는 Micro Task Queue에 쌓임
  8. c 함수를 호출한 실행 컨텍스트 (b 함수) 로 돌아가서 console.log(b2) 실행 및 출력
  9. b 함수를 호출한 실행 컨텍스트 (a 함수) 로 돌아가서 console.log(a2) 실행 및 출력
  10. 콜 스택이 모두 비워지고, 이벤트 루프가 Micro Task Queue를 확인. 내부에는 then 콜백, async 함수가 쌓여있음
  11. 먼저 등록된 then 콜백을 실행, console.log(then!) 출력.
  12. 이후 async 함수 c가 중단되었던 곳 이후로 실행, console.log(c2) 실행 및 출력
function a() {
    console.log('a1');
    b();
    console.log('a2');
}

function b() {
    console.log('b1');
    c();
    console.log('b2');
}
  
async function c() {
    console.log('c1');
    d();
    console.log('c2');
}
  
function d() {
    return new Promise(resolve => {
      console.log('d1');
      resolve();
      console.log('d2');
    }).then(() => console.log('then!'));
}

a(); 

// a1
// b1
// c1
// d1
// d2
// c2
// b2
// a2
// then!
  1. a 함수 호출, console.log(a1) 실행 및 출력
  2. b 함수 호출, console.log(b1) 실행 및 출력
  3. c 함수 호출, console.log(c1) 실행 및 출력
  4. d 함수 호출, 첫 번째 console.log(d1) 실행 및 출력
  5. 이후 두 번째 console.log(d2) 실행 및 출력
  6. then 콜백 함수는 Micro Task Queue 에 쌓임. Call Stack이 빌 때까지 대기
  7. d 함수 호출 완료 후 console.log(c2) 실행 및 출력 (await 키워드가 없어 후속 Task가 Micro Task Queue 로 인계되지 않음)
  8. c 함수를 호출한 실행 컨텍스트 (b 함수) 로 돌아가서 console.log(b2) 실행 및 출력
  9. b 함수를 호출한 실행 컨텍스트 (a 함수) 로 돌아가서 console.log(a2) 실행 및 출력
  10. 콜 스택이 모두 비워지고, 이벤트 루프가 Micro Task Queue를 확인. 내부에는 then 콜백이 쌓여있음
  11. 이후 등록된 then 콜백을 실행, console.log(then!) 출력.
  • 결국 await 키워드를 만나면, async 함수는 중단되며 이후의 작업은 Micro Task Queue 로 넘어간다.
  • 하지만 await 키워드를 사용하지 않으면 async 함수는 중단되지 않아 이후의 작업을 Call Stack에 넣게 된다.

✏️ Animation Frame Queue

  • Task의 실행과 렌더링은 별개로 진행되기에, Task가 5번 진행되었다고 해서 렌더링도 5번 진행되지는 않는다.
  • 일반적으로는 Task가 빨리 실행되는 것을 가장 큰 목표로 하지만, 애니메이션 같은 화면 효과와 관련된 코드는 화면의 렌더링되는 시점에 맞춰 실행되어야 한다.
  • requestAnimationFrame 은 인자로 받은 콜백 함수들을 화면이 렌더링 되기 이전에 실행한다. 정확히는 resize, scroll 등의 이벤트가 발동된 후에 실행된다.
  • 따라서 requestAnimationFrame 은 다음 화면이 리렌더링 될 때 실행하고픈 Task를 예약하는 역할을 하며, 이러한 Task는 Animation Frame Queue 라는 별도의 공간에 저장된다.
profile
항상 왜 이걸 써야하는지가 궁금한 사람

0개의 댓글