[Javascript] 비동기 처리를 위한 이벤트 루프

studying  · 6 mins read

  • js는 단일스레드 기반 언어이지만 이벤트 루프를 이용해서 비동기 방식으로 concurrency를 지원한다.
  • v8과 같은 js 엔진은 단일 호출 스택(call stack)을 사용하고 들어온 요청을순차적으로 호출 스택에 담아 처리한다. 그리고 js 엔진을 구동하는 환경, 즉 브라우저나 node.js에서 비동기 요청에 대한 동시성 처리를 진행한다.

  • 브라우저 환경

1

  • 비동기 호출을 위해 사용하는 setTimeout, XMLHttpRequest등의 함수들은 js엔진이 아닌 Web API 영역에 따로 정의되어 있고, 이벤트 루프와 task queue도 js 엔진 외부에 구현되어 있다.

  • nodejs 환경

2

  • node.js는 비동기IO를 지원하기 위해 libuv 라이브러리르 사용하는데 libuv에서 이벤트루프를 제공한다.
  • js엔진은 비동기 작업을 위해 node.js의 api를 호출(setTimeout 등의 api), 이때 념거진 콜백은 libuv의 이벤트 루프를 통해 스케줄되고 실행된다.
  • js 함수가 실행되는 방식을 run to complete라고 하는데 하나의 함수가 실행되면 이 함수의 실행이 끝날 때까지 다른 어떤 작업도 중간에 끼어들지 못한다. 즉, 현재 call stack에 쌓여있는 모든 함수들이 실행을 마치고 스택에서 제거되기 전까지 다른 어떤 함수도 실행될 수 없다.

* taskqueue: 비동기 함수들의 callback함수들이 대기하는 큐 형태의 배열

* 이벤트 루프: 호출 스택이 비워질 때마다 큐에서 콜백 함수를 꺼내와서 실행하는 역할을 해줌(하나씩 꺼내서 call stack에 push). 현재 실행중인 태스크가 없는지 → 태스크 큐에 태스크가 있는지를 반복적으로 확인한다. 이벤트 루프는 현재 실행중인 태스크가 없을 때(call stack이 비워졌을 때) task 큐의 첫 번째 태스크를 꺼내와 실행

const test = () => {
  setTimeout(() => console.log(1), 0); //
  setTimeout(() => console.log(1), 0); //
  setTimeout(() => console.log(1), 0); //
  setTimeout(() => console.log(1), 0); //
};
test();
console.log(20000); // task queue에 콜백함수 4개 들어감
for (i = 0; i < 10000000; i++) {} // 0초 끝나기 전에 이 루프 실행됨

console.log(200000000000);
  • 위에 for문 실행되고 나면 콜스택 비워진 것 같지만 전역 환경에서 실행되는 코드는 한 단위의 코드 블록으로써 가상의 익명함수로 감싸져 있다고 생각하면 익명함수가 남아있기 때문에(전체 코드를 감싸고 있는) 익명함수 위에 console.log 함수가 쌓이고 여기까지 다 실행된 다음에 이벤트 루프가 taskqueue에 있는 함수를 콜스택에 넣어서 호출시킨다.
  • 비동기 함수가 실행될 때의 call stack은 전체 코드를 감싸고 있는 익명 함수가 call stack 맨 아래에 있어 전체 코드 실행되고 나서 익명함수까지 빠져야 task queue에 쌓인 콜백 함수 하나가 콜스택으로 들어와서 실행된 후 콜스택이 빠지면 다음 taskqueue에 쌓인 콜백함수가 콜스택으로 들어와서 실행된다. 즉, taskqueue에서 하나의 task를 call stack에 푸쉬한 후 task가 종료되면 taskqueue에 들어있는 다음 task가 call stack에 푸시된다.
  • 모든 비동기 API들은(setTImeout, XMLHttpRequest, …) 작업이 완료되면(timer설정, .then():받아온 data가지고 다루는 부분?) 작업이 완료되면 콜백함수를 태스크 큐에 추가된다.

    * 다른 비동기 함수들: addEventListener, XMLHttpRequest, …

$(".btn").click(function () {
  // (A)
  try {
    $.getJSON("/api/members", function (res) {
      // (B)
      // 에러 발생 코드
    });
  } catch (e) {
    console.log("Error : " + e.message);
  }
});

위의 코드에서 B는 A가 실행될 때와는 전혀 다른 독립적인 컨텍스트에서 실행이 되기 때문에 A내부의 trycatch문에 영향을 받지 않는다. (이런 이유로 Node.js의 비동기 API들은 중첩된 콜백 호출에 대한 에러 처리를 위해 모든 중첩된 콜백 함수에서 ‘첫 번째 인수는 에러 콜백 함수’ 라는 컨벤션을 따르고 있다)

setTimeout(fn, 0)

setTimeout(function () {
  console.log("A");
}, 0);
console.log("B");
  • B → A 출력
  • 프론트엔드 환경에서는 렌더링엔진과 관련해서 이런 코드가 특히 요긴하게 쓰일 때가 있다.
  • 브라우저 환경에서는 자바스크립트 엔진 뿐 만 아니라 다른 여러 가지 프로세스가 함께 구동되고 있다. 렌더링 엔지도 그중의 일부이다.
  • 렌더링 엔진의 태스크(컴포넌트 부분 리렌더링 등…)는 대부분의 브라우저에서 js 엔진과 동일한(동일한 taskqueue를 사용) 단일 태스크 큐를 통해 관리

→ 문제점이 있음

$(".btn").click(function () {
  showWaitingMessage();
  longTakingProcess();
  hideWaitingMessage();
  showResult();
});
  • taskqueue에 해당 콜백이 들어가고 showWaitingMessage 함수의 호출로 인해 화면을 리렌더링하는 task가 taskqueue로 들어감 → clickevent 콜백이 끝난 후에 해당 렌더링이 실행되기 때문에 hideWaitingMessage()가 호출되고 show Result까지 호출되어 콜백이 종료된 이후에 taskqueue에 쌓인 다음 작업들인 렌더링 task가 두 번 실행됨(아마 hideWaitingMEssage함수로 인해 콜백이 종료될 시점에는 메세지를 보여주는 거에 대한 state가 false로 지정되어 있을 것으로 예상 → 렌더링 2번 다 화면에 메세지가 없는 상태로 렌더링이 됨.)
$(".btn").click(function () {
  showWaitingMessage();
  setTimeout(function () {
    longTakingProcess();
    hideWaitingMessage();
    showResult();
  }, 0);
});
  • 이렇게 되면 메세지를 보여주는 상태로 변경된 후에 화면 렌더링 task가 taskQueue에 들어가고 두 번째 callback이 다음으로 taskqueue에 들어갈 것임 → 첫번쨰 콜백 실행이 종료된 후에taskqueue에서의 첫번 째 태스크인 화면 렌더링이 진행 → 로딩 메세지 보여짐 → taskqueue에있는 두번쨰 콜백 실행 → 긴 작업 실행 후 → 메세지를 숨기는 상태로 변경된 후에 홤녀 렌더링 task가 taskqueue에 들어가고 showREsult함수가 실행된후 두번쨰 콜백 종료 → taskqueue에 있는 홤녀 렌더링이 진행되어 화면에 로딩 메세지가 사라진 채로 렌더링!

  • setTimeout에서 사용되는 숫자 0은 즉시를 의미x
  • 브라우저는 내부적으로 타이머의 최소단위(Tick)을 정하여 관리 → 실제로는 그 최소만큼 지난 후에 태스크 큐에 추가됨… 크롬 브라우저의 경우 최소단위로 4ms 사용. setTImeout(ftn, 4)와 동일한 의미
  • setImmediate라는 api: setTimeout과 같은 최소단위 지연이 없이 바로 taskqueue에 해당 콜백을 추가 → 표준의 반열에 오르지 x

Promise는 마이크로 태스크를 사용

  • 일반 task보다 더 높은 우선순위를 갖는 태스크 즉, 태스크 큐에 대기중인 태스크가 있떠라도 마이크로 태스크가 먼저 실행된다.
setTimeout(function () {
  // (A)
  console.log("A");
}, 0);
Promise.resolve()
  .then(function () {
    // (B)
    console.log("B");
  })
  .then(function () {
    // (C)
    console.log("C");
  });

b → c → a로 실행된다.

  • setTImeout 함수가 콜백 함수 A를 태스크 큐에 추가 → 프라미스의 then 메소드는 콜백 B를 마이크로 태스크 큐에 추가 → (다음 then부분은 이전 then 실해된 다음 실행..) 위의 코드 실행이 끝나면 이벤트 루프는 taskqueue대신 마이크로 태스큐를 확인해서 콜백 b실행.. → b실행되고나면 then메소드가 콜백 C를 마이크로태스크 큐에 추가(이벤트루프가 마이크로 태스크, taskqueue 확인하기 전에!) → 이벤트 루프는 마이크로 태스크를 확인하여 콜백 C를 실행한 후 마이크로 태스크 큐가 비었음을 확인, taskqueue에서 콜백 A를 꺼내와 실행!

  • 마이크로 태스크가 계속돼서 실행될 경우 일반 태스크인 UI 렌더링이 지연되는 현상이 발생할 수도 있다.

references