Multithreading in Node.js. Event Loop

How the Event Loop works in Node.js, why async operations help, and when to bring in worker_threads for performance.

  • How Node.js is built. Async capabilities
  • Multithreading - the worker_threads module
  • Additional information
  • Async capabilities. Let's look at this code: it clearly demonstrates the synchronous execution of code in Node.js.

Reading time: 9 min. Read the article on habr.com. Vladimir Zeynalov, Developer. This will be useful for JS developers who want a deep understanding of how Node.js and the Event Loop work. You'll be able to manage your program's (web server's) execution flow deliberately and more flexibly. I put this article together from my recent talk for colleagues. At the end there are useful materials for further study.

How Node.js is built. Async capabilities

Let's look at this code: it clearly demonstrates the synchronous execution of code in Node.js. A request is made somewhere to GitHub, then a file is read and the result is printed to the console. What is clear from this synchronous code? Slide No. 1

Assess where AI can deliver impact in your process

Event Loop

gradually and synchronously processes each callback from this queue. And, accordingly, returns the result to the application. Then (if needed) it all happens again. Thus, thanks to this non-blocking I/O, Node.js can be asynchronous. To clarify, in this case the non-blocking I/O is provided to us specifically by the operating system. Under non-blocking I/O (and I/O operations in general) we include network requests and working with files.

This is the general concept of non-blocking I/O. When this capability appeared, Ryan Dahl, the developer of Node.js, was inspired by the experience of Nginx, which used non-blocking I/O, and decided to create a platform specifically for developers. The first thing he needed to do was make his platform work with an event demultiplexer.

The problem was that the event demultiplexer is implemented differently in every operating system, so he had to write a wrapper that later came to be called libuv. It is a library written in C. It provides a unified interface for working with these event demultiplexers. Features of the libuv library. Slide No.

In Linux, at the moment, all operations with local files are blocking. That is, non-blocking I/O exists in theory, but when working with local files the operation is still blocking. That is why libuv uses threads internally to emulate non-blocking I/O.

Out of the box, four threads are spun up, and here is the most important takeaway: if we run four heavy operations on local files, we will block our entire application (specifically on Linux; other OSes don't have this). Slide No.

On this slide we see the Node.js architecture. To interact with the operating system it uses the libuv library, written in C; to compile JavaScript code into machine code it uses Google's V8 engine; there is also the Node.js Core library, which bundles modules for network requests, the file system, and a logging module. To make all of these work together, the Node.js Bindings were written. These four components make up the very structure of Node.js. The mechanism itself

Event Loop

's Event Loop lives in libuv. Event Loop Slide No.

This is the simplest representation of what the Event Loop looks like

There is a certain event queue, and there is an infinite event loop that synchronously executes operations from the queue and distributes them further. This slide shows what the Event Loop looks like inside Node.js itself. Slide no.

The implementation there is more interesting and more complex

In essence, the Event Loop is an event cycle, and it is infinite as long as there is something to execute. The Event Loop in Node.js is divided into several phases (the phases on slide 8 should be matched with the source code on slide 9). Slide no.

The first phase is timers. This phase is executed directly by the Event Loop. Pay attention to the code fragment with uv_update_time on slide 9 - it simply updates the time when the Event Loop started running. uv_run_timers is the method that performs the next action for a timer. There is a certain stack, or rather a heap of timers, which is essentially the same as the queue where timers are stored.

The timer with the smallest delay is taken and compared with the current Event Loop time, and if it is time to execute that timer, its callback runs. It is worth noting that Node.js has both setTimeout and setInterval. For libuv, they are essentially the same thing, except setInterval also has a repeat flag. Accordingly, if that timer has the repeat flag set, it is placed back into the event queue and then processed in the same way again. The second phase is I/O callbacks.

Here we should go back to the non-blocking I/O diagram. When the event demultiplexer reads some file and queues a callback for execution, this corresponds exactly to the I/O callback stage. This is where callbacks for non-blocking I/O run — that is, precisely the functions used after a query to a database or another resource, or after a file read/write. They execute specifically in this phase.

On slide 9, execution of the I/O callbacks function is triggered by line 367: ran_pending = uv_run_pending(loop). The third phase is wait and prepare. These are internal operations for callbacks, and in practice we cannot influence the phase directly, only indirectly. There is process.nextTick, and its callback may unintentionally be executed in the wait and prepare phase. process.nextTick runs in the current phase, which means process.nextTick can effectively fire in absolutely any phase.

There is no ready-made tool in Node.js to run code in the wait and prepare phase. On slide 9, this phase corresponds to lines 368 and 369: uv_run_idle(loop) for waiting, and uv_run_prepare(loop) for preparation. The fourth phase is poll. This is where all the code we write in JS runs. Initially, all requests we make land here, and this is where Node.js can be blocked.

If a heavy computation lands here, the application may simply freeze at this stage and wait until that operation completes. On slide 9, the polling function is in line 370: uv_io_poll(loop, timeout). The fifth phase is check. In Node.js, there is a setImmediate timer, and its callbacks run in this phase. In the source code, this is line 371: uv_run_check(loop). The sixth phase, and the last one, is close event callbacks.

For example, a web socket needs to close a connection; in this phase the callback for that event will be invoked. In the source this is line 372: uv_run_closing_handless(loop). And in the end the Node.js Event Loop looks as follows. Slide No.

First, the timer whose deadline has arrived is executed in the timers queue. Next, I/O callbacks are executed. Then comes the main code, followed by setImmediate and close events. After that, everything repeats in a loop. To demonstrate this, I'll open the code. How will it execute? We have no timers in the queue, so the Event Loop moves on. There are no I/O callbacks either, so we go straight to the poll phase. All the code here initially executes in the poll phase.

So first we print script_start, setInterval is placed into the timers queue (not executed, just queued). setTimeout is also placed into the timers queue, and then the promises run: first promise 1 and then promise

On the next tick of the event loop, we return to the timers phase, where the queue already contains two timers: setInterval and setTimeout. Both have zero delay, so they are ready to run. setInterval executes first, then setTimeout is logged to the console.

There are no non-blocking I/O callbacks, next comes the poll phase, and promise 3 and promise are printed to the console

Next, the setTimeout timer is registered

This tick ends, and we move to the next tick. There are timers again, setInterval and setTimeout 2 are logged to the console, and then promise 5 and promise are logged.

We have covered the Event Loop and can now talk about multithreading in more detail.

Multithreading - the worker_threads module

  1. Multithreading arrived in Node.js via the worker_threads module in version 10.5. In version 10 it ran only with the --experimental-worker flag, and from version 11 it became possible to run without it.

  2. Node.js also has the cluster module, but it does not create threads, it creates several more processes.

  3. Application scalability is its main goal.

  4. What a single process looks like overall: one Node.js process, one thread, one

Event Loop

, one V8 engine and libuv

If we start X threads, it looks like this: one Node.js process, X threads, X Event Loops, X V8 engines, and X libuv instances.

Schematically, it looks like this. Slide no. 11 Let's walk through an example.

A simple web server built with Express.

There are 2 routes: / and /fat-operation.

There is also a generateRandomArr() function.

It fills the array with two million entries and sorts it. Let's start the server.

We make a request to /fat-operation

And at the moment when the array sorting operation is running, we send another request to route /, but to get the response we have to wait until the array sorting finishes.

This is the classic single-threaded implementation.

Now we import the worker_threads module.

We make a request to /fat-operation and then to /, from which we immediately get the response Hello world!

For the array sort operation we spun up a separate thread with its own Event Loop instance, and it does not affect code execution on the main thread at all.

The thread will be terminated when it has no operations left to execute. Let's look at the source code.

We register the worker on line 26 and, if needed, pass data to it

In this case, I pass nothing. Then we subscribe to the events: error and message. Inside the worker itself, the function is called and an array of two million entries is sorted.

As soon as it is sorted, we use post_message to send the result to the main thread: ok. On the main thread we catch this message and send the result: finish. The worker and the main thread share memory, so we have access to the global variables of the entire process.

When we pass data from the main thread to a worker, the worker receives only a copy.

We can describe the main thread and the worker thread in a single file.

The worker_threads module provides an API that lets us determine which thread the code is currently running in.

Additional information

I'm sharing links to useful resources and to Ryan Dahl's presentation where he introduced the Event Loop (worth watching). Event Loop

Translation of the article from the Node.js documentation 2 https://blog.risingstack.com/node-js-at-scale-understanding-node-js-event-loop/ 3 https://habr.com/ru/post/336498/ worker_threads 1 https://nodejs.org/api/worker_threads.html#worker_threads_worker_workerdata - API 2 https://habr.com/ru/company/ruvds/blog/415659/ 3 https://nodesource.com/blog/worker-threads-nodejs/

Original slides from Ryan Dahl's presentation (through VPN)

Discuss the article: Multithreading in Node.js. Event Loop

Send via: