Asynchronous JavaScript
Overview
Cookbook approach to asynchronous code in modern JavaScript.
The JavaScript event loop and run-to-completion semantics.
Callbacks for event handlers.
Pyramid of doom.
Taming asynchronous code: promises.
Taming asynchronous code: async, await.
Cookbook Approach
Can use asynchronous code without understanding underlying concepts by using
"keywords" async and await:
If a function is documented as async or as returning a promise, then it is
possible to call it using the await keyword.
The await returns with the success value only when the underlying
asynchronous operation completes.
If an error occurs in the asynchronous function, then the resulting exception
can be handled using the usual try - catch .
The await keyword can only be used within functions declared using the
async keyword. Consequently, any use of asynchronous code within a
program will necessitate declaring the top-level function in the program
async .
Enables writing asynchronous code in a synchronous style.
Cookbook Approach to using fetch()
The browser provides fetch() to allow accessing resources asynchronously.
async function getUrl(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error '${response.status}'`);
}
else {
return await response.json();
}
}
This was used in the crawler discussed earlier.
Cookbook Approach to using MongoDB
Docs for connect(), db, collection, findOne() and close().
const mongo = require('mongodb').MongoClient;
async function(mongoUrl, dbName, collectionName, id) {
//bad code: reuse db conn; close() should be in finally.
try {
const client = await mongo.connect(mongoUrl);
const db = client.db(dbName);
const collection = db.collection(collectionName);
const value = await collection.findOne({_id: id});
await client.close();
return value;
}
catch (err) {
console.error(err);
throw err;
}
}
The Need for Concurrency
Modern CPUs have clocks in the low GHz. That means individual CPU
operations occupy under 1 nanosecond.
Typically, I/O may take in the order of milliseconds which is around a million
times slower than CPU operations.
Highly inefficient to have CPU wait for an I/O operation to complete.
Need to concurrently do other stuff while waiting for I/O to complete.
Note that browser responsiveness is usually controlled by I/O responsiveness.
Approaches to Concurrency
Two commonly used approaches to concurrency:
Synchronous Blocking Model
When a program attempts to do I/O, the program blocks until the I/O is
completed. To allow concurrency, the operating system will schedule some
other activity while waiting for the I/O. The unit of scheduling is usually a
process or thread; leads to the process/thread model used by many current
OS's.
Asynchronous Event Model
When a program attempts to do I/O, it merely starts the I/O after registering
a callback handler to handle the I/O completion event. The program
continues running while the I/O is happening concurrently. The completion
of the I/O results in a event which results in the registered handler being
called.
Asynchronicity in JavaScript
JavaScript uses the asynchronous event model.
JavaScript started out simply using callbacks for asynchronous programming.
Promises introduced into JavaScript in ES6 around 2012.
Allow using asynchronous functions in a synchronous style using
async / await in ES2017.
Asynchronicity is required for message passing which is an essential
ingredient of Reactive Programming.
JavaScript Event Loop
The top-level JavaScript runtime consists of an event loop which pulls tasks
corresponding to completed events off a task queue and calls their handler function.
while (taskQueue.notEmpty()) {
const task = taskQueue.remove();
const handler = task.handler();
handler.call(); //pass suitable arguments
}
//terminate program
The hander.call() runs to completion.
Code does not need to deal with an event handler being interrupted.
Code still needs to deal with the fact that the order of running of event
handlers is not defined.
Run to Completion Consequences
In run-to-completion.js:
#!/usr/bin/env nodejs
//BAD CODE!!
function sleep(seconds) {
const stop = Date.now() + seconds*1000;
while (Date.now() < stop) {
//busy waiting: yuck!
}
}
setTimeout(() => console.log('timeout'),
1000 /*delay in milliseconds*/);
sleep(5);
console.log('sleep done');
Run to Completion Log
$ ./run-to-completion.js
sleep done
timeout
$
Because of run-to-completion semantics, it will always be the case that the sleep
done message will be output before the timeout message.
Why This Concurrency Model
JavaScript was designed as a language which should be easy for inexperienced
programmers to use for scripting dynamic behavior in browsers.
Browser reacts to user actions by generating events like key-press, mouse-
click, etc.
Browser programmer needs to provide optional event handlers for these events
in order to implement browser dynamic behavior.
Since every event handler runs to completion, programmer can simply
concentrate on code for that event, ignoring other events (at least for
independent events).
No need for the programmer to understand complex process / threading
models.
Lower overhead for I/O bound tasks; well suited for browser environment.
Playing with Asynchronous Functions
Simulate asynchronous success and failure using async-sim.mjs which uses
setTimeout() to run function asynchronously or fail after a 2-second delay:
const TIMEOUT_MILLIS = 2*1000;
export function asyncSucc(fn, ...args) {
setTimeout(fn, TIMEOUT_MILLIS, ...args);
}
export function asyncErr(msg) {
setTimeout(() => { throw new Error(msg) }, TIMEOUT_MILLIS);
}
// Utilities
export const p = console.log;
export function t() { return new Date().toTimeString(); }
Playing with Asynchronous Functions: Usage
> const { asyncSucc, asyncErr, p, t } =
await import('./async-sim.mjs')
undefined
> p(1, 2, 3)
1 2 3
undefined
> t()
'08:56:59 GMT-0400 (Eastern Daylight Time)'
> asyncSucc(() => p('done'))
undefined
> done
> asyncErr('some error message')
undefined
> Uncaught Error: some error message
>
Accessing Return Value of an Asynchronous
Operation
> function f() {
asyncSucc(() => { p('f run'); return 42; });
}
undefined
> let ret = f();
undefined
> f run
> ret
undefined
>
How do I get hold of the 42 return value.
Return Value of an Asynchronous Operation:
Another Attempt
Try using a global var to get hold of return value.
> function f() {
asyncSucc(() => { p('f run'); ret = 42; });
}
undefined
> ret = -1; f(); p(`ret after f() is ${ret}`);
ret after f() is -1
undefined
> f run
> ret
42
>
The only way to access the return value of an asynchronous operation is via a
parameter to the callback function.
> asyncSucc(callback => callback(42), v => console.log(v))
undefined
> 42
>
Placing Order Simulation
We use a simple async simulation of placing an order. Have an orderer object
which simulates consecutive asynchronous steps:
validate
Validate parameters.
order
Place order.
email
Send confirmation email.
The command-line allows up to two arguments:
1. The module to be used for placing an order.
2. An optional argument which specifies the step at which an error occurs.
Placing an Order using Callbacks: Happy Path
In happy-cb-order.mjs
function doOrder(orderer, params) {
orderer.validate(params, succ => {
orderer.placeOrder(succ, succ => {
orderer.sendEmail(succ, succ => {
console.log(succ);
});
});
});
}
$ ./main.mjs happy-cb-order.mjs
happy-cb-order validated; order placed; email sent
# same result, even tho placing order fails!!
# (extra "order" argument forces error when placing order)
$ ./main.mjs happy-cb-order.mjs order
happy-cb-order validated; order placed; email sent
$
Errors
//Normal exception catching
> try {
throw 'throwing';
} catch (ex) {
p(`caught ${ex}`);
}
caught throwing
undefined
Errors Continued
//Exception in Async not caught
try {
asyncErr('some error');
}
catch (ex) {
p(`caught ${ex}`);
}
undefined
> Uncaught Error: some error
>
Using Node fs.readFile() with Callback
for (const path of [ './readfile.js',
'./xxx.js', ]) {
fs.readFile(path, (err, data) => {
if (err) {
console.error(`cannot read ${path}: ${err}`);
}
else {
console.log(`read ${path}:\n${data.slice(0, 15)}...`);
}
});
}
Placing an Order using Callbacks with Error
Checking
In err-cb-order.mjs:
function doOrder(orderer, params) {
orderer.validate(params, (succ, err) => {
if (err) {
console.error(err);
}
else { //validate ok
orderer.placeOrder(succ, (succ, err) => {
if (err) {
console.error(err);
}
else { //placeOrder ok
orderer.sendEmail(succ, (succ, err) => {
if (err) {
console.error(err);
}
else { //email ok
console.log(succ);
}
}); //email
} //placeOrder ok
}); //placeOrder
} //validate ok
}); //validate
}
Placing an Order using Callbacks with Error
Checking: Log
# happy path
$ ./main.mjs err-cb-order.mjs
err-cb-order validated; order placed; email sent
# catches errors properly
$ ./main.mjs err-cb-order.mjs validate
validate error
$ ./main.mjs err-cb-order.mjs order
order error
$ ./main.mjs err-cb-order.mjs email
email error
$
Problems with Callbacks
A top-level exception handler does not work for asynchronous callbacks since
the handler runs before the callback. Hence exceptions occurring within the
callback are not caught by the top-level exception handler.
If an asynchronous function result needs to be further processed by another
asynchronous function, then we need to have nested callbacks.
A chain of callbacks leads to the pyramid of doom because of nesting of
callbacks.
Promises
A Promise is an object representing the eventual completion or failure of an
asynchronous operation.
When a function which requires an asynchronous callback as an argument is
called, it returns immediately with an object called a pending Promise.
Subsequently, the callbacks can be added to the promise. The callbacks will be
called after the promise has been settled.
let promise = some_call_which_returns_promise(...);
promise.
then(callback1).
then(callback2).
...
catch(errorCallback);
Promise Advantages
Promises can be chained; this avoids the pyramid of doom.
Callbacks are never called before completion of current run of js event loop.
Callbacks added using then even after completion of the asynchronous
operation will still be called.
then() can be called multiple times on the same promise to add multiple
callbacks (called in order of insertion).
Allows catching errors much more easily using catch() ; similar to
exception handling.
then() can even be chained after a catch.
Promise API
new Promise(
/* executor */
function(resolve, reject) { ... }
);
Creates a promise.
resolve and reject are single argument functions.
Executor function executed immediately. Usually will start some kind of
asynchronous operation which may return some result.
1. If the async operation succeeds with some result succ, then the
executor function should call resolve(succ).
2. If the async operation fails with some error err, then the executor
function should call reject(err).
Outline of Using Promises for Asynch Operations
function doOperation(...params) {
return new Promise((resolve, reject) => {
asyncOperation(...params, (result) => { //callback
if (isOk(result)) {
resolve(result);
}
else {
reject(result);
}
});
});
}
doOperation(...).
then(result => { ... }).
catch( err => ...);
Promise States
Pending
The underlying operation is not yet complete.
Settled
The underlying operation completed; it is known whether or not it
succeeded resulting in two settled sub-states:
Fulfilled
The underlying operation completed successfully.
Rejected
The underlying operation failed.
A promise is settled only once. The state of the promise will not change once it is
settled.
Getting Promise Settlement: then()
somePromise.then(value, err)
Arguments are one argument functions called when somePromise is settled;
specifically value / err are called with fulfillment / rejection value
depending on settlement.
Usually then() is called with only the value argument, with rejection of
somePromise handled using a catch() .
then() itself returns a promise; this allows chaining then 's.
If the function passed to then() returns a value, then the return'd
promise fulfills with that value.
If the function passed to then() throws an error, then the return'd
promise rejects with that error.
If the function passed to then() returns a promise, then the return'd
promise has the same settlement as it.
Getting Promise Rejection: catch()
somePromise.catch(err)
err is a one argument functions called with the rejection value of promise
somePromise.
catch() itself returns a promise; this allows continued promise chaining.
Return value is similar to that of then() .
nodejs util.promisify()
API for nodejs evolved before promises were added to language.
util.promisify(fn) can be used with existing callback-based nodejs
library function fn to make it return a Promise .
For example util.promisify(fs.readFile) returns a wrapped version of
fs.readFile which will return a Promise .
Using Node fs.readFile() with Promise
function readFilePromise(path) {
return util.promisify(fs.readFile)(path, 'utf8');
}
for (const path of [ './readfile.js',
'./xxx.js', ]) {
readFilePromise(path)
.then(data => {
console.log(`read ${path}:\n${data.slice(0, 15)}...`);
})
.catch(err => {
console.error(`cannot read ${path}: ${err}`);
});
}
Playing with Promises
> pr = new Promise((resolve, reject) => resolve(22))
Promise { 22, ... }
> pr.then((v) => p(v))
Promise { <pending>, ... }
> 22
//Promise is settled only once
> pr = new Promise((succ) => { succ(42); succ(22); })
Promise { 42, ... }
> pr.then((v) => p(v))
Promise { <pending>, ... }
> 42
Playing with Promises: Chaining then()'s
> function f(a, b) { p(a); return a * b; }
undefined
> pr = new Promise((resolve) => resolve(22))
Promise { 22, ... }
> pr.then((val) => f(val, 2)).
then((val) => f(val, 3)).
then((val) => p(val))
> Promise { <pending>, ... }
> 22
44
132
>
Creating Settled Promises
Promise.resolve(value)
Returns a promise which is already fulfilled with value.
Promise.reject(err)
Returns a promise which is already rejected with err.
Playing with Promises: Asynchronous Functions
> function f(a, b, ret) {
p(`${t()}: ${a}`);
setTimeout(() => ret(a*b), 2000);
}
undefined
> pr = Promise.resolve(22)
> pr.
then((v) => new Promise((succ) => f(v, 2, succ))).
then((v) => new Promise((succ) => f(v, 3, succ))).
then((v) => p(`${t()}: ${v}`))
> 08:21:42 GMT-0400 (Eastern Daylight Time): 22
08:21:44 GMT-0400 (Eastern Daylight Time): 44
08:21:46 GMT-0400 (Eastern Daylight Time): 132
>
Playing with Promises: Errors
> p(t()); pr1 =
Promise.reject(new Error(t())); pr1.catch(()=>{})
08:27:07 GMT-0400 (Eastern Daylight Time)
...
> p(t()); pr1.
then((v) => p(v)).
then((v) => p(v)).catch((err)=>p(err))
08:27:51 GMT-0400 (Eastern Daylight Time)
...
> Error: 08:27:07 GMT-0400 (Eastern Daylight Time)
...
Playing with Promises: Errors Continued
> pr1.
then((v) => p(`got value ${v}`)).
then((v) => p(`got value ${v}`)).
catch((e) => { p(`caught ${e}`); return 42; }).
then((v) => p(`got value ${v}`))
Promise { <pending>, ... }
> caught Error: 08:27:07 GMT-0400 (Eastern Daylight Time)
got value 42
>
Playing with Promises: Errors Continued
then()-chain continues past catch():
> Promise.resolve(1).
then((v) => { p(`then1: ${v}`); return v*2; }).
then((v) => { p(`then2: ${v}`); return v*2; }).
catch((e) => p(`caught ${e}`)).
then((v) => { p(`then3: ${v}`); return v*2; })
Promise { <pending>, ... }
> then1: 1
then2: 2
then3: 4
>
Placing an Order using Promises
In promise-order.mjs:
function doOrder(orderer, params) {
orderer.validate(params)
.then(succ => orderer.placeOrder(succ))
.then(succ => orderer.sendEmail(succ))
.then(succ => console.log(succ))
.catch(err => console.error(err));
}
$ ./main.mjs promise-order.mjs
promise-order validated; order placed; email sent
# catches errors properly
$ ./main.mjs promise-order.mjs validate
validate error
$ ./main.mjs promise-order.mjs order
order error
$ ./main.mjs promise-order.mjs email
email error./main.mjs happy-cb-order.mjs
$
main program for asynchronous order simulator.
Promise.all()
Given an iterable of promises, returns a promise containing array of fulfilled values,
or rejection if any promise rejected. (note that mulN(i) returns promise for N*i
after 2 second delay):
> Promise.all([mul2(3), mul3(4), mul4(5)]).
then((v) => p(v))
Promise { <pending>, ... }
> [ 6, 12, 20 ]
// err() returns a rejected promise
> Promise.all([mul2(3), err(3)(2), mul3(4), mul4(5)]).
then((v) => p(v)).
catch((e) => p(`caught ${e}`))
Promise { <pending>, ... }
> caught Error: err
Promise.all() Continued
Promise.all() runs all promises in parallel:
> p(t()); Promise.all([mul2(3), mul3(4), mul4(5)]).
then((v) => p(`${t()}: ${v}`))
15:49:41 GMT-0500 (EST)
Promise { <pending>, ... }
> 15:49:43 GMT-0500 (EST): 6,12,20
Took 2 seconds to run all 3 functions even though each function takes 2 seconds
apiece.
Promise.race()
Given an iterable of promises, returns a promise containing settlement of which
ever incoming promise completes first.
> Promise.race([mul2(3), mul3(4), mul4(5)]).
then((v) => p(v))
Promise { <pending>, ... }
> 6
>
Placing an Order using Generators
Combine promises and generators to linearize control flow:
In generator-order.mjs:
function generatorOrder(orderer, params) {
const gen = doOrder(orderer, params);
gen.next().value //validation promise
.then(succ => gen.next(succ).value) //order result promise
.then(succ => gen.next(succ).value) //email result promise
.then(succ => console.log(succ)) //output overall result
.catch(err => console.error(err)); //catch any error
}
function* doOrder(orderer, params) {
const validationResult = yield orderer.validate(params);
const orderResult =
yield orderer.placeOrder(validationResult);
yield orderer.sendEmail(orderResult);
}
Placing an Order using Generators: Log
# happy path
./main.mjs generator-order.mjs
generator-order validated; order placed; email sent
# catches errors properly
$ ./main.mjs generator-order.mjs validate
validate error
$ ./main.mjs generator-order.mjs order
order error
$ ./main.mjs generator-order.mjs email
email error
$
async / await
Extra syntax around promises and generators to allow writing asynchronous
code in a synchronous style.
If a function or function expression has the async (contextual) keyword in
front of it, then that function always returns a promise.
When the await (contextual) keyword is used in front of an expression
which is a promise, it blocks the program until the promise is settled. The
value of an await expression is the fulfillment value of the promise.
The await keyword can only be used within a async function.
Errors can be handled using try - catch .
Seems a big win.
Note that we may need to fall back on promises using Promise.all() when
we want to run code in parallel rather than sequentially.
async / await Example
> function msgPromise() {
return new Promise(function (resolve) {
setTimeout(() =>
resolve(`hello@${t()}`), 2000)});
}
undefined
> async function msg(n) {
const m = await msgPromise();
return `${n}: ${m}`
}
undefined
Top-Level async / await Example: Invoking using
IIFE
Until ES-2022, top-level await not allowed. Used IIFE as workaround.
> ( async () => { //must use async to use await
p(await msg(22));
p(await msg(42));
})() //async IIFE
Promise { <pending>, ... }
> 22: hello@21:06:53 GMT-0500 (EST)
42: hello@21:06:55 GMT-0500 (EST)
>
Async sleep()
> async function sleep(millis) {
return new Promise((resolve) =>
setTimeout(() => resolve(), millis));
}
> p(t()); await sleep(2000); p(t()); //es-2022 top-level await
18:49:06 GMT-0400 (Eastern Daylight Time)
18:49:08 GMT-0400 (Eastern Daylight Time)
undefined
>
Placing an Order using async and await
In async-await-order.mjs:
async function doOrder(orderer, params) {
try {
const validation = await orderer.validate(params);
const order = await orderer.placeOrder(validation);
const result = await orderer.sendEmail(order);
console.log(result);
}
catch (err) {
console.error(err);
}
}
# happy path
./main.mjs async-await-order.mjs
async-await-order validated; order placed; email sent
# catches errors properly
$ ./main.mjs async-await-order.mjs validate
validate error
$ ./main.mjs async-await-order.mjs order
order error
$ ./main.mjs async-await-order.mjs email
email error
$