이벤트 루프와 큐

정호진·2023년 11월 17일
0

목록 보기
7/7

자바 스크립트

흔히 우리가 아록 있는 자바 스크립트의 특징이라고 하면 "자바 스크립트는 싱글스레드" 입니다. 즉 자바 스크립트는 한번에 하나의 일밖에 하지 못합니다.

그렇다면 그 하나의 일이 너무 오래걸린다면 어떻게 해야할까요? 저희가 아는 자바스크립트 코드에서 외부로 부터 용량이 큰 파일을 받아온다던지, 특정 시간 혹은 이벤트 뒤에 함수를 실행시키는 등 순서를 결정하고 싶은 경우는 어떻게 해야할까요?

만약 싱글 스레드에서 그것들을 실행하려고 한다면 상당히 답이 없습니다. 사용자는 파일이 다운로드 될 때까지 무한정 대기를 타거나 함수 실행 순서의 보장이 되지 않아서 순서가 엉킬수 있습니다. 그렇게 된다면 사용자는 해당 서비스를 이용하지 않고 결국 떠날 가능성이 매우 높습니다.

하지만 자바스크립트에서는 파일을 다운로드 받는다고 해서 다른 행동을 취하지 못하는 것이 아닙니다. 과연 무엇때문에 싱글 스레드이면서 비동기로 동작이 가능한 것일까요? 바로 자바스크립트는 싱글스레드가 맞지만 런타임은 싱글 스레드가 아니기 때문입니다. 아래 이미지를 한번 보고 가겠습니다.

이벤트 루프 이미지

자바스크립트라고 한다면 위 이미지에 있는 JAVASCRIPT ENGINE에 해당하는 부분이 맞습니다. 그리고 해당 부분은 싱글 스레드로 동작하고 있습니다. 하지만 저희는 자바스크립트 코드를 브라우저 위에서 실행시키고 있기 때문에 브라우저 API와 같이 외부 API 사용이 가능하고, 이 과정에서 비동기 동작을 실행할 수 있습니다. 흔히 저희가 알고 있는 fetch, setTimeout, DOM 등의 API가 WEB API의 종류중 하나입니다.

저희가 자바스크립트로 논블로킹을 구현하기 위해서는 모든 코드를 자바스크립트로 실행시켜서 콜스택에 차근차근 실행시키기 보다 외부 API를 통해 외부로 일을 위임하고 나머지 단계를 진행하면 되는 것입니다.

이벤트 루프

사용자(클라이언트)가 이벤트 처리, 사용자 상호작용, 스크립트 실행, 렌더링, 네트워킹 등을 조율하기 위해 사용하는 개념. 각각의 에이전트는 해당 에이전트에 대한 고유한 이벤트 루프를 가지고 있습니다.

이벤트 루프란 클라이언트에서 발생하는 각종 상호작용을 조율하기 위한 개념입니다. 이벤트 루프는 위에서 얘기한 에이전트에 따라 크게 3가지 종류라 나뉘게 되는데 window agent에 종속된 window event loop, service worker에 종속돈 worker event loop 그리고 worklet agent에 종속된 worklet event loop가 있습니다.

저희가 이번에 다룰 이벤트 루프는 window event loop 입니다.

// 이벤트 루프의 수도 코드
while (queue.waitForMessage()) {
  queue.processNextMessage();
}

이벤트 루프의 수도 코드는 위와 같이 간단합니다. queue가 비어있는지 확인하고, 있다면 해당 큐를 실행시켜줍니다.

태스크 큐와 태스크

An event loop has one or more task queues. A task queue is a set of tasks.

이벤트 루프는 하나 또는 여러개의 task queues로 이루어져 있고 그 task queuetask들의 집합 이라고 합니다. 위 이미지에서 "Hey!"를 반환하는 함수가 그 task가 되는 것입니다. 그렇다면 어떤 종류의 작업들이 task에 할당되는지 확인해 보겠습니다.

  • Events
    click, input과 같은 이벤트를 의미합니다.
  • Parsing
    저희가 아는 HTML parsing이 맞습니다. 토큰화 과정입니다
  • Callbacks
  • Using a resource
    resource(이미지, 영상, 데이터)를 비동기 방식으로 fetch했을 때 해당 리소스의 상태가 준비된 상태를 의미합니다
  • Reacting to DOM manipulation
    elementdocument에 삽입되는 것과 같이 dom과 관련된 반응들을 의미합니다

태스크 큐에 대기중인 태스크는 자바스크립트 콜 스택이 비워지기까지 기다렸다가 이벤트 루프를 통해 콜 스택에 push되고 해당 태스크가 실행됩니다.이와 같은 일련의 과정들이 비동기적으로 실행되어서 자바스크립트에서 논-블로킹 구현이 가능합니다.

큐의 종류

자바스크립트의 콜 스 택이 비워지게 된다면 이벤트 루프에서 실행될 태스크를 순차적으로 콜 스택에 올려져서 실행이 됩니다. 여기서 실행되는 태스크의 속성에 따라 할당되는 큐가 다르고, 큐의 종류에 따라 실행되는 방식 역시 달라집니다. 큐에는 microtask queue, macrotask queue, render queue 3개가 있습니다.
https://blog.xnim.me/event-loop-and-render-queue#heading-screen-updating

MacroTask Queue

MacroTask Queue에 포함되는 task에는 위에서 언급했던 HTML파싱, 사용자 이벤트, setTimeout등의 callback
등이 있습니다. MacroTask Queue는 말이 Queue지 사실상 Set입니다. 그리고 하나의 origin에 따라 여러개의 Task Queue가 존재합니다. timeout관련된 set이 있고, 사용자 이벤트 관련한 set이 있는 것 처럼 따로 분리가 되어 있습니다.


  const timeoutLoop = () =>{
    setTimeout(() => {
      while(true){}
    }, 0); 
  }

timeoutLoop 함수를 실행시켰을 때 주화입마에 빠져버리게 됩니다. 콜 스택이 비워져서 태스크 큐에서 태스크를 실행하는데 그게 빠져나올 수 없는 것이라면 당연한 결과입니다.

하지만 재귀를 통해 계속해서 호출한다면 어떻게 될까요?

 let count = 0;
 const timeoutLoop = () =>{
   setTimeout(() => {
     console.log(++count);
     timeoutLoop();
   },1000)
 }

계속해서 재귀를 하면서 함수를 호출하는데 과연 무한 루프에 빠질지가 의문입니다.

하지만 주화입마에 빠지지 않고 그대로 함수를 호출하게 됩니다. 이 이유가 위에서 말했던 한번에 하나의 태스크만 실행하고 태스크 실행 도중에 큐에 태스크가 추가된다고 해도 그 다음 호출에 태스크 큐가 실행이 되고 바로 실행되지 않는다는 특징이 있습니다.

MicroTask Queue

MicroTask Queue는 Promise를 통해 전달된 callback들이 task로 들어가게 됩니다.물론 try~catch~finallyasync/await도 함께 해당합니다. MicroTask Queue는 한번 실행되면 해당 큐가 전부 비워질때까지 계속해서 실행됩니다. 만약 아래 코드를 실행하게 된다면 상당히 재미있는 일이 벌어지게 됩니다.

let count = 1
const queue = () =>{
	++count
	console.log('this is queue: ',count);
	queueMicrotask(queue);
}

queue();

MicroTask Queue는 태스크 실행 도중에 새로운 태스크가 들어온다면 해당 태스그 까지 실행을 시킵니다. 이 점이 위에 MacroTask Queue와의 차이점입니다. 위 코드에서 setTimeout 함수를 queueMicrotask로 바꿨을 뿐인데 주화입마에 빠져버리는 것을 보면 확실히 그 차이를 알 수 있습니다.

Render Queue

브라우저 렌더링 과정은 다음과 같은 과정을 통해 발생하게 됩니다.
Style -> Layout -> Paint -> Composite

Style과정은 작성한 css 코드들을 바탕으로 CSSOM을 만드는 과정입니다. (우리가 흔히 CSSOM을 트리형태로 알고 있지만 사실은 Object형태입니다.) CSS를 파싱하고, 실제 DOM에 적용될 rule들을 찾고 rule과 기타 정보를 합쳐서 최종적으로 style을 계산하게 됩니다.이를 통해 ComputedStyle이라는 것을 만들게 됩니다.

Layout 과정은 파싱되어 있는 DOM Tree와 Computed Style을 합쳐서 LayoutObject를 만드는 과정입니다. element의 위치를 결정합니다.

Paint과정은 Layout과정에서 만든 LayoutObject들을 화면에 어떻게 칠할지에 대한 명령어를 생성하는 단계입니다.

Composite 위 과정을 통해 만들어진 결과물을 합성해서 최종 화면을 생성합니다.

브라우저 렌더링이 메인이 아니기 때문에 자세한 이야기를 보고싶다면 브라우저의 렌더링 파이프라인을 본다면 정말 자세히 나와있습니다.

render queue라는 것은 사실 이 전반적인 과정을 의미합니다. 그리고 RequestAnimationFrame이라는 과정이 이 모든 과정의 앞에 추가됩니다.

requestAnimationFrame은 브라우저가 렌더링 준비가 되었을 때 해당 함수에 콜백을 등록해서 실행시킬 수 있습니다. requestAnimationFrmae에 콜백으로 주어지는 함수는 주로 애니메이션이나 렌더링 되기 직전에 DOM을 동적으로 업데이트하는데 활용될 수 있습니다.

만약에 16.7ms마다 0.1px씩 우측으로 이동하는 박스가 있다고 생각을 해봅시다. 1000/60ms당 콜백을 호출하도록 requestAnimationFramesetTimeout을 통해 각각 구현한다면 어떻게 될까요?

rAF 코드 샌드박스

같은 속도로 진행될 것이라고 예상했지만 rAF를 통해 태스크를 호출한 것이 훨씬 부드럽고 버벅거림이 없는 것을 확인할 수 있습니다. 이는 rAF가 브라우저에 최적화 되어있고 프레임 속도를 딱 맞춰주기 때문입니다.
보통의 렌더링은 아래와 같이 주어지고 페인트가 발생하고 난다음에 태스크가 실행이 되는 형태입니다 (노란 막대가 테스크, 투명 막대가 하나의 프레임 입니다. 보통은 하나의 테스크에 1000 / 60 = 16.7ms 입니다)

하지만 태스크가 밀리게 된다면 1번 프레임에서 마무리해야 할 태스크가 2번 프레임으로 밀리는 현상이 발생하게 됩니다.

만약 이런 일이 발생하게 된다면 사용자 입장에서는 부드러운 애니메이션이 부자연스러워 지는 것입니다. 마치 위에 setTimeout이 약간 버벅거리는 것처럼 말이죠

하지만 rAF를 사용하게 된다면 렌더링이 일어나기 전에 태스크를 실행하게 됩니다. 즉 이미 업데이트가 다 된 상태의 dom이 렌더링 되기 때문에 상대적으로 좀 더 부드러운 애니메이션 확인이 가능합니다.

rAF는 거의 모든 브라우저에서 Style 과정 이전에 발생하지만, Safari에서는 Composite 과정 이후에 발생하게 됩니다. 즉 모든 브라우저에 동일하게 적용이 되지 않는다는 단점이 있습니다. 따라서 크로스 브라우징을 생각한다면 이 점을 유의하고 사용하시는게 좋습니다.

그리고 render queue의 태스크는 큐에 있는 모든 태스크가 빠질때 까지 계속해서 실행됩니다. 또한 콜백에서 render queue에 새로운 태스크 추가가 가능합니다. 하지만 새롭게 추가된 태스크는 다음 렌더링에서 실행이 됩니다.

큐의 실행 순서

3가지 종류의 큐에는 실행되는 순서가 서로 다릅니다. MicroTask Queue -> Render Queue -> MacroTask Queue 의 순서대로 태스크가 실행이 됩니다. 아래 코드에서 버튼을 직접 클릭했을 때 어떤 순서로 로그가 찍히는지 한번 예상 해봅시다.


const $button1 = document.getElementById('button1');

  const handle1 = (event) =>{
    setTimeout(()=>{
      console.log('timeout1')
    });
    
    Promise.resolve().then(() => console.log('promise1'))
    
    console.log('console1')
    
  }


  const handle2 = (event) =>{
    setTimeout(()=>{
      console.log('timeout2')
    });
    
    Promise.resolve().then(() => console.log('promise2'))
    
    console.log('console2')
  }

  $button1.addEventListener('click',handle1);
  $button1.addEventListener('click',handle2);

결과는 다음과 같습니다.

보통 handle1() 전체가 실행되고handle2()가 실행되는 것을 예상했지만, handle1과 handle2에 있는 함수들이 섞여서 실행이 되는 것을 볼 수 있습니다. 그 이유는 사용자 클릭 이벤트 자체가 MacroTask Queue에 포함되는 태스크이기 때문입니다.

버튼 클릭

QueueTask
Call Stack
Macro Taskhandle1(), handle2()
Micro Task
Console

handle1() 콜스택에 push

QueueTask
Call Stackhandle1()
Macro Taskhandle2()
Micro Task
Console

handle1() 실행

QueueTask
Call Stack
Macro Taskhandle2(), ()=>{console.log('timeout1')}
Micro Task() => console.log('promise1')
Consoleconsole1

MicroTask Queue Task 콜스택에 push

QueueTask
Call Stack() => console.log('promise1')
Macro Taskhandle2(), ()=>{console.log('timeout1')}
Micro Task
Consoleconsole1

MicroTask Queue Task 실행

QueueTask
Call Stack
Macro Taskhandle2(), ()=>{console.log('timeout1') }
Micro Task
Consoleconsole1, promise1

handle2() 콜스택에 push

QueueTask
Call Stackhandle2()
Macro Task()=>{console.log('timeout1') }
Micro Task
Consoleconsole1, promise1

handle2() 실행

QueueTask
Call Stack
Macro Task()=>{console.log('timeout1')}, ()=>console.log('timeout2')
Micro Task() => console.log('promise2')
Consoleconsole1, promise1, console2

MicroTask Queue Task 콜스택에 push

QueueTask
Call Stack() => console.log('promise2')
Macro Task()=>{ console.log('timeout1')}, () =>console.log('timeout2')
Micro Task
Consoleconsole1, promise1, console2

MicroTask Queue Task 실행

QueueTask
Call Stack
Macro Task()=>{console.log('timeout1')}, () => console.log('timeout2')
Micro Task
Consoleconsole1, promise1, console2, promise2

MacroTask Queue 2번 실행

QueueTask
Call Stack()=>{console.log('timeout1')}, () => console.log('timeout2')
Macro Task
Micro Task
Consoleconsole1, promise1, console2, promise2

실행

QueueTask
Call Stack
Macro Task
Micro Task
Consoleconsole1, promise1, console2, promise2, timeout1, timeout2

하나의 문제?

하지만 여기에 Render Queue에 태스크를 넣었을 때 timeout과 animation의 순서에 문제가 있습니다.


 const handle1 = (event) =>{
    requestAnimationFrame(() => {
      console.log("animation1");
      requestAnimationFrame(() =>{
        console.log("animation3");
      })
    });

    setTimeout(()=>{
      console.log('timeout1')
      setTimeout(() => {console.log('timeout3')})

    });
    
    Promise.resolve().then(() => console.log('promise1')).then(() => console.log('then1'));
    Promise.resolve().then(() => console.log('promise3')).then(() => console.log('then3'));
    
    console.log('console1')
  }


  const handle2 = (event) =>{
    requestAnimationFrame(() => {
      console.log("animation2");
      requestAnimationFrame(() =>{
        console.log("animation4");
      })
    });

    setTimeout(()=>{
      console.log('timeout2')
      setTimeout(() => {console.log('timeout4')})
    });
    
    Promise.resolve().then(() => console.log('promise2')).then(() => console.log('then2'));;
    Promise.resolve().then(() => console.log('promise4')).then(() => console.log('then4'));;
    
    console.log('console2')

  }

  $button1.addEventListener('click',handle1);
  $button1.addEventListener('click',handle2);

위에서 공부한 내용에 따르면 적어도 animation은 timeout 찍혀야 합니다. 위에서 말하는 내용에 따르면 then4 이후에 찍혀야 하는 순서는 다음과 같습니다.

animation1, animation2, timeout1, animation3, animation4, timeout3, timeout2, timeout4

하지만 저희가 위에서 공부한 내용과는 약간(?) 결과가 다르게 나옵니다. 분명히 callstack이 비워지면 MicroTask -> render -> MacroTask 순서대로 태스크가 실행되어야 합니다.하지만 MacroTask와 Render의 순서가 제대로 보장되지 않습니다. 심지어 여러번 클릭하면 그 순서가 너무 뒤죽박죽으로 나타나게 됩니다.

그래서 한 가지 생각을 해봤을때, Render Queue에 들어가야하는 태스크가 너무 빨리 들어가서 그런게 아닐까? 하는 생각이 들었습니다. 1초에 60번 콜백을 실행하고 있는데 지금 사용하는 메서드는 너무 빠르지 않나? 라는 생각이었습니다. 그래서 화면에 렌더링 지연을 위해 일부러 메인 스레드에 블락을 걸었습니다 그리고 렌더링 회수자체를 1000번 반복하도록 설정했습니다.

const queueHandler = () =>{
    for(let i =0; i < 1000; i++){
      Promise.resolve().then(() => console.log('promise1')).then(() => console.log('then1'));

      requestAnimationFrame(() => {
        console.log("animation1");
        requestAnimationFrame(() =>{
          console.log("animation2");
        })
      });

      setTimeout(()=>{
        console.log('timeout1')
        setTimeout(() => {console.log('timeout2')})
      });

      const now = Date.now();
      while (performance.now() - now > 1000) {}

      console.log('console1')

    }
  }

  const queueHandler2 = () =>{
    for(let i =0; i < 1000; i++){
      Promise.resolve().then(() => console.log('promise2')).then(() => console.log('then2'));

      requestAnimationFrame(() => {
        console.log("animation3");
        requestAnimationFrame(() =>{
          console.log("animation4");
        })
      });

      setTimeout(()=>{
        console.log('timeout3')
        setTimeout(() => {console.log('timeout4')})
      });

      const now = Date.now();
      while (performance.now() - now > 1000) {}

      console.log('console2')
    }
  }

    $button1.addEventListener('click',queueHandler);
    $button1.addEventListener('click',queueHandler2);

결과는 아래와 같습니다.

원래 생각했던 순서대로 animation1,2 -> timeout1 -> animation3,4 -> timeout3,2,4가 실행됩니다. (여기서 timeout1은 60프레임을 넘어서 옆으로 이월되는 것 같습니다)

즉, Render Queue -> MacroTask Queue의 순서가 맞지만, 렌더 큐에 태스크가 할당되고 실행되는데에는 약간의 시간이 걸린다는 것을 알 수 있습니다.

지연 코드 실행

Q. 왜 setTimeout(callback,0)은 바로 실행되지 않는가?

setTimeout에 callback을 넣고 실행하면 약간의 차이가 있다는 이야기를 들었습니다. 그래서 진짜인지 직접 확인해 봤습니다.


const timeCallback = () => {
  console.timeLog("time record");
  id = setTimeout(timeCallback)
};

$start.addEventListener("click", () => {
  console.time("time record");
  timeCallback();
});

그리고 결과를 실행했더니 다음과 같은 결과가 나왔습니다.

Timeout Stamp

5번째 콜백까지는 그래도 엄청 1ms가 안되는 시간 내로 지연이 됐는데 6번째 부터 4ms 이상 지연이 되면서 콜백이 실행되기 시작했습니다. 그 이유는 다음과 같습니다.

  • 중첩된 타임아웃
  • 브라우저별로 존재하는 차이점
  • Throttling of tracking scripts (Firefox)

여러가지 이유가 있을 수 있지만 모든 브라우저에서 통용되는 이유는 "중첩된 타임아웃"이 가장 큽니다.

As specified in the HTML standard, browsers will enforce a minimum timeout of 4 milliseconds once a nested call to setTimeout has been scheduled 5 times.

HTML 자체에서 setTimeout이 5번 이상 schduled된다면 4ms의 딜레이를 준다고 명시하고 있습니다.

MacroTask에서 task를 실행시킬 때는 자바스크립트 콜 스택이 모두 비워진 다음에 이벤트 루프에서 콜 스택이 비워진 것을 감지하고 각각의 태스크를 할당하게 됩니다. 위에서 언급했듯이 MacroTask는 큐에 아무리 많은 태스크가 쌓여 있어도 한번의 틱당 하나의 태스크 만을 실행합니다. 만약에 딜레이 없이 0ms로 매번 콜백이 실행하게 된다면 브라우저가 해당 콜백 함수를 실행할 때 필요한 준비시간이 너무 짧게됩니다.

브라우저는 MacroTask를 실행하기 전에 실행해야 할 다양한 일들이 존재합니다. 브라우저가 할 일이 너무 많아서 실행되어야 할 함수가 실행되지 못하는 것을 starvation 이라고 합니다.

This can lead to a phenomenon called “starvation” where some tasks are never executed because the browser is too busy executing the short timeouts.

즉, setTimeout은 MacroTask에 할당되는 콜백이고, MacroTask의 특징상 한번에 하나의 Task만을 처리합니다. 이 하나의 Task를 실행하고 다음 Task를 실행하기 까지 중간에 브라우저에서 많은 일들이 발생합니다. 따라서 최소한의 지연 시간이 발생하게 됩니다.

무수히 렌더링 되는 콜백을 실행시키고 싶다면 MicroTask에 무한 callback을 제공하면서 실행할 수 있습니다. (메세지 채널을 사용하는 방법도 있다고 하네요!)

결론

  • 이벤트 루프는 자바스키립트의 동시성을 가능하게 하는 핵심 기능입니다.
  • 자바스크립트의 콜 스택이 비워진다면 이벤트 루프에서 이를 감지하고 MicroTask Queue -> Render Queue -> MacroTask Queue 순서대로 태스크를 뽑아서 콜 스택에 push 합니다.
  • MicroTask는 한번 실행될 때 모든 태스크를 비워냅니다. 태스크 실행 도중에 태스크가 추가된다면 해당 태스크까지 실행합니다.
  • Render는 한번 실행될때 모든 태스크를 비워내지만, 태스크 실행 도중 추가된 태스크들은 다음으로 미룹니다.
  • MacroTask는 한번에 하나만 태스크를 실행합니다.
  • setTimeout을 통해 콜백을 전달할 경우 약간의 시간차이가 있을 수 있습니다.

참조

https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif
https://www.youtube.com/watch?v=cCOL7MC4Pl0
https://blog.xnim.me/event-loop-and-render-queue#heading-screen-updating
https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
https://developer.mozilla.org/en-US/docs/Web/API/setTimeout
https://medium.com/geekculture/why-settimeout-can-only-be-set-to-4ms-minimum-7ad9e2c2822e

0개의 댓글