站长资讯网
最全最丰富的资讯网站

深入聊聊Node 异步和事件循环的底层实现和执行机制

深入聊聊Node 异步和事件循环的底层实现和执行机制

Node 最初是为打造高性能的 Web 服务器而生,作为 JavaScript 的服务端运行时,具有事件驱动、异步 I/O、单线程等特性。基于事件循环的异步编程模型使 Node 具备处理高并发的能力,极大地提升服务器的性能,同时,由于保持了 JavaScript 单线程的特点,Node 不需要处理多线程下状态同步、死锁等问题,也没有线程上下文切换所带来的性能上的开销。基于这些特性,使 Node 具备高性能、高并发的先天优势,并可基于它构建各种高速、可伸缩网络应用平台。

本文将深入 Node 异步和事件循环的底层实现和执行机制,希望对你有所帮助。

为什么要异步?

Node 为什么要使用异步来作为核心编程模型呢?

前面说过,Node 最初是为打造高性能的 Web 服务器而生,假设业务场景中有几组互不相关的任务要完成,现代主流的解决方式有以下两种:

  • 单线程串行依次执行。

  • 多线程并行完成。

单线程串行依次执行,是一种同步的编程模型,它虽然比较符合程序员按顺序思考的思维方式,易写出更顺手的代码,但由于是同步执行 I/O,同一时刻只能处理单个请求,会导致服务器响应速度较慢,无法在高并发的应用场景下适用,且由于是阻塞 I/O,CPU 会一直等待 I/O 完成,无法做其他事情,使 CPU 的处理能力得不到充分利用,最终导致效率的低下,

而多线程的编程模型也会因为编程中的状态同步、死锁等问题让开发人员头疼。尽管多线程在多核 CPU 上能够有效提升 CPU 的利用率。

虽然单线程串行依次执行和多线程并行完成的编程模型有其自身的优势,但是在性能、开发难度等方面也有不足之处。

除此之外,从响应客户端请求的速度出发,如果客户端同时获取两个资源,同步方式的响应速度会是两个资源的响应速度之和,而异步方式的响应速度会是两者中最大的一个,性能优势相比同步十分明显。随着应用复杂度的增加,该场景会演变成同时响应 n 个请求,异步相比于同步的优势将会凸显出来。

综上所述,Node 给出了它的答案:利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞,以更好地使用 CPU。这就是 Node 使用异步作为核心编程模型的原因。

此外,为了弥补单线程无法利用多核 CPU 的缺点,Node 也提供了类似浏览器中 Web Workers 的子进程,该子进程可以通过工作进程高效地利用 CPU。

如何实现异步?

聊完了为什么要使用异步,那要如何实现异步呢?

我们通常所说的异步操作总共有两类:一是像文件 I/O、网络 I/O 这类与 I/O 有关的操作;二是像 setTimeOutsetInterval 这类与 I/O 无关的操作。很明显我们所讨论的异步是指与 I/O 有关的操作,即异步 I/O。

异步 I/O 的提出是期望 I/O 的调用不会阻塞后续程序的执行,将原有等待 I/O 完成的这段时间分配给其余需要的业务去执行。要达到这个目的,就需要用到非阻塞 I/O。

阻塞 I/O 是 CPU 在发起 I/O 调用后,会一直阻塞,等待 I/O 完成。知道了阻塞 I/O,非阻塞 I/O 就很好理解了,CPU 在发起 I/O 调用后会立即返回,而不是阻塞等待,在 I/O 完成之前,CPU 可以处理其他事务。显然,相比于阻塞 I/O,非阻塞 I/O 多于性能的提升是很明显的。

那么,既然使用了非阻塞 I/O,CPU 在发起 I/O 调用后可以立即返回,那它是如何知道 I/O 完成的呢?答案是轮询。

为了及时获取 I/O 调用的状态,CPU 会不断重复调用 I/O 操作来确认 I/O 是否已经完成,这种重复调用判断操作是否完成的技术就叫做轮询。

显然,轮询会让 CPU 不断重复地执行状态判断,是对 CPU 资源的浪费。并且,轮询的间间隔很难控制,如果间隔太长,I/O 操作的完成得不到及时的响应,间接降低应用程序的响应速度;如果间隔太短,难免会让 CPU 花在轮询的耗时变长,降低 CPU 资源的利用率。

因此,轮询虽然满足了非阻塞 I/O 不会阻塞后续程序的执行的要求,但是对于应用程序而言,它仍然只能算是一种同步,因为应用程序仍然需要等待 I/O 完全返回,依旧花费了很多时间来等待。

我们所期望的完美的异步 I/O,应该是应用程序发起非阻塞调用,无须通过轮询的方式不断查询 I/O 调用的状态,而是可以直接处理下一个任务,在 I/O 完成后通过信号量或回调将数据传递给应用程序即可。

如何实现这种异步 I/O 呢?答案是线程池。

虽然本文一直提到,Node 是单线程执行的,但此处的单线程是指 JavaScript 代码是执行在单线程上的,对于 I/O 操作这类与主业务逻辑无关的部分,通过运行在其他线程的方式实现,并不会影响或阻塞主线程的运行,反而可以提高主线程的执行效率,实现异步 I/O。

通过线程池,让主线程仅进行 I/O 的调用,让其他多个线程进行阻塞 I/O 或者非阻塞 I/O 加轮询技术完成数据获取,再通过线程之间的通信将 I/O 得到的数据进行传递,这就轻松实现了异步 I/O:

深入聊聊Node 异步和事件循环的底层实现和执行机制

主线程进行 I/O 调用,而线程池进行 I/O 操作,完成数据的获取,然后通过线程之间的通信将数据传递给主线程,即可完成一次 I/O 的调用,主线程再利用回调函数,将数据暴露给用户,用户再利用这些数据来完成业务逻辑层面的操作,这就是 Node 中一次完整的异步 I/O 流程。而对于用户来说,不必在意底层这些繁琐的实现细节,只需要调用 Node 封装好的异步 API,并传入处理业务逻辑的回调函数即可,如下所示:

const fs = require("fs");  fs.readFile('example.js', (data) => {   // 进行业务逻辑的处理 });

Nodejs 的异步底层实现机制在不同平台下有所不同:Windows 下主要通过 IOCP 来向系统内核发送 I/O 调用和从内核获取已完成的 I/O 操作,配以事件循环,以此完成异步 I/O 的过程;Linux 下通过 epoll 实现这个过程;FreeBSD下通过 kqueue 实现,Solaris 下通过 Event ports 实现。线程池在 Windows 下由内核(IOCP)直接提供,*nix 系列则由 libuv 自行实现。

由于 Windows 平台和 *nix 平台的差异,Node 提供了 libuv 作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立。Node 在编译期间会判断平台条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中:

深入聊聊Node 异步和事件循环的底层实现和执行机制

赞(0)
分享到: 更多 (0)