
브라우저에서 이벤트루프와 콜스택, web API가 어떻게 작동하는지 공부하기위해 간략하게 자바스크립트로 구현해봤습니다.
자료구조는 javascript array를 사용하지 않고 직접 stack과 queue를 구현했습니다.
우리는 인터프리터를 구현하는 것이 아니기 때문에, 코드를 직접적으로 해석할 방법은 없습니다.
그래서 객체를 통해 함수의 모양을 흉내내는 방법으로 진행합니다.
function func1() {
setTimeout(cb1, 0);
const promise1 = Promise.resolve().then(cb2);
const promise2 = new Promise((resolve, reject) => {
setTimeout(function cb4() {
resolve();
}, 1000);
func2();
});
promise2.then(cb3);
requestAnimationFrame(cb5);
}
이 함수를 객체로 표현하면 아래와 같습니다.
const executeContext = {
name: 'func1',
childFunc: [
{
name: 'setTimeout1',
type: 'macro',
ms: 0,
callback: {
name: 'cb1',
},
},
{
name: 'promise1',
type: 'micro',
resolve: 'promise1',
callback: {
name: 'cb2',
},
},
{
name: 'promise2',
type: 'micro',
state: 'pending',
callback: {
name: 'cb3',
},
childFunc: [
{
name: 'setTimeout2',
type: 'macro',
ms: 1000,
callback: {
name: 'cb4',
resolve: 'promise2',
},
},
{ name: 'func2' },
],
},
{
name: 'raf1',
type: 'raf',
callback: {
name: 'cb5',
},
},
],
};
각 함수 객체는 공통적으로 이름 (name)을 가집니다.
함수안에서의 함수 실행은 childFunc 프로퍼티로 표현합니다.
func1에서 func2가 실행됐다면 func2는 func1의 childFunc입니다.
추가적으로 비동기 함수는 type 프로퍼티를 통해 macro (일반 태스크 => settimeout, fetch ...), micro (Promise, MutationObserver ...), requestAnimationFrame (raf) 중에 어떤 비동기 함수인지를 판별합니다.
이번 구현에서
macro는setTimeout,micro는Promise만을 사용합니다.
비동기 함수는 callback 함수를 가집니다.
setTimeout의 경우 milisecond (ms) 프로퍼티를 가집니다.
Promise의 경우 스코프 내에서 resolve되는 위치가 어딘지 정해주어야 합니다. 위 예제에서는 setTimeout2의 callback에서 promise2를 fullfilled상태로 변경합니다. resolve 프로퍼티의 값은 promise객체의 name으로 맞춰줍니다.
자 그럼 해당 객체가 어떤식으로 실행되는지 알아보겠습니다.
queue, stack 구현 코드는 생략하겠습니다.
class Browser {
constructor() {
this.callStack = new CallStack();
this.eventLoop = new EventLoop();
this.WebAPIThreadPool = new WebAPIThreadPool();
this.callStack.eventLoop = this.eventLoop;
this.callStack.WebAPIThreadPool = this.WebAPIThreadPool;
this.eventLoop.callStack = this.callStack;
this.WebAPIThreadPool.eventLoop = this.eventLoop;
}
start() {
this.callStack.push(executeContext);
rl.on('line', line => {
this.callStack.push(JSON.parse(line));
});
}
}
const browser = new Browser();
browser.start();
브라우저는 이벤트루프, 콜스택, web API Thread를 관리합니다.
각각 인스턴스들의 상호작용을 위해 의존성을 주입합니다.
start 함수가 실행되면 위에서 생성한 객체 (실행컨텍스트)를 콜스택에 push합니다.
import { Stack } from './dataStructure.js';
class CallStack extends Stack {
WebAPIThreadPool;
eventLoop;
constructor() {
super();
}
push(context) {
console.log(`Push context! => ${context.name}`);
super.push(context);
this.run();
}
pop() {
console.log(`Pop context! => ${this.top.data.name}`);
super.pop();
if (this.isEmpty() && !this.eventLoop.isLooping) {
console.log('callstack is empty. run event loop');
this.eventLoop.runLoop();
}
}
run() {
const { data } = this.top;
console.log(`Run context! => ${data.name}`);
data.childFunc?.forEach(context => this.push(context));
if (data.callback)
switch (data.type) {
case 'micro':
this.eventLoop.microtaskQueue.enqueue(data);
break;
case 'macro':
this.WebAPIThreadPool.webAPITaskEnQueue(data);
break;
case 'raf':
this.eventLoop.rafQueue.enqueue(data.callback);
break;
default:
break;
}
if (data.resolve)
this.eventLoop.microtaskQueue.forEach(node => {
if (node.data.name === data.resolve) {
node.data.state = 'fullfilled';
console.log(`${node.data.name} has fullfilled!`);
}
});
this.pop();
}
}
export default CallStack;
콜스택은 스택 자료구조를 상속받아 구현합니다.
실행컨텍스트가 push되면 log를 출력하고 실행(run)합니다.
실행컨텍스트안에서 실행된 함수가(childFunc) 있다면 forEach를 통해 추가로 콜스택에 push합니다.
실행컨텍스트의 callback 함수가 있다면 type에 맞게 queue에 push합니다.
macro task의 경우 별도의 web API 스레드에서 실행 후 queue로 이동하기 때문에 web API Thread에 push해줍니다.
추가적으로 현재 실행중인 실행컨텍스트에 resolve하는 코드가 있다면, queue를 탐색하여 일치하는 Promise 객체의 상태를 fullfilled로 변경합니다.
실행컨텍스트가 실행이 되고나면 pop이 되는데, 이때 콜스택이 전부 비었다면 이벤트 루프를 작동시킵니다.
다음은 Web API가 처리되는 부분을 먼저 보겠습니다.
import { Queue } from './dataStructure.js';
class WebAPIThreadPool {
maximumThread = 5;
eventLoop;
constructor() {
this.threadQueue = new Queue();
this.webAPITaskQueue = new Queue();
}
webAPITaskEnQueue(func) {
this.webAPITaskQueue.enqueue(func);
this.allocateThread();
}
allocateThread() {
if (this.threadQueue.size >= maximumThread) {
console.log('Thread is fulled');
} else
this.threadQueue.enqueue(
new WebAPI(
this.onCompleteFunc.bind(this),
this.webAPITaskQueue.dequeue(),
),
);
}
deallocateThread() {
this.threadQueue.dequeue();
}
onCompleteFunc(func) {
console.log(
`Complete async & Macro task send to Macrotask queue => ${func.name}`,
);
this.eventLoop.macrotaskQueue.enqueue(func);
this.eventLoop.runLoop();
this.deallocateThread();
if (this.webAPITaskQueue.top) this.allocateThread();
}
}
class WebApi {
constructor(onCompleteFunc, func) {
this.onCompleteFunc = onCompleteFunc;
this.func = func;
this.startAsyncFunction();
}
startAsyncFunction() {
console.log(`Start async => ${this.func.name}`);
if (this.func.ms === 0) this.onCompleteFunc(this.func.callback);
else
setTimeout(() => this.onCompleteFunc(this.func.callback), this.func.ms);
}
}
export default WebAPIThreadPool;
Web API는Thread Pool로 구현했습니다. 동시에 여러 일을 하기 위함입니다.
Thread Pool은 작업 대기열인 webAPITaskQueue와 Thread 대기열인 threadQueue를 가집니다.
web API 작업이 push되면 작업을 queue에 넣고 Thread를 할당합니다. (allocateThread)
Maximum Thread는 5개로 제한했습니다. Thread Pool이 꽉차지 않았다면, Thread에 web API 객체를 할당합니다.
할당된 각 web API객체는 비동기 함수인 setTimeout을 실행하고 완료가 되면 Thread 할당을 해제하고 이벤트 루프에 callback함수를 보낸 뒤 루프를 실행합니다.
이렇게 되면 모든 콜백함수가 queue로 이동했습니다.
이제 이벤트 루프가 어떻게 처리하는지 살펴보겠습니다.
import { Queue } from './dataStructure.js';
class EventLoop {
callStack;
macrotaskQueue = new Queue();
rafQueue = new Queue();
microtaskQueue = new Queue();
runMicroTask() {
while (this.isMicroTaskExist()) {
const { data } = this.microtaskQueue.top;
console.log(`Micro task send to call stack => ${data.callback.name}`);
const task = data.callback;
this.microtaskQueue.dequeue();
this.callStack.push(task);
this.isLooping = false;
}
}
runMacroTask() {
if (this.macrotaskQueue.top) {
const { data } = this.macrotaskQueue.top;
console.log(`Oldest macro task send to call stack => ${data.name}`);
this.macrotaskQueue.dequeue();
this.callStack.push(data);
}
}
runRafTask() {
if (this.rafQueue.top) {
const { data } = this.rafQueue.top;
console.log(`Raf task send to call stack => ${data.name}`);
this.rafQueue.dequeue();
this.callStack.push(data);
}
}
isMicroTaskExist() {
return (
this.microtaskQueue.top &&
this.microtaskQueue.top.data.state === 'fullfilled'
);
}
isTaskExist() {
return (
this.rafQueue.top || this.macrotaskQueue.top || this.isMicroTaskExist()
);
}
runLoop() {
this.isLooping = true;
while (this.callStack.isEmpty() && this.isTaskExist()) {
this.runMacroTask();
this.runMicroTask();
this.runRafTask();
}
this.isLooping = false;
}
}
export default EventLoop;
이벤트 루프는 html spec을 따릅니다.
이 부분은 설명할 것이 좀 있습니다.
많은 분들이 micro task의 우선순위가 가장 높다고 알고 있습니다.
실제로 크롬에서 실험을 해봐도 macro task인 setTimeout보다 micro task인 Promise가 먼저 실행됩니다.
하지만 이는 html spec과는 다릅니다.
html spec에서의 동작 순서는 아래와 같습니다.
- 가장 오래된 macro task를 꺼내서 실행한다.
- micro task queue가 비워질때까지 실행한다.
- requestAnimationFrame의 콜백함수를 실행하고 렌더링을 트리거한다.
- 1번을 다시 시작한다.
그렇습니다. 명세에 따르면 가장 오래된 macro task가 1회 실행된 후에 micro task 실행이 된다고 합니다.
크롬 브라우저에서 micro task가 먼저 실행된 건 브라우저마다 명세를 100% 준수하지 않고 다르게 구현되기 때문입니다.
※ raf 역시 브라우저마다 실행 순서가 다릅니다!
이번 포스팅은 크롬과 다르게 html spec을 따라서 구현하였습니다.
그럼 다시 코드로 돌아가겠습니다.
이벤트 루프는 콜스택이 비었고, task가 존재하면 loop를 실행합니다.
첫번째는 가장 오래된 macro task를 콜스택에 push하고 ,
micro task queue가 비워질 때까지 콜백함수를 콜스택에 push합니다.
마지막으로 raf 콜백함수를 콜스택에 push한 뒤,
위 과정을 task가 없거나, 콜스택이 비어있지 않을때까지 반복합니다.
이렇게 모든 구현이 끝났습니다.
맨 위에서 작성했던 함수 객체를 push하여 실행하면
Push context! => func1
Run context! => func1
Push context! => setTimeout1
Run context! => setTimeout1
Push setTimeout to web API Thread! => setTimeout1
Start async => setTimeout1
Complete async & Macro task send to Macrotask queue => cb1
Pop context! => setTimeout1
Push context! => promise1
Run context! => promise1
Push micro task to micro task queue! => cb2
promise1 has fullfilled!
Pop context! => promise1
Push context! => promise2
Run context! => promise2
Push context! => setTimeout2
Run context! => setTimeout2
Push setTimeout to web API Thread! => setTimeout2
Start async => setTimeout2
Pop context! => setTimeout2
Push context! => func2
Run context! => func2
Pop context! => func2
Push micro task to micro task queue! => cb3
Pop context! => promise2
Push context! => raf1
Run context! => raf1
Push raf task to raf task queue! => cb5
Pop context! => raf1
Pop context! => func1
callstack is empty. run event loop
Oldest macro task send to call stack => cb1
Push context! => cb1
Run context! => cb1
Pop context! => cb1
Micro task send to call stack => cb2
Push context! => cb2
Run context! => cb2
Pop context! => cb2
Raf task send to call stack => cb5
Push context! => cb5
Run context! => cb5
Pop context! => cb5
Complete async & Macro task send to Macrotask queue => cb4
Oldest macro task send to call stack => cb4
Push context! => cb4
Run context! => cb4
promise2 has fullfilled!
Pop context! => cb4
Micro task send to call stack => cb3
Push context! => cb3
Run context! => cb3
Pop context! => cb3
func1가 실행되고 첫번째 줄인 setTimeout1이 web API Thread에서 실행됩니다. ms가 0초이기 때문에 바로 완료되어 macro task queue에 콜백함수 cb1이 push되고 컨텍스트는 pop됩니다.
promise1가 즉시 resolve되어 fullfilled 상태가 되고 pop됩니다.
그리고 then을 만나 콜백함수 cb1이 micro task queue에 push됩니다. 그러나 아직 콜스택이 비어있지 않기 때문에 콜백함수가 실행되지 않습니다.
promise2가 실행되고 함수안의 setTimeout2가 web API Thread에서 실행되고 pop됩니다.
then함수를 만나 콜백함수 cb3이 micro task queue에 push됩니다.
일반 함수인 func2가 실행되고 pop됩니다.
raf가 실행되고 pop됩니다.
func1 함수의 모든 실행컨텍스트가 pop되고 func1도 pop됩니다.
이제 콜스택이 비었으니 이벤트 루프가 일을 시작합니다.
먼저 가장 오래된 macro task인 cb1을 콜스택에 push합니다.
컨텍스트가 실행되고 pop됩니다.
가장 오래된 macro task를 처리했으니 micro task를 순차적으로 처리합니다.
현재 queue에 담긴 micro task는 cb2 cb3이지만 cb3은 pending 상태이므로 cb2만 콜스택으로 push되고 실행 후 pop됩니다.
이제 실행가능한 micro task가 없으니 raf task인 cb5를 콜스택에 push하고 실행 후 pop됩니다.
1초가 지나고 setTimeout2가 완료됐습니다. 콜백함수 cb4를 콜스택에 push하고 실행합니다.
콜백함수안에 promise2를 resolve하는 코드가 있기 때문에 Promise를 fullfilled상태로 변경하고 pop됩니다.
마지막 남은 micro task인 cb3을 콜스택으로 push하고 실행한 뒤 pop됩니다.
매우 복잡하고 길지만, 흐름을 천천히 읽어보시면 이벤트 루프를 이해하는데에 많은 도움이 될겁니다.
추가로
readline을 통해 터미널에서JSON을 입력받아 함수를 실행할 수 있게 했습니다.
위에서 정의한 함수 객체를JSON형태로 입력하시면 브라우저의 콘솔창에서 코드를 실행하는 것처럼 실험해볼 수 있습니다.
{ "name" : "func1" }
이런식으로 입력하시면 결과가 출력됩니다.
전체 코드는 아래 링크에서 확인 가능합니다.
좋은 글 감사합니다. :)