从异步 I/O 开始到 Event Loop

异步 IO 与 非阻塞 IO

首先,我们需要知道的是,**异步/同步 **和 阻塞/非阻塞 不是同一回事。
异步与非阻塞 都可以达到结果响应且不印象同步代码执行的效果。
但二者不一样。

1. 非阻塞 IO 的调用

非阻塞 IO 在调用的时候,会立即返回,但此时返回的信息是 文件描述符

操作系统对计算机做了抽象处理,将所有的输入输出设备抽象为文件,内核在进行文件I/O 操作时,通过 文件描述符 进行管理,而文件描述符类似于应用程序和系统内核之间的凭证。

非阻塞 IO 在返回后,CPU 的时间片可以用来处理其他事务,此时的性能提升是明显的。
但非阻塞 IO 并没有完成的完全的 IO 操作。而只是当前的状态判断(文件描述符)。所以 我们需要使用轮询的操作重复调用判断操作来获取结果。

多种方式实现轮询。

  • read。这是最原始、性能最低的一种,通过重复调用来检查 I/O 状态来完成完整数据的读取。在得到最终数据前,CPU 一直耗用在等待上。下图为通过 read 进行轮询的示意图。

image.png

  • select。它是在 read 的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断。下图为通过 select 进行轮询的示意图。(select 轮询具有一个较弱的限制,那就是它采用一个 1024 长度的数组来存储状态,所以它最多可以同时检查 1024个文件描述符)

image.png

  • poll。它是基于 select 基础上的进一步优化,使用链表的方式来避免数组长度的限制。

image.png

  • epoll。该方案是 Linux 下效率最高的 I/O 事件通知机制,在进入轮询的时候如果没有检查到 I/O 事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费 CPU,执行效率较高。

image.png
**
轮询技术满足了非阻塞 I/O 确保获取完整数据的需求,但是对于应用程序而言,它依然只能算作一种同步,因为应用程序本身还是需要等待 I/O 完全返回。依旧要花费时间再等待返回的结果,只是使用轮询的手段不同,降低了使用 CPU 的频率。

2. 理想的非阻塞异步 I/O

尽管epoll 已经利用了事件来降低 CPU 的耗用,但是休眠时,当前线程的利用率不够。对于理想的非阻塞异步 I/O 来说还不够。

我们期待的完美的异步 I/O 应该是应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需在 I/O 完成后通过信号或者回调将数据传递给应用程序。如图所示:
image.png
在 Linux 下存在这一的方式(AIO),但是其它操作系统中并没有。同时 AIO 还有缺陷,AIO 仅支持内核中的 0_DIRECT 方式读取。

但在各种操作系统中,只有 Linux 下有,而且它还有缺陷——AIO 仅支持内核 I/O 中的 0_DIRECT 方式读取,导致无法利用系统缓存。

3. 现实中的异步 I/O

因为操作系统的限制,我们没有办法直接使用原生的异步 IO,在单线程的情况下是不行的,所以换成多线程模拟实现(线程池原理),曲线救国,也就完成了我们的操作。

如下图所示,通过线程之间的通信将 I/O 得到的数据跨进行传递:
image.png

linux 中的 glibc 的 AIO(异步 I/O)就是使用线程池模拟异步 I/O,但它存在一定缺陷——AIO 仅支持内核 I/O 中的 0_DIRECT 方式读取,导致无法利用系统缓存。之后Linux 重现实现了一个异步 I/O 的库:libeio。libeio 实际上还是采用线程池模拟异步 I/O:调用异步方法,等待阻塞 I/O 线程完成之后的通知,执行回调。但其内部还是线程池原理。

Node 在最初的时候,在*nix平台下采用了 libeio 配合 libev 实现了 I/O 部分。其中在 Node v0.93中, 自行实现了线程池来完成异步 I/O。

其中在 window 平台下,采用的是 IOCP 来实现异步 I/O 方案。其内部也是采用的线程池原理。不同之处在于这些线程池由系统内核接手。

由于window 平台和*nix 平台下的差异,Node 提供了 libuv 作为抽象封装层,使得所有的平台兼容判断都是基于 libuv 层来完成。并保证上层的 Node 与下层自定义线程池及 window 下的 IOCP 之间各自独立。架构如图所示:
image.png
基于 libuv 的架构示意图

在各个操作系统中,计算机操作是被抽象的,磁盘文件、网络请求、硬件、套接字都被抽象成了文件读写,所以我们日常所说的 I/O 操作都包括这些操作。所以阻塞与非阻塞也能适用于套接字。

我们常说 Node 是单线程,这一点没有错,但是这个单线程指的是 JavaScript 代码的执行线程只有一个。但是实际上无论是 window 还是*nix ,在 node 的执行环节中都是多线程的。内部完成 I/O 操作需要线程池。

Node中的异步 I/O

上面主要说的是异步 I/O 的具体定义,以及各种实现方式。在这个基础上,再解析Node 的执行模型。

1. 事件循环

Node自身的执行模型 —— 事件循环。正是因为它,才使得回调函数十分普遍。

在进程启动的时候,Node 便会创建一个类似于 while(true) 的循环,每执行一次循环的过程称之为 tick,每次 tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下一个循环,如果不再有事件处理,就退出进程。(下面有详细执行事件过程)
image.png

2. 观察者

在 tick 的过程中,如何判断是否有事件需要处理呢?这里必须要引入的概念是观察者。每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。

这个过程就好像在饭店,厨房制作菜肴,具体制作哪些菜肴取决于客人的下单,厨房没做完一轮菜肴就要去问收银员接下来需要做啥,如果没有就下班。这个过程中收银员的角色就是观察者。收银员可以多个,观察者也可以有多个。

浏览器也采用了类似的机制,事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在 Node 中,事件主要来源于网络请求,文件 I/O 等。这些事件对应的观察者有文件 I/O 观察者,网络 I/O 观察者等。观察者将事件进行了分类。

事件循环是一个典型的生产者/消费者模型。异步 I/O、网络请求等则是生产者,源源不断为 Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

3. 请求对象

当我们在 JavaScript 执行线程中调用一个异步 I/O 时,到回调函数执行。此过程在 JavaScript 执行线程中完成。而从 JavaScript 发起调用到内核执行完成 I/O 操作的过渡过程中,存在一个中间产物,它就是请求对象。
**
以下面这个例子,我们可以知道 Node 与底层之间是如何执行异步 I/O 调用以及回调函数究竟是如何被调用执行的:

// 假设是在 window 平台下执行(window 下异步 I/O 利用 IOCP 实现)
fs.open = function(path, flags, mode, callback){
		// ...
    binding.open(pathModule._makeLong(path), stringToFlages(flags)
  			mode, callback)
}

fs.open() 的作用是根据指定路径和参数去打开一个文件,得到一个文件描述符,这是所有 I/O 操作的初始操作。接下来是通过调用 C++ 核心模块来进行下层的操作。
image.png
调用示意图

从 JavaScript 调用 node 的核心模块,node 调用 C++ 内建模块,内建模块通过libuv 进行系统调用。最终本质是调用 uv_fs_open() 方法。

在这个 uv_fs_open 方法的调用过程如下

  1. 创建并包装 FSReqWarp 请求对象。将 JavaScript 层传入的参数和和当前方法(fs.open)封装在这个请求对象上。其中传入的回调函数参数则设置在这个对象的 oncomplete_sym 上。

req_wrap -> object_ -> Set(oncomplete_sym, callback)

当前方法为 fs.open 为个人理解,因为不然没办法记录是谁调用的

  1. 调用 QueueUserWorkItem() 方法将 FSReqWarp 对象推入线程池中等待执行。
// 该方法的代码如下所示
/**
 * 
 * @param {*} &uv_fs_thread_proc 
 需要执行的函数,比如底层真实的打开文件方法 uv_fs_open
 * @param {*} req 
 前文封装的请求对象, 里面包括回调函数,参数,引用执行上下文.也就是第一个参数所需要的参数
 * @param {*} 
 WT_EXECUTEDEFAULT 执行状态
 */
QueueUserWorkItem(&uv_fs_thread_proc,
                 req,
                 WT_EXECUTEDEFAULT)

当线程池内有可用线程时,我们就会调用 uv_fs_thread_proc 方法。

到这里,JavaScript 主线程的调用结束,立即返回,JavaScript 主线程可以接续执行当前线程内其他任务。此后的 I/O 操作在线程池中等待执行,不管是否阻塞 I/O 都不会影响到 JavaScript 线程的后续操作。这就达到了理想状态下的异步解决。

4. 执行回调

在组装好请求对象、送入 I/O 线程池中等待执行,这时候完成了异步 I/O 的上一部分工作任务。剩下的就是回调通知。

  1. 线程池在接收到操作系统的 I/O 调用结果后,会将获取的结果存在 req(请求对象) -> result 属性,然后调用 PostQueueCompleionStatus 通知 IOCP,告知当前对象操作已完成:

PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))

  1. PostQueuedCompletionStatus() 方法的作用是向 IOCP 提交执行状态,表示调用完成,并且将线程归还线程池。

通过PostQueuedCompletionStatus() 方法提交的状态,可以通过GetQueuedCompletionStatus() 获取。

  1. 之后将请求对象交给 I/O 观察者队列中,然后将其当做事件处理。

在一次Tick的循环中,它会使用 IOCP 相关的方法 GetQueuedCompletionStatus() 来检查线程池中是否还有执行完的请求,如果存在,则会将请求对象加入到 I/O 观察者

Node 中异步 I/O 过程包括事件循环的总结

在这个异步 I/O 执行的过程中,主要要素包括

  • 事件循环
  • 事件循环内的观察者(观察者决定事件的回调去哪个队列)
  • 请求对象
  • I/O 线程池

非 I/O 的异步 API

在 Node 中,还有一些异步的 API。其中包括:setTimeout()、setInterval()、setImmediate() 、Promise.resolve() 和 process.nextTick()

1. 定时器

setTimeout() 和 setInterval() 与浏览器的 API 是一致的,分别用于单次和多次定时执行任务。它们的实现原理与异步 I/O 类似,只是不需要 I/O 线程池的参与。调用 setTimeout() 或者 setInterval() 创建的定时器会被插入到定时器观察器内部的一个红黑树。每次 Tick 执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数就立即执行。

定时器的问题在于,它并非精准的。尽管事件循环十分快。但是在某次循环占用的时间较多时,那么下次循环时,它也许已经超时很久了。通过 setTimeout() 设定时间 10ms 的话,假如 9ms 后遇到一个 5ms 的任务,那么再次执行到定时器任务时,时间就已经过期 4ms。
image.png
setTimeout() 的行为

2. process.nextTick()

process.nextTick() 之前,为了在同步任务之后执行,一般会使用这样的代码执行。

setTimeout(()=>{
	// TODO
},0)

但因为事件循环本身特点,想要实现这一功能 setTimeout 的精准性是不够的。setTimeout 需要使用红黑树,创建定时器对象喝迭代等操作, setTimeout(fn, 0) 的方式也比较耗费性能。
而 process.nextTick() 方法的操作相较更轻便。

process.nextTick = function( callback){
  // 进程被退出时
  if (process._exiting) return;
  // 防止队列过多元素
  if (tickDepth >= process.maxTickDepth) maxTickWarn();
  var tock = { callback: callback };
  if (process.domain) tock.domain = process.domain; 
  // 将回调函数放入队列
	nextTickQueue.push(tock);
  if (nextTickQueue.length) {
    process._needTickCallback(); 
	}
}

每次调用 process.nextTick() ,事实上只执行了一个任务,那就是将回调函数放入队列。在下一次 tick 前被取出执行。定时器中采用红黑树的操作时间复杂度为 O(lg(n)) ,nextTick() 的时间复杂度为 O(1)。相比之下 process.nextTick() 更高效。

3. setImmediate()

setImmediate() 方法与 proces.nextTock() 方法十分类似,都是将回调函数延迟执行。
但是二者还是有着不同的区别,setImmediate() 是将回调函数插入 每轮循环中的执行链表。
而 proces.nextTock()是插入到一个数组中,在循环之前执行数组的回调。
这是因为二者属于不同的观察者。process.nextTick(),属于idle 观察者,setImmediate() 属于check 观察者。
在每一轮的事件循环中,idle 观察者先于 I/O 观察者,I/O 观察者先于check 观察者。

4. Promise.then

属于微任务,在 idle 观察者后执行,优先于 I/O 观察者,优先于 check 观察者。

总结

共有四个队列,可以分为两类,微任务队列和宏任务队列
微任务队列包括 : Promise.then:会进入 Other Micro Queue,proces.nextTick() : 会进入 Next Tick Queue
宏任务的包括 I/O 操作与 setTimeout() 与 setImmediate()

NodeJS 的事件循环完整执行流程

┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

每个框被称为事件循环机制的一个阶段

  1. timers : 执行 setTimeoutsetInterval 回调 (定时器检查阶段)
  2. pending callback: 执行延迟到下一个循环迭代的 I/O 回调
  3. idle,prepare:仅系统内部使用 (闲置阶段)
  4. poll:检索新的 I/O 事件,执行与 I/O 相关的回调。除了其他阶段以外,基本几乎所有的异步都在这一阶段 (轮询阶段)
  5. check:**setImmediate **在这里执行 (检查阶段)
  6. close callbacks:一些关闭的回调函数,比如:socket.on('close',...)

**
每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,知道队列用尽或最大回调树已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一个阶段。

每次事件循环之间,Node.js 都会检查它是否在等待任何一个 I/O 或者定时器,如果没有的话,程序就关闭退出。(我们 debuger 的时候可以看到,只要同步任务的 node,都在执行完结束)

其中轮询阶段的流程如下图所示:
image.png

setTimeout 与 setImmediate 的区别

setTimeout(callback,0) 实际上不是在 0 毫秒之后执行,他会被强制改写成 setTimeout(fn,1)

所以当同步任务重执行 二者时,顺序有可能会不一致,因为同步代码执行时间不一定超过 1 毫秒

console.log("start")
setTimeout(()=>{
	console.log("setTimeout")
},0)
setImmediate(()=>{
	console.log("setImmediate")
})
// 打印顺序不可靠,需要判断

但二者处于一个异步任务时会不一样,
如果在 setTimeout 或者 I/O 操作回调中,setImmediate 会先执行。
如果在 setImmediate 中,setTimeout 会先执行。

process.nextTick()

process.nextTick() 属于不一样的阶段,他不属于 EventLoop 阶段。只要 node 中遇到该 Api,同步任务后就会执行此任务,他并不加入等待执行的队列中。

总结

  1. JS 的单线程只是指主线程为单线程,整个运行环境是多线程的
  2. 异步 I/O 本质解决都是依靠线程池解决
  3. 不同的异步 API 对应不同的实现线程
  4. 异步线程与主线程之间依靠 EventLoop (它存在于主线程)完成,
  5. 异步线程将回调函数防止到任务队列
  6. 主线程不断轮询 多个 任务队列,
    a. 顺序是不一致的,浏览器中是先执行微任务 Promise,再执行宏任务
    b. node 复杂的多,先执行 timer ->pending callback -> idle ,prepare(系统内部) -> check -> close callbacks
  7. process.nextTick 不在 event loop 的任何阶段,他会立即执行
  8. Promise 与 process.nextTick 类似,在每个eventloop迭代阶段时都会优先执行Promise
posted @ 2022-07-14 16:48  人云  阅读(58)  评论(0编辑  收藏  举报