Asynchronous processing in JavaScript
Maciej Wakuła
Posted on April 15, 2023
This is a continuation of Introduction to node and npm
1. Introduction
JavaScript engines are generally single-threaded. Long call processing would normally block any other call but we leverage asynchronous processing to interrupt currently executed procedure allowing engine to switch between the jobs. Behind the scenes Input/Output is multi-threaded.
There are many ways JS developers could achieve effects similar to multi-threading.
2. How asynchronous processing works
You send a message (ex. a request to process a resource like "add a new user to the system") and usually get a confirmation that the request was received. In REST this might be 202 (Accepted) and "location" header, in messaging systems a valid ID of the request.
But the message is only queued for processing, not yet processed.
Whenever system has free resources (ex. is not doing anything more important or queued before your request) then your request could be processed. This takes usually between milliseconds and weeks. It could be for example a request to add a new bank account that must be verified by someone.
When requesting something, often a feedback (confirmation and/or return data) is expected. This is usually done using message sent by the processing system to a place you can access. Often it would contain "correlation-id" that is the ID of the request to indicate it is a response to previous request. Simply saying: We cannot send response (not yet) but we can send instruction (ex. URL) to check if it was already finished.
3. Several attempts to use asynchronous processing
3.1. How the code execution gets postponed
In JavaScript calls could be postponed whenever you use a promise (or async/await which is using promises behind the scene).
async function log1(msg) {
await console.log(msg);
}
async function log2(msg) {
console.log(msg);
}
function log3(msg) {
console.log(msg);
}
function log4(msg) {
return new Promise(resolve=>console.log(msg));
}
setTimeout(()=>log1(1), 0);
setTimeout(()=>log2(2), 0);
setTimeout(()=>log3(3), 0);
setTimeout(()=>log4(4), 0);
Usually prints 1,2,3,4 as expected.
Internally those are 4 different calls scheduled to happen with 0ms delay (no delay). And the order of execution doesn't need to keep the sequence.
log1
is async (returns a promise) and stops execution when calling console.log
because there is await
. Once called, the promise is not yet fulfilled - it would be after console.log
returned,then log1
could continue, and finally it returns. You are sure that console.log
returns before log1
gets fulfilled (only because there is "await").
log2
is async (returns a promise) and triggers console.log
and returns. You have no guarantee that console.log
prints output before log2
gets fulfilled.
log3
is just a function. The console.log
might be scheduled but log3
returns and continues to execute your code. The JavaScript thread is blocked until it returns (though it can schedule some promises).
log4
returns a promise. Its execution could be delayed (then you see that returned promise is "pending" until it gets "fulfilled").
Think about it thoroughly and you could also omit setTimeout
in 3 out of 4 cases - then call is made and a "pending promise" is returned. In fact the case of log3
is probably most dangerous.
If you are using node, then internally libuv is used for calls and also for anything like input/output (ex. to print something onto the console).
3.2. Calling other applications
You can sent a request to another system, service or application. You can send a REST request and should wait for the response (even "Accepted" when task is only queued). Or publish this to a queue yourself.
3.3. Workers
Node can run parallel instances of node called "workers". Workers can act as a parallel process to perform heavy work without blocking the main thread.
4. What to look at
4.1. Intervals
You can schedule a method to be called with a fixed interval:
let counter = 0;
let intervalHandle = undefined;
const intervalInMilliseconds = 10;
function myMethod() {
counter++;
for(let i = 0; i < 9999; i++){console.log(i);}
if(counter > 10) {
clearInterval(intervalHandle);
intervalHandle = null;
}
}
intervalHandle = setInterval(myMethod, intervalInMilliseconds);
You could expect that it prints number from 0 to 9999 every 10ms until 10 iterations are made within 100ms.
The issue is that you have no guarantee that methods would be called every 10ms. This could lead to 2 problems:
- Your method could be called more than once at the same time (if it is using asynchronous processing internally)
- Your method could be executed with longer interval (it needs to print the numbers first)
A bit corrected code:
let counter = 0;
const minimumIntervalInMilliseconds = 10;
function myMethod() {
counter++;
for(let i = 0; i < 9999; i++){console.log(i);}
if(counter <= 10) {
setTimeout(myMethod, minimumIntervalInMilliseconds);
}
}
setTimeout(myMethod, minimumIntervalInMilliseconds);
4. Dangers of single-threaded app
If your application runs inside a docker container that has a healthcheck and restarts whenever healthcheck is not responded (ex. timeout 1s, interval 1s), then any job blocking main thread for 1000ms would result in container being restarted.
If your app runs on kubernetes and your is blocked over the timeout threshold then your application might be "disconnected" (readiness probe) or restarted (liveness probe).
If you are connected to a queue (ex. kafka or RabbitMQ) and your application is busy for the timeout (ex. 3000ms for kafka subscriber) then your application will be kicked-out and considered "dead". I have seen this scenario where developers tried to update RabbitMQ and its libraries searchign for an issue in the server - where the cause was in the node code.
Posted on April 15, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024