Measuring Node.js and Page Performance with the Event Loop

Event Loop

Everyone should understand the Event Loop mechanism. Let me repeat the summary first.

The Event Loop of Node.js is different from that of Javascript. Intuitively, there are two more APIs, setImmediate and process.nextTick. Secondly, due to the different runtimes, Html Standrad will consider that different sources such as multiple pages and DOM operations will have different task queues. Not so much to consider in the Node.js Event Loop.

According to my understanding, the two sides are conceptually consistent and can be summarized like this (or see here):

  1. task queue task queue. Some events, etc. will be defined as tasks, which are often called MacroTask (macro task) to correspond to MicroTask. Each time, the task at the head of the queue will be obtained for execution.
  2. microtask queue Microtask queue. There will be a microtask queue, and the microtask queue will generally be cleared within a Task.
  3. So back and forth.

Performance measurement

After the above understanding, there is a simple way to measure performance: how many Event Loop cycles are completed per second, or how many MacroTasks are executed, so that we can roughly know the execution of synchronous code in the code .

Test function

class MacroTaskChecker {<!-- -->
    constructor(macroTaskDispatcher, count = 1000, cb = () => {<!-- --> }) {<!-- -->
        this.macroTaskDispatcher = macroTaskDispatcher
        this.COUNT = count
        this.cb = cb
    }
    start(cb) {<!-- -->
        this.cb = cb || this.cb
        this.stop = false
        const scope = () => {<!-- -->
            let count = this. COUNT
            const startTime = performance.now()
            const fn = () => {<!-- -->
                count--
                if (count > 0) this. macroTaskDispatcher(fn)
                else {<!-- -->
                    const endTime = performance. now()
                    // After executing COUNT macro tasks, calculate the average number of macro tasks executed per second
                    this.cb({<!-- -->
                        avg: this. COUNT / (endTime - startTime) * 1000,
                        timestamp: endTime
                    })
                    !this.stop & amp; & amp; this.macroTaskDispatcher(scope)
                }
            }
            this. macroTaskDispatcher(fn)
        }
        scope()
    }

    stop() {<!-- -->
        this.stop = true
    }
}

Afterwards, some infinite loops are executed to test whether intensive synchronous code execution can be detected.

function meaninglessRun(time) {<!-- -->
    console.time('meaninglessRun')
    for (let i = time; i--; i > 0) {<!-- -->
        // do nothing
    }
    console.timeEnd('meaninglessRun')
}

setTimeout(() => {<!-- -->
    meaninglessRun(1000 * 1000 * 1000)
}, 1000 * 5)

setTimeout(() => {<!-- -->
    checker. stop()
    console.log('stop')
}, 1000 * 20)

setTimeout

const checker = new MacroTaskChecker(setTimeout, 100)

checker.start(v => console.log(`time: ${<!-- -->v.timestamp.toFixed(2)} avg: ${<!-- -->v.avg.toFixed(2 )}`))

It can be clearly seen from the output that avg drops when the synchronization is blocked. However, there will be a significant gap between testing on browser and node.js.

// node.js
time: 4837.47 avg: 825.14
time: 4958.18 avg: 829.83
meaninglessRun: 918.626ms
time: 6001.69 avg: 95.95
time: 6125.72 avg: 817.18
time: 6285.07 avg: 635.16
// browser
time: 153529.90 avg: 205.21
time: 154023.40 avg: 204.46
meaninglessRun: 924.463ms
time: 155424.00 avg: 71.62
time: 155908.80 avg: 208.29
time: 156383.70 avg: 213.04

Although our purpose is achieved, using setTimeout is not completely able to accurately record every task. According to HTML Standrad and MDN, setTimeout waits at least 4ms. From this perspective, browser avg * 4ms ≈\approx 1000ms. And node.js probably did not follow the agreement on the browser side, but it did not execute to record every loop.

setImmediate

If using setImmediate of node.js:

const checker = new MacroTaskChecker(setImmediate, 1000 * 10)

It can be seen that the number of executions is about an order of magnitude higher than that of Node.js setTimeout:

time: 4839.71 avg: 59271.54
time: 5032.99 avg: 51778.84
meaninglessRun: 922.182ms
time: 6122.44 avg: 9179.95
time: 6338.32 avg: 46351.38
time: 6536.66 avg: 50459.77

According to the explanation in the Node.js documentation, setImmediate will be executed in the check phase of each loop (phase). Using setImmediate should be able to accurately record every Loop. The number of cycles on my machine is probably between 40000 and 60000.

window. postMessage

Since there is no setImmediate on the browser, we can use window.postMessage to implement one according to the guidelines on MDN.

If you want to implement a 0ms delay timer in the browser, you can refer to the ?window.postMessage() mentioned here

const fns = []
window.addEventListener("message", () => {<!-- -->
    const currentFns = [...fns]
    fns.length = 0
    currentFns. forEach(fn => fn())
}, true);
function messageChannelMacroTaskDispatcher(fn) {<!-- -->
    fns. push(fn)
    window. postMessage(1)
}

It can be seen that the magnitude of node.js setImmediate is consistent.

time: 78769.70 avg: 51759.83
time: 78975.60 avg: 48614.49
meaninglessRun: 921.143 ms
time: 80111.50 avg: 8805.14
time: 80327.00 avg: 46425.26
time: 80539.10 avg: 47169.81

MessageChannel

browser

In theory, it should be possible for the browser to use MessageChannel, and avoid invalid messages being received by other window.addEventListener("message", handler):

const {<!-- --> port1, port2 } = new MessageChannel();
const fns = []
port1.onmessage = () => {<!-- -->
    const currentFns = [...fns]
    fns.length = 0
    currentFns. forEach(fn => fn())
};
function messageChannelMacroTaskDispatcher(fn) {<!-- -->
    fns. push(fn)
    port2. postMessage(1)
}

I don’t quite understand why it is more frequent than window.postMessage. If you start two checkers at the same time, you can see that the logs appear in pairs, which means that everyone in a loop executes it only once. My guess is that the implementation of window.postMessage consumes more.

time: 54974.80 avg: 68823.12
time: 55121.00 avg: 68493.15
meaninglessRun: 925.160888671875 ms
time: 56204.60 avg: 9229.35
time: 56353.00 avg: 67430.88
time: 56503.10 avg: 66666.67
// Execute together wp=window.postMessage mc=MessageChannel
wp time: 43307.90 avg: 25169.90
mc time: 43678.40 avg: 27005.13
wp time: 43678.60 avg: 26990.55
mc time: 44065.80 avg: 25833.12
wp time: 44066.00 avg: 25819.78
mc time: 44458.40 avg: 25484.20

node

There is also MessageChannel on node.js, can it also be used to measure the number of loops?

mc time: 460.99 avg: 353930.80
mc time: 489.52 avg: 355088.11
mc time: 520.30 avg: 326384.64
mc time: 551.78 avg: 320427.29

The magnitude is very abnormal. In theory, it should not exceed setImmediate. If the checkers of setImmediate and setTimeout are enabled at the same time:

...
(messagechannel) time: 1231.10 avg: 355569.31
(messagechannel) time: 1260.14 avg: 345825.77
(setImmediate) time: 1269.95 avg: 339.27
(setTimeout) time: 1270.09 avg: 339.13
(messagechannel) time: 1293.80 avg: 298141.74
(messagechannel) time: 1322.50 avg: 349939.04
...

Obviously, it is not a macro task. I guess MessageChannel is classified as the same level as socket in node.js, that is, tasks after exceeding the threshold will move to the next loop.

Summary

It is quite interesting to use this method to test performance, but this indicator feels too unstable when used officially (even if nothing is done, there will be 20%-30% vibration). It is recommended to combine it with other serious methods (such as performance, etc.).

At the same time, this method is very likely to affect the normal Event Loop. For example, there will be a pull stage in Node.js. After all microtasks are executed, if there is no timer, it will stay in this stage , ready to execute the next microtask that occurs immediately.

By the way, I reviewed the Event Loop. Unexpectedly, there is such a big gap between MessageChannel on both sides.