查看原文
其他

Node.js 事件循环工作原理及最佳实践

小懒 FED实验室 2024-02-12
关注下方公众号,获取更多文章

如果你是前端开发或者全栈开发者,一定听过事件循环。在 Node.js 中,事件循环是性能的核心,它通过利用内核帮助 Node.js 执行异步和非阻塞操作,全面准确地了解事件循环有利于我们更好地掌握 Node.js 的内部工作原理,并防止出现一些线上问题。

本文主要介绍事件循环的概念、重要性及最佳实践,如果对您有帮助,欢迎关注、点赞、分享,让更多需要的同学看到。

1.事件循环的工作原理

处理器可以在一个 CPU 上处理多个请求,而不需要等待其他请求完成。说到事件循环模式,它比基于线程的模式性能更高,因为线程的执行会耗费内核大量内存。

1.1.为什么事件循环在 Node.js 中很重要?

事件循环在 Node.js 中非常重要,主要归因于以下几个方面:

  • 首先,它是 Node.js 异步架构的支柱,使 Node.js 能够在没有多线程的情况下处理多个并发操作。
  • 其次,事件循环有助于提高 Node.js 的性能和资源效率。事件循环的非阻塞特性允许开发人员能够编写可在可用系统资源上执行的代码,从而提供快速的响应。
  • 与基于线程的模式相比,事件循环模式有一个显著的优势:它能让 CPU 同时处理更多请求。它的性能也比基于线程的模式更强,内核执行时使用的内存更少。

1.2.了解事件循环

通常,事件循环由以下阶段组成:

  • 定时器
  • 待处理回调
  • 空闲/准备
  • 轮询
  • 检查
  • 关闭回调
  • 传入连接和数据

定时器是第一个阶段,也是最重要的一个阶段,它通过 setTimeout() 或者 setInterval() 注册回调函数。

它们还允许我们使用选项来监视事件循环,最终提供了一种检查事件是否空闲的良好方式。然后,事件循环会执行已过期的定时器,并再次检查待处理的回调。

在轮询阶段,首先检查 I/O 回调,然后是 setImmediate() 回调。Node.js 还有一个特殊的回调,即 process.nextTick(),它在每个循环阶段后执行。该回调具有最高优先级。

在轮询阶段,事件循环会查找已完成异步任务并准备处理的事件。

然后进入检查阶段,在这一阶段中,事件循环会执行所有通过 setImmediate() 注册的回调函数。

关闭回调与关闭网络连接或处理 I/O 事件中的错误相关联。然后,事件循环将查找预定计时器。

然后继续执行循环,使应用程序保持响应和非阻塞。

1.3.Node.js 中 HTTP 请求的正常流程

当 Node.js 收到请求时,会同步处理请求,然后对响应进行类似处理。但是,当请求需要调用数据库时,则会以异步方式运行。这意味着对于每个请求,都有两个同步进程和一个异步进程。通常,响应时间可以根据以下公式计算:

响应时间 = 2SP + 1AP

其中,SP 是同步处理,AP 是异步处理。

例如,如果一个请求需要 10ms 的同步处理时间和 10ms 的异步处理时间,总响应时间将为:

2*10 + 10 = 30ms

可以使用以下公式计算一个CPU可处理的总请求数量:

1000ms /(10ms * 2)= 50

事件循环是同步运行的,不考虑 I/O 等待时间。

2.当所有请求同时到达时会发生什么?

例如,如果服务器同时收到三个请求,那么处理最后一个请求需要多长时间呢?

在处理第一个请求的同时,第二个和第三个请求正在排队。然后按照到达的顺序处理第二和第三个请求,等待前一个请求处理完毕。

在事件循环同步运行的情况下,使用标准公式计算的每个请求的处理时间分别为 30ms、50ms 和 70ms。如果要计算最后一个请求的响应时间,而不考虑请求的数量,可以使用以下公式:

ResponseTime = SP * 2 + AP + SP * (x - 1) *2

其中 x 标识第几次请求。

那么如何建设执行时间呢?一个可行解决方案是根据 CPU 使用情况扩展服务器,但是,部署新的新服务器需要时间,而且经常会导致资源利用率不足,因为尽管使用率达到 100%,系统中仍有可用容量。

原因很简单:Node.js 在多个线程上运行,垃圾回收器和 CPU 优化器分别运行。这意味着,在 Node.js 中,可能会有大量的空闲 CPU,然后才会开始明显放缓。

事件循环延迟是可测量的,这意味着开发人员可以跟踪事件应该触发的时间和实际触发的时间。

要了解其工作原理,你可以使用以下代码来调试:

'use strict'

const EE = require('events').EventEmitter

const defaults = {
  limit42,
  sampleInterval5
}

function loopBench (opts) {
  opts = Object.assign({}, defaults, opts)

  const timer = setInterval(checkLoopDelay, opts.sampleInterval)
  timer.unref()

  const result = new EE()

  result.delay = 0
  result.sampleInterval = opts.sampleInterval
  result.limit = opts.limit
  result.stop = clearInterval.bind(null, timer)

  let last = now()

  return result

  function checkLoopDelay () {
    const toCheck = now()
    const overLimit = result.overLimit
    result.delay = Number(toCheck - last - BigInt(result.sampleInterval))
    last = toCheck

    result.overLimit = result.delay > result.limit

    if (overLimit && !result.overLimit) {
      result.emit('unload')
    } else if (!overLimit && result.overLimit) {
      result.emit('load')
    }
  }

  function now () {
    return process.hrtime.bigint() / 1000000n
  }
}

module.exports = loopBench

示例文件:

'use strict'

const http = require('http')
const server = http.createServer(serve)
const loopBench = require('./')()

loopBench.on('load'function () {
  console.log('max delay reached', loopBench.delay)
})

function sleep (msec) {
  let i = 0
  const start = Date.now()
  while (Date.now() - start < msec) { i++ }
  return i
}

function serve (req, res) {
  console.log('current delay', loopBench.delay)
  console.log('overLimit', loopBench.overLimit)

  if (loopBench.overLimit) {
    res.statusCode = 503 // Service Unavailable
    res.setHeader('Retry-After'10)
  }

  res.end()
}

server.listen(0function () {
  const req = http.get(server.address())

  req.on('response'function (res) {
    console.log('got status code', res.statusCode)
    console.log('retry after', res.headers['retry-after'])

    setTimeout(function () {
      console.log('overLimit after load', loopBench.overLimit)
      const req = http.get(server.address())

      req.on('response'function (res) {
        console.log('got status code', res.statusCode)

        loopBench.stop()
        server.close()
      }).end()
    }, parseInt(res.headers['retry-after'], 10))
  }).end()

  setImmediate(function () {
    console.log('delay after active sleeping', loopBench.delay)
  })

  sleep(500)
})

运行后:

➜  demo ✗ node index.js
max delay reached 507
delay after active sleeping 507
current delay 0
overLimit false
got status code 200
retry after undefined
overLimit after load false
current delay 0
overLimit false

3.事件循环利用率

事件循环利用率(ELU)指的是事件循环作为一个高分辨率毫秒级定时器时空闲和活动的累计时间。我们可以使用它来判断事件循环中是否有“闲置”容量ELU 是一个监控事件循环在 CPU 上的利用时间的指标,可以直接从 libuv 读取,libuv 是 Node.js 用来实现事件循环的 C 语言库。

你可以使用 perf_hooks(https://nodejs.org/api/perf_hooks.html) 库来计算 ELU。这将返回一个介于 0 和 1 之间的小数,告诉你事件循环的使用情况。

在 Fastify (一个最快的Node.js Web框架) 中,有一个自动设置的模块叫做 @fastify/under-pressure 。你可以使用它来指定最大的事件循环延迟、内存和事件循环利用率。

下面来看看这个包是如何工作的。

当该包在一定时间后接收到多个请求时,事件循环利用率会在 0.98 秒时超出限制。在此之后,任何进来的请求都会得到 503 的响应状态码。

想象一下,如果有多个请求,事件循环可能会累积超过 2 秒的延迟。用户可能不愿意等待那么长时间。在这种情况下,你可以返回一个响应,告诉用户服务器不会返回请求。

那么如何使用呢?

首先,克隆这个仓库,进入thrashing目录,找到启动服务器的 server.js 文件。

# clone repo
git clone https://github.com/platformatic/node-masterclass.git

# start server

node server.js

然后,在另一个终端运行命令,模拟 50 个连接,持续 10 秒,连接到服务器:

➜  thrashing git:(main) ✗ autocannon -c 50 -d 10 -t 1 --renderStatusCodes http://127.0.0.1:3000
Running 10s test @ http://127.0.0.1:3000
50 connections

┌─────────┬───────┬────────┬────────┬────────┬───────────┬───────────┬────────┐
│ Stat    │ 2.5%  │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev     │ Max    │
├─────────┼───────┼────────┼────────┼────────┼───────────┼───────────┼────────┤
│ Latency │ 67 ms │ 526 ms │ 962 ms │ 986 ms │ 530.85 ms │ 273.35 ms │ 986 ms │
└─────────┴───────┴────────┴────────┴────────┴───────────┴───────────┴────────┘
┌───────────┬─────┬──────┬─────┬─────────┬───────┬─────────┬─────────┐
│ Stat      │ 1%  │ 2.5% │ 50% │ 97.5%   │ Avg   │ Stdev   │ Min     │
├───────────┼─────┼──────┼─────┼─────────┼───────┼─────────┼─────────┤
│ Req/Sec   │ 0   │ 0    │ 0   │ 40      │ 4     │ 12      │ 40      │
├───────────┼─────┼──────┼─────┼─────────┼───────┼─────────┼─────────┤
│ Bytes/Sec │ 0 B │ 0 B  │ 0 B │ 7.04 kB │ 704 B │ 2.11 kB │ 7.04 kB │
└───────────┴─────┴──────┴─────┴─────────┴───────┴─────────┴─────────┘
┌──────┬───────┐
│ Code │ Count │
├──────┼───────┤
│ 200  │ 40    │
└──────┴───────┘

Req/Bytes counts sampled once per second.
# of samples: 10

540 requests in 10.04s, 7.04 kB read
450 errors (450 timeouts)

从上面的输出结果来看,延迟时间 1 秒左右,平均每秒有 4 个请求。

现在让我们看看 @fastify/under-pressure 的不同之处。在 server-protected.js 文件中,最大事件循环延迟设置为 200 毫秒,事件循环利用率设置为 0.80。

现在使用下面的命令启动服务器:

node server-protected.js

然后在另一个终端运行命令,在 10 秒内模拟 50 个连接到服务器的情况。这次,您会得到如下所示的不同结果。

➜  thrashing git:(main) ✗ autocannon -c 50 -d 10 -t 1 --renderStatusCodes http://127.0.0.1:3000
Running 10s test @ http://127.0.0.1:3000
50 connections

┌─────────┬──────┬──────┬───────┬────────┬─────────┬─────────┬─────────┐
│ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%    │ Avg     │ Stdev   │ Max     │
├─────────┼──────┼──────┼───────┼────────┼─────────┼─────────┼─────────┤
│ Latency │ 2 ms │ 3 ms │ 8 ms  │ 134 ms │ 8.57 ms │ 58.5 ms │ 1000 ms │
└─────────┴──────┴──────┴───────┴────────┴─────────┴─────────┴─────────┘
┌───────────┬─────┬──────┬────────┬─────────┬─────────┬──────────┬─────────┐
│ Stat      │ 1%  │ 2.5% │ 50%    │ 97.5%   │ Avg     │ Stdev    │ Min     │
├───────────┼─────┼──────┼────────┼─────────┼─────────┼──────────┼─────────┤
│ Req/Sec   │ 0   │ 0    │ 2,771  │ 9,591   │ 3,554.4 │ 3,383.19 │ 42      │
├───────────┼─────┼──────┼────────┼─────────┼─────────┼──────────┼─────────┤
│ Bytes/Sec │ 0 B │ 0 B  │ 865 kB │ 3.01 MB │ 1.11 MB │ 1.06 MB  │ 7.39 kB │
└───────────┴─────┴──────┴────────┴─────────┴─────────┴──────────┴─────────┘
┌──────┬───────┐
│ Code │ Count │
├──────┼───────┤
│ 200  │ 164   │
├──────┼───────┤
│ 503  │ 35372 │
└──────┴───────┘

Req/Bytes counts sampled once per second.
# of samples: 10

164 2xx responses, 35372 non 2xx responses
36k requests in 10.03s, 11.1 MB read
155 errors (155 timeouts)

从这里我们可以看到,我们收到了36k的请求,而第一个实例中只有 540 个。在这里,我们获得了 164 个成功请求,而使用未受保护的服务器时只有 40 个。

从 503 响应状态的数量可以看出,压力包的功能非常明显。延迟时间也更短,仅为 134 毫秒。

server-load-aware.js 文件的功能略有提升,因为它甚至可以判断服务器是否处于压力之下,从而为您提供更多控制,让您可以在服务器处于压力之下和未处于压力之下时对其进行控制。

当我们启动服务器负载并再次运行演示时,这次我们将获得更好的数据。

➜  thrashing git:(main) ✗ autocannon -c 50 -d 10 -t 1 --renderStatusCodes http://127.0.0.1:3000
Running 10s test @ http://127.0.0.1:3000
50 connections


┌─────────┬───────┬───────┬───────┬────────┬──────────┬──────────┬─────────┐
│ Stat    │ 2.5%  │ 50%   │ 97.5% │ 99%    │ Avg      │ Stdev    │ Max     │
├─────────┼───────┼───────┼───────┼────────┼──────────┼──────────┼─────────┤
│ Latency │ 10 ms │ 11 ms │ 16 ms │ 546 ms │ 22.22 ms │ 83.03 ms │ 1003 ms │
└─────────┴───────┴───────┴───────┴────────┴──────────┴──────────┴─────────┘
┌───────────┬─────┬──────┬────────┬────────┬─────────┬──────────┬─────────┐
│ Stat      │ 1%  │ 2.5% │ 50%    │ 97.5%  │ Avg     │ Stdev    │ Min     │
├───────────┼─────┼──────┼────────┼────────┼─────────┼──────────┼─────────┤
│ Req/Sec   │ 0   │ 0    │ 1,145  │ 3,855  │ 1,632.2 │ 1,485.54 │ 37      │
├───────────┼─────┼──────┼────────┼────────┼─────────┼──────────┼─────────┤
│ Bytes/Sec │ 0 B │ 0 B  │ 202 kB │ 678 kB │ 287 kB  │ 261 kB   │ 6.51 kB │
└───────────┴─────┴──────┴────────┴────────┴─────────┴──────────┴─────────┘
┌──────┬───────┐
│ Code │ Count │
├──────┼───────┤
│ 200  │ 16319 │
└──────┴───────┘

Req/Bytes counts sampled once per second.
# of samples: 10

16k requests in 10.03s, 2.87 MB read
129 errors (129 timeouts)

与前两个实例相比,这里的服务器每秒可以处理更多的请求。在这种情况下,与我们查看的其他两个实例相比,200 响应状态是最高的。延迟时间也很短。

这里最大的权衡是服务器发送缓存数据而不是返回 503 响应状态。这样,它就可以处理更多的流量和请求。

4.事件循环最佳实践

日常开发中,必须始终高效地使用事件循环,以确保不间断的响应能力、更高的性能、可维护性和可扩展性。

4.1.不要阻塞事件循环

将所有同步处理移出事件循环。考虑将它们移动到工作线程中,工作线程被优化用于执行繁重的任务,即将同步、计算密集型的任务从Node.js事件循环的主线程中移出这可以使您的应用程序保持响应和可扩展性,同时执行计算密集型操作。这样,您的应用程序就能在执行计算密集型操作的同时保持响应速度和可扩展性。

4.2.减少重叠的异步调用

通过减少重叠的异步任务数量,可以使应用程序更快。这就是去重的作用,它是关于使用单个唯一的数据副本并且摒弃多余的数据副本,这些数据副本仍然指向已使用的数据副本。如果应用程序同时接收到三个对相同数据的请求,它们会被去重,只有一个请求会发送到数据库进行处理。我们现在可以通过之前生成的数据来响应它们这样,您的应用程序就不再需要为每个请求逐个处理数据了。

要解决这个问题,可以使用 async-cache-dedupe 包。它是一个用于异步获取资源的缓存,具有完全去重功能,这意味着同一资源在任何给定时间只会被提供一次。

该 API 提供了一些选项,例如 ttl,它指定条目可以存在的最长时间。stale 选项指定了值在过期后从缓存中提取的时间。

它还提供了一个默认为存储的内存选项,并且与Redis兼容。也可以设置该内存选项的大小。

总结

在这篇文章中,我们介绍了 Node.js 中事件循环的核心概念。我们还学习了关于事件循环利用率的内容,事件循环的关键最佳实践以及为什么在构建应用程序时遵循这些实践很重要。

大家都在看

  1. 现代CSS:纯 CSS 实现飘雪动画效果 🔥

  2. Firefox:一款被低估的浏览器,120 版本正式发布!🔥

  3. Rspack Family 介绍 🔥

  4. 基于 Node.js 的 HTML 转 PDF 几种实现方案 🔥

  5. 现代CSS:纯 CSS 实现倒计时效果 🔥

  6. Vite 5 正式发布!🔥

  7. React Server Component 综合指南 🔥

  8. 你应该掌握的 8 种实用的 JavaScript 编码技术

  9. 前端快讯|重要提醒!第三方 Cookie 即将被禁用 🔥

继续滑动看下一个

Node.js 事件循环工作原理及最佳实践

小懒 FED实验室
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存