React는 Scheduler를 통해 Concurrency 작업을 진행할 수 있게 해줍니다. React Scheduler는 여러 Task들을 어떤 순서로 처리해야 할지 Scheduling하고 pause 및 resume작업을 할 수 있게 도와주는 라이브러리입니다. 그리고 Scheduler는 기본적으로 Task를 효율적으로 peek, push, pop 하기 위해 우선순위 큐를 사용합니다.(이번 포스트에서 우선순위 큐에 대해 다루지는 않습니다.)
기본적으로 Javascript는 싱글스레드 환경에서 돌아갑니다. 이는 Javascript 코드가 실행되는 동안 브라우저가 다른 처리(이벤트, reflow, repaint 등)을 처리할 수 없습니다. 때문에 React에서 Sync환경에서 렌더링이 오래 걸릴 경우 화면이 멈추는 현상이 발생하기도 했을 것 입니다. 이를 해결하기 위해서는 화면이 그려지는 한 프레임 당 최대 16ms의 시간을 가져야 Jank현상이 발생하지 않습니다. React scheduler에서는 한 프레임이 5ms 정도만 잡고있어야 브라우저 상에서 다른 처리들을 수행할 수 있기 때문에 5ms 프레임 씩 잡고 Host에 제어권을 양도하게 됩니다.
(한 프레임의 budget이 16ms)
자바스크립트는 Event loop 덕분에 비동기처리를 할 수 있습니다. 자바스크립트의 기능은 아니고 브라우저, Node.js에서 지원하는 기능입니다. setTimeout이나 Fetch 등의 작업을 호출하면 Web API(Browser API) 함수를 호출하고 여기서 브라우저가 지원하는 Thread가 있어 해당 작업이 완료되는 것을 기다리고 완료가 되면 Callback Queue에 넣어주게 됩니다. 그리고 Call Stack이 비어있을 때 Callback Queue에서 값을 하나씩 호출하면서 처리하게 됩니다. (물론 setTimeout, setImmediate 등은 macro task queue에, fetch, promise 등은 micro task queue에 넣어주고 micro task queue의 작업 먼저 수행하긴 합니다)
이러한 Event Loop도 당연히 React에서도 활용을 하는데 Host에 제어권을 양도 할 때 진행중이던 작업을 다시 Callback Queue에 넣어줌으로써 Host에서 필요하는 다른 작업들을 마무리 한 후 다시 Callback Queue에서 하던 작업을 가져올 수 있습니다.
scheduler에 Task를 추가하는 작업을 진행합니다.
우선 options에 delay가 있다면 바로 시작해야하는 작업이 아닌 delay된 후 시작해야하는 작업이므로 startTime에 delay를 넣어줍니다. 물론 delay가 없을 경우 현재 시간을 넣어주게 됩니다.
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}
각 Task는 priority라는 것이 있습니다. Lane과도 관련이 있으니 제 포스트에서 Lane글을 참고해주셔도 좋을 것 같습니다. 아무튼 Sync작업은 ImmediatePriority이고 transition 작업의 경우 NormalPriority를 가지는 등 각 Task마다 priority가 다른데 priority는 이 작업이 마무리 되어야하는 시간인 expirationTime과 관련이 있습니다. 또한 expirationTime은 우선순위 큐의 sortIndex 값이 되니 expirationTime이 작을 수록 급한 작업이고 그만큼 큐에서 빨리 값을 pop 할 수 있습니다.
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT; // -1
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT; // 250
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT; // 1073741823
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT; // 10000
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT; // 5000
break;
}
var expirationTime = startTime + timeout;
var newTask = {
id: taskIdCounter++,
callback: callback,
priorityLevel: priorityLevel,
startTime: startTime,
expirationTime: expirationTime,
sortIndex: -1
}
현재 시간이 시작시간 보다 작으면 현재 처리해야 할 작업이 아닙니다. requestHostTimeout을 호출하여 남은 시간이 지난 후에 작업을 처리할 수 있게 해줍니다. (setTimeout으로 timeout이 되면 requestHostCallback를 호출하여 해당 작업을 시작해줍니다.)
반면 바로 시작해야 할 함수이면 해당 Task를 우선순위 큐에 push해주고 requestHostCallback를 호출합니다.
if (startTime > currentTime) {
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
if (isHostTimeoutScheduled) {
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
}
현재 작업을 schedulePerformWorkUntilDeadline을 통해 Callback Queue에 넣어주게 됩니다. 그리고 scheduledHostCallback에 인자로 받은 callback 함수를 넣어주어 performWorkUntilDeadline에서 해당 callback를 호출할 수 있게 해줍니다.
function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
Callback Queue에 함수를 넣어주기 위해 setImmediate, MessageChannel, setTimeout 등의 방법이 있습니다. setTimeout은 4ms가 지연되는 이슈가 있어서 되도록이면 setImmediate나 MessageChannel을 이용해 넣어주게 됩니다. 그리고 performWorkUntilDeadline 호출하게 됩니다.
const localSetTimeout = typeof setTimeout === 'function' ? setTimeout : null;
const localClearTimeout =
typeof clearTimeout === 'function' ? clearTimeout : null;
const localSetImmediate =
typeof setImmediate !== 'undefined' ? setImmediate : null; // IE and Node.js + jsdom
...
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
현재 진행중인 함수가 없다면 requestHostCallback에서 넣어준 scheduledHostCallback를 바로 호출해줍니다. scheduledHostCallback이 true를 뱉을 때, 즉 workLoop에서 continuationCallback이 남아있을 때 다시 schedulePerformWorkUntilDeadline함수를 호출시켜 자바스크립트 macrotask queue에 넣어주어 이후에 host 작업이 끝난 후에도 해당 작업을 resume할 수 있게 해줍니다.
var performWorkUntilDeadline = function () {
if (scheduledHostCallback !== null) {
var currentTime = getCurrentTime();
startTime = currentTime;
var hasTimeRemaining = true;
var hasMoreWork = true;
try {
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
};
requestHostCallback를 호출할 때 현재는 flushWork를 callback으로 넣어주게 됩니다. 즉 위에 performWorkUntilDeadline에서 scheduledHostCallback는 flushWork를 뜻하게 됩니다. 그리고 flushWork는 결국은 workLoop를 호출해주는 함수입니다.
workLoop에서는 Task Queue에 들어가있는 Task들을 처리하는 함수입니다.
advanceTimers는 시작 시간이 지난 것을 taskQueue에 넣어주는 함수입니다. 즉 현재시간이 시작시간 보다 작은 Task들이 timeout이 일어나도 현재 진행중인 작업이 있어서 바로 처리할 수 없을 때 advanceTimers를 통해 taskQueue에 해당 작업들을 넣어줄 수 있습니다.
var currentTime = initialTime;
advanceTimers(currentTime);
현재 남은 시간을 체크하여 호스트한테 양보해야 하면 양보하고 현재 task의 콜백함수를 호출해줍니다. 그리고 들어온 콜백함수에서 continuationCallback이 반환 되었을 경우 아직 일이 마무리 되지 않았지만 호스트한테 양보를 해야하기 때문에 pause한 상태임으로 뜻으로 다음 호스트한테 다시 제어권을 받을 때 일을 재개하기 위해 currentTask에 다시 continuationCallback을 넣어줍니다.
while (currentTask !== null && !(enableSchedulerDebugging )) {
if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())) {
}
var callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
var didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
var continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
currentTask.callback = continuationCallback;
advanceTimers(currentTime);
return true;
} else {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
Scheduler의 함수들을 분석해보면서 전체적으로 어떻게 돌아가는지 알아보았습니다. Scheduler는 가장 급한 작업부터 처리를 할 수 있게 해주고 주어진 시간을 계속 체크하여 Host에 제어권을 양도해주기도 합니다. 또한 원래 하던 작업을 Pause, Resume할 수 있게 도와주는 기능을 하고 있습니다. 그렇기 때문에 Scheduler는 동시성 작업에 있어서 중요한 부분이라 알아두면 좋을 것 같아서 정리 해 보았습니다.
오류가 있거나 이해가 안되는 부분 있으면 댓글 부탁드립니다!
감사합니다!
https://www.webdevolution.com/blog/Javascript-Event-Loop-Explained
https://itchallenger.tistory.com/643
최근들어 리액트 소스코드 분석에 관심이 생겨서 이리 저리 찾아보던 중에, 우연찮게 이 블로그를 발견하게 되었습니다.
모든 글이 쉽게 쓰여지기 힘든, 스스로 공부해서 오랜시간을 들여야만 적을 수 있는 퀄리티의 글이라고 생각됩니다.
무엇보다도, 저만 관심있어하지 않는구나 하는 안도감과 동질감에 댓글을 남겨봅니다.
이런 블로그 운영해주셔서 감사합니다!