Chen Chen, front-end engineer of Wedoctor Cloud service team, is a programmer of “Life lies in stillness”.

The origin of asynchrony

JavaScript is a single-threaded language, and the browser assigns only one main thread to execute the task, meaning that if there are multiple tasks, they must be executed in sequence, with the first one completed before the next one can proceed.

This mode is relatively clear. However, when the task takes a long time, such as network request, timer and event listening, the efficiency of subsequent tasks is low. Our common unresponsive pages are sometimes caused by tasks that take too long or loop forever. So how to solve this problem now…

First, a “task queue” is maintained. JavaScript is single-threaded, but the running host environment (browser) is multi-threaded. The browser has opened up additional threads for these time-consuming tasks, mainly including HTTP request threads, browser timing triggers, and browser event trigger threads. These threads mainly call back tasks and place them in a task queue, waiting for the main thread to execute.

A brief introduction is as follows:

In this way, the single-threaded asynchronous JavaScript is realized. The tasks are divided into synchronous tasks and asynchronous tasks: Synchronous tasks: queued tasks. The latter task waits for the end of the previous task. Asynchronous task: a task that is placed in a task queue and will trigger an event to be executed in the future.

Asynchronous execution mechanism

Asynchronous tasks are divided into macro tasks and micro tasks.

MacroTasks

Macro tasks, which are normal tasks under the standard mechanism, i.e. events in the “task queue” waiting to be executed by the main thread, are tasks initiated by the browser host, such as:

  • Script (can be understood as the outer main program synchronization code)
  • SetTimeout, setInterval, requestAnimationFrame.
  • I/O.
  • Render events (DOM parsing, layout, drawing, etc.).
  • User interaction events (mouse click, page scroll, zoom in and out, etc.).

Macro tasks will be placed in the macro task queue, the principle of first in, first out, two macro tasks may be inserted into other system tasks, the interval is uncertain, low efficiency.

Microtasks

Due to the variable interval and large time particles of macro tasks, the scenes requiring high real-time performance need to be controlled more accurately, and tasks need to be inserted into the current macro task execution, thus the concept of micro-task is generated. Microtasks are functions initiated by the JavaScript engine that need to be executed asynchronously. Such as:

  • Promise: Asynchronous programming for ES6. The various apis of Promise generate microtasks, as described below in the asynchronous implementation.
  • MutationObserver (browser) : Monitors DOM tree changes, and DOM node changes are microtasks.

When a JavaScript script is executed to create a global execution context, the JavaScript engine creates a microtask queue, and the microtasks generated during the execution of the current macro task are stored in the microtask queue. Clear the microtask queue after the macro task main function is finished and before the macro task is finished.

Microtasks and macro tasks are bound, and each macro task creates its own microtask:

Event loop

When the main thread runs JavaScript code, it generates an execution stack (first in, last out) that manages the data structure of the function call relationship on the main thread. When all synchronous tasks in the stack are completed, the system continuously reads events from the “task queue” in a continuous Loop called an Event Loop. The event loop mechanism schedules macro and micro tasks as follows:

  1. Execute a macro task (the first is the outermost synchronization code), and join the microtask queue if it encounters a microtask during execution;
  2. After the code is executed, check whether there are microtasks. If there are, perform step 3. If there are not, perform Step 4.
  3. All microtasks are executed in turn. New microtasks generated during the execution of microtasks will also be processed by the event cycle until the queue is emptied and the macro task is completed, and step 4 is executed.
  4. Check to see if the next macro task exists. If so, perform step 1. If not, end.

Because the microtasks themselves can be loaded with more microtasks, and the event loop continues to process the microtasks until the queue is empty, there is a real risk that the event loop will process the microtasks indefinitely. Be careful how you handle recursively increasing microtasks.

Asynchronous implementation process

The callback function

A callback function is a function that is passed as an argument to another function, and when the other function completes, the callback is executed. Ajax requests, IO operations, timer callbacks, etc. Here is the setTimeout example:

Console. log(' before setTimeout call ') setTimeout(() => {console.log('setTimeout output ')}, 0); Console. log(' after setTimeout call ') // result before setTimeout call setTimeout after call setTimeout outputCopy the code

The setTimeout callback is put into the task queue. The callback of the task queue will be executed only after the synchronization code of the main thread has finished executing, so this is the output above.

The advantages and disadvantages

Advantages: The callback function is relatively simple and easy to understand. Cons: Bad code to read and maintain, highly coupled parts, messy process, and only one callback per task, easy callback hell. As follows:

setTimeout(function(){ let value1 = step1() setTimeout(function(){ let value2 = step2(value1) setTimeout(function(){ step3(value2) },0); }, 0); }, 0);Copy the code

Promise

Promise is a new asynchronous programming approach in ES6 that partially addresses the issue of callback regions. Simply put, it is a container that holds the result of some event (usually an asynchronous operation) that will end in the future. Syntactically, a Promise is an object from which to get messages for asynchronous operations. The first thing you need to know about using promises is:

  1. Promise has three states: Pending, Rejected and Resolved. Once the state is determined, it cannot be changed and can only be changed from the pending state to the Rejected or Resolved state.
  2. The primary method for Promise instances is the implementation of THEN, which takes two parameters. On success, the first callback of the THEN method is called, on failure, the second callback is called, and the THEN method returns a new Promise instance.
  3. The second most common method is the catch method, which specifies the callback function if an error occurs when the first argument to the then method is null.
  4. There are many other finally, All, Race, allSettled, Any, Resolve, Reject, and a series of other apis.

The following examples are common asynchronous operations using then and catch:

new Promise((resolve) => {
    resolve(step1())
}).then(res => {
    return step2(res)
}).catch(err => {
    console.log(err)
})
Copy the code

Step1 and Step2 are asynchronous operations. The return value after step1 is executed will be transparently transmitted to the THEN callback, which is used as the input parameter of Step2 to replace the callback region through then layer by layer. Where then callbacks are added to the microtask queue.

Why is Promise a microtask?

When the Promise entry is synchronous code:

Console. log('start') new Promise((resolve) => {console.log('start resolve') resolve('resolve return value ')}). Then (data => {console.log('start resolve') Console. log(data)}) console.log('end') // Native promise output result start start resolve end resolve returns the valueCopy the code

Let’s take a look at the minimalist implementation of Promise:

Class Promise {constructor (executor) {constructor () {this.value = "// Successful callback this.onResolvedCallbacks = [] executor(this.resolve.bind(this)) } resolve (value) { this.value = value this.onResolvedCallbacks.forEach(callback => callback()) } then (onResolved, OnRejected) {enclosing onResolvedCallbacks. Push (() = > {onResolved (enclosing value)})}} / / at this point in the above example the execution result start began to resolve end as followsCopy the code

Since Promise is a deferred binding mechanism (the callback comes after the business code) and executors are synchronous code that has not yet executed then at resolve, onResolvedCallbacks are empty arrays. Add a timer to resolve. As follows:

resolve (value) {
    setTimeout(() => {
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    })
}
Copy the code

The output is as expected, and setTimeout is used to delay the execution of resolve. However, setTimeout is a macro task with low efficiency, so it is only replaced by setTimeout. In the browser, the JavaScript engine will map the Promise callback to the microtask, which can not only delay being called, but also improve the efficiency of the code.

The advantages and disadvantages

Advantages:

  • Express asynchronous operations as a flow of synchronous operations, avoiding layers of nested callback functions.
  • Provides a unified interface that makes it easier to control asynchronous operations.

Disadvantages:

  • There is no way to cancel a Promise, which is executed as soon as it is created and cannot be cancelled halfway through.
  • If the callback function is not set, errors thrown inside a Promise will not be reflected outside.
  • When you are in a pending state, you have no way of knowing what stage of progress you are currently in (just started or about to complete).

Generator/yield

Generator is an asynchronous solution provided by ES6. The most important feature of Generator is that it can control the execution of functions. The entire Generator function is a encapsulated asynchronous task, or a container for asynchronous tasks, and where asynchronous operations need to be paused, use yield statements. Characteristics of Generator functions:

  1. There is an asterisk between the function keyword and the function name;
  2. Use yield expressions inside the function body to define different internal states.
  3. Suspend execution by yield;
  4. Next resumes execution and returns an object containing both value and done properties, where value indicates the yield expression value and done indicates whether the traverser is complete.
  5. The next method can also accept arguments as the return value of the previous yield statement.
Function * getData () {let value1 = yield 111 let value2 = yield Value1 + 111 value2 } let meth = getData() let val1 = meth.next() console.log(val1) // { value: 111, done: false } let val2 = meth.next(val1.value) console.log(val2) // { value: 222, done: false } let val3 = meth.next(val2.value) console.log(val3) // { value: 222, done: true }Copy the code
  1. Calling getData returns an internal pointer, meth (traverser);
  2. Call the next method of pointer meth, move the internal pointer to the first yield statement encountered, and return the output value{value: 111, done: false}
  3. The next method of pointer meth is called again, with an input parameter of 111, assigned to value1, and the internal pointer is moved to the next yield statement. The return value of the output expression is{value: 222, done: false}
  4. Keep calling the next method of pointer meth with input of 222, assign to value2, encounter the return end traversal, and output the return value{ value: 222, done: true }.

How does the Generator pause and resume execution?

Generator is an implementation of a coroutine. Coroutines: Coroutines are more lightweight than threads. In the context of threads, a thread can have more than one coroutine, which can be understood as a task in a thread. Control through application code. The coroutine flow in the above example is as follows:

  1. An meth coroutine was created using the generator function getData and not executed immediately after creation.
  2. Call meth.next() for the coroutine to execute;
  3. When a coroutine executes, it is paused by the yield keyword;
  4. When the coroutine executes, it encounters a return, the JavaScript engine terminates the current coroutine and returns the result to the parent coroutine.

Meth coroutine and parent coroutine are executed alternately on the main thread, controlled by next() and yield, with user mode only, and high switching efficiency.

The advantages and disadvantages

Advantages: The Generator implements asynchronous control flows in a seemingly sequential, synchronous manner, improving code readability. Disadvantages: You need to manually next to perform the next step.

async/await

Async /await encapsulates Generator functions and auto-executors in a function. It is a syntactic sugar of Generator, simplifying the code of external executors, and replacing yield with await and async with Generator (*) signs.

Improvements to Async compared to Generator:

  • Built-in actuators that do not need to be manually executed using next().
  • An await command can be followed by a Promise object or a primitive type value, or if the primitive value is promised.
  • Async returns a Promise. When a non-promise is returned, async wraps it as a Promise.

Here’s an example of sleep:

function sleep(time) { return new Promise((resolve, reject) => { time+=1000 setTimeout(() => { resolve(time); }, 1000); }); } async function test () { let time = 0 for(let i = 0; i < 4; i++) { time = await sleep(time); console.log(time); }} test() // Result 1000 2000 3000Copy the code

The execution result will output time and await every second. The execution will continue only after sleep completes and resolve is returned. The interval is at least one second.

Implement async/await as Generator and Promise.

Function test () {function* stepGenerator() {for (let I = 0; i < 4; i++) { let result = yield sleep(time); console.log(result); }} let step = stepGenerator() let info return new Promise((resolve) => next() function stepNext () {info = If (info.done) {resolve(info.value)} else {// If (info.done) {resolve(info.value)} else { Return promise.resolve (info.value).then((res) => {time = res return stepNext()}} stepNext()})} test()Copy the code
  1. First, wrap async as a Promise, convert async/await to stepGenerator, yield to await;
  2. Perform stepNext ();
  3. In stepNext, the step traverser will execute next(). If done is false, the traversal is not complete. Use promise. resolve to wait for the result and continue next() until done is true and async resolve returns the final result.

The advantages and disadvantages

Advantages: A more simplified approach to Generator, equivalent to automatic Generator execution, cleaner code, simpler. Cons: Abusing await can cause performance problems because await blocks code and non-dependent code loses concurrency.

Multiple asynchronous execution order problems

The execution order of multiple asynchrons is challenging to understand. Let’s put setTimeout, Promise, async/await together to see if the result is the same as expected:

console.log('start') setTimeout(function() { console.log('setTimeout') }, 0); async function test () { let a = await 'await-result' console.log(a) } test() new Promise(function(resolve) { Console. log('promise-resolve') resolve()}). Then (function() {console.log('promise-then')}) console.log('end') // result of execution start promise-resolve end await-result promise-then setTimeoutCopy the code

In the above example, the outer main program and setTimeout are macro tasks, while Promise and async/await are micro tasks, so the whole process is as follows:

  1. The first macro task (the main program) starts by executing —— output start
  2. SetTimeout Joins the macro task queue
  3. Execute test() and async/await to join the microtask queue
  4. The Promise initial entry is synchronized code, and the main program executes —— together to output promise-resolve
  5. Promise’s then callback joins the microtask queue
  6. Continue with the main program —— and print end
  7. Execute the first microtask —— and print await-result
  8. Perform the second microtask —— to print promise-then
  9. Execute the next macro task (setTimeout) —— print setTimeout

conclusion

Front-end programmers often use asynchronous programming in their daily code. Understanding the mechanism and sequence of asynchronous operation is helpful to achieve asynchronous code more smoothly and clearly. This paper mainly analyzes the origin of asynchronous code and asynchronous code implementation, which can be selected according to different scenarios and requirements.

The resources

  • es6.ruanyifeng.com/
  • www.imooc.com/article/287…
  • www.ruanyifeng.com/blog/2012/1…
  • Geek time – dacac (time.geekbang.org/column/intr…).