In recent times, Node.js has emerged as the most popular language among developers. Its reputation is largely owed to its asynchronous nature and non-blocking IO capabilities. Having personally utilized Node.js for the past four years, I can attest to its exceptional speed and scalability. However, what many people fail to grasp is how Node.js works internally to handle these IO operations and CPU-intensive tasks.
In this blog post, we will delve into the patterns employed by Node.js to handle incoming requests and explore why it outperforms other languages. We will cover the following topics in this journey:
- Blocking I/O
- Non-Blocking I/O
- Event Demultiplexing
- Node.js Core Pattern
Blocking I/O:
Traditionally, in programming languages, when a call corresponding to an IO operation is made, it blocks the execution of the thread until the data is available. Consequently, the thread remains idle, awaiting the data and serving no other purpose during this time. This scenario can be visualized in the diagram below (Image1).
The problem becomes evident when we consider that various IO operations, such as interacting with databases or the filesystem, can potentially block a request. As a result, threads may frequently block to wait for the results of IO operations, leading to increased memory consumption, higher context switching overhead, and excessive CPU usage. Such idle times are detrimental to a thread’s efficiency.
Non-Blocking IO:
The concept of non-blocking IO involves not waiting for any data or response while executing IO operations. If a response is not immediately available, the system call must return immediately to the engine. This way, the thread does not get blocked for a response, making it available to handle other requests. Consequently, the thread’s idle time is significantly reduced compared to blocking IO, as illustrated in Image2.
Event Demultiplexing:
Event demultiplexing is an advanced technique used in Node.js. In telecommunications, multiplexing is the process of combining multiple signals into one to facilitate transmission over a limited capacity medium. Demultiplexing, on the other hand, splits the combined signal back into its original components. Similar concepts apply in various fields, including video processing.
In Node.js, a synchronous event demultiplexer watches multiple resources simultaneously. When any operation on any resource is completed, it returns a new event. Please note that it returns a new event. The synchronous event demultiplexer blocks until new events are available to process. Below is a pseudocode representation of a synchronous event demultiplexer.
watchedList.add(socketA, FOR_READ) // (1)
watchedList.add(fileB, FOR_READ)
while (events = demultiplexer.watch(watchedList)) { // (2)
// event loop
for (event of events) { // (3)
// This read will never block and will always return data
data = event.resource.read()
if (data === RESOURCE_CLOSED) {
// the resource was closed, remove it from the watched list
demultiplexer.unwatch(event.resource)
} else {
// some actual data was received, process it
consumeData(data)
}
}
}
The process works as follows:
Resources (IO events) are added to the data structure associated with an operation.
The demultiplexer is set to watch these events, and each call is synchronous, blocking until the resources are ready for the specified operation (e.g., read).
When the operation is completed, the demultiplexer creates a new event, which is further processed or executed.
Each event returned by the event demultiplexer is processed. At this point, the associated resource is guaranteed to be ready to read and not block during the operation. Once all events are processed, the flow blocks again on the event demultiplexer until new events are available to be processed. This cycle is known as the event loop.
The diagram below demonstrates how a server utilizes a synchronous event demultiplexer and a single thread to handle multiple concurrent connections.
Node.js Core Pattern:
The core pattern employed by Node.js is called the reactor pattern. It revolves around associating a handler with each IO operation, represented by a callback in Node.js. Below is a representation of a the reactor pattern:
The following steps elucidate the application’s behavior using the reactor pattern:
The application generates a new IO operation by submitting a request to the Event Demultiplexer. Simultaneously, the application specifies a handler, which will be invoked when the operation completes. This submission is a non-blocking call, and control is immediately returned to the application.
When a set of IO operations completes, the Event Demultiplexer pushes corresponding events into the Event Queue.
The Event Loop iterates over the items in the Event Queue.
For each event, the associated handler is invoked.
The handler, being part of the application code, relinquishes control back to the Event Loop once its execution is complete (5a). During execution, the handler can request new asynchronous operations (5b), leading to new items being added to the Event Demultiplexer (1).
Once all items in the Event Queue are processed, the Event Loop blocks on the Event Demultiplexer, triggering another cycle when a new event is available.
The asynchronous behavior emerges clearly. The application expresses its interest in accessing a resource without blocking, and it provides a handler, which will be invoked when the operation is complete.
Node.js capitalizes on this asynchronous pattern, contributing to its remarkable speed and efficiency.
In Conclusion:
Node.js has rightfully earned its popularity by leveraging its asynchronous nature and non-blocking IO capabilities to achieve unparalleled speed and scalability. The combination of event demultiplexing and the reactor pattern forms the core of Node.js, enabling it to handle multiple concurrent requests with remarkable efficiency.
I hope you found this article enlightening. Please consider sharing it and showing your appreciation with a clap!
resource-Node.js Design Patterns — Third Edition Mario Casciaro, Luciano Mammino
Top comments (0)