Amada Coding Club

[React Core Deep Dive] How React Scheduler works internally? 본문

Mash-Up/Study

[React Core Deep Dive] How React Scheduler works internally?

아마다회장 2024. 5. 21. 17:32

Jser의 글 을 직역했습니다. 

 

1. React 스케줄러가 필요한 이유.

이 시리즈의 첫 번째 에피소드에서 이미 다룬 바 있는 다음 코드부터 시작하겠습니다.

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

 

한마디로 React는 내부적으로 파이버 트리의 각 파이버에서 작업하고, workInProgress는 현재 위치를 추적합니다. 


workLoopSync()는 동기식이고 작업의 중단이 없기 때문에 React는 그냥 while 루프에서 계속 작업하기 때문에 이해하기가 매우 쉽습니다.

 

동시 모드에서는 상황이 달라집니다.

 

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

 

동시 모드에서는 우선순위가 높은 작업이 우선순위가 낮은 작업을 중단할 수 있으므로 작업을 중단하고 다시 시작할 수 있는 방법이 필요하며, 이를 위해 shouldYield()가 트릭을 수행하지만 분명히 그 이상의 기능이 있습니다.

 

2. 몇 가지 배경 지식부터 시작하기

2.1 Event Loop

솔직히 설명이 잘 안 되니 javascript.info에서 설명을 읽어보시거나 Jake Archibald의 멋진 동영상을 시청하시기 바랍니다.

 

간단히 말해, 자바스크립트 엔진은 다음과 같은 작업을 수행합니다.

1. 작업 대기열에서 작업(매크로 작업)을 가져와 실행합니다.
2. 예약된 마이크로 태스크가 있으면 실행합니다.
3. 렌더링이 필요한지 확인하고 실행합니다.
4. 작업이 더 있으면 1을 반복하거나 더 많은 작업을 기다립니다.

실제로 일종의 루프가 있기 때문에 이 루프는 매우 자명합니다.

 

2.2 렌더링을 차단하지 않고 새 작업을 예약하려면 setImmediate()를 사용하세요.

렌더링을 차단하지 않고 일부 작업을 예약하기 위해(위의 3단계), 우리는 이미 새로운 매크로 작업을 예약하는 setTimeout(callback, 0)의 트릭에 익숙해져 있습니다.

이보다 더 나은 이벤트 API인 setImmediate()가 있지만 IE와 node.js에서만 사용할 수 있습니다.

setTimeout()은 중첩 호출에서 실제로 최소 약 4ms가 걸리는 반면, setImmediate()는 지연이 없기 때문에 더 좋습니다.

이제 React Scheduler(source)의 첫 번째 코드를 만질 준비가 되었습니다.

 

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 = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

여기에서는 두 가지 다른 setImmediate()의 폴백을 볼 수 있는데, MessageChannel과 setTimeout이 있습니다.

 

2.3 우선순위 큐

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

 

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

 

React는 min-heap으로 우선순위 큐를 구현하며, 소스 코드는 여기에서 확인할 수 있습니다.

 

3. workLoopConcurrent 에서의 콜 스택

모든 코드는 ReaccFiberWorkLoop.js에 있습니다. 시작해보죠!

 

우리는 ensureRootIsScheduled()를 여러 번 만났고, 꽤 많은 곳에서 사용하고 있습니다. 이름에서 알 수 있듯이 ensureRootIsScheduled()는 업데이트가 있는 경우 React가 작업을 수행하도록 예약합니다.

 

performConcurrentWorkOnRoot()를 직접 호출하지 않고 scheduleCallback(우선순위, 콜백)을 통해 콜백으로 처리합니다. scheduleCallback()은 스케줄러의 API입니다.

 

곧 스케줄러에 대해 자세히 살펴보겠지만, 지금은 스케줄러가 적절한 시간에 작업을 실행한다는 점만 기억하세요.

 

3.1 performConcurrentWorkOnRoot()는 중단된 경우 자체 클로저를 반환합니다.

진행 상황에 따라 performConcurrentWorkOnRoot()가 다르게 반환되는 것을 보셨나요?

1. shouldYield()가 참이면 workLoopConcurrent가 중단되어 불완전한 업데이트(RootInComplete)가 발생하고, performConcurrentWorkOnRoot()는 performConcurrentWorkOnRoot.bind(null, root)를 반환합니다. (코드)

2. 완료되면 null을 반환합니다.

작업이 shouldYield()에 의해 중단된 경우 어떻게 다시 시작될 수 있는지 궁금할 수 있습니다. 네, 이것이 정답입니다. 

스케줄러는 작업 콜백의 반환값을 보고 작업이 계속되는지 확인하며, 반환값은 일종의 리스케줄링입니다. 이 부분은 곧 다루겠습니다.

 

4. 스케줄러

마지막으로 스케줄러의 영역에 들어섰습니다. 처음에는 겁이 났지만 곧 불필요하다는 것을 깨달았으니 부담스러워하지 마세요.

메시지 큐는 제어권을 넘겨주는 방법이고, 스케줄러는 정확히 그 역할을 합니다.

위에서 언급한 scheduleCallback()은 스케줄러 세계에서는 unstable_scheduleCallback(불안전_스케쥴콜백)입니다.

 

4.1 scheduleCallback() - 스케줄러가 exipriationTime을 기준으로 작업을 예약합니다.

스케줄러가 작업을 예약하려면 먼저 우선순위와 함께 작업을 저장해야 합니다. 이 작업은 우선순위 큐에 의해 수행됩니다.

우선순위 큐는 만료 시간을 사용하여 우선순위를 나타냅니다. 만료 시간이 빠르면 빠를수록 더 빨리 처리해야 하는 것은 당연합니다. 다음은 작업이 생성되는 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 = {
  id: taskIdCounter++,
  callback,
  priorityLevel,
  startTime,
  expirationTime,
  sortIndex: -1,
};

/*
  ⬆️task는 스케줄러가 처리하는 작업의 단위입니다.
*/

 

코드는 매우 간단하며, 각 우선순위에 따라 다른 시간 제한이 있으며 여기에 정의되어 있습니다.

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
/*
  ⬆️Default is 5 second timeout
*/

var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

따라서 기본적으로 5초의 시간 제한이 설정되어 있으며 사용자 차단에 대해서는 250ms가 설정되어 있습니다. 곧 이러한 우선순위에 대한 몇 가지 예를 살펴보겠습니다.

작업이 생성되었으니 이제 우선순위 대기열에 넣을 차례입니다.

if (startTime > currentTime) {
  // 이것은 지연된 task입니다..
  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);
  }
}

 

작업을 예약할 때 setTimeout()과 같은 지연 옵션이 있을 수 있습니다. 이 부분은 따로 두고 나중에 다시 살펴봅시다.

else 부분에 집중하세요. 두 가지 중요한 호출을 볼 수 있습니다.

push(taskQueue, newTask) - 작업을 큐에 추가합니다. 이것은 우선순위 큐 API일 뿐이므로 그냥 넘어가겠습니다.
요청호스트콜백(flushWork) - 처리합니다!

Scheduler는 호스트에 구애받지 않고 모든 호스트에서 실행될 수 있는 독립적인 블랙박스가 되어야 하므로 요청해야 합니다.

4.2 requestHostCallback()

function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}
const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    // 메인 스레드가 차단된 시간을 측정할 수 있도록 시작 시간을 추적합니다.
    startTime = currentTime;
    const hasTimeRemaining = true;
    // 스케줄러 작업에서 오류가 발생하면 오류를 관찰할 수 있도록 현재 브라우저 작업을 종료하세요.
    // 일부 디버깅 기술을 더 어렵게 만들 수 있으므로 의도적으로 try-catch를 사용하지 않습니다. 
    // 대신 `scheduledHostCallback` 오류가 발생하면 `hasMoreWork`가 참으로 유지되고 작업 루프를 계속 진행합니다.
    let hasMoreWork = true;
    try {
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      if (hasMoreWork) {
        // 작업이 더 있는 경우 이전 메시지 이벤트가 끝날 때 다음 메시지 이벤트를 예약합니다.
        schedulePerformWorkUntilDeadline();
        /*
        ⬆️스케줄러가 다음을 예약하여 대기열에서 작업을 계속 처리하는 것을 볼 수 있습니다. 
        여기서 브라우저에 페인트할 기회를 제공합니다.
        */
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
  // 브라우저에 양보하면 페인트할 수 있는 기회가 주어지므로 재설정할 수 있습니다.
  needsPaint = false;
};

 

2.2에서 언급했듯이 schedulePerformWorkUntilDeadline()은 performWorkUntilDeadline()의 Wraper일 뿐입니다.

scheduledHostCallback은 requestHostCallback()에서 설정되고 performWorkUntilDeadline()에서 바로 호출되는데, 이는 비동기 특성으로 인해 메인 스레드가 렌더링할 기회를 주기 위한 것입니다.

자세한 내용은 무시하고 가장 중요한 줄만 소개합니다.

hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime)

 

즉, flushWork()는 (true, currentTime)과 함께 호출됩니다.

왜 여기에 True로 하드코딩되어 있는지 모르겠습니다. 아마도 리팩토링 실수 때문일 수 있습니다.

4.3 flushWork()

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

flushWork는 workLoop()를 마무리합니다. 

4.4 workLoop()  - 스케줄러의 핵심

조정의 workLoopConcurrent()와 마찬가지로, 스케줄러의 핵심은 workLoop()입니다. 프로세스가 비슷하기 때문에 이름이 비슷합니다.

if (
  currentTask.expirationTime > currentTime &&
  //                    (               )
  (!hasTimeRemaining || shouldYieldToHost())
) {
  // 이 현재 작업이 만료되지 않았으며 데드라인에 도달했습니다.
  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();
  if (typeof continuationCallback === "function") {
  /*
  ⬆️여기서 작업의 반환 값이 중요한 이유를 확인할 수 있습니다. 
  이 분기에서는 작업이 팝업되지 않습니다!
  */

    currentTask.callback = continuationCallback;
  } else {
    if (currentTask === peek(taskQueue)) {
      pop(taskQueue);
    }
  }
  advanceTimers(currentTime);
} else {
  pop(taskQueue);
}

자세히 살펴봅시다.

currentTask.callback(이 경우 실제로는 performConcurrentWorkOnRoot())입니다.

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

 

만료 여부를 나타내는 플래그와 함께 호출됩니다.

시간 초과가 발생하면 performConcurrentWorkOnRoot()는 동기화 모드로 돌아갑니다(code). 즉, 이제부터는 중단이 없어야 합니다.

 

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

 

workLoop()로 돌아가볼게용

if (typeof continuationCallback === "function") {
  currentTask.callback = continuationCallback;
} else {
  if (currentTask === peek(taskQueue)) {
    pop(taskQueue);
  }
}

여기서 중요한 점은 콜백의 반환값이 함수가 아닐 때만 태스크가 팝업된다는 것입니다. 함수인 경우 작업의 콜백이 팝업되지 않기 때문에 작업의 콜백만 업데이트되며, 다음에 workLoop()를 호출하면 동일한 작업이 다시 수행됩니다.

즉, 이 콜백의 반환값이 함수인 경우 이 작업이 완료되지 않았으므로 다시 작업해야 합니다.

advanceTimers(currentTime)

지연된 작업의 경우 나중에 다시 돌아오겠습니다.

 

4.5 shouldYield() 가 어떻게 작동하는가?

function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // 메인 스레드는 한 프레임보다 짧은 시간 동안만 차단되었습니다. 아직 양보하지 마세요.
    return false;
  }
  // 메인 스레드가 무시할 수 없는 시간 동안 차단되었습니다. 
  // 브라우저가 우선순위가 높은 작업을 수행할 수 있도록 
  // 메인 스레드의 제어권을 양보하고 싶을 수 있습니다. 
  // 주요 작업은 페인팅과 사용자 입력입니다. 
  // 보류 중인 페인트나 보류 중인 입력이 있으면 양보해야 합니다. 
  // 하지만 둘 다 없다면 응답성을 유지하면서 양보하는 빈도를 줄일 수 있습니다. 
  // 요청 페인트` 호출이 수반되지 않은 보류 중인 페인트나 네트워크 이벤트와 같은 
  // 다른 메인 스레드 작업이 있을 수 있기 때문에 결국에는 양보할 것입니다.
  if (enableIsInputPending) {
    if (needsPaint) {
      // 보류 중인 페인트가 있습니다('요청 페인트'로 신호). 지금 양보합니다.
      return true;
    }
    if (timeElapsed < continuousInputInterval) {
      // 그렇게 오랫동안 스레드를 차단한 적은 없습니다. 
      // 보류 중인 개별 입력(예: 클릭)이 있는 경우에만 양보합니다. 
      // 대기 중인 연속 입력(예: 마우스오버)이 있어도 괜찮습니다.
      if (isInputPending !== null) {
        return isInputPending();
      }
    } else if (timeElapsed < maxInterval) {
      // 보류 중인 이산형 또는 연속형 입력이 있는 경우 출력합니다.
      if (isInputPending !== null) {
        return isInputPending(continuousOptions);
      }
    } else {
      // 오랫동안 스레드를 차단했습니다. 
      // 대기 중인 입력이 없더라도 우리가 모르는 다른 예정된 작업이 있을 수 있습니다, 
      // 네트워크 이벤트처럼요. 지금 양보합니다.
      return true;
    }
  }
  // isInputPending`을 사용할 수 없습니다. 지금 양보합니다.
  return true;
}

사실 복잡하지 않고 댓글에 모든 것이 설명되어 있습니다. 가장 기본적인 라인은 다음과 같습니다.

const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
  // 메인 스레드는 한 프레임보다 짧은 시간 동안만 차단되었습니다. 아직 양보하지 않습니다.
  return false;
}
return true;

 

따라서 각 작업에는 5ms(frameInterval)가 주어지며, 시간이 다 되면 양보해야 합니다.

이것은 각 performUnitOfWork()가 아니라 스케줄러에서 작업을 실행하기 위한 것입니다. startTime은 performWorkUntilDeadline()에서만 설정되며, 이는 각 flushWork()에 대해 재설정된다는 것을 알 수 있으며, 여러 작업이 flushWork()에서 처리될 수 있다면 그 사이에는 양보가 없다는 것을 알 수 있습니다.

이 React 퀴즈를 이해하는 데 도움이 될 것입니다.

 

{...}
function App() {
  const [state, setState] = useState(0);
  console.log(1);
  useEffect(() => {
    console.log(2);
  }, [state]);
  Promise.resolve().then(() => {
    console.log(3);
  });
  setTimeout(() => {
    console.log(4);
  }, 0);
  const onClick = () => {
    console.log(5);
    setState((num) => num + 1);
    console.log(6);
  };
  return (
    <div>
      <button onClick={onClick} data-testid="action">
        click me
      </button>
    </div>
  );
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
setTimeout(() => {
  const action = document.querySelector('[data-testid="action"]');
  fireEvent.click(action);
Click the button after 100ms
}, 100);

어떤 순서로 console.log가 출력될까요? 
답: 1 2 3 4 5 6 1 2 3 4

5. 요약

휴휴휴 겁나 많네요. 다이어그램으로 살펴봅시다.

아직 몇 가지 누락된 부분이 있지만 큰 진전을 이루었습니다. React 내부를 더 잘 이해하는 데 도움이 되었기를 바랍니다. 이미 소화하기에는 너무 큰 다이어그램이므로 다른 내용은 다음 에피소드에서 다뤄보도록 하겠습니다.