async-await.md 9.33 KB

async-await

Recap of Promises

JavaScript's Promise abstraction was discussed in the notes near the start of the course and you had a chance to use it on the Make project. I'll give a quick refresher since Promises are essential to the async-await syntax.

A Promise represents an asynchronous task or computation. When the task or computation finishes, the Promise "resolves" to a value. If an error occurs, the Promise "rejects" with the error.

The usefulness of Promises comes from the ways they can be composed into more complex Promises. There are two main ways to combine Promises:

  • In sequence. .then() chains two Promises into a Promise that performs the asynchronous tasks or computations one after another. You can build long chains by calling .then() more times:
    // Build a Promise that performs 4 asynchronous computations in sequence
    promiseA
      .then(a => { /* build and return promiseB... */ })
      .then(b => { /* build and return promiseC... */ })
      .then(c => { /* build and return promiseD... */ })
  • In parallel. Promise.all() combines an array of Promises into a Promise that performs all the tasks or computations in parallel and resolves to an array containing all their results:
    // Build a promise that performs 3 asynchronous computations in parallel
    // and resolves with their results once they have all finished
    Promise.all([promiseA, promiseB, promiseC])

async-await syntax

Promises can greatly simplify asynchronous programs. Frequently, several asynchronous tasks need to happen in sequence. In this case, .then() provides an interface that looks closer to how the program would be written in a synchronous language. Recently, new syntax was added to JavaScript that makes asynchronous code look even more like synchronous code. It relies on two keywords:

  • async: we can declare an "async function" by putting async in front of it:
    async function name(...arguments) {
      /* ... */
    }
    // or
    async (...arguments) => { /* ... */ }
    Unlike normal functions, async functions return Promises. If the function returns a value, the Promise resolves with that value. If the function throws an error, the Promise rejects with that error.
  • await: inside an async function, the keyword await can be used to "wait" for a Promise to resolve. If the Promise throws an error, it can be caught by a try-catch block.

It is easiest to explain async-await with some examples.

Example: recursively exploring a directory

We can rewrite the code from the Promises notes for computing the modification time of a directory. Recall that we want to find the latest modification time of any file inside the directory (or any subdirectories). By making modifiedTime() an async function, we can use await instead of explicit .then() calls on Promises. You can compare this async-await version with the Promises version.

// Returns a Promise<number> that computes the most recent
// modification time for any file inside a directory
async function modifiedTime(path) {
  // First, we wait for the fs.stat() Promise
  const stats = await fs.stat(path)
  // Use the modification time of the file or directory itself
  let latestTime = stats.mtimeMs
  // If this is a directory, check all files inside for later modified times
  if (stats.isDirectory()) {
    // Wait to get a list of all files/subdirectories in the directory
    const files = await fs.readdir(path)
    // Then get the modified time of each file/subdirectory
    for (const file of files) {
      const fileModifiedTime = await modifiedTime(path + '/' + file)
      // update latestTime if the modification time is later
      latestTime = Math.max(latestTime, fileModifiedTime)
    }
  }
  // Return the latest modification time
  return latestTime
}

However, this code no longer explores subdirectories in parallel, which was the main advantage of asynchronicity. Can you see why?

The issue is that we have replaced Promise.all(files.map(file => /* ... */)) with a for (const file of files) loop. Since await modifiedTime(path + '/' + file) waits for modifiedTime() to finish, the loop waits for file to be completely explored before continuing. This means the subdirectories are explored sequentially.

If we want to perform asynchronous tasks in parallel inside an async function, we still need to use Promise.all(). Here is a parallel version using Promise.all():

async function modifiedTime(path) {
  const stats = await fs.stat(path)
  const {mtimeMs} = stats
  // If this path is a file, return its modification time
  if (!stats.isDirectory()) return mtimeMs

  // If this is a directory, check all files inside for later modified times
  const files = await fs.readdir(path)
  // Get the modified time of each file/subdirectory in parallel
  const modifiedTimes = await Promise.all(files.map(file =>
    modifiedTime(path + '/' + file)
  ))
  // Return the latest modification time
  return Math.max(mtimeMs, ...modifiedTimes)
}

Example: emoji search

The Unicode Consortium defines the mapping from numbers to characters (including emoji) that almost everyone uses. For example, Unicode defines 65 to mean A, 233 to mean é, and 128027 to mean 🐛.

We use this mapping to make an emoji search that finds a Unicode character by name (e.g. pile of poo) and prints out the character. We first fetch the current Unicode version (updated each year) from Unicode's ReadMe.txt file. We then fetch the current UnicodeData.txt file which lists the tens of thousands of Unicode characters. Note how await allows us to execute 4 Promises sequentially in getUnicodeNumber() without needing to combine them with .then().

async function getUnicodeNumber(searchName) {
  // Fetch the readme to determine the latest Unicode version (e.g. 13.0.0)
  const readmeResponse = await fetch(README_URL)
  const readme = await readmeResponse.text() // read readme as a string
  // The last line of the readme contains the current Unicode URL,
  // e.g. https://www.unicode.org/Public/13.0.0/ucd/
  const [latestURL] = readme.trim().split('\n').slice(-1)

  // Then fetch the list of Unicode characters
  const charactersResponse = await fetch(latestURL + UNICODE_FILE)
  const charactersData = await charactersResponse.text()
  // Each line of the data file corresponds to one Unicode character
  for (const characterData of charactersData.split('\n')) {
    // Each line has several fields separated by `;`.
    // The first is the character's number and the second is its name.
    const [characterNumber, characterName] = characterData.split(';')
    if (characterName.toLowerCase() === searchName) {
      // If this is the requested character, read its number in base-16
      return parseInt(characterNumber, 16)
    }
  }
  return undefined
}

async function printUnicode(searchName) {
  // getUnicodeNumber() returns a Promise, so we need to await it
  const number = await getUnicodeNumber(searchName)
  // Convert the Unicode number to a character
  if (number !== undefined) console.log(String.fromCodePoint(number))
}

(fetch() turns an HTTP/HTTPS request into a Promise, as explained in the HTTP notes.)

Example: create files without overwriting existing files

When an awaited Promise rejects with an error, you can catch it using a try-catch block, just like a normal JavaScript error.

For example, suppose we want to create some files but not overwrite any existing files with the same name. We can use the wx flag to make fs.writeFile() reject if a file already exists. We want either all or none of the files to be created, so if one already exists, we stop and remove all the files that were already created.

// Creates an array of files. If any file already exists,
// no files are created and an error is thrown.
async function writeFiles(files) {
  for (let i = 0; i < files.length; i++) {
    const file = files[i]
    try {
      // Write to the file, but throw an error if the file exists
      await fs.writeFile(file.name, file.contents, {flag: 'wx'})
    }
    catch (e) {
      // File already exists, so remove all the previously created files
      await Promise.all(files.slice(0, i).map(file =>
        fs.unlink(file.name)
      ))
      // Re-throw the error
      throw e
    }
  }
}

// Try to create 3 files
writeFiles([
  {name: 'a.txt', contents: 'abc'},
  {name: 'b.txt', contents: '123'},
  {name: 'c.txt', contents: 'xyz'}
])
  .catch(_ => console.log('Some files already existed'))

You may notice that the files are not written in parallel. It is possible to fix this by Promise.all()ing the fs.writeFile()s, but this is tricky since the Promise returned by Promise.all() rejects as soon as any of the Promises reject. If the the Promise.all() rejected, we would need to wait for each file to finish being written before trying to remove it.