I. The Legend Created by Node.js
I have a job now, and this guy is the reason why I have that now. His hobby project is what I use for living. Thanks. —— Shajan Jacob
In 2009, Ryan Dahl introduced Node.js at the JSConf EU conference. Initially, he hoped to break through the high-concurrency bottlenecks of traditional Web servers through an asynchronous model. Since then, it has matured and gained wider application, leading to a flourishing Node.js ecosystem.
With the help of Node.js stepping out of the browser, the JavaScript language also became unstoppable:
Any application that can be written in JavaScript, will eventually be written in JavaScript. —— Jeff Atwood
(Excerpted from The Principle of Least Power)
As early as 2017, NPM became the world's largest package registry thanks to its vast number of community modules. Currently, the number of modules has exceeded 1.25 million and is still growing rapidly (with over 900 new ones added every day).
Node.js engineer has even become a burgeoning profession. So, how exactly is the legendary Node.js itself implemented?
II. Node.js Architecture Overview

JS code runs on the V8 engine. Built-in Node.js core modules like fs and http call C/C++ libraries such as libuv, c-ares, and llhttp via C++ Bindings, thereby accessing platform capabilities provided by the operating system.
Among them, the most important parts are V8 and libuv.
III. Source Code Dependencies
V8
V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others.
A JavaScript engine written in C++, maintained by Google, and used in the Chrome browser and Node.js.
libuv
libuv is cross-platform support library which was originally written for Node.js. It’s designed around the event-driven asynchronous I/O model.
Tailor-made for Node.js, libuv is a cross-platform asynchronous I/O library written in C. It provides non-blocking file system, DNS, network, child process, pipe, signal, polling, and streaming mechanisms:

For tasks that cannot be performed asynchronously at the operating system level, they are handled via a thread pool, such as file I/O, DNS queries, etc. For specific reasons, see Complexities in File I/O.
P.S. The capacity of the thread pool is configurable, with a default of 4 threads. See Thread pool work scheduling for details.
Additionally, the event loop and event queues in Node.js are all provided by libuv:
Libuv provides the entire event loop functionality to NodeJS including the event queuing mechanism.
The specific operation mechanism is shown in the figure below:

Other Dependency Libraries
Additionally, it depends on several C/C++ libraries:
-
llhttp: A lightweight HTTP parsing library written in TypeScript and C. It is 1.5 times faster than the previous http_parser and contains no system calls or memory allocation (nor does it cache data), so the memory footprint per request is minimal.
-
c-ares: A C library used to handle asynchronous DNS requests, corresponding to the
resolve()series of methods provided by thednsmodule in Node.js. -
OpenSSL: A general-purpose cryptography library, mostly used for TLS and SSL protocol implementations in network transmission, corresponding to the
tlsandcryptomodules in Node.js. -
zlib: Provides support for fast compression and decompression.
P.S. For more information on Node.js source code dependencies, see Dependencies.
IV. Core Modules
Just like the DOM/BOM APIs provided by browsers, Node.js not only provides a JavaScript runtime environment but also extends a series of platform APIs, for example:
-
File system related: Corresponding to the fs module.
-
HTTP communication: Corresponding to the http module.
-
Operating system related: Corresponding to the os module.
-
Multi-process: Corresponding to the child_process and cluster modules.
These built-in modules are called core modules, giving hands and feet to JavaScript as it steps out of the browser world.
V. C++ Bindings
Beneath the core modules, there is a layer of C++ Bindings that bridges the upper-level JavaScript code with the lower-level C/C++ libraries.
Bottom-level modules are implemented in C/C++ for better performance, while upper-level JavaScript code cannot communicate directly with C/C++. Therefore, a bridge (i.e., Binding) is needed:
Bindings, as the name implies, are glue codes that “bind” one language with another so that they can talk with each other. In this case (Node.js), bindings simply expose core Node.js internal libraries written in C/C++ (c-ares, zlib, OpenSSL, llhttp, etc.) to JavaScript.
On the other hand, Bindings also allow for the reuse of reliable, long-standing open-source libraries without having to manually implement all underlying modules.
Taking file I/O as an example, reading the content of the current JS file and outputting it to standard output:
// readThisFile.js
const fs = require('fs')
const path = require('path')
const filePath = path.resolve(__filename);
// Parses the buffer into a string
function callback (data) {
return data.toString()
}
// Transforms the function into a promise
const readFileAsync = (filePath) => {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (err, data) => {
if (err) return reject(err)
return resolve(callback(data))
})
})
}
(() => {
readFileAsync(filePath)
.then(console.log)
.catch(console.error)
})()
However, the fs.readFile interface used here is neither provided by V8 nor built into JS; rather, it is implemented by Node.js with the help of libuv in the form of a C++ Binding:
// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L58
const binding = internalBinding('fs');
// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L71
const { FSReqCallback, statValues } = binding;
// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L297
function readFile(path, options, callback) {
callback = maybeCallback(callback || options);
options = getOptions(options, { flag: 'r' });
if (!ReadFileContext)
ReadFileContext = require('internal/fs/read_file_context');
const context = new ReadFileContext(callback, options.encoding);
context.isUserFd = isFd(path); // File descriptor ownership
const req = new FSReqCallback();
req.context = context;
req.oncomplete = readFileAfterOpen;
if (context.isUserFd) {
process.nextTick(function tick() {
req.oncomplete(null, path);
});
return;
}
path = getValidatedPath(path);
const flagsNumber = stringToFlags(options.flags);
binding.open(pathModule.toNamespacedPath(path),
flagsNumber,
0o666,
req);
}
The final binding.open is a C++ call used to open a file descriptor. The three parameters are the file path, the file access mode string for C++ fopen (such as r, w+), the file read/write permissions in octal format (666 means everyone has read and write permissions), and the req callback for receiving returned data.
In this context, internalBinding is a C++ binding loader. The actual C++ code loaded by internalBinding('fs') is located in node/src/node_file.cc.
By now, most of the critical parts are clear. So, how exactly does a piece of Node.js code run?
VI. Operating Principles
First, the written JavaScript code is run by the V8 engine. Event listeners registered during execution are retained to receive notifications when the corresponding events occur.
When network, file I/O, and other events occur, the registered callback functions are queued in the event queue. They are then picked up by the event loop and placed on the call stack. Once the callback function completes execution (and the call stack is cleared), the event loop takes another one and places it there...
If an I/O operation is encountered during execution, it is handed over to a worker in the libuv thread pool. Upon completion, libuv generates an event and puts it into the event queue. When the event loop processes the return event, the corresponding callback function starts executing on the main thread. Meanwhile, the main thread continues with other work without blocking or waiting.
Node.js is like a cafe. There is only one waiter (main thread) in the shop. When a crowd of customers arrives, they wait in line (enter the event queue). The orders of the customers whose turn it is are passed to the manager (libuv). The manager assigns the orders to the baristas (worker threads). Baristas use different ingredients and tools (underlying C/C++ modules) to make the various coffees requested. Typically, there are 4 baristas on duty, which may increase during peak hours. After the order is passed to the manager, the waiter doesn't wait for the coffee to be made but continues to process the next order. Once a cup of coffee is finished, it is placed on the pickup line (IO Events queue). When it arrives at the counter, the waiter calls the name, and the customer comes to pick it up.
No comments yet. Be the first to share your thoughts.