맞다. 근데 아니다.
보는 관점에 따라 조금 다를 수 있다. JavaScript 코드를 수행할 때 하나의 스레드를 쓰는지를 묻는 질문이라면 '맞다'가 정답이다. 그치만 JavaScript의 런타임 전체적으로 봤을때 하나의 스레드를 쓰는지 묻는 질문이라면 '아니다'가 정답이다.
JavaScript 엔진은 단순히 코드를 돌리는 인터프리터 역할만 수행하는게 아니다. 경우에 따라 코드를 캐싱하기도 하고 메모리 관리를 위해 가비지 콜렉터가 돌아가기도 한다.
브라우저의 경우 Web API도 있다. 엔진 밖에서 여러가지 비동기 작업과 페이지 조작을 위한 여러 API들을 제공한다. 요즘은 TTS도 되고 별게 다 되더라. 신기하다.
아무튼. 자바스크립트는 코드를 실행할 때는 하나의 스레드만 사용한다. 그치만 concurrency를 지원한다. 이게 어떻게 가능할까?
이번에도 V8 엔진을 놓고 설명해보려 한다. 사실 다른 엔진을 몰라서 그렇다.
V8 엔진은 크게 3부분+a로 나눠볼 수 있다. 경우에 따라 2부분으로 나누는 분들도 있는데 나는 3부분이 이해하기 편하더라.
Heap은 주로 동적인 객체들이 올라간다. Garbage Collector가 작동하기 때문에 참조가 소멸된 객체들은 알아서 없어진다. GC를 위해 별도의 스레드가 존재한다. C같은 low level 언어들은 힙을 할당받고 소멸시키는 과정을 수동으로 해줘야 하지만 JS는 GC 덕분에 그렇지 않아도 된다.
Call Stack에는 context가 올라간다. 함수라고 봐도 대애충 맞는 말이다. 전역 컨텍스트에는 전역 변수들과 함수들이 포함되어있고 class 선언같은 내용들도 포함되어있다. 컨텍스트를 돌리기 위해 필요한 내용이 전부 포함되어있다. 동적 객체를 직접 call stack에 쌓는 것은 몹시 비효율적인 일이기 때문에 객체는 힙 영역에 잡고 그걸 참조하는 포인터만 들고 있는다.
만약 함수 내부에서 새로운 함수를 호출할 경우 현재 컨텍스트 위에 새로운 함수의 컨텍스트가 쌓인다. 물론 함수도 객체이므로 함수에 대한 내용은 Heap에 잡힌다.
Call Stack이 중요한 이유는 '현재 실행중인 컨텍스트'가 위치해 있다는 것이다. 싱글 스레드 언어인 JavaScript는 동시에 여러 작업을 처리할 수 없다. 따라서 call stack의 최상단에 위치한 작업만 수행할 수 있다. 그럼 동시성 처리는 어떻게 되나? Event Loop와 엔진 외부의 여러 헬퍼들이 돕는다.
(출처 : https://towardsdev.com/event-loop-in-javascript-672c07618dc9)
Event Queue와 Event Loop는 같이 움직인다.
fetch
같은 비동기 함수가 호출되었다고 가정해보자. 콜스택에 fetch
함수가 올라오고 컨텍스트가 잡히지만 fetch
는 Web API에 속한 함수이기 때문에 Web API에 위임되어 처리된다. 위임시키고 난 후 자바스크립트 컨텍스트는 fetch
에 대한 리턴값을 받는다. 물론 fetch
는 완료되지 않았기에 당장 리턴받은 값을 써먹을 수는 없다. 그리고 컨텍스트는 아무일도 없었던 듯 계속 진행한다.
Web API는 위임받은 작업을 수행한다. 만약 위임받을 때 '이 작업이 종료되면 이 작업을 해줘' 하고 콜백을 같이 전달받았다면 그 콜백함수가 Event Queue에 들어간다.
Event Loop는 Call stack을 계속 감시한다. 진짜 말 그대로 계속 감시한다. Event Loop는 looper라고 하는 스레드가 별도로 존재한다. 그래서 계속 돌 수 있다.
while(queue.waitForEvent()) {
queue.processEvent();
}
(출처 : MDN)
사실 MDN에 있는 코드는 Event 대신 Message라는 키워드를 사용했다. 어쨌든 의미하는 바는 같다. queue.waitForEvent()
는 콜스택이 비어있는지 확인하는 함수라고 생각하면 된다. 콜스택이 비었을 때 이벤트 큐의 맨 앞에서 콜백(Event)을 하나 꺼내 콜스택으로 올린다. 이게 이벤트 루프가 하는 일의 전부이다.
조금 더 깊게 보면 Event Queue도 3개로 나뉜다. 우선순위를 부여하기 위해서다. MicroTask Queue
, Animation Frames
, Task Queue(Macro Queue)
가 그것이고, 우선순위는 적은 순서대로 높다. Promise의 then으로 넘겨주는 콜백이 Microtask Queue로 들어가고 우선순위가 굉장히 높다. Animation Frames는 애니메이션 효과 처리를 위해 필요하다. vSync를 보장해주는 requestAnimationFrame()
함수를 통해 전달된 비동기 함수가 여기 들어간다. 굳이 애니메이션 처리가 아니어도 초당 60번 호출될 필요가 있는 함수를 집어넣어도 된다. Task Queue는 가장 우선순위가 낮다. setTimeout
같은 함수의 콜백들이 여기 들어간다.
정리하자면
1. JavaScript Engine은 Heap, Call stack, Event Queue + Event Loop로 구성된다.
2. Call stack은 현재 자바스크립트의 스레드가 실행중인 컨텍스트가 위치한다.
3. 비동기 작업이 호출되면 자바스크립트는 결과를 기다리지 않은 채 바로 리턴값을 받는다. 그리고 아무일도 없던 듯 다음 내용을 처리한다.
4. 무시당한 비동기 작업은 Web API같은 자바스크립트 외부 런타임으로부터 처리받고, 완료되었을 때 Event Queue에 콜백을 추가한다.
5. Event Loop는 항상 Call Stack을 째려보고 있는다. call stack이 빈 순간 Event Queue의 맨 앞을 deque해 call stack에 집어넣어 실행시킨다.
(5-1. 사실 dequeue하고 집어넣는게 아니고 call stack에 복사한 후 콜백 수행이 완료돼 call stack에서 콜백이 제거될 때 Event Queue에서 같이 제거한다.)
console.log("script start");
setTimeout(function() {
console.log("setTimeout");
}, 0);
Promise.resolve().then(function() {
console.log("promise1");
}).then(function() {
console.log("promise2");
});
requestAnimationFrame(function {
console.log("requestAnimationFrame");
})
console.log("script end");
이 내용이 이해되면 위 코드의 실행 결과를 예상해볼 수 있다.
수행 결과는 아래와 같다.
script start
script end
promise1
promise2
requestAnimationFrame
setTimeout
아마 script start
로 시작하고 바로 뒤에 script end
가 온다는 것은 맞췄을 것이다. 그 뒤로 헷갈리는 부분이 연달아 등장하는 3개의 비동기 함수다.
setTimeout
은 Web API로 위임되어 처리된 후 0ms 뒤에 'Task Queue'에 function() { console.log("setTimeout"); }
를 집어넣는다. 이 함수가 Task Queue에 들어가는 것보다 Promise.resolve()
가 호출되는게 더 빠르거나 최소한 같다. then
콜백 하나를 등록하고 이는 'MicroTask Queue'로 들어간다.
그리고 requestAnimationFrame
이 호출된다. 마찬가지로 콜백이 'Animation Frames'로 들어간다.
이들의 우선순위는 MicroTask Queue > Animation Frames > Task Queue라고 했다. 그래서 promise1
이 먼저 출력되는 것이다. 그리고 새로 then을 MicroTask Queue에 추가하지만 아직 첫번째 then도 큐에서 제거된 것이 아니다. 콜스택에서 pop되어야 얘도 큐에서 dequeue 된다. 그래서 requestAnimationFrame
보다 promise2
가 먼저 출력되는 것이다.
이후는 뻔하다.