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.
👉 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?
👉 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).
👉 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.
👉 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.
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:
👉 Online Preview, view the example code here
By now, we have a preliminary understanding of scheduling. The next article will discuss priority scheduling.