Scheduler

Hee Suh·2024년 11월 5일
1
post-thumbnail

JSer.devReact Internals Deep Dive를 번역하여 정리한 글입니다.

⚠️ React@19commit 7608516을 기반으로 작성되었으며, 최신 버전에서는 구현이 변경되었을 수 있습니다.

📝 How React Scheduler works internally?

1. Why React Scheduler is needed.

Sync Render

동기적인 렌더링에서는 작업이 중단(interrupt)되지 않고, while문에서 작업을 계속 진행한다. workLoopSync()에서 root로부터 모든 fiber를 순회하면서 performUnitOfWork()를 수행한 후 commit 단계에서 host DOM에 변경 사항을 적용한다. 이 작업은 일시 중지되거나 취소될 수 없어서, 이 콜 스택이 전부 처리되기 전까지 메인 스레드는 다른 작업을 할 수 없고, 앱은 일시적으로 무반응 상태가 되거나 버벅거리게 된다.

Cf. Initial mount는 DefaultLane이 blocking lane이기 때문에, sync로 작동하는 경우 중 하나다.

💻 src: workLoopSync()

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

Concurrent Render

렌더링 작업을 잘게 쪼개어 여러 프레임에 걸쳐 실행할 수 있고, 특정 작업에 “우선순위”를 매겨 작업의 작은 조각들을 concurrent하게 “일시 정지”, “재가동”할 수 있게 하는 concurrent feature가 등장했고, 이때 스케줄러가 필요하다. Cf. Concurrency 동시성

Concurrent 렌더에서는 우선순위가 높은 작업이 우선순위가 낮은 작업을 중단할 수 있으므로, 작업을 중단(interrupt)하고 재개(resume)하는 방법이 필요하다. 스케줄러가 우선순위에 따라 task를 처리하고, shouldYield()를 통해 frame interval보다 task 처리 시간이 길어지면 workLoopConcurrent()를 중단한 후, Frame을 업데이트하거나 우선순위가 더 높은 task를 처리할 수 있도록 양보(yield)한다.

💻 src: workLoopConcurrent()

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

💡 세 가지 우선순위 시스템

  1. Scheduler Priority — 스케줄러에서 task의 우선순위를 지정하는 데 사용된다.
  2. Event Priority - 사용자 이벤트의 우선순위를 표시한다.
  3. Lane Priority — 작업(work) 우선순위를 표시한다.

💻 src: ReactFiberRootScheduler.js
SyncLane이 아닌 경우에 getHighestPriorityLane()를 이용해 가장 높은 우선순위를 가진 lane을 가져와서 callback 우선순위로 사용하고, lane을 event 우선순위로 매핑하여 scheduleCallback()의 우선순위로 사용한다.

우선순위를 이용해 스케줄러에서 작업을 예약하여 fiber 트리를 조정한다.

Cf. What are Lanes in React source code?

2. Background Knowledge

2.1 Event Loop

JavaScript 엔진은 다음과 같은 작업을 수행한다.

  1. Sync 작업은 Call Stack에 들어간다.
    Call Stack이 비면(현재 실행중인 태스크가 없는 경우), Event Loop가 작업을 수행할 수 있다.
  2. 예약된 Microtask가 있으면 모두 실행한다. (FIFO)
  3. Microtask Queue가 비었다면, Task Queue에서 Task(Macrotask)를 하나 가져와 실행한다. (FIFO)
  4. 리렌더링이 필요한지 확인하고 렌더한다.
  5. task가 더 있으면 1을 반복하거나, task를 기다린다.

Cf. [lydiahallie] JavaScript Visualized:
Event Loop, Web APIs, (Micro)task Queue

2.2 setImmediate() to schedule a new task without blocking the rendering

브라우저에서 렌더링을 막지 않고 task를 스케줄 하기 위해서는 해당 task를 macrotask로 등록하여 call stack이 비워진 후에 Event Loop를 거쳐 실행하도록 해야 한다. 이를 위해 흔히 setTimeout(callback, 0)을 사용한다. 그러나 setTimeout()HTML Standard에 명세되어있듯이 중첩 호출에서 최소 4ms 지연이 발생하고, setImmediate()는 지연이 없기 때문에 React에서 더 선호된다.

하지만 setImmediate()는 브라우저 호환성이 좋지 않기 때문에, 대체 방법인 MessageChannel())이나 setTimeout()을 주로 사용하여 새로운 macrotask를 예약한다.

💻 src: Scheduler.js

let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === "function") {
  // Node.js and old IE.
  // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
  // But also, it runs earlier which is the semantic we want.
  // If other browsers ever implement it, it's better to use it.
  // Although both of these would be inferior to native scheduling.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} 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 {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

2.3 Priority Queue

우선순위 큐는 스케줄링을 위한 일반적인 데이터 구조다.

우선순위가 다른 이벤트가 큐에 들어오기 때문에, 처리할 우선순위가 가장 높은 이벤트를 빠르게 찾아야 하는 React의 요구 사항에 완벽하게 일치한다.

React는 Min Heap으로 우선순위 큐를 구현한다. (SchedulerMinHeap.js)

3. Call stack of workLoopConcurrent

workLoopConcurrent() 가 어떻게 호출되는지 확인해보자. Cf. ReactFiberWorkLoop.js

workLoopConcurrent()
출처: https://jser.dev/react/2022/03/16/how-react-scheduler-works#3-call-stack-of-workloopconcurrent

ensureRootIsScheduled()는 업데이트가 있는 경우 React가 업데이트를 수행하도록 task를 예약한다. performConcurrentWorkOnRoot()를 직접 호출하지 않고, scheduleCallback(priority, callback)의 콜백으로 처리한다는 점을 유의하자. scheduleCallback()은 스케줄러의 API다. 일단 스케줄러가 적절한 시점에 task를 실행한다는 점만 명심하고 뒤에서 자세히 살펴보자.

3.1 performConcurrentWorkOnRoot() returns a closure of itself if interrupted.

performConcurrentWorkOnRoot()는 진행 상황에 따라 다른 값을 리턴한다.

  1. shouldYield()가 true인 경우, workLoopConcurrent()가 중단되어, 불완전한 update(RootInComplete)가 발생하고, performConcurrentWorkOnRoot() 는 클로저(performConcurrentWorkOnRoot.bind(null, root))를 리턴한다.
  2. 만약 work loop가 완료되면, null을 리턴한다.

task의 중단(interrupt)은 shouldYield()에 의해 발생하며, 재개(resume)는 스케줄러가 task 콜백의 리턴 값을 살펴보며 계속 진행해야 하는 task가 있다면 수행한다.

4. Scheduler

스케줄러가 어떻게 작동하는지 살펴보자. (Scheduler.js)

위에서 언급한 scheduleCallback()unstable_scheduleCallback이다.

Cf. 오픈소스에서 ‘unstable’로 표시된 기능이나 버전은 아직 완전하게 안정화되지 않았고, 앞으로 변경될 가능성이 크다는 것을 나타낸다.

4.1 scheduleCallback() - Scheduler schedules tasks by expirationTime

스케줄러가 tasks를 예약하기 위해, 우선순위 큐를 이용하여 task를 우선순위와 함께 저장해야 한다.

expirationTime을 이용하여 우선순위를 나타낸다. 만료가 빠르면, 더 빨리 처리해야 하므로, 공평한 방법이다. 다음은 task가 생성되는 scheduleCallback() 내부 코드다.

💻 src: unstable_scheduleCallback()

var currentTime = getCurrentTime();

var startTime;

...

var timeout;
switch (priorityLevel) {
  case ImmediatePriority:
    // Times out immediately
    timeout = -1;
    break;
  case UserBlockingPriority:
    // Eventually times out
    timeout = userBlockingPriorityTimeout;
    break;
  case IdlePriority:
    // Never times out
    timeout = maxSigned31BitInt;
    break;
  case LowPriority:
    // Eventually times out
    timeout = lowPriorityTimeout;
    break;
  case NormalPriority:
  default:
    // Eventually times out
    timeout = normalPriorityTimeout;
    break;
}

var expirationTime = startTime + timeout;

// 📌 task는 스케줄러가 처리하는 작업(work)의 단위다.
var newTask: Task = {
  id: taskIdCounter++,
  callback,
  priorityLevel,
  startTime,
  expirationTime,
  sortIndex: -1,
};

...

return newTask;

각 우선순위마다 다른 timeout을 갖고 있다.

💻 src: SchedulerFeatureFlags.js

export const frameYieldMs = 5;

export const userBlockingPriorityTimeout = 250;
// 📌 Default는 5초 timeout이다.
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;

💻 src: Scheduler.js

// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
var maxSigned31BitInt = 1073741823;

default timeout은 5초이며, user blocking의 경우 250ms다.

task가 생성되면, 우선순위 큐에 넣어준다.

💻 src: performWorkUntilDeadline()

if (startTime > currentTime) {
  // This is a delayed task.
  newTask.sortIndex = startTime;
  push(timerQueue, newTask);
  if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
    // All tasks are delayed, and this is the task with the earliest delay.
    if (isHostTimeoutScheduled) {
      // Cancel an existing timeout.
      cancelHostTimeout();
    } else {
      isHostTimeoutScheduled = true;
    }
    // Schedule a timeout.
    requestHostTimeout(handleTimeout, startTime - currentTime);
  }
} else {
  newTask.sortIndex = expirationTime;
  push(taskQueue, newTask);
  if (enableProfiling) {
    markTaskStart(newTask, currentTime);
    newTask.isQueued = true;
  }
  // 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();
  }
}

task를 예약할 때, setTimeout()처럼 delay 옵션을 가질 수 있다.

else 분기에 있는 두 개의 중요한 호출을 보자.

  1. push(taskQueue, newTask) — task를 큐에 추가한다.
  2. requestHostCallback() — task들을 처리한다!

스케줄러가 host(e.g. 브라우저, Node.js)를 알 수 없기 때문에, 어떤 host에서도 실행될 수 있도록 독립적인 black box가 되어야 한다. 따라서 requestHostCallback()에서 host에게 요청을 해서, schedulePerformWorkUntilDeadline()을 통해 Event Loop의 macrotask로 flushWork()를 넘긴다.

4.2 requestHostCallback()

function requestHostCallback() {
  // 💬 상호 배제를 위한 변수인 것 같다.
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}
const performWorkUntilDeadline = () => {
  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.
        // 📌 스케줄러가 큐에 있는 task들을 예약해서 처리한다는 것을 알 수 있다.
        // 📌 여기에서 브라우저에게 페인트할 기회를 준다.
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
      }
    }
  }
};

schedulePerformWorkUntilDeadline()performWorkUntilDeadline()의 wrapper일 뿐이다.

performWorkUntilDeadline()에서 flushWork()가 바로 실행되고, 비동기적으로 진행되는 스케줄링에 따라 메인 스레드가 렌더할 수 있는 기회를 준다.

4.3 flushWork()

function flushWork(initialTime: number) {
	try {
    // No catch in prod code path.
    return workLoop(initialTime);
  } finally {
	  ...
  }
}

flushWork()workLoop()을 감싸고 있을 뿐이다.

4.4 workLoop() - the core of Scheduler

workLoopConcurrent()은 재조정(reconciliation)을 위한 것이라면, workLoop()은 스케줄러의 핵심부다. 둘은 비슷한 일을 하기 때문에 비슷한 이름을 갖고 있다.

💻 src: workLoop()

if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
  // This currentTask hasn't expired, and we've reached the deadline.
  break;
}

workLoopConcurrent()처럼, shouldYieldToHost()가 여기에서 체크된다.

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();
  // 📌 tasks의 리턴 값이 왜 중요한지 알 수 있다.
  // 이 분기에서는 task가 pop되지 않는다!
  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;
    if (enableProfiling) {
      markTaskYield(currentTask, currentTime);
    }
    advanceTimers(currentTime);
    return true;
  } else {
    if (currentTask === peek(taskQueue)) {
      pop(taskQueue);
    }
    advanceTimers(currentTime);
  }
} else {
  pop(taskQueue);
}
	currentTask = peek(taskQueue);
}

currentTask.callbackperformConcurrentWorkOnRoot()다.

리렌더링이 필요할 때, performConcurrentWorkOnRoot()를 task로 등록하여 실행한다. 이때 이 함수를 바로 실행하는 것이 아니라, scheduleCallback()이라는 스케줄러 함수를 통해 실행을 예약한다. 이 함수는 performConcurrentWorkOnRoot()를 macrotask로 등록하고, call stack이 비워지면 Event Loop를 거쳐 실행된다.

const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
const continuationCallback = callback(didUserCallbackTimeout);

콜백이 만료되었는지 여부를 나타내는 flag와 함께 호출된다.

performConcurrentWorkOnRoot()는 timeout이 되면, sync 모드로 작동한다. 이제부터는 어떠한 중단(interruption)도 없다는 것을 의미한다.

💻 src: performConcurrentWorkOnRoot()

const shouldTimeSlice =
  !includesBlockingLane(root, lanes) &&
  !includesExpiredLane(root, lanes) &&
  (disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
  ? renderRootConcurrent(root, lanes)
  : renderRootSync(root, lanes);

다시 workLoop()로 돌아오자.

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);
}

task는 콜백의 리턴 값이 함수가 아닌 경우에만 pop된다. 만약 함수라면, task의 콜백을 업데이트하기만 하고, pop되지 않았으므로, workLoop()의 다음 tick은 같은 task를 다시 수행한다.

콜백의 리턴 값이 함수라면, task가 완료되지 않았고, 작업을 재개해야 한다는 것을 의미한다.

advanceTimers(currentTime);

advanceTimers()는 지연된 task를 위한 것이다.

4.5 how shouldYield() work?

let frameInterval = frameYieldMs;

function shouldYieldToHost(): boolean {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // The main thread has only been blocked for a really short amount of time;
    // smaller than a single frame. Don't yield yet.
    return false;
  }
  // Yield now.
  return true;
}

각 task에는 5ms(frameInterval)가 주어지고, 시간이 다 되면 양보해야 한다.
Cf. SchedulerFeatureFlags.js에서 frameYieldMs는 5로 정의되어있다.

스케줄러가 실행하는 task를 말하는 것이며, 각 performUnitOfWork()를 의미하는 것이 아니라는 점을 유의하자. startTimeperformWorkUntilDeadline()에서만 설정되는 것을 보았을 때, startTime은 각 flushWork()마다 초기화되며, flushWork()에서 여러 task들을 처리할 수 있다면, task 간 양보가 발생하지 않는다.

5. Summary

출처: https://jser.dev/react/2022/03/16/how-react-scheduler-works#5-summary
출처: https://jser.dev/react/2022/03/16/how-react-scheduler-works#5-summary

React Concurrent Feature의 핵심은 task를 수행하다가 우선순위가 더 높은 task가 들어오면 작업을 일시중지하고, 우선순위가 더 높은 task를 수행한 후, 중지되었던 task를 재개하는 것이다.

task의 우선순위는 expirationTime으로 정하고, Min Heap으로 구현한 우선순위 큐를 이용해 스케줄링한다.

각 task의 callback에는 재조정(reconciliation) 함수인 performConcurrentWorkOnRoot()가 할당된다. 해당 함수는 workLoopConcurrent()를 실행하면서 렌더 작업을 수행하다가 shouldYield() 에 의해 중단(interrupt)되면 일시중지(suspend)되는데, 이때 클로저를 리턴해서 상태를 기억하며 TaskQueue에서 대기하다가, 순서가 오면 실행을 마치고 null을 리턴하여 재조정을 마치고 pop된다.

References

[JSer.dev] How React Scheduler works internally?

[JSer.dev] What are Lanes in React source code?

[JSConf] Jake Archibald on the web browser event loop, setTimeout, micro tasks, requestAnimationFrame, ...

[JAVASCRIPT.INFO] Event loop: microtasks and macrotasks

[Yeoul Kim] Concept of React Scheduler

[Ivan Akulov] React Concurrency, Explained: What useTransition and Suspense Hydration Actually Do

[lydiahallie] JavaScript Visualized:
Event Loop, Web APIs, (Micro)task Queue

profile
원리를 파헤치는 것을 좋아하는 프론트엔드 개발자입니다 🏃🏻‍♀️

0개의 댓글