Concept of React Scheduler

2023. 1. 14. 20:22Frontend

 

 

 

Overview

이전 포스팅에서 React Sync Mode(Concurrent Mode가 아닌 것, Legacy Mode)가 어떻게 동작하는지에 대해 Source Code Level에서 간략하게 살펴보았습니다. 이번 포스팅부터는 해당 내용을 기반으로 해서 Concurrent Mode가 어떻게 동작하는지에 대해서 한 단계씩 살펴보려고 합니다. Fiber, workInProgress Node, workLoopSync와 같은 용어들을 이해하고 있다는 전제로 전개되므로 이전 포스팅을 먼저 읽어보시는 것을 추천합니다.

 

Concurrent Mode의 핵심은 Task를 수행하는 도중에 더 높은 우선순위를 지니고 있는 Task가 들어오면, 지금 처리 중인 Task를 "일시중단(Pause)"하고, 우선순위가 더 높은 Task를 먼저 처리한 후에, 다시 중단된 Task를 "재개(Resume)"하는 것입니다. React는 이것을 "Lane Model"이라는 개념과 "Scheduler"를 통해 해결합니다. 두 내용을 한 번에 다루기에는 분량이 많기 때문에, 이번 포스팅에서는 "Scheduler"에 대한 내용을 다루고, 다음 포스팅에서 Lane Model과 Event Priority에 대해 다루도록 하겠습니다.

 

Basic Idea

이전 포스팅에서 살펴보았던 Sync Mode에서는 renderRootSync에서 호출하는 workLoopSync가 root로 부터 모든 Fiber를 순회하면서 performUnitOfWork를 수행하고, 그 뒤에 commitPhase를 거쳐 화면에 반영됩니다. 화면을 다시 렌더해야 할 때, 모든 Fiber를 순회하면서 performUnitOfWork를 모두 처리하기 전까지는 commitPhase로 넘어가지 않기 때문에 이 workLoopSync를 처리하는 시간이 길어질 경우에는 아래와 같이 사용자에게 Delay를 주어 Application이 느리다는 인상을 주게 됩니다.

 

// The work loop is an extremely hot path. Tell Closure not to inline it.
/** @noinline */
function workLoopSync() {
  // Perform work without checking if we need to yield between fiber.
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

 

 

반면, Concurrent Mode에서는 renderRootConcurrent에서 workLoopConcurrent를 호출하는데, 이 함수에서는 "shouldYield"라는 함수를 매 performUnitOfWork를 실행하기 전에 체크함으로써, Frame interval보다 Task를 처리하는 시간이 길어지거나, 우선순위가 더 높은 Task가 들어올 경우, 언제든지 현재의 workLoopConcurrent를 중단하고, Frame을 업데이트하거나, 더 우선순위가 높은 Task를 처리할 수 있도록 양보(yield) 합니다. (Concurrent Mode라고 renderRootConcurrent만 호출하는 것은 아닙니다. 우선순위가 높은 Task의 경우 renderRootSync를 호출하기도 하지만, 해당 내용은 Concurrent의 개념적인 내용보다는 Detail에 해당하므로 이번 포스팅에서 다루지 않겠습니다.)

 

아래의 그림과 같이 한번의 사용자 input 변화는 처리하는데 시간이 오래 걸리는 Cell 변화를 수반합니다. 하지만 Application의 입장에서 는 해당 Cell을 렌더 하는 것보다 사용자 input update에 빠르게 반응하는 것이 더 중요하기 때문에 Cell을 렌더 하는 작업을 수행하다가 더 높은 Task(사용자 input update로 인한 변경사항)가 들어오면 기존 작업을 잠시 중단하고 사용자 input update에 반응하는 task를 더 먼저 수행한 뒤, Cell을 렌더 하게 됩니다. (부연 설명을 추가하자면, 아래의 예시에서 모든 Cell의 변경사항을 DOM에 반영하는 것은 아닙니다. transition API를 잘 사용하면 중간 상태를 건너뛰고, 최종 상태만 렌더하기도 하는데, 이 내용은 이번 Scheduler의 범위를 벗어나므로 이번 포스팅에서는 다루지 않겠습니다.)

/** @noinline */
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    // $FlowFixMe[incompatible-call] found when upgrading Flow
    performUnitOfWork(workInProgress);
  }
}

 

즉, Concurrent Mode는 사용자에게 더 중요한 Task를 먼저 수행하도록 양보함으로써 사용자 경험을 높이는 방법에 대한 것입니다.

 

 

Backgrounds

본격적으로 Scheduler로 들어가기 전에 이해해야 할 배경지식이 있습니다. React Scheduler는 Task 스케줄링을 위해 Host 환경(Browser, Node.js)의 Task Scheduling을 사용합니다. 따라서 Event Loop과 setImmediate, MessageQueue와 setTimeout에 대한 이해가 필요합니다. 두 번째로, task의 우선순위를 관리하기 위해 React는 minHeap 기반의 Priority Queue를 사용하기 때문에 Priority Queue와 MinHeap에 대한 이해가 필요합니다. 이 포스팅에서는 이 개념들에 대해 간단히 짚고 넘어가는 정도로만 설명할 예정이며, 자세한 내용은 아래 Reference Section을 참고해주세요.

 

Event Loop, setImmediate

Browser, Node.js와 같은 Host 환경에서 렌더링을 block 하지 않고 task를 스케줄 하기 위해서는 해당 task를 macroTask로 등록하여 mainStack이 비워진 후에 EventLoop를 거쳐 실행하도록 해야 합니다. 이를 위해 setTimeout을 사용할 수 있으나, 해당 API는 역사적인 이슈로 인해 4ms clamping 이슈가 있고, 이를 개선하기 위해 React에서는 setImmediate와 MessageChannel을 사용합니다. (물론 둘 다 지원하지 않는 환경에서는 setTimout을 쓰긴 합니다. 

 

즉, userEvent 등의 업데이트로 인해 화면의 rerender가 필요할 때, performConcurrentWorkOnRoot를 task로 등록하여 실행하는데, 이때, 이 함수를 바로 실행하는 것이 아니라, scheduleCallback이라는 스케줄러 함수를 통해 실행하고, 이 함수는 넘어온 performConcurrentWorkOnRoot를 macroTask로 등록하여 main call stack이 비워지면, EventLoop를 거쳐 실행되게 되는 것입니다. 여기까지의 내용을 간략하게 도표로 그려보면 다음과 같습니다. (실제로 performConcurrentWorkOnRoot가 바로 macroTask에 등록되는 것은 아니지만 개념상 지금은 제어권의 관점에서 이렇게 이해한 뒤에, 글을 진행해 가면서 구체적으로 살펴보도록 하겠습니다.)

 

 

 

 

// packages/scheduler/src/forks/Scheduler.js

let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // There's a few reasons for why we prefer setImmediate.
  //
  // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
  // (Even though this is a DOM fork of the Scheduler, you could get here
  // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
  // https://github.com/facebook/react/issues/20756
  //
  // 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 = () => {
    // $FlowFixMe[not-a-function] nullable value
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

 

Min Heap, Priority Queue

앞서 간단히 언급한 것처럼, React는 각 Task에 우선순위를 붙여 Priority Queue에 저장한 후, 우선순위에 따라 정렬된 task를 peek 해서 사용합니다. 따라서 하나의 task를 수행하다가 shouldYield가 호출되어 해당 task가 중단된 후, 다시 재개할 때가 되어 priority Queue를 peek 했을 때, 그 사이에 queue에 다른 task가 push 되고, 이 task가 기존에 작업하던 task보다 우선순위가 높다면, 새롭게 push 된 task가 나오게 되며, 따라서 React는 이 작업을 먼저 수행하게 되는 것입니다. 

 

자세한 내용을 ensureRootIsScheduled에서 호출하는 scheduleCallback의 SourceCode를 살펴봄으로써 확인해 보겠습니다. 아래 코드에서 확인할 수 있는 것처럼, reconciler에서 호출하는 scheduleCallback은 Scheduler 패키지의 scheduleCallback에 priorityLevel과 callback(여기서의 callback은 performConcurrentWorkOnRoot가 됩니다.)을 넘기고 종료됩니다.

 

// packages/react-reconciler/src/ReactFiberWorkLoop.js
function scheduleCallback(priorityLevel, callback) {
  if (__DEV__) {
    // If we're currently inside an `act` scope, bypass Scheduler and push to
    // the `act` queue instead.
    const actQueue = ReactCurrentActQueue.current;
    if (actQueue !== null) {
      actQueue.push(callback);
      return fakeActCallbackNode;
    } else {
      return Scheduler_scheduleCallback(priorityLevel, callback);
    }
  } else {
    // In production, always call Scheduler. This function will be stripped out.
    return Scheduler_scheduleCallback(priorityLevel, callback);
  }
}

 

이 부분부터는 reconciler에서 Scheduler로 제어권이 넘어오게 되는데 Scheduler의 scheduleCallback은 queue에 넣을 task를 생성하는 부분과, 해당 Task를 Queue에 넣고 해당 Task를 실행하는 Callback Function을 Event Loop에 던지는 부분입니다. task에 들어가는 callback은 위에서 소개한 도식의 흐름에서 "performWorkOnConcurrentRoot"에 해당합니다.

 

여기서 중요한 부분은 expirationTime인데, 해당 Task를 PriorityQueue에서 정렬할 때 사용하는 기준인 sortIndex가 이후 코드에서 이 expirationTime으로 설정되며, 이는 해당 task가 queue에 push 된 시간인 startTime에 해당 task의 Priority Level에 따른 Timout이 더해져서 정해집니다. 즉, Task가 빨리 push 되었을수록, 그리고 Task의 우선순위가 높을수록 Scheduler에 의해 먼저 처리된다는 것입니다.

 

// unstable_scheduleCallback
var currentTime = getCurrentTime();

var startTime;
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;
}

var timeout;
switch (priorityLevel) {
case ImmediatePriority:
  timeout = IMMEDIATE_PRIORITY_TIMEOUT;
  break;
case UserBlockingPriority:
  timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
  break;
case IdlePriority:
  timeout = IDLE_PRIORITY_TIMEOUT;
  break;
case LowPriority:
  timeout = LOW_PRIORITY_TIMEOUT;
  break;
case NormalPriority:
default:
  timeout = NORMAL_PRIORITY_TIMEOUT;
  break;
}

var expirationTime = startTime + timeout;

var newTask: Task = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
};

 

 

위에서 언급한 것처럼 task의 sortIndex가 expirationTime으로 설정되며, 이 task가 taskQueue에 push 된 이후에 requestHostCallback함수를 통해 flushWork라는 함수를 EventLoop에 macroTask로 schedule 합니다. 

 

// unstable_scheduleCallback
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(flushWork);
}

 

requestHostCallback함수에서는 flushWork라는 callback을 받아서 scheduledHostCallback으로 설정하고 schedulePerformWorkUntilDeadline이라는 위에서 살펴보았던 setImmediate, setTimeout, MessageQueue를 사용한 Scheduling Wrapper 함수를 실행함으로써 EventLoop의 macroTask로 해당 callback(flushWork)를 넘깁니다.

 

주의할 점은 여기서 2가지 종류의 callback이 나오는데 이 두 개를 같은 callback으로 혼동하기 쉽다는 것입니다. taskQueue에 들어가는 task의 callback인 performConcurrentWorkOnRoot와 schedulePerformWorkUntilDeadline에 의해 EventLoop로 들어가는 callback인 flushWork입니다. EventLoop에 의해 실행되는 callback은 flushWork이며, 이 함수가 workLoop를 호출하고, 여기서 taskQueue를 peek 해서 task의 callback을 실행하는데, 이때의 callback이 performConcurrentWorkOnRoot인 것입니다. 이 흐름은 이후 도식을 통해 다시 한번 살펴보도록 하겠습니다.

 

function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

if (typeof localSetImmediate === 'function') {
	...
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} 

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    let hasMoreWork = true;
    try {
      // $FlowFixMe[not-a-function] found when upgrading Flow
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } 
    ...
  }
...
};

 

React에서 사용하는 MinHeap은 Scheduler package에 들어있으며, 다음과 같이 구현되어 있습니다.

// packages/scheduler/src/SchedulerMinHeap.js

type Heap<T: Node> = Array<T>;
type Node = {
  id: number,
  sortIndex: number,
  ...
};

export function push<T: Node>(heap: Heap<T>, node: T): void {
...
}

export function peek<T: Node>(heap: Heap<T>): T | null {
...
}

export function pop<T: Node>(heap: Heap<T>): T | null {
...
}

function siftUp<T: Node>(heap: Heap<T>, node: T, i: number): void {
...
}

function siftDown<T: Node>(heap: Heap<T>, node: T, i: number): void {
...
}

function compare(a: Node, b: Node) {
...
}

 

Scheduling Processs

지금까지의 과정을 살펴보면 Scheulder와 Reconciler가 제어를 주거니 받거니 하면서 다음과 같이 협력하는 것을 확인할 수 있습니다.

 

Reconciler에서 update 해야 하는 이벤트를 하나 받으면 performConcurrentWorkOnRoot라는 함수를 scheduleCallback이라는 함수를 통해 Scheduler로 넘기게 됩니다. Scheduler는 이 scheduleCallback이라는 함수를 실행하면서 performConcurrentWorkOnRoot를 callback으로 하는 task를 만들고 이를 taskQueue에 밀어 넣습니다. 그리고 requestHostCallback에 flushWork라는 함수를 macroTask로 밀어 넣으면서 이 flushWork라는 함수가 EventLoop에 의해 스케줄 되어 호출될 수 있도록 합니다. 

 

flushwork & workLoop

그렇다면 flushWork는 어떤 역할을 하는 함수일까요? 실제 구현을 살펴보면 다음과 같습니다. 굉장히 간단하게 되어 있는데, 그냥 workLoop라는 함수를 실행하고 종료하는 것으로 함수가 구성되어 있습니다.

try {
  // No catch in prod code path.
  return workLoop(hasTimeRemaining, initialTime);
} finally {
  //
}

 

flushWork에서 실행되는 workLoop라는 함수는 Scheduler의 핵심 모듈(Core Module)이라고 할 수 있습니다. 이 함수는 먼저 자기 자신이 실행될 수 있을 정도로 잔여시간이 남아 있는지를 확인한 후에, 남아있다면, taskQueue에서 우선순위가 가장 높은 task를 꺼내 이 task의 callback을 실행합니다. 바로 이 코드를 통해 performConcurrentWorkOnRoot가 비로소 실행되게 되는 것이며, 제어권이 reconciler로 넘어와 이 함수 안에서 root로부터 fiber tree를 순회하며 재조정과정을 거치는 것입니다. 

  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || 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') {
        currentTask.callback = continuationCallback;
        advanceTimers(currentTime);
        return true;
      } else {
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }

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

 

Suspend and Resume Task

지금까지 이벤트가 발생했을 때, 어떤 과정을 통해 task가 스케줄 되며, 실행되는지에 대해 알아보았습니다. 이제 마지막으로 task를 실행하다가 더 높은 우선순위를 가진 task가 들어왔을 때, 기존에 실행하는 task를 어떻게 suspend 하고 이후 다시 resume 하는지에 대해 살펴보도록 하겠습니다. 코드의 구현은 reconciler의 performConcurrentWorkOnRoot와 Scheduler의 workLoop에 들어있습니다.

 

우선은 performConcurrentWorkOnRoot에 대한 부분입니다. 주목해야 하는 부분은 return 하는 부분인데, root.callbackNode가 아직 바뀌지 않은 경우, 즉 해당 task가 아직 종료되지 않은 경우 해당 함수를 다시 리턴하고, root.callbackNode가 바뀐 경우, 즉 해당 task가 종료된 경우 null을 리턴합니다. 여기서 리턴하는 값이 중요한 이유는 이 값이 Scheduler의 workLoop에서 해당 task를 resume 하는 데에 사용되기 때문입니다. 

// This is the entry point for every concurrent task, i.e. anything that
// goes through Scheduler.
function performConcurrentWorkOnRoot(root, didTimeout) {
  ...
  const originalCallbackNode = root.callbackNode;
  let lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );

  ...

  ensureRootIsScheduled(root, now());
  if (root.callbackNode === originalCallbackNode) {
    ...
    return performConcurrentWorkOnRoot.bind(null, root);
  }
  return null;
}

 

이제 workLoop를 보겠습니다. currentTask의 callback이 fiber를 재조정하는 performConcurrentWorkOnRoot라는 사실에 주의하면서 따라가 보겠습니다. 우선 typeof callback이 function이므로 첫 번째 분기로 들어가게 되는데, 만약 task가 완료되지 않은 상태에서 해당 callback이 shouldYield에 의해 중단된 경우, 위에서 살펴본 것처럼 자기 자신을 리턴하게 되며, 이 값은 continuationCallback에 할당됩니다. 하지만 만약 callback이 모든 Fiber 작업을 마무리한 뒤에 null을 리턴했다면 해당 task는 모든 fiber 재조정 작업을 성공적으로 마무리한 것으로 간주하고  taskQueue에서 빠지게 됩니다. 

 

즉, Scheduler의 workLoop는 taskQueue를 계속 peek 하면서 task에 대해 작업을 수행하는데 만약 해당 task의 callback의 실행결과가 함수이면 아직 실행을 마무리하지 못하고 중간에 suspend 된 것이므로 taskQueue에 남겨두고, 실행결과가 null이면 해당 task는 실행이 끝난 것으로 간주하고 taskQueue에서 pop 하는 방식으로 동작하는 것입니다. 

// workLoop
// 여기서의 callback은 reconciler의 performConcurrentWorkOnRoot를 의미합니다.
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') {
    currentTask.callback = continuationCallback;
    advanceTimers(currentTime);
    return true;
  } else {
    if (currentTask === peek(taskQueue)) {
      pop(taskQueue);
    }
    advanceTimers(currentTime);
  }
} else {
  pop(taskQueue);
}

 

Conclusion

지금까지 React Concurrent Mode의 핵심인 Scheduler에 대해 살펴보았습니다. Concurrent Mode의 핵심은 task를 수행하다가 우선순위가 더 높은 task가 들어오면 잠깐 작업을 중단하고, 우선순위가 더 높은 task를 수행한 뒤 기존 task를 재개하는 것입니다. React는 task의 우선순위를 expirationTime으로 정하고, 이를 minHeap 기반의 priorityQueue에 넣는 방식으로 정렬합니다. 각 task의 callback은 재조정 함수인 performConcurrentWorkOnRoot가 할당되며, 해당 함수가 workLoopConcurrent를 실행하다가 shouldYield함수에 의해 interrupt 되면 Suspend 되는데, 이때는 함수 자기 자신을 리턴함으로써 taskQueue에 중단된 상태 그대로 남아있다가 다시 자기 차례가 오면 실행을 마무리하고 null을 리턴함으로써 재조정을 마치고 taskQueue에서 빠져나오게 됩니다.

 

다음 포스팅에서는 task의 우선순위를 결정하는 React의 방식인 LaneModel에 대해 살펴봄으로써 Concurrent Mode에 대한 이해도를 높여보도록 하겠습니다.

 

 

 

Reference

https://javascript.info/event-loop

https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate

https://en.wikipedia.org/wiki/Min-max_heap

https://en.wikipedia.org/wiki/Priority_queue

 

 

 

반응형

'Frontend' 카테고리의 다른 글

Suspense Deep Dive (Code Implementation)  (0) 2023.03.26
How @next/font Works  (0) 2023.01.15
React Mount System Deep Dive (Sync Mode)  (5) 2023.01.08
Node.js + Puppeteer Memory Leak Handling  (2) 2022.11.20
Suspense SSR Architecture in React 18  (1) 2022.09.18