Lesson 06-Asynchronous Programming in Node.js

Asynchronous I/O Model

Blocking I/O vs. Non-Blocking I/O

  • In operating systems, I/O operations are categorized into two types: blocking and non-blocking.
  • Blocking I/O: The call does not return until all operations are completed at the system kernel level. For example, when reading a file, the call waits for the kernel to complete disk seeking, data reading, and copying data to memory.
  • Non-Blocking I/O: The call returns immediately without waiting for operations like disk seeking, data reading, or copying to memory.
  • Non-blocking I/O requires repeated calls (polling) to retrieve data.
  • The epoll polling mechanism is the most efficient polling scheme. It sleeps when no I/O events are detected and wakes up when an event occurs.
  • Node.js implements non-blocking asynchronous I/O using a thread pool, where some threads handle polling to retrieve data, while others perform computations. Data is passed between threads via communication.
  • Node.js uses the libuv library to achieve asynchronous I/O on both *nix and Windows platforms.

Event Loop

Order of the Event Loop in Node.js

The event loop in Node.js follows this sequence:
External input data → Poll phase → Check phase → Close callbacks phase → Timers phase → I/O callbacks phase → Idle/Prepare phase → Poll phase (repeating in this order).

  • Timers Phase: Executes callbacks for timers (setTimeout, setInterval).
  • I/O Callbacks Phase: Handles unexecuted I/O callbacks from the previous loop cycle.
  • Idle/Prepare Phase: Used internally by Node.js.
  • Poll Phase: Retrieves new I/O events; Node.js may block here under certain conditions.
  • Check Phase: Executes setImmediate() callbacks.
  • Close Callbacks Phase: Handles close event callbacks for sockets.

Observers

In Node.js, events primarily originate from network requests, file I/O, etc., each associated with corresponding observers. The event loop follows a producer-consumer model: asynchronous I/O and network requests act as event producers, passing events to observers, which the event loop then retrieves and processes.

Request Object

During the transition from a JavaScript call to the completion of an I/O operation by the kernel, an intermediate entity called a request object is created. Callbacks are not invoked directly by developers but by the request object.

  1. JavaScript calls a Node.js core module.
  2. The core module invokes a C++ built-in module.
  3. The built-in module makes a system call via libuv, generating a request object that encapsulates parameters and methods from the JavaScript layer, including the callback function (stored in the oncomplete property).
  4. On Windows, the request object is pushed to the thread pool for execution.

Executing Callbacks

  1. Once an I/O operation in the thread pool completes, the result is stored in the req->result property, and the IOCP (Windows’ asynchronous I/O solution) is notified that the operation is complete.
  2. During each tick of the event loop, the I/O observer calls IOCP-related methods to check for pending requests in the thread pool. If found, the request object is added to the I/O observer’s queue and processed as an event, completing the asynchronous I/O operation.

Asynchronous Programming Solutions

Publish-Subscribe Pattern

class EventEmitter {
  private events = {}; // Stores events
  private key = 0; // Unique key for events

  on(name, event) {
    event.key = ++this.key;
    this.events[name]
      ? this.events[name].push(event)
      : (this.events[name] = []) && this.events[name].push(event);
    return this;
  }

  once(name, cb) {
    let callback = (...args) => {
      cb.call(this, ...args);
      this.off(name);
    };
    this.on(name, callback);
    return this;
  }

  off(name, key) {
    if (this.events[name]) {
      this.events[name] = this.events[name].filter((x) => x.key !== key);
    } else {
      this.events[name] = [];
    }
    return this;
  }

  emit(name, key) {
    if (this.events[name].length === 0) throw Error(`Sorry, no ${name} listener defined`);
    if (key) {
      this.events[name].forEach((x) => x.key === key && x());
    } else {
      this.events[name].forEach((x) => x());
    }
    return this;
  }
}

Avalanche Problem

In scenarios with high traffic and concurrency, cache invalidation can cause a flood of simultaneous requests to hit the database, overwhelming it and slowing down the website’s overall response time.

// Using a partial function
// Example of on-demand loading
let after = function (times, cb) {
  let count = 0,
    results = {};
  return function (key, value) {
    results[key] = value;
    count++;
    if (count === times) cb(results);
  };
};

const emitter = new EventEmitter();
let done = after(times, render);
emitter.on('done', done);
emitter.on('done', other);
fs.readFile(template_path, 'utf8', function (err, template) {
  emitter.emit('done', 'template', template);
});
db.query(sql, function (err, data) {
  emitter.emit('done', 'data', data);
});
l10n.get(function (err, resources) {
  emitter.emit('done', 'resources', resources);
});

Promise/Deferred Pattern

  • Promise.then attaches callback functions.
  • Callbacks are executed by resolve or reject in the deferred object.
function MyPromise(constructor) {
  let self = this;
  this.status = 'pending';
  this.value = undefined;
  this.reason = undefined;
  this.resolveQueue = [];
  this.rejectQueue = [];

  function resolve(value) {
    if (self.status === 'pending') {
      self.status = 'fulfilled';
      self.value = value;
      self.resolveQueue.forEach((fn) => fn());
    }
  }

  function reject(reason) {
    if (self.status === 'pending') {
      self.status = 'rejected';
      self.reason = reason;
      self.rejectQueue.forEach((fn) => fn());
    }
  }

  try {
    constructor(resolve, reject);
  } catch (e) {
    reject(e);
  }
}

MyPromise.prototype.then = function (res, rej) {
  this.status === 'fulfilled' && res(this.value);
  this.status === 'rejected' && rej(this.reason);
  if (this.status === 'pending') {
    this.resolveQueue.push(() => res(this.value));
    this.rejectQueue.push(() => rej(this.reason));
  }
};

let p = new MyPromise((res, rej) => {
  setTimeout(() => res(1), 1000);
}).then((e) => console.log(e));

Async and Await

async function fn() {
  const a = await new Promise((res) => {
    res(1);
  });
  console.log(a);
}
// Output: 1
Share your love