Fundamentals of Task Scheduling

Published on
6 mins read
––– views

Introduction

The React Fiber architecture introduces the concept of task scheduling and implements a scheduler. However, task scheduling is framework-agnostic. Many articles refer to operating system scheduling and process management in other languages, which can be dense and hard to grasp for frontend developers. Therefore, this article aims to explain the principles of task scheduling step by step through examples and the use of Chrome's performance tools.

This article attempts to answer the following questions:

  • The impact of the browser's rendering process and long tasks
  • Why task scheduling is necessary and what cooperative task scheduling is
  • Why macro tasks are used instead of micro tasks for scheduling
  • Issues with scheduling using setTimeout
  • Using messageChannel as a scheduling solution
  • What are task slices and time slices?

Long Synchronous Tasks Blocking Page Updates

We know that in a browser, the JS thread and the UI rendering thread are mutually exclusive. Long synchronous JS tasks will block browser rendering, causing page stuttering (animation lag) and even loss of responsiveness (unresponsive to user input).

Here is a demo for demonstration. When you click the Simulate Task button, we execute a piece of JS code that dynamically creates 3000 tasks, each taking 2ms to execute, totaling 6000ms (there might be some error, but it's generally accurate).

// ----------Task Creation----------
const createWorks = () => {
  const taskDuration = 2
  let works = []
  for (let i = 0; i < 3000; i++) {
    const work = () => {
      let startTime = Date.now()
      while (Date.now() - startTime < taskDuration) {
        // Simulate task execution time
      }
    }
    works.push(work)
  }
  return works
}
const works = createWorks()

// -------Page Update------
const flushWorks = () => {
  works.forEach((work) => work())
}

We can observe that after clicking the button, the JS execution process causes the entire page to freeze, which severely affects user experience.

Using the Performance panel, we can see that the total JS task execution time is extremely long, so we need to find a way to optimize this logic.

Performance Panel

👉 Online Preview, view the example code here

Asynchronous Updates with setTimeout

Since our synchronous long task is unacceptable, we need to find a way to make the task interruptible. We use the macro task API setTimeout for asynchronous updates (in practice, we need to consider more factors, such as saving the execution context and resuming after interruption, as in the React Fiber architecture).

Note that micro task APIs like Promise cannot be used here because they execute all at once after a macro task completes, resulting in the same effect as synchronous execution.

// --------Task Scheduling--------
const workLoop = () => {
  const work = works.shift()
  if (work) {
    work()
    setTimeout(workLoop, 0)
  } else {
    updateView(Date.now() - startTime)
  }
}

const flushWorks = () => {
  setTimeout(workLoop, 0)
}

After observing the page with the button click, we can see that asynchronous updates with setTimeout no longer block page animations. However, there is an issue with the scheduling timing of setTimeout. Even though we wrote setTimeout(workLoop, 0), there is actually a 4ms delay each time the browser schedules, resulting in only 3 tasks executed per frame. This extends the total task execution time to 18s. How can we increase the scheduling frequency?

Performance Panel

👉 Online Preview, view the example code here

Asynchronous Updates with messageChannel

To increase the browser's scheduling frequency, we use messageChannel. Testing messageChannel's call frequency without considering task execution time, it can reach up to 160,000 calls per second (depending on machine performance).

Performance Panel

👉 Online Preview, view the example code here

Using this data, we can modify our example with messageChannel. To make it easier for those unfamiliar with messageChannel, we simulate the use of setTimeout for task scheduling, keeping the rest of the code unchanged.

const setTimeout = ((workLoop) => {
  let channel = null
  return (onMessageCb) => {
    if (!channel) {
      channel = new MessageChannel()
      channel.port1.onmessage = onMessageCb
    }
    channel.port2.postMessage(null)
  }
})()

const workLoop = () => {
  const work = works.shift()
  if (work) {
    work()
    setTimeout(workLoop)
  } else {
    updateView(Date.now() - startTime)
  }
}

Using the Performance panel, we see that with each task taking 2ms, 8 tasks can be executed within one frame. However, there is still some scheduling overhead.

Performance Panel

👉 Online Preview, view the example code here

Task Slicing

Based on the above, we might think: "Why not reduce the number of task schedules?" We can modify the workLoop function to execute as many tasks as possible within one frame.

const workLoop = () => {
  let workExecutedCount = 0
  while (workExecutedCount < 7) {
    const work = works.shift()
    if (work) {
      work()
      workExecutedCount++
    } else {
      updateView(Date.now() - startTime)
      break
    }
  }
  if (works.length) {
    setTimeout(workLoop)
  }
}

Using the Performance panel, we see a slight performance improvement. Performance Panel

Time Slicing

The above "task slicing" scheme assumes we know the execution time of each task, but in actual business, the execution time of each task is uncertain. Therefore, we might consider another slicing method: "time slicing." Given a predetermined time, such as 5ms, each task checks if it exceeds the time slice limit after execution. If it does, it yields the execution right to the browser for rendering.

We modify the createWorks method to generate a task that takes 0-1 milliseconds to execute and observe the effect.

const createWorks = () => {
  const taskDuration = Math.random() // Randomly generate 0-1ms
  let works = []

  for (let i = 0; i < 3000; i++) {
    works.push(() => {
      const start = performance.now()
      const time = Math.random()
      while (performance.now() - start < time) {}
    })
  }
  return works
}
const works = createWorks()

The effect is as follows: Performance Panel

👉 Online Preview, view the example code here

By now, we have a preliminary understanding of scheduling. The next article will discuss priority scheduling.

Built with
Copyright © 2024
York's Blog - York's coding journey