17

Asynchronous programming in JavaScript • JavaScript for impatient programmers

 4 years ago
source link: https://exploringjs.com/impatient-js/ch_async-js.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

36 Asynchronous programming in JavaScript


This chapter explains the foundations of asynchronous programming in JavaScript.

36.1 A roadmap for asynchronous programming in JavaScript #

This section provides a roadmap for the content on asynchronous programming in JavaScript.

eye-regular.svg  Don’t worry about the details!

Don’t worry if you don’t understand everything yet. This is just a quick peek at what’s coming up.

36.1.1 Synchronous functions #

Normal functions are synchronous: the caller waits until the callee is finished with its computation. divideSync() in line A is a synchronous function call:

function main() {
  try {
    const result = divideSync(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

36.1.2 JavaScript executes tasks sequentially in a single process #

By default, JavaScript tasks are functions that are executed sequentially in a single process. That looks like this:

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

This loop is also called the event loop because events, such as clicking a mouse, add tasks to the queue.

Due to this style of cooperative multitasking, we don’t want a task to block other tasks from being executed while, for example, it waits for results coming from a server. The next subsection explores how to handle this case.

36.1.3 Callback-based asynchronous functions #

What if divide() needs a server to compute its result? Then the result should be delivered in a different manner: The caller shouldn’t have to wait (synchronously) until the result is ready; it should be notified (asynchronously) when it is. One way of delivering the result asynchronously is by giving divide() a callback function that it uses to notify the caller.

function main() {
  divideCallback(12, 3,
    (err, result) => {
      if (err) {
        assert.fail(err);
      } else {
        assert.equal(result, 4);
      }
    });
}

When there is an asynchronous function call:

divideCallback(x, y, callback)

Then the following steps happen:

  • divideCallback() sends a request to a server.
  • Then the current task main() is finished and other tasks can be executed.
  • When a response from the server arrives, it is either:
    • An error err: Then the following task is added to the queue.

      taskQueue.enqueue(() => callback(err));
    • A result r: Then the following task is added to the queue.

      taskQueue.enqueue(() => callback(null, r));

36.1.4 Promise-based asynchronous functions #

Promises are two things:

  • A standard pattern that makes working with callbacks easier.
  • The mechanism on which async functions (the topic of the next subsection) are built.

Invoking a Promise-based function looks as follows.

function main() {
  dividePromise(12, 3)
    .then(result => assert.equal(result, 4))
    .catch(err => assert.fail(err));
}

36.1.5 Async functions #

One way of looking at async functions is as better syntax for Promise-based code:

async function main() {
  try {
    const result = await dividePromise(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

The dividePromise() we are calling in line A is the same Promise-based function as in the previous section. But we now have synchronous-looking syntax for handling the call. await can only be used inside a special kind of function, an async function (note the keyword async in front of the keyword function). await pauses the current async function and returns from it. Once the awaited result is ready, the execution of the function continues where it left off.

36.1.6 Next steps #

36.2 The call stack #

Whenever a function calls another function, we need to remember where to return to after the latter function is finished. That is typically done via a stack – the call stack: the caller pushes onto it the location to return to, and the callee jumps to that location after it is done.

This is an example where several calls happen:

function h(z) {
  const error = new Error();
  console.log(error.stack);
}
function g(y) {
  h(y + 1);
}
function f(x) {
  g(x + 1);
}
f(3);
// done

Initially, before running this piece of code, the call stack is empty. After the function call f(3) in line 11, the stack has one entry:

  • Line 12 (location in top-level scope)

After the function call g(x + 1) in line 9, the stack has two entries:

  • Line 10 (location in f())
  • Line 12 (location in top-level scope)

After the function call h(y + 1) in line 6, the stack has three entries:

  • Line 7 (location in g())
  • Line 10 (location in f())
  • Line 12 (location in top-level scope)

Logging error in line 3, produces the following output:

Error: 
    at h (demos/async-js/stack_trace.mjs:2:17)
    at g (demos/async-js/stack_trace.mjs:6:3)
    at f (demos/async-js/stack_trace.mjs:9:3)
    at demos/async-js/stack_trace.mjs:11:1

This is a so-called stack trace of where the Error object was created. Note that it records where calls were made, not return locations. Creating the exception in line 2 is yet another call. That’s why the stack trace includes a location inside h().

After line 3, each of the functions terminates and each time, the top entry is removed from the call stack. After function f is done, we are back in top-level scope and the stack is empty. When the code fragment ends then that is like an implicit return. If we consider the code fragment to be a task that is executed, then returning with an empty call stack ends the task.

36.3 The event loop #

By default, JavaScript runs in a single process – in both web browsers and Node.js. The so-called event loop sequentially executes tasks (pieces of code) inside that process. The event loop is depicted in fig. 21.

5ff3ceec8864686095e9580568899744400e5b00.svg

Figure 21: Task sources add code to run to the task queue, which is emptied by the event loop.

Two parties access the task queue:

  • Task sources add tasks to the queue. Some of those sources run concurrently to the JavaScript process. For example, one task source takes care of user interface events: if a user clicks somewhere and a click listener was registered, then an invocation of that listener is added to the task queue.

  • The event loop runs continuously inside the JavaScript process. During each loop iteration, it takes one task out of the queue (if the queue is empty, it waits until it isn’t) and executes it. That task is finished when the call stack is empty and there is a return. Control goes back to the event loop, which then retrieves the next task from the queue and executes it. And so on.

The following JavaScript code is an approximation of the event loop:

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

36.4 How to avoid blocking the JavaScript process #

36.4.1 The user interface of the browser can be blocked #

Many of the user interface mechanisms of browsers also run in the JavaScript process (as tasks). Therefore, long-running JavaScript code can block the user interface. Let’s look at a web page that demonstrates that. There are two ways in which you can try out that page:

  • You can run it online.
  • You can open the following file inside the repository with the exercises: demos/async-js/blocking.html

The following HTML is the page’s user interface:

<a id="block" href="">Block</a>
<div id="statusMessage"></div>
<button>Click me!</button>

The idea is that you click “Block” and a long-running loop is executed via JavaScript. During that loop, you can’t click the button because the browser/JavaScript process is blocked.

A simplified version of the JavaScript code looks like this:

document.getElementById('block')
  .addEventListener('click', doBlock); // (A)

function doBlock(event) {
  // ···
  displayStatus('Blocking...');
  // ···
  sleep(5000); // (B)
  displayStatus('Done');
}

function sleep(milliseconds) {
  const start = Date.now();
  while ((Date.now() - start) < milliseconds);
}
function displayStatus(status) {
  document.getElementById('statusMessage')
    .textContent = status;
}

These are the key parts of the code:

  • Line A: We tell the browser to call doBlock() whenever the HTML element is clicked whose ID is block.
  • doBlock() displays status information and then calls sleep() to block the JavaScript process for 5000 milliseconds (line B).
  • sleep() blocks the JavaScript process by looping until enough time has passed.
  • displayStatus() displays status messages inside the <div> whose ID is statusMessage.

36.4.2 How can we avoid blocking the browser? #

There are several ways in which you can prevent a long-running operation from blocking the browser:

  • The operation can deliver its result asynchronously: Some operations, such as downloads, can be performed concurrently to the JavaScript process. The JavaScript code triggering such an operation registers a callback, which is invoked with the result once the operation is finished. The invocation is handled via the task queue. This style of delivering a result is called asynchronous because the caller doesn’t wait until the results are ready. Normal function calls deliver their results synchronously.

  • Perform long computations in separate processes: This can be done via so-called Web Workers. Web Workers are heavyweight processes that run concurrently to the main process. Each one of them has its own runtime environment (global variables, etc.). They are completely isolated and must be communicated with via message passing. Consult MDN web docs for more information.

  • Take breaks during long computations. The next subsection explains how.

36.4.3 Taking breaks #

The following global function executes its parameter callback after a delay of ms milliseconds (the type signature is simplified – setTimeout() has more features):

function setTimeout(callback: () => void, ms: number): any

The function returns a handle (an ID) that can be used to clear the timeout (cancel the execution of the callback) via the following global function:

function clearTimeout(handle?: any): void

setTimeout() is available on both browsers and Node.js. The next subsection shows it in action.

lightbulb-regular.svg  setTimeout() lets tasks take breaks

Another way of looking at setTimeout() is that the current task takes a break and continues later via the callback.

36.4.4 Run-to-completion semantics #

JavaScript makes a guarantee for tasks:

Each task is always finished (“run to completion”) before the next task is executed.

As a consequence, tasks don’t have to worry about their data being changed while they are working on it (concurrent modification). That simplifies programming in JavaScript.

The following example demonstrates this guarantee:

console.log('start');
setTimeout(() => {
  console.log('callback');
}, 0);
console.log('end');

// Output:
// 'start'
// 'end'
// 'callback'

setTimeout() puts its parameter into the task queue. The parameter is therefore executed sometime after the current piece of code (task) is completely finished.

The parameter ms only specifies when the task is put into the queue, not when exactly it runs. It may even never run – for example, if there is a task before it in the queue that never terminates. That explains why the previous code logs 'end' before 'callback', even though the parameter ms is 0.

36.5 Patterns for delivering asynchronous results #

In order to avoid blocking the main process while waiting for a long-running operation to finish, results are often delivered asynchronously in JavaScript. These are three popular patterns for doing so:

  • Events
  • Callbacks
  • Promises

The first two patterns are explained in the next two subsections. Promises are explained in the next chapter.

36.5.1 Delivering asynchronous results via events #

Events as a pattern work as follows:

  • They are used to deliver values asynchronously.
  • They do so zero or more times.
  • There are three roles in this pattern:
    • The event (an object) carries the data to be delivered.
    • The event listener is a function that receives events via a parameter.
    • The event source sends events and lets you register event listeners.

Multiple variations of this pattern exist in the world of JavaScript. We’ll look at three examples next.

36.5.1.1 Events: IndexedDB #

IndexedDB is a database that is built into web browsers. This is an example of using it:

const openRequest = indexedDB.open('MyDatabase', 1); // (A)

openRequest.onsuccess = (event) => {
  const db = event.target.result;
  // ···
};

openRequest.onerror = (error) => {
  console.error(error);
};

indexedDB has an unusual way of invoking operations:

  • Each operation has an associated method for creating request objects. For example, in line A, the operation is “open”, the method is .open(), and the request object is openRequest.

  • The parameters for the operation are provided via the request object, not via parameters of the method. For example, the event listeners (functions) are stored in the properties .onsuccess and .onerror.

  • The invocation of the operation is added to the task queue via the method (in line A). That is, we configure the operation after its invocation has already been added to the queue. Only run-to-completion semantics saves us from race conditions here and ensures that the operation runs after the current code fragment is finished.

36.5.1.2 Events: XMLHttpRequest #

The XMLHttpRequest API lets us make downloads from within a web browser. This is how we download the file http://example.com/textfile.txt:

const xhr = new XMLHttpRequest(); // (A)
xhr.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
  if (xhr.status == 200) {
    processData(xhr.responseText);
  } else {
    assert.fail(new Error(xhr.statusText));
  }
};
xhr.onerror = () => { // (D)
  assert.fail(new Error('Network error'));
};
xhr.send(); // (E)

function processData(str) {
  assert.equal(str, 'Content of textfile.txt\n');
}

With this API, we first create a request object (line A), then configure it, then activate it (line E). The configuration consists of:

  • Specifying which HTTP request method to use (line B): GET, POST, PUT, etc.
  • Registering a listener (line C) that is notified if something could be downloaded. Inside the listener, we still need to determine if the download contains what we requested or informs us of an error. Note that some of the result data is delivered via the request object xhr. (I’m not a fan of this kind of mixing of input and output data.)
  • Registering a listener (line D) that is notified if there was a network error.
36.5.1.3 Events: DOM #

We have already seen DOM events in action in §36.4.1 “The user interface of the browser can be blocked”. The following code also handles click events:

const element = document.getElementById('my-link'); // (A)
element.addEventListener('click', clickListener); // (B)

function clickListener(event) {
  event.preventDefault(); // (C)
  console.log(event.shiftKey); // (D)
}

We first ask the browser to retrieve the HTML element whose ID is 'my-link' (line A). Then we add a listener for all click events (line B). In the listener, we first tell the browser not to perform its default action (line C) – going to the target of the link. Then we log to the console if the shift key is currently pressed (line D).

36.5.2 Delivering asynchronous results via callbacks #

Callbacks are another pattern for handling asynchronous results. They are only used for one-off results and have the advantage of being less verbose than events.

As an example, consider a function readFile() that reads a text file and returns its contents asynchronously. This is how you call readFile() if it uses Node.js-style callbacks:

readFile('some-file.txt', {encoding: 'utf8'},
  (error, data) => {
    if (error) {
      assert.fail(error);
      return;
    }
    assert.equal(data, 'The content of some-file.txt\n');
  });

There is a single callback that handles both success and failure. If the first parameter is not null then an error happened. Otherwise, the result can be found in the second parameter.

puzzle-piece-regular.svg  Exercises: Callback-based code

The following exercises use tests for asynchronous code, which are different from tests for synchronous code. Consult §9.3.2 “Asynchronous tests in AVA” for more information.

  • From synchronous to callback-based code: exercises/async-js/read_file_cb_exrc.mjs
  • Implementing a callback-based version of .map(): exercises/async-js/map_cb_test.mjs

36.6 Asynchronous code: the downsides #

In many situations, on either browsers or Node.js, you have no choice, you must use asynchronous code. In this chapter, we have seen several patterns that such code can use. All of them have two disadvantages:

  • Asynchronous code is more verbose than synchronous code.
  • If you call asynchronous code, your code must become asynchronous too. That’s because you can’t wait synchronously for an asynchronous result. Asynchronous code has an infectious quality.

The first disadvantage becomes less severe with Promises (covered in the next chapter) and mostly disappears with async functions (covered in the chapter after next).

Alas, the infectiousness of async code does not go away. But it is mitigated by the fact that switching between sync and async is easy with async functions.

36.7 Resources #


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK