“Parallelism is about physically doing two or more things at the same time. Concurrency is about undefined, out of order, execution.” - William Kennedy
Javascript typically isn’t the first language that comes to mind when you think of concurrent & parallel programming - i’d imagine c++, go, and elixir are higher up on the list.
Despite preconceived notions (i myself didn’t think that javascript could be parallel, especially on the client) javascript is definitely concurrent - and even parallel at times.
Execution Model #
JavaScript is single threaded, a HTML page gets a thread on which can execute code. Issues arise when long blocking operations must run - fetch()
and JSON.parse()
calls or long running loops for example.
If these long running operations were to block the main thread (the thread that processes user events(click, hover etc) and paints) it would lead to a page unresponsive to user inputs.
To avoid this a concurrent programming model, powered by the event loop, is widely use on the web.
Callbacks #
To prevent these blocking actions, callbacks are utilized. a callback (or job) is bound to the resolution or rejection of an asynchronous event, callbacks are added to queue - once their async event completes they are invoked with resulting data/error.
There are two queues used by the main thread to process these callbacks/jobs.
- Micro task queue: high priority, must be drained before the task queue is worked on. API’s that have a prong in javascript land and a prong in the browser live here (
fetch
) - Task Queue (sometimes also called macro task queue): lower priority, just in js land, (
setTimeout
)
The Event Loop first attempts to process any synchronous code, if there is none then the micro task queue is checked and jobs with resolved/rejected promises executed, then finally the macro task queue is check and jobs there are executed.
If you’re not aware of how the event loop works, it can lead to confusing output from code like the following block:
// async (micro-task, high priority)
new Promise((resolve) => resolve("Second")).then(v => console.log(v))
// async (macro-task, low priority)
setTimeout(() => console.log("Third"), 0)
// sync
console.log("First")
// Output:
// First
// Second
// Third
Preemption #
Each job is processed completely before any other job is run this ensures that functions won’t be preempted.
In C, functions in a thread can be preempted to run code in another thread, the lack of preemption ensures that program state won’t be unexpectedly modified as a function runs.
// the C version of this function would have a race condition
// 1, 2 could be an output
// 2, 2 could also be an output if one of the callbacks is pre-empted
const promise = Promise.resolve();
let i = 0;
promise.then(() => {
i += 1;
console.log(i);
});
promise.then(() => {
i += 1;
console.log(i);
});
the order of the instructions here due to the lack of preemption is always going to be increment, log, increment, log.
We will never have a case like increment,<preempt>
, increment, log, log.
The Olden Days #
Before Promises (added in 2015/ES6) and async/await (added in 2017/ES2017) were a thing, to create a function that used multiple asynchronous features, nested callbacks would be used.
a function that looks like this now:
async function getUserData(username) {
try {
const user = await getUser(username)
const posts = await getUserPosts(user.id)
const comments = await getPostComments(posts[0].id)
console.log(user, posts, comments)
} catch (e) {
console.log("Oops!", e)
}
}
might have looked like this:
function getUserData(username) {
getUser(username, function(err, user) {
if (err) {
console.log("Oops!", err)
return
}
getUserPosts(user.id, function(err, posts) {
if (err) {
console.log("Oops!", err)
return
}
getPostComments(posts[0].id, function(err, postId) {
if (err) {
console.log("Oops!", err)
return
}
console.log(user, posts, comments)
})
})
})
}
If we wanted to return the users data from the callback version, we couldn’t.
Instead we would pass the function we want to
do work with into getUserData()
as a callback, and call it with the value we would have returned.
Promises and async/await improved code readability significantly and allowed for returning of values from async functions.
Modern Concurrency #
Now the majority of concurrent programming on the web utilizes Promises.
a promise represents the idea that an async operation will complete(resolve) or fail(reject).
We can then create handlers, which are then bound to the promise’s eventual success value or failure reason - giving async methods the ability to return values like sync methods.
To bind the functions to the fulfillment/resolution of a promise we can use the .then()
method which has two parameters.
- first is the callback function that will run promise if successful,
- second is the callback function that will run if the promise is unsuccessful
There are also .catch()
and .finally()
methods, but they are just sugared versions of .then()
.catch()
is.then()
without the first success callback,.finally()
is.then()
with both failure and success callbacks set to the same function- primarily used to avoid duplicate code in
.then()
and.catch()
- primarily used to avoid duplicate code in
each chained method returns a promise
- if the success handler returns a value, the returned promise is fulfilled and contains that value
- if the handler throws an error, the promise is rejected, and contains that error
Promise.resolve("Hello!")
.then((v) => {
console.log(v);
})
.then(() => {
throw new Error("Some Error")
})
.catch((e) => {
console.log('->', e);
return "World!"
})
.finally((v) => console.log(v))
/*
Hello!
-> Error: Some Error
at promise_chain.js:6:15
World!
*/
async/await were added to further improve the ergonomics of concurrent programming in javascript.
async
is used to declare that a function should be run asynchronously,
and allows the await
keyword to be used inside its body.
await
unwraps the given function’s promise on fulfillment, returning its value.
If the function rejects, the error is thrown.
The above function might be written as follows using async/await.
async function Helloer() {
try {
const hello = await Promise.resolve("Hello!")
console.log(hello)
throw new Error("Some Error")
} catch (e) {
console.log("->", e)
} finally {
console.log("World!")
}
}
This function now reads as synchronous code - despite is asynchronous nature, offering improved developer experience.