Javascript being a single-threaded language means that JS can only do one thing at a time. Each rendering process has only one main thread, and the main thread is very busy, responsible for the necessary operations to generate the page (such as DOM building, style calculation, layout calculation, etc.), various user interaction events (such as button clicks, mouse scrolling, etc.), and js code execution. To provide a good user experience, JS uses non-blocking I/O operations. So how does JS implement this feature? The answer is the Event Loop.
JS host environment
Before we start the event loop in earnest, let’s talk about the js host environment.
We all know that both Chrome and Nodejs run JS code using V8 engines. The aforementioned Chrome and Node hosts V8, so how does V8 relate to its hosting environment?
Before the JS code can be executed, V8 needs to initialize the runtime environment of the code, including the Heap, Stack, global execution context, global scope, built-in functions, extension functions and objects provided by the host environment, and the event loop system. These runtime environments are provided by V8’s host environment. V8 only implements the ECMAScript standard and the core functions defined by the ECMAScript standard. Besides, V8 also provides garbage collectors, coroutines, etc.
In Chrome’s case, their relationship looks like this:
We can think of V8 and Chrome as a virus-cell relationship: a virus contains only the core genetic material, RNA, and the resources it needs to survive are provided by its host cell, Chrome.
Stack and heap
V8 runtime environment including the heap and stack, stack is a continuous space in memory, adopting the tactics of “advanced”, in the execution of js function, involving the execution context related content will be stored in a stack, as mentioned below the function of the execution context, when the function starts, the function of the execution context will be pressed into the stack, implement the end, The execution context of the function will be destroyed.
The biggest advantage of stack space is the space continuity, so the search efficiency is particularly high, but the size of stack space has a certain limit, after all, it is difficult to find a very large contiguous memory space in memory. A stack overflow error occurs if the call stack exceeds its size.
Since the size of the stack is limited, it is not suitable for storing some large data, so a new storage structure is needed: the heap. Heap space is a tree storage space dedicated to storing object type data.
When the host starts V8, it creates both the heap and the stack space, where the data generated by subsequent JS execution will be stored.
After these two Spaces are initialized, the global execution context and global scope are initialized, and then the actual execution of our code begins. This is why we can directly call global variables such as window objects and setTimeout functions in our code.
We use a simple code to illustrate the js execution context change process, to help understand the subsequent event loop:
function foo() {
bar();
}
function bar() {
console.log("bar call");
}
foo();
bar();
Copy the code
Before this code is actually executed, the host initializes the environment for V8 for subsequent use, so the stack and global execution context have been created (regardless of the rest) :
Each time a function is executed, the corresponding execution context is generated and pushed to the top of the stack. When the function is finished, the corresponding execution context is removed from the stack. The execution process is as follows:
Event loop
V8 does not have its own main thread; it uses the main thread provided by the host. In the browser, is borrowed from each rendering process of the main thread, but only one is not enough to the main thread, in order to perform the js code, the page is still has the ability to response the I/O operations, so you also need an I/O threads, and news of column, the I/O threads except for receiving user interactions, also used to receive other threads the incoming message, For example, API results returned by network processes, message queues are used to store I/O events and other tasks, and the main thread performs its tasks through the event loop system.
Nodejs, as one of V8’s hosts, also provides an event loop for V8. But Nodejs provides a different event loop than browsers do. This is why we often distinguish between the Node environment and the browser environment when discussing the js event loop mechanism.
Of course, this article focuses only on the browser environment.
What does the event loop do?
The event loop strategy is simple:
- Polling pulls tasks from the message queue and executes them in the main thread
- After executing a task, proceed to the next task
- If no task is currently in the message queue, wait for the arrival of a new task
Use pseudocode to illustrate this process:
while (true) {
const task = getNextTask(); // If there is no task, wait here
processTask(task);
}
Copy the code
Macro task, micro task
So what is the task in the message queue, and how does the browser generate a task?
Js divides all tasks into macro tasks and micro tasks. The following behaviors generate macro tasks and micro tasks respectively:
- Macro Task
- Browser rendering events (such as DOM parsing, style calculation, drawing, etc.)
- User interaction events (such as mouse clicks, scrolling, and page zooming)
- Script tag execution
- SetTimeout and setInterval
- Network request callback
- Micro tasks:
- Promise.then
- MutationObserver
Macro task
All macro tasks are put into the message queue and then, in an event loop, are pulled out and executed on the main thread.
Let’s start with an example: there are two buttons on the page, block and log. Clicking the Block button leads to a five-second operation, while clicking the Log button brings up a prompt. The key codes are as follows:
function block() {
const time = new Date().getTime();
while (true) {
if (new Date().getTime() - time > 5000) {
break; }}}document.getElementById("block").addEventListener("click".function handleBlock() {
console.log("start calling");
block();
console.log("finish calling");
});
document.getElementById("log").addEventListener("click".function handleLog() {
alert("hello");
});
Copy the code
What happens if we click the log button right after we click the Block button?
As you can see, the page is outputstart calling
Then the response is lost until the outputfinish calling
After, the popup window is displayed. The whole process is as follows:
becauseblock
Function execution time is 5 seconds, so the main thread is always executingblock
Button clicking on the macro task resultslog
The button click event is always waiting to be executed in the message queue.
SetTimeout, setTimeInterval
We often use setTimeout to make the callback function run after a specified time. This function returns the current timer number. We can cancel the timer by clearTimeout.
Here’s how setTimeout is used in general:
setTimeout(function callback() {
console.log("setTimeout callback");
}, 1000);
Copy the code
As mentioned earlier, setTimeout generates a macro task, so how does it work? Look at the following code:
console.log("start");
setTimeout(function cb1() {
console.log("callback 1");
}, 0);
setTimeout(function cb2() {
console.log("callback 2");
}, 1000);
console.log("end");
Copy the code
This code outputs the log in order:
"start"
"end"
"callback 1"
"callback 2"
Copy the code
Ok, let’s look at the execution of this code:
The renderer process has dedicated timer threads and timer queues that hold all macro tasks created by setTimeout or setTimeInterval, and timer threads that place expired tasks on message queues.
SetTimeout does not indicate the number of milliseconds after the callback function is executed, but the number of milliseconds after the callback function is placed in the message queue. Therefore, even setTimeout(fn,0) does not mean that fn can be executed immediately, but only that fn can be placed in the message queue immediately. If there are many time-consuming macro tasks ahead of it, its execution time will also be delayed.
SetTimeInterval puts the macro task of the callback function into the message queue again at certain intervals, except that the process is similar to setTimeout, so I won’t repeat it.
XMLHttpRequest
In the browser, you can use the native XMLHttpRequest function for web requests, and you can specify the callback function for each stage of the web request. There are also network threads in the renderer process that handle network requests, and when the network request status is updated, macro tasks are generated according to the callback function we set and placed in the message queue. The main thread executes these macro tasks in the subsequent event loop.
Take the following code for example:
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = function change() {
console.log(`readyState: ${xhr.readyState}, status: ${xhr.status}`);
};
xhr.onloadend = function loaded() {
console.log("response:", xhr.response);
};
xhr.open("get"."/api/example");
xhr.send();
Copy the code
This code will print:
"readyState: 1, status: 0" "readyState: 2, status: 200" "readyState: 3, status: 200" "readyState: 4, status: 200 "" response: {" data" : [1, 2, 3, 4]}"Copy the code
Ok, let’s look at the execution of this code:
Note that in the callopen
Method is executed immediately after the synchronizationchange
Method callback while insend
The network thread then actually sends the request, detects the status of the request, and puts the corresponding callback task into the message queue when the status of the request changes.
Micro tasks
Macro tasks can handle most everyday requirements, but if some requirements require high time accuracy, then macro tasks may not be able to handle them. For example, if you want some callback functions to be executed as early as possible, even using setTimeout(fn,0) may be delayed by existing macro tasks.
To meet the need for higher precision, microtasks have emerged.
Microtask has corresponding microtask queue. The event loop system will check whether there are microtasks in the microtask queue before the end of the current macro task. If there are, the microtasks will be executed successively until there are no new microtasks in the microtask queue. Proceed to the next macro task.
At this point, we can improve the process of the event cycle as follows:
while (true) {
const task = getNextTask(); // If there is no task, wait here
processTask(task);
while (microQueue.hasMicroTask()) {
constmicroTask = microQueue.pop(); processTask(microTask); }}Copy the code
Promise.then
A Promise, after resolve, generates a microtask when the then method is executed.
Take the following code for example:
console.log("start");
setTimeout(function macro() {
console.log("macro callback");
}, 0);
Promise.resolve().then(function micro() {
console.log("micro callback");
});
console.log("end");
Copy the code
This code will print:
"start"
"end"
"micro callback"
"macro callback"
Copy the code
Ok, let’s see how this code executes:
Microtasks are like VIP customers, while macro tasks are ordinary customers. When the main thread completes its current task, it receives VIP clients first. After completing all VIP clients’ tasks, it goes back to the next macro task.
What if new microtasks are constantly being created in the process of performing them?
function foo() {
Promise.resolve().then(foo);
}
foo();
Copy the code
The code above makes the page completely unresponsive because after executing foo, a microtask is generated, and when it continues, another microtask is generated. Page interactions are stored in the message queue as macro tasks that are never executed.
If you change the above code to:
function foo() {
setTimeout(foo, 0);
}
foo();
Copy the code
The page responds properly to user interactions, and although we set the latency to 0, the system does not actually queue callbacks immediately (but very quickly), and other interaction or rendering tasks are inserted in between callbacks. This also shows that the macro task is not very accurate.
Async/Await
Now that ES6 is mainstream, we tend to write asynchronous code with Async/Await. Async/Await is essentially the syngrammatical sugar of Generator and Promise, and it is easy to understand Async/Await as long as we understand how promises work in event loops.
Take the following code for example:
console.log("start");
async function getNumber() {
console.log("async start");
const a = await 1;
console.log("get a", a);
const b = await Promise.resolve(2);
console.log("get b", b);
return a + b;
}
getNumber().then((number) = > {
console.log("result", number);
});
setTimeout(() = > {
console.log("macro callback");
}, 0);
console.log("end");
Copy the code
This code will print:
"start"
"async start"
"end"
"get a 1"
"get b 2"
"result 3"
"macro callback"
Copy the code
The code before the first await function in the getNumber function is executed synchronously, and any object followed by an await function is converted to a Promise. When the function execution encounters an await function, it will return and wait until the object is resolved.
To make it easier to understand, we can convert the getNumber code to:
function getNumber() {
return new Promise((resolve) = > {
console.log("async start");
Promise.resolve(1).then((a) = > {
console.log("get a", a);
Promise.resolve(2).then((b) = > {
console.log("get b", b);
resolve(a + b);
});
});
});
}
Copy the code
Does every Event Loop cause the page to render?
In addition to executing JS code, the main thread will also perform tasks of the rendering pipeline, such as style calculation, layout calculation, layering, drawing and so on. Then whether every Event Loop will cause the page to be re-rendered and the rendering pipeline to be re-executed?
The answer is that the render pipeline does not execute every Event Loop. The browser is smart enough to determine if the code we execute and our interactions with the page will cause the page content to change, and only perform the necessary rendering pipeline steps to update the page if we do change the page content in the current loop or if the requestAnimationFrame callback is not empty.
At this point, we can further improve the event loop as:
while (true) {
const task = getNextTask(); // If there is no task, wait here
processTask(task);
while (microQueue.hasMicroTask()) {
const microTask = microQueue.pop();
processTask(microTask);
}
if(needRepaint()) { repaint(); }}Copy the code
When we add the rendering pipeline, the event rendering flow will look like this:
requestAnimationFrame
The official recommendation for animation is to use requestAnimationFrame (hereafter referred to as rAF), which gives us an additional opportunity to change the structure of the page before each frame actually goes to the render step, and render it quickly in the subsequent drawing.
When rAF is added, the event rendering process will look like this:
ifrAF
If there are multiple callback tasks, they will all be executed at this point in the rendering.If therAF
Called again in the callbackrAF
The newrAF
It will be executed in the next render, not this one.
// cb1 and CB2 are executed in the same render
requestAnimationFrame(function cb1() {
console.log("task 1");
});
requestAnimationFrame(function cb2() {
console.log("task 2 ");
});
// CB4 will be executed in the next render of CB3
requestAnimationFrame(function cb3() {
console.log("task 3");
requestAnimationFrame(function cb4() {
console.log("task 4 ");
});
});
Copy the code
Event cycle after joining rAF:
while (true) {
/ /...
if (needRepaint()) {
const rAFTasks = animationQueue.copyTasks(); // Get all current rAF tasks, so rAF tasks generated during rAF execution will not be executed in this round
for (const task inrAFTasks) { processTask(task); } repaint(); }}Copy the code
Timer merge
Timer macro tasks may skip rendering directly, which is one of the browser’s optimizations. Take the following code for example:
setTimeout(() = > {
console.log("callback1");
requestAnimationFrame(() = > console.log("rAF1"));
});
setTimeout(() = > {
console.log("callback2");
requestAnimationFrame(() = > console.log("rAF2"));
});
Copy the code
The actual output is:
"Callback1" "callback2" "rAF1 rAF2"Copy the code
The rendering step actually begins after both due tasks have been executed.
A more real world
So far, we’ve focused on a message queue where the main thread executes its tasks in sequence.
However, the actual situation is more complicated. There are usually multiple message queues in a rendering process, such as the queue for storing keyboard and mouse input events, the timer queue for storing timer callback, and the idle queue with low real-time performance such as garbage collection events, etc. And give different priorities to different queues in different scenarios.
In each event cycle, the browser selects the task with the highest priority from multiple message queues to execute on the premise of ensuring the task order. After executing multiple high-priority tasks in a row, the browser must execute a low-priority task in the middle to ensure that the low-priority task will not starve to death.
So far, this is basically the full view of the browser event loop, with the following pseudo-code for the entire process:
while (true) {
const queue = getNextQueue(); // Select the appropriate queue from multiple task queues
const task = queue.pop(); // Delete the oldest tasks according to the first in, first out principle
processTask(task); // Execute the macro task
while (microQueue.hasMicroTask()) { // Perform all microtasks until the macro task is complete
const microTask = microQueue.pop();
processTask(microTask);
}
if (needRepaint()) { // Determine whether the operation performed by the current task needs to be re-rendered
const rAFTasks = animationQueue.copyTasks(); // Get all current rAF tasks, so rAF tasks generated during rAF execution will not be executed in this round
for (const task inrAFTasks) { processTask(task); } repaint(); }}Copy the code
If you have any comments or suggestions on this article, welcome to discuss and correct!