Skip to main content
  1. Posts/

Event loop?

·6 mins

First of all, wtf is an event loop? #

If you are working with Javascript you have heard about Event Loop. The event loop is implemented in multiple languages like Python in asyncio, Php through a library called ReactPhp (and others). Some languages can create new threads but they have to enable extensions (like php), and in some cases (like Python) there is a big limitation caused by the GIL (Global interpreter lock) that avoids executing code in multiple threads at once.

In simple terms (the way we like), an event loop is a loop that is listening for events to dispatch callbacks, this technique or pattern allows code to be executed in a pseudo-parallel way. Why pseudo-parallel? because code is not being executed simultaneously, it looks like is parallel-executed but is paused and executed during the process, we will see this more in-depth.

Each implementation of the event loop differs, for example, the web event loop is not the same as the node.js event loop but they are similar. In this post, we are going to focus on the node.js event loop.

How an event loop works? #

Let’s start with the phases it goes through:

graph LR; A[Timer]-->B[Pending Callbacks]; B-->C[Idle, prepare]; C-->D[Poll]; D-->E[Check]; E-->F[Close callbacks];

I have to admit the names suck, it’s so difficult to imagine what a timers phase does, but we will see in a moment what are those.

Things to have in mind to understand better this explanation:

  • These phases are just for synchronous code
  • Each phase has a FIFO (first in, first out) queue
  • The event loop is going through each phase all the time

Timers #

This phase executes the code scheduled by setTimeout and setInterval. Whatever you put as a callback for these two functions is executed as soon as it reaches the threshold and it is not blocked waiting for another process in another phrase, for example the poll phase (we will see it in a moment).

Pending callbacks #

Responsible for executing some callbacks from the OS like TCP errors, nothing to go deep here, we just need to know that if we have an ECONNREFUSED while trying to connect, the error will be queued in this phase.

Poll #

This is where callbacks related to I/O are processed, for example file system, and http and it is a bit tricky because it has conditions related to other other phases, let’s check:

  • If the queue is not empty it will be dequeued until the queue is empty or the system hard limit has been reached. So, what is the hard limit? for example in Linux, if you get 1024 events for 48 iterations it stops, as it’s said in a reference from node.js repo.
  • If the poll queue is empty, if there are callbacks in the queue of the check phase, the poll phase ends, and the loop moves to the Check phase to execute the callbacks immediately, otherwise, the poll phase will wait for more callbacks were added to the queue. When the queue is empty, it checks if there are callbacks in the Timers queue to take the closest threshold as a limit for the execution (this is because we want to jump as soon as possible to execute Timers callbacks) and if there are callbacks ready the event loop will move to the Timers phase to execute them.

Check #

This phase schedule the setTimmediate callbacks and they get executed as soon the poll queue is empty.

Close callbacks #

As the name suggests, callbacks that are related to closing processes, for example the one you get when calling socket.destroy(), these kind of callbacks are queued in this phase.

Let’s check an example (taken from node.js website)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const fs = require('node:fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

As we can see here, the Timers phase schedule the callback for setTimeout in line 10, then someAsyncOperation is called in line 17 and the callback is executed BUT blocks for an extra 10ms. Reading the file (95ms) + the callback block (10ms) give us 105 ms, after that time, Timers phase can to execute the callback, so the setTimeout function took 105 ms instead of 100ms.

Sometimes is better to see the code running the event loop to have a better picture of it, well… here you have, this is a piece of code of the library libuv which is the master piece behind the event loop.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
while (r != 0 && loop->stop_flag == 0) {
  can_sleep =
      uv__queue_empty(&loop->pending_queue) &&
      uv__queue_empty(&loop->idle_handles);

  uv__run_pending(loop);
  uv__run_idle(loop);
  uv__run_prepare(loop);

  timeout = 0;
  if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
    timeout = uv__backend_timeout(loop);

  uv__metrics_inc_loop_count(loop);

  uv__io_poll(loop, timeout);

  /* Process immediate callbacks (e.g. write_cb) a small fixed number of
    * times to avoid loop starvation.*/
  for (r = 0; r < 8 && !uv__queue_empty(&loop->pending_queue); r++)
    uv__run_pending(loop);

  /* Run one final update on the provider_idle_time in case uv__io_poll
    * returned because the timeout expired, but no events were received. This
    * call will be ignored if the provider_entry_time was either never set (if
    * the timeout == 0) or was already updated b/c an event was received.
    */
  uv__metrics_update_idle_time(loop);

  uv__run_check(loop);
  uv__run_closing_handles(loop);

  uv__update_time(loop);
  uv__run_timers(loop);

  r = uv__loop_alive(loop);
  if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
    break;
}

And as an extra, this is a good visualization, just for understanding the logic, it could not be precise but it helps us to have a picture in our mind of how it works.

This video is not mine, it was taken from the url https://www.youtube.com/watch?v=AiPrW8zAiL4, I just downloaded it to present it here in case the video is not available anymore in YouTube but the owner of the video is the owner of the YouTube account.

A few things to have in mind:

  • process.nextTick takes precedence over everything (executed as soon as possible)
  • Promise await and callbacks take precedence over setImmediate
  • setImmediate takes precedence over setTimeout and setInterval in the poll phase
  • setTimeout and setInterval takes precedence over I/O operations

Conclusions #

Having a better understanding of the execution model helps us to take a better decisions in terms of how we want to run our code choosing the appropriate method to set the priority + understand possible blocks of other functions caused by a long-running task (like the example).