Slow I/O operations such as accessing disk and network resources are handled via Node’s Event Loop. The Event Loop is started once you run a node script and exits once there are no more callbacks to process.
Heap and Stack
V8 is node’s runtime engine and mainly consists of a Heap and a Stack. The Heap is memory that V8 allocated to store objects, for example if we invoke a function then an area in the heap is allocated to act as a local scope of that function.
A Stack is a First In Last Out data structure. Every time a function is invoked it gets pushed to the stack and every time it returns it gets popped off the stack. A function together with its arguments is called stack frame.
Event Loop and Queue
Node is providing timers, events and wrappers to interact with filesystem and networks. Node also provides the Event Loop and Queue (also referred to as Message Queue or Callback Queue) using the libuv library.
A queue is a First In First Out data structure. You add functions to Node’s queue via specific methods that Node provides, such as Timer functions. For example when the call stack sees setTimeout(cb, 1000)
then setTimeout
invokes a Node timer function outside of the V8 JavaScript runtime, pops setTimeout
off the stack and let other code continue to run. Then after (at least) 1000 milliseconds the Timer adds cb
to the Node queue. The Event Loop processes the queue by calling its functions. In the example the queue invokes cb
, or in other words, adds cb
to the stack.
Job of the event loop: If call stack is NOT empty, then wait. But if it is empty and there is an event waiting in the queue, then execute the queued function which will put it on the stack. If the stack and queue are both empty, Node will exit the process.
Slow code on the stack will block the Event Loop.
Phases of Event Loop
The event loop itself has different sequential phases, for example timers run in one phase, I/O callbacks in another. setImmediate(cb)
is a Node timer function that runs in a separate phase, that’s why setImmediate(cb)
is executed before setTimeout(cb, 0)
. Then there is also the misleading process.nextTick(ntCb)
function, which actually does NOT execute on the next tick, because the function is not part of the event loop and does not care about the different phases of the event loop. ntCb
is processed after the current operation completes and before the event loop continues, which is both useful and dangerous.
Creating custom events with Event Emitter
const EventEmitter = require('events'); const ee = new EventEmitter(); ee.on('greet', (evt) => { console.log('Event is: ' + evt); }); ee.emit('greet', 'hello');
results in Event is: hello
.
A more elaborate example
In the following example we
- read the current script file and use event ‘data’ to display its file length
- read a non-existent file and handle the error event
- measure execution time of the asynchronously invoked file-reading function
- use class inheritance
- use ES6 modules with
import
instead ofrequire
.
import {EventEmitter} from 'events'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; /* Note that __filename and __dirname are not available in ES6 modules, that’s why we have to create them manually. */ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); class RunWithLog extends EventEmitter { run(asyncFunc, ...args) { console.time('measure'); this.emit('start'); asyncFunc(...args, (err, data) => { if(err) { return this.emit('error', err); } this.emit('data', data); console.timeEnd('measure'); this.emit('end'); }); } } const runWithLog = new RunWithLog(); /* We listen to start event */ runWithLog.on('start', () => console.log('Starting')); /* We listen to start event again and usually implementation order counts, but here we use prependListener to have this event be handled earlier */ runWithLog.prependListener('start', () => console.log('Before start')); /* We listen but remove listener for event 'end' just for illustration */ const onEnd = function() { console.log('Ending') }; runWithLog.on('end', onEnd); runWithLog.removeListener('end', onEnd); runWithLog.on('error', (err) => console.log('Error', err)); runWithLog.on('data', (result) => console.log('Data length', result.length)); runWithLog.run(fs.readFile, __filename);
Results in (roughly):
Before start Starting Data length 1384 measure: 6.201ms