This article discusses how JavaScript does not block a thread while waiting for an I/O event to happen. This is a continuation of my previous article:
which explains what thread blocking is and its different types.
Let’s first look at an example of blocking I/O. Read/writes from/to the hard disk are I/O operations.
I created a file “ice.txt” with “ice cream delivered” as its contents. We are calling 2 methods:
OrderIceCream()
which will read ice.txt and log its contents (the word “ice cream delivered”). Reading from the disk can take time, especially if dealing with a large amount of data. Therefore, I want to do other important work while the read I/O operation is happening. That is to say, I want to order ice cream, and eat pizza while waiting for the ice cream to be delivered. What do you think the actual logging order is going to be?
So, we ordered some ice cream and only after it got delivered were we able to eat the pizza, which is probably cold by now. As many of you might have guessed already, this is because we read the file synchronously, using
fs.readFileSync().
This means that the JavaScript main thread will be blocked until the data has been read from the disk, i.e., until the I/O operation is complete. You cannot do anything else on it until then.
Here is a more common example, a network request (network requests are I/O operations as they involve data being written to and read from a socket by the network interface card):
I am running a server that listens on localhost:5000 and responds with “Ice cream delivered” after 5 seconds. While waiting for the response, I want to do Other Important Work
, w
hich involves “eating pizza”. What do you think the order of logs will be this time?
Once again, we ordered the ice cream, and we had to wait for it to get delivered before we could eat the pizza. This is definitely not what we want. We want to eat the pizza while waiting for the ice cream to arrive.
Just like the previous one, this network I/O operation is also synchronous. The thread will be blocked until we get a network response, and we can’t do anything until then. This means that our UI will also be blocked if this is a client-side application.
Alright, so those were 2 examples of our thread being blocked while waiting for I/O. But how can we prevent this?
A traditional way to overcome I/O bound thread blocking was to relocate the work to another thread. Then, the other thread would be blocked waiting for the I/O and your main thread will be free to do other important stuff. Some of you might say that this cannot be done as JavaScript is single-threaded. Indeed, that is the case, but JavaScript runs on the browser or using NodeJs. And both the browser and NodeJs have worker threads of their own that can be used. But this approach has some disadvantages.
First of all, a thread is expensive. It requires around 1 MB of memory. While this may not seem much, if you have to perform thousands of network operations, you may run out of RAM due to the sheer number of threads in the waiting queue. Also, this is a very counterintuitive approach. Here instead of you waiting for the ice cream to get delivered and then eating the pizza, you ask your friend (a worker thread) to wait for the ice cream while you eat the pizza.
This is why JavaScript uses asynchronous programming, which allows the main thread to be free and not wait for an I/O operation to complete in order to resume doing other important work.
JavaScript says that why not use the main memory (RAM) to store the state of execution up until the I/O operation starts and leave the thread free to handle other events. Then when the I/O is complete, just read the state from the memory and run the rest of the code that depends on the result of the I/O operation.
How do we know when the I/O operation is complete? The device performing that operation will raise an interrupt to signify its completion.
Now let’s look at how we can solve the problems presented in the above 2 examples using asynchronous JavaScript (async-await, promises and callbacks). I will also explain how these work under the hood.
This time, we are going to use the
fs.readFile()
method which allows reading the data asynchronously and scheduling a callback (
AcceptDelivery)
when the data has been read. What do you think the logging order will be this time?
We order the ice cream, then we eat the pizza and then the ice cream gets delivered. Just what we wanted, and we didn’t even have to ask our friend, the worker thread, to wait on our behalf!!! How does this work?
As the third argument of the
fs.readFile()
method, we have specified the callback function (
AcceptDelivery()
) that should be executed once the disk read operation is complete and we have the contents of the file.
When the
fs.readFile()
function is executed (note that this function, like any other library function, will itself execute on the main thread), it will store the information about its callback in the main memory. Then this function will ask the Operating system to contact the disk (using the device driver for the hard disk) and perform the actual read. While the read is happening, the JavaScript main thread is free to handle other events, such as executing the
EatPizza()
function. Once the disk read is complete, the hard disk will raise an interrupt (all devices in your computer system have tiny little processors of their own for such purposes). The device driver will handle this interrupt and ultimately the callback
AcceptDelivery()
will be pushed into the callback queue (if you are unfamiliar with the JS event loop and callback queue, watch
). Whenever the thread is free (the call stack is empty), the JavaScript event loop will pick a method from the callback queue and assign it to the main thread for execution.
Now let’s look at this process once again, in more detail by solving the second problem (network operation), using async-await and promises:
This time, I have used the fetch API with async-await. On running this, the results from the console are what we wanted:
The thread will begin executing this code sequentially, first executing the
OrderIceCream()
function. “Ordering ice cream” will be logged to the console. Now the control reaches the
await fetch()
. This
fetch()
library method will be executed and it will tell the Operating system to ask the network card to begin a network write I/O operation (send the HTTP request) followed by a network read operation (read the response from the socket). When the request from the OS reaches the device (network card), it will return immediately with the status as “pending”. The OS will convey this back to the thread and a promise will be returned by the
fetch()
method with the status as “pending”.
Now comes the real magic. The await keyword together with the compiler will store the current state of execution (things like what line in the code we are on and the values of the local variables) in the main memory and immediately return from the async function (
OrderIceCream()
)
with a pending Promise (you have the choice to await this promise or schedule a callback on it using “
.then()”
but here we aren’t doing either of that).
The main thread is now free to execute other code, here
EatPizza()
. When both the network I/O operations are finished, the network card will interrupt a CPU and its device driver will send the data to the Operating system, telling it that the I/O operation is now complete. The OS will convey this to your application, where a worker thread (from NodeJs or the browser) will look into the memory to get the state of execution that we had stored earlier, and then queue up the rest of the code, after the await fetch (
console.log(res.text())
) on a callback queue. The event loop will add this code to the call stack as soon as the main thread is free (after it is done executing the
EatPizza()
function).
This is akin to writing down “accept ice cream delivery and eat ice cream” on a piece of paper, and then eating the pizza. When the delivery person rings your doorbell (raising an interrupt), you will look at what you wrote down and then accept the delivery and eat the ice cream!
To summarize, JavaScript uses a single thread to run all your application’s code. This thread is not blocked when an I/O operation is being performed, instead, the state of execution is stored in the RAM, leaving the thread free to execute other code that does not depend on the result of the I/O operation.
When the I/O operation is complete, the device that was performing that operation raises an interrupt. To transmit this completion to your application, several threads are borrowed briefly. At the application level, a worker (browser or NodeJs) thread handles the I/O completion response, looks at the state of execution (in the RAM) that it had stored earlier and queues up the rest of the code that depends on the I/O response to be picked up by the event loop and added to the call stack once the main thread is free.
Thank you for reading 😃, here are some interesting reads on the topics of non-blocking I/O, asynchrony and the event loop:
Software Developer @Microsoft. Writer @BetterProgramming. Programming for more than 7 years. Learning and writing about new technology every day. Love to read. Check out https://akshaydagar.netlify.app.