본문 바로가기

Javascript

[Javascript + Web] Event loop & Async (이벤트 루프 & 비동기)

일상 loop

Intro.

제가 2017년, 처음에 자바스크립트를 접했을 때, 이전에 쓰던 c언어와는 달리, setTimeout을 통해 시간지연도 할 수 있고, http request 보내서 데이터를 가져올 수 도 있었으며, 애니메이션도 만들어 볼 수 있는 등, 이런 다양한 활용성에 매료된것 같습니다. 그렇게 잘 가지고 놀다가, 초심자의 자신감이 뿜뿜 차오를 때 쯤, 자바스크립트가 어떤식으로 동작하는지 묻는 질문에 아무말도 못하는 제 자신을 보고 다시 공부하기 시작했습니다. 그리고 이번 주제는 공부하면서 배운 것들 중 공유하면 상당히 유용할것같아 써봅니다.

 

 

 

 

Javascript Runtime

먼저 자바스크립트는 어떤 구조를 가질까요? 다음과 같습니다.

생각보다 별 거 없죠? 

heap에서 메모리 할당이 일어나고,  execution stack에 각 execution context가 쌓이고 각 실행들이 일어납니다.

execution stack를 쉽게 call stack이라 부르겠습니다. 

자바스크립트는 single thread이기에, 하나의 call stack을 가지고 한번에 하나의 실행이 일어납니다.

 

그럼 여기서 의문이 듭니다.

    console.log('1')
    setTimeout(() =>{
      console.log('2')
    },5000);
    console.log('3');

//1 3 2

위 코드는 왜 1 -> 3 -> 2 순으로 실행 될까요?

 

지금까지의 지식을 바탕으로 하면 다음과 같이 일어나야할 것 같습니다.

  1. global context가 call stack에 쌓임 :: callstack = [ global ]
  2. console.log context가 call stack에 쌓임 :: callstack = [ global , console.log ]
  3. console.log 실행이 끝남 -> call stack pop :: call stack = [ global ]
  4. setTimeout context가 call stack에 쌓임 :: call stack = [global , setTimeout ]
  5. 5000ms 지남
  6. console.log context가 call stack에 쌓임 :: callstack = [ global , setTimeout , console.log ]
  7.  console.log 후, setTimeout종료  :: callStack = [ global ]
  8. console.log context 가 stack에 쌓임 :: callstack = [ global , console.log ]
  9.  log 종료 후 프로그램 종료 :: callstack = [] 

하지만 생각한 것과 다릅니다. 사기당한걸까요?

 

사실 자바스크립트는 설계의 맞게 잘 동작했습니다만, 저희가 동작 환경에 대해 간과한게 있습니다.

자바스크립트 엔진은 브라우저 위에서 돌아간다는 점이지요.

 

그리고 timeout Api , HTTP request , DOM 등은 브라우저에서 제공하는 Web api입니다.

(Nodejs의 경우, web api가 아니라 C++ api를 통해 호출합니다, 나머지 구조는 거의 똑같습니다 :))

 

Javascript Runtime With Browser

실제로는 다음과 같이 동작합니다.

  1. global context가 call stack에 쌓임 :: callstack = [ global ]
  2. log(1) context가 쌓임 :: callstack = [global , log]
  3. log(1) context end -> pop :: callstack = [global]
  4. timeout Api 호출 :: callstack = [global , settimeout]
    1. (flow 2)
  5. timeout 호출끝 :: callstack :: callstack = [global]
  6. log(3) context 쌓임 -> pop :: callstack = [global,  log]
  7. log(3) context end -> pop :: callstack = [global]

flow 2

  1. Timeout 5000ms 시작
  2. 5000ms 경과 후, 콜백 함수(cb)를 task queue로 보냄
  3. Event loop이 task queue를 감시하고 있다가 task queue에 task가 들어온걸 확인
  4. Event loop이 call stack이 비었는지 확인
  5. call stack이 비었으면, task queue에서 deque -> call stack에 push

비록 자바스크립트는 single thread여도 브라우저는 multi thread입니다. 그리고 브라우저의 여러 thread중 하나가 event loop를 실행하고 있습니다. 이 event loop는 기본적으로 call stack과 task queue를 구독하고 있으며, task queue가 쌓였을 시, 바로 call stack으로 push하는게 아닌, call stack이 모두 비워질 때 까지 기다리다가 순차적으로 push합니다.

다르게 말하자면, call stack이 영원히 비워지지 않으면 영원히 실행되지 않습니다.

 

그렇다는 것은, 다음 코드의 경우도 'time out'이 0.1초 후에 실행 되는게 아니라 상당히 더 오랜 시간이 지난 후 출력 되겠지요?

setTimeout을 특정 시간이 지난 후에 무조건적으로 실행을 보장하는 함수가 아닌, 말 그대로 지연 함수라 생각하셔야 할 것 같습니다.

setTimeout(() => {
	console.log('time out!');
} ,100)

for(let i = 0 ; i < 100000000000000000000000000000000; i ++){
	console.log(i)
}

In Practice..

 

자 그럼 저희는 이제 다음 질문에 답을 할 수 있어야 합니다.

const heavyTask = function(task){
	setTimeout(task , 0);
}

...

heavyTask()

..

다음의 코드의 경우는 무엇을 위해 0초 지연 후 task를 실행한 것일까요?

 

call stack이 비워질 때 까지 실행을 지연시키고  싶을 때(defer) 위처럼 표현할 수 있습니다.

 

실제로 백그라운드 상 꽤나 복잡한 연산이 있을 때, 자주 사용합니다.

 

먼저 브라우저는 60fps를 보장하기 위해, 약 16ms 마다 render 함수를 호출합니다. 단, render 함수는 고 순위 함수이기에, task queue에서 가장 높은 우선 순위로 실행됩니다. 단, callstack이 비워져있어야 가능하겠지요?

 

한번 예시를 들어보겠습니다.

 

let fin = false
function heavyTask(idx){
  let cnt = 0;
  for(let i = 0 ; i < 1000000000 ; i ++){
    cnt += i
  }
  console.log('task '+ idx +' fin')
}
function count(){
  [1,2,3,4].forEach((i) => heavyTask(i))
}
function task(time = 0){
    console.log(time)
    if(!fin){
      count()
      fin = true
      console.log('task all fin!!!')
    }
    requestAnimationFrame(task)
}
function run(){
  task()
}
run()

 

간단한 모의실험을 위해 requestanimationFrame함수(web api)를 사용했습니다.

(requestanimationFrame의 작업은, animation frame 에 입력되나, 현재 시행하고 있는 파일이 하나이기에, 그냥 하나의 큐에서 돌아가는 것처럼 표현하겠습니다. )

당연하지만, time = 0 출력후 globalFunction 함수가 끝나기 전까진 추가적인 시간 로깅은 없습니다.

call stack                                    queue               web api
[global - task - run - task - count]           []			
[global - task - run - task - count - forEach] []				
[global - task - run - task ]                  []                   call RequestAniFrame
[global]                                       [task]				
[global - task]                                []                   call RequestAniFrame
[global]                                       [task]
....

count에 의해 지속적으로 main thread가 점유되니, 블로킹된 모습을 볼 수 있습니다만, setTimeout(0)를 통한 비동기 방식을 사용하면 해당 이슈를 해결 할 수있습니다.

 

  function asyncCount(arr, func){
    arr.forEach((i) => setTimeout(() => func(i),0))
  }


let fin = false
function heavyTask(idx){
  let cnt = 0;
  for(let i = 0 ; i < 1000000000 ; i ++){
    cnt += i
  }
  console.log('task '+ idx +' fin')
}

function task(time = 0){
    console.log(time)
    if(!fin){
      asyncCount([1,2,3,4] , heavyTask)
      fin = true
      console.log('task all fin!!!')
    }
    requestAnimationFrame(task)
}

function run(){
  task()
}
run()

call stack                                     queue               web api
[global - task - run - task - asyncCnt]        []			
[... task - asyncCnt - forEach]                []				    all * 4 setTimeout
[global - task - run - task ]                  [timeout*4]          call RequestAniFrame
[global]                                       [task , to ,tast ,to ...]				
[global - task]                                [to , task ...]     call RequestAniFrame
[global - timeoutCB]                           [task , req ..]
....

보다시피, task queue에서 순차적으로 실행됨을 알 수 있습니다. count함수가 온전히 main thread를 점유하지는 않죠?

 

Async 

지금까지 task queue와 call stack , 그리고 event loop이 어떻게 상호작용하는지 알아봤습니다만, 아직 두 가지 를 설명 안했습니다. 그 중 micro queue가 Promise task관련하여 처리하는 큐입니다. (animation Frames에 대해선, 일단 macro queue보단 우선 순위가 높고 micro queue보단 낮다는 정도만 설명하겠습니다)

 

 

 

기본적으로 Micro queue가 Task Queue(Macro Queue)보다 높은 우선순위기에, 먼저 MicroTask가 다 비워질때까지 MacroTask는 지연됩니다.

 

예시를 보겠습니다.

 

async function microTask(){
  return new Promise((resolve)=>{
    resolve(console.log('micro task fin'))
  })
}
function macroTask(){
  setTimeout(()=>{
    console.log('macro task fin')
  },0)
}

async function init(){
  macroTask()
  await microTask()

}

let onanimationFrame = false
const animate = () => {

  if(onanimationFrame){
    console.log('animation frames')
  }
  onanimationFrame = true;

  requestAnimationFrame(animate)
}
animate()
init()

//Micro - > animationFrame -> macro

먼저 animate함수 호출을 통해, requestAnimationFrame를 통해 지속적으로 animation frame에 태스크를 넘기겠습니다.

후엔 순차적으로, macro task , micro task를 실행합니다.

 

당연하지만 앞서 서술한것처럼 micro -> animation -> macro 순으로 실행됩니다.

 

global context에서 init이 종료된 시점에서 부터 보자면,

  1. callStack = [global] macroQueue = [console.log('macro' )] microQueue = [console.log('micro')] animationFrame = [animate]
  2. event loop이 callStack이 비워진것을 확인
  3. microQueue.length > 0 인지 확인
  4. microQueue.dequeue() -> callStack에 push
  5. event loop이 callStack이 비워진것을 확인
  6. microQueue.length > 0 인지 확인 -> animationFrame.length > 0 인지 확인
  7. animationFrame.dequeue() -> callStack에 push
  8. event loop이 callStack이 비워진것을 확인
  9. microQueue.length > 0 인지 확인 -> animationFrame.length > 0 인지 확인 -> macroQueue.length > 0 인지 확인
  10. macroQueue.dequeue() -> callStack에 push

 

 

 

 

 

---------------------

제가 필력이 부족하기도하고, 그림 툴을 잘 못다뤄서 다른 분들 velog 링크를 남깁니다!

시청각 자료가 굉장하더군요...!!

https://velog.io/@titu/JavaScript-Task-Queue%EB%A7%90%EA%B3%A0-%EB%8B%A4%EB%A5%B8-%ED%81%90%EA%B0%80-%EB%8D%94-%EC%9E%88%EB%8B%A4%EA%B3%A0-MicroTask-Queue-Animation-Frames-Render-Queue

 

[JavaScript] Task Queue말고 다른 큐가 더 있다고? (MicroTask Queue, Animation Frames)

자바스크립트에서 비동기 함수가 동작하는 원리에 대해서 공부했다면, Task Queue에 대해 들어보았을 것이다. Task Queue는 Web API가 수행한 비동기 함수를 넘겨받아 Event Loop가 해당 함수를 Call Stack에

velog.io

ref : https://html.spec.whatwg.org/multipage/webappapis.html#clean-up-after-running-script