reconciler에서 work를 microtask를 통해 schedule할때, scheduleCallback함수를 통해 performWorkOnRootViaSchedulerTask를 schedule하는 것을 보았다.
const newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performWorkOnRootViaSchedulerTask.bind(null, root),
);
react의 reconciler는 react element를 fiber로 확장시키는(mount, reconcile) 역할을 한다. 해당 fiber를 dom으로 이식하는 work를 schedule하는 것은 scheduler package가 하게 되는데, 이번 글에서는 scheduler에 대해서 살펴보자.
// packages/scheduler/src/forks/Scheduler.js
// Tasks are stored on a min heap
var taskQueue: Array<Task> = [];
// Incrementing id counter. Used to maintain insertion order.
var taskIdCounter = 1;
var currentTask = null;
var currentPriorityLevel = NormalPriority;
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: {delay: number},
): Task {
var currentTime = getCurrentTime();
var startTime;
if (typeof options === 'object' && options !== null) {
/*
*/
} else {
startTime = currentTime;
}
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
// Times out immediately
timeout = -1;
break;
case UserBlockingPriority:
// Eventually times out
// userBlockingPriorityTimeout = 250;
timeout = userBlockingPriorityTimeout;
break;
case IdlePriority:
// Never times out
// maxSigned31BitInt = 1073741823;
timeout = maxSigned31BitInt;
break;
case LowPriority:
// Eventually times out
// lowPriorityTimeout = 10000;
timeout = lowPriorityTimeout;
break;
case NormalPriority:
default:
// Eventually times out
// const normalPriorityTimeout = 5000;
timeout = normalPriorityTimeout;
break;
}
var expirationTime = startTime + timeout;
var newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
if (startTime > currentTime) {
/*
*/
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
// Schedule a host callback, if needed. If we're already performing work,
// wait until the next time we yield.
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback();
}
}
return newTask;
}
React의 Reconciler는 업데이트의 종류에 따라 Lane을 할당하고, 이를 통해 Scheduler의 우선순위를 결정한다.
// packages/react-reconciler/src/ReactFiberRootScheduler.js
function scheduleTaskForRootDuringMicrotask(...){
switch (lanesToEventPriority(nextLanes)) {
// Scheduler does have an "ImmediatePriority", but now that we use
// microtasks for sync work we no longer use that. Any sync work that
// reaches this path is meant to be time sliced.
case DiscreteEventPriority:
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
}
앞서 reconciler의 scheduleTaskForRootDuringMicrotask 함수에서는 lanesToEventPriority를 통해 Lane을 EventPriority로 변환한 후, 다시 Scheduler의 우선순위로 매핑하는 것을 보았다.이 우선순위 매핑은 다음과 같다:
- DiscreteEventPriority/ContinuousEventPriority → UserBlockingSchedulerPriority (250ms)
- DefaultEventPriority → NormalSchedulerPriority (5000ms)
- IdleEventPriority → IdleSchedulerPriority (거의 무한대)
Scheduler는 각 우선순위에 따라 서로 다른 timeout 값을 설정한다. 우선순위가 높을수록 timeout이 짧아지는 구조로, 이는 중요한 작업이 더 빨리 만료되어 우선적으로 처리되도록 보장한다.특히 IdleSchedulerPriority는 maxSigned31BitInt(약 10억)라는 매우 큰 값을 사용한다. 이는 Idle 작업이 브라우저가 유휴 상태일 때만 실행되어도 되는 작업이기 때문에, JavaScript에서 표현 가능한 가장 큰 숫자를 timeout으로 설정하여 사실상 만료되지 않도록 한 것이다.
taskqueue는 minheap으로 구현되어 있으며, 정렬기준은 expirationTime이다.
//packages/scheduler/src/forks/Scheduler.js
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// Node.js and old IE 환경.
// setImmediate 사용.
}
else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// non-browser 환경.
// setTimeOut 사용.
}
react를 사용하는 환경에 따라 macro task queue에 task를 예약하는 방법이 달라지지만, 모두 macro task queue를 활용하는 것을 볼 수 있다. macro task queue를 통해 performWorkUntilDeadline함수를 실행한다.
즉, schedule 함수는 micro task queue를 통해 실행되고 실제 work를 수행하는performWorkOnRootViaSchedulerTask.bind(null, root), callback 함수, 는 macro task queue를 통해 실행된다.
따라서 discrete event는 micro task의 flushSync를 통해 실행되고, inputContinous event 등은 macro task를 통해 실행되므로 discrete event의 우선순위가 보장됨을 알 수 있다.
//packages/scheduler/src/forks/Scheduler.js
const performWorkUntilDeadline = () => {
if (enableRequestPaint) {
needsPaint = false;
}
if (isMessageLoopRunning) {
const currentTime = getCurrentTime();
// Keep track of the start time so we can measure how long the main thread
// has been blocked.
startTime = currentTime;
// If a scheduler task throws, exit the current browser task so the
// error can be observed.
//
// Intentionally not using a try-catch, since that makes some debugging
// techniques harder. Instead, if `flushWork` errors, then `hasMoreWork` will
// remain true, and we'll continue the work loop.
let hasMoreWork = true;
try {
hasMoreWork = flushWork(currentTime);
} finally {
if (hasMoreWork) {
// If there's more work, schedule the next message event at the end
// of the preceding one.
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
}
}
}
};
이 함수는 macro task를 통해 실행되므로 start time을 계속해서 track하는 것을 볼 수 있다. 이는 메인 스레드를 오랫동안 점유하지 않기 위함이다. Concurrent render를 할때 메인 스레드 점유를 오래하면 브라우저이 렌더링이 block된다. 이는 frame rate를 지키지 못해 사용성을 떨어트릴 수 있으므로 work의 start time을 추적하여 설정된 시간을 넘긴 경우 workLoop에서 work를 중단할 수 있는 지표가 된다.
//packages/react-reconciler/src/ReactFiberWorkLoop.js
function workLoopConcurrentByScheduler() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
스레드를 점유할 수 있는 시간인 frameYieldMs는 기본적으로 5이다. 물론 모니터의 주사율인 60hz를 맞추기 위해서는 16.6ms마다 paint를 해야 하지만, javascript 실행 후 critcal rendering path에 소요되는 시간까지 고려하여 16.6ms 보다 짧은 5ms로 설정한 것이다. 또한 다양한 환경에서 사용되는 react이기에 보수적으로 시간을 잡은것으로 보인다. 페이스북 웹사이트의 경우 10ms로 설정하여 더 높은 성능을 보인다 한다.
performWorkUntilDeadline 함수는 재귀적으로 사용되는데, workloop에서 callback함수의 반환값이 함수라면 work가 남아잇다는 것이므로 schedulePerformWorkUntilDeadline를 다시 호출한다. schedulePerformWorkUntilDeadline는 macro task에 performWorkUntilDeadline를 예약하는 함수임을 기억하자. 즉, 이 재귀적 호출은 동기적이 아니라 비동기적으로 이루어진다.
이는 렌더링 작업을 중단 가능한 단위로 나누면서 브라우저가 중간에 다른 작업(이벤트 처리, 렌더링 등)을 수행할 기회를 얻는다.
하나의 큰 React 렌더링 작업이 여러 개의 작은 매크로태스크로 분할되어 실행되며, 각 태스크 사이사이에 브라우저가 다른 작업을 처리할 수 있어 전체적인 반응성이 유지된다.
//packages/scheduler/src/forks/Scheduler.js
function flushWork(initialTime: number) {
isHostCallbackScheduled = false;
if (isHostTimeoutScheduled) {
isHostTimeoutScheduled = false;
cancelHostTimeout();
}
isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
try {
if (enableProfiling) {
return workLoop(initialTime);
/*
성능 측정
*/
} else {
return workLoop(initialTime);
}
} finally {
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
}
}
//packages/scheduler/src/forks/Scheduler.js
function workLoop(initialTime: number) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (currentTask !== null) {
if (!enableAlwaysYieldScheduler) {
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// If a continuation is returned, immediately yield to the main thread
// regardless of how much time is left in the current time slice.
currentTask.callback = continuationCallback;
advanceTimers(currentTime);
return true;
} else {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
if (enableAlwaysYieldScheduler) {
if (currentTask === null || currentTask.expirationTime > currentTime) {
// This currentTask hasn't expired we yield to the browser task.
break;
}
}
}
// Return whether there's additional work
if (currentTask !== null) {
return true;
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}
callback함수를 실행하고 callback함수는 performWorkOnRootViaSchedulerTask.bind(null, root)이였다.
스케줄은 micro task를 통해 실행되므로 root를 미리 binding해놓고,
performWorkOnRootViaSchedulerTask(
root: FiberRoot,
didTimeout: boolean,
)
didTimeout은 didUserCallbackTimeout를 통해 workLoop에서 전달한다.
'React' 카테고리의 다른 글
| transition과 concurrent render (0) | 2025.12.20 |
|---|---|
| React 톺아보기 4. reconciler (2) (0) | 2025.07.14 |
| React 톺아보기 2. reconciler (1) (0) | 2025.06.09 |
| React 톺아보기 - 1. ReactDom (0) | 2025.06.05 |
| React에서 batch 처리와 렌더링 주기: 무한 재렌더링 (1) | 2025.01.15 |