事件循环

# 事件循环

结论:event-Loop,实现异步的一种机制,JavaScript runtime 下的一种执行机制。

这里先区分一些前置概念

# JS 引擎/Engine

引擎一般指的是虚拟机,对于 node 来说就是 V8,chrome 也是 V8,Safari 是 JScore,FireFox 是 SpiderMonkey。

对于引擎来说,就是实现 ECMA 的标准。对于什么是 Event Loop,不属于引擎的范畴,事件循环属于 Runtime 的执行机制。

简单来说,JS 引擎主要是对 JS 代码进行词法、语法等分析,通过编译器将代码编译成可执行的机器码让计算机去执行。

Java 著名的一次编译到处运行,靠的就是 JVM(Java Virtual Machine)。

顺带结合 Java 的三个名词来理解一下:

JVM(Java Virtual Machine)JVM 是 Java 实现跨平台最核心的部分,所有的 Java 程序会首先被编译为.class 的类文件,JVM 按照自己的指令集(类库 lib)解释、映射到本地的 CPU 的指令集或 OS 的系统调用。 JVM-wiki (opens new window)

JRE(Java Runtime Environment,Java 运行环境)内部有一个 JVM、标准类库(Class Library)

JDK(Java Development Kit,Java 开发工具包)包含了 JRE、编译器 javac,调试工具等

如果只需要运行 Java 程序,安装 JRE 就可以了。如果要开发 Java 程序则需安装 JDK

# JS 执行环境/Runtime

浏览器、NodeJS、RingoJS(基于 JVM 的 JavaScript 平台 )

事件循环就属于 Runtime 的执行机制

# Event Loop

在浏览器和 Node 中 Event Loop 其实是不同的。

# 浏览器中的事件循环

浏览器端事件循环中的异步队列有两种:macro(宏任务)队列和 micro(微任务)队列。

宏任务队列可以有多个,微任务队列只有一个

  • 常见的 macro-task 比如:setTimeout、setInterval、script(整体代码)、 I/O 操作、UI 渲染等。
  • 常见的 micro-task 比如: new Promise().then(回调)、MutationObserver(html5 新特性) 等。

# Event Loop 过程:

  1. 当某个宏任务执行完后,会查看是否有微任务队列。
  2. 如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务。
  3. 执行宏任务的过程中,遇到微任务,依次加入微任务队列。
  4. 栈空后,再次读取微任务队列里的任务,Loop。

# Node 中的事件循环

Node 端事件循环中的异步队列也是这两种:macro(宏任务)队列和 micro(微任务)队列。

  • 常见的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作等。

  • 常见的 micro-task 比如: process.nextTick、new Promise().then(回调)等。

# node 运行过程

  • V8 引擎解析 JavaScript 脚本。
  • 解析后的代码,调用 Node API。
  • libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。
  • V8 引擎再将结果返回给用户。

# Event Loop 过程:

  1. 外部输入数据-->
  2. 轮询阶段(poll)-->
  3. 检查阶段(check)-->
  4. 关闭事件回调阶段(close callback)-->
  5. 定时器检测阶段(timer)-->
  6. I/O 事件回调阶段(I/O callbacks)-->
  7. 闲置阶段(idle, prepare)-->
  8. 轮询阶段(按照该顺序反复运行)...

# 6 个阶段

  • timers 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调
  • I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
  • idle, prepare 阶段:仅 node 内部使用
  • poll 阶段:获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里
  • check 阶段:执行 setImmediate() 的回调
  • close callbacks 阶段:执行 socket 的 close 事件回调

# setTimeout 和 setImmediate

二者非常相似,区别主要在于调用时机不同。

  • setImmediate 设计在 poll 阶段完成时执行,即 check 阶段;
  • setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行

# process.nextTick

这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

# 磁盘异步 IO 操作过程:

  1. 遇到异步操作
  2. 将调用封装成中间对象,交给 event-loop,然后直接返回
  3. 中间对象进入线程池等待执行
  4. 执行完成后,将数据放入事件队列,形成事件
  5. 本次循环继续执行,处理事件。拿到事件的关联函数和数据并执行
  6. 继续下一个事件,进入循环

准确讲,事件驱动的系统中必然有非常多的事件,不能全部由主线程处理,会导致阻塞。

所以采用多线程模拟异步 IO,引入宏任务、微任务队列概念处理事件,具体实现和 watcher 概念相关。

# 概念:异步非阻塞 IO

# 什么是阻塞

进程的生命周期:

  • new:代表进程创建
  • ready:进程等待操作系统调度
  • running:操作系统调度,进程拿到 cpu 开始执行代码
  • waiting:当发生 IO、或者调用内核方法主动释放 cpu,进入等待状态
  • terminated:进程正常或异常结束

阻塞就发生在 waiting 阶段,由于进程在 running 状态下发起了一个系统调用如(read()调用),该调用不能立即完成,需要等待一段时间,于是内核将该进程标记为 waiting 状态,也就是阻塞该进程,以确保它不会被 cpu 调度,浪费 cpu 资源(即使拿到了 cpu,从内核空间获取数据没有准备好,也无法执行后续的逻辑)。

当内核把数据准备好之后,就会从 waiting 状态变为 ready 状态,等待操作系统的调用。

# 几个要点

  • 在进程通信这个层面,同步和异步针对的是发送方而言,取决于将数据写到内核缓冲区进程的行为,继续等待发送则为同步,反之即为异步。
  • 在进程通信这个层面,阻塞非阻塞针对的是接收方而言,取决于将内核中的数据能否立即拷贝到用户空间,如果不能直接拷贝则为阻塞,反之则为非阻塞。
  • 阻塞是进程的一种状态,由于 cpu 的速度远远高于磁盘速度,为了提高 cpu 利用率,对于涉及系统调用的进程(涉及磁盘读写),会把进程置为阻塞状态,防止 cpu 调度。

# 什么是回调

回调就是一个函数的调用过程。回调与同步、异步并没有直接的联系,回调只是一种实现方式,既可以有同步回调,也可以有异步回调,还可以有事件处理回调和延迟函数回调。

回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。此外它不能使用 try catch 捕获错误,不能直接 return。

# 同步

发出调用,立即得到结果。

调用后一直等待,直到结果返回

# 异步

发出调用,需要额外的操作才能拿到结果。

不一定需要回调函数。

发起调用=>拿到结果中间这段时间可以介入其他任务

实现异步的方案有:event-loop、轮询、事件订阅...

# 任务队列

就是在主线程上发起的一切调用。注意区别:既不是事件队列、也不是消息队列

# 事件驱动

将一切抽象成事件。I\O 是一个事件,点击鼠标是一个事件,发起 Ajax 是一个事件...

一个任务不一定产生一个事件,比如获取当前时间

事件产生后,就被放进任务队列,等待处理

# 阻塞式 IO

调用结果返回之前,当前线程会被挂起,(直到内核层面其他操作都完成,才会结束这次调用——有待考证)

造成了 CPU 等待 IO,浪费了时间片

# 非阻塞式 IO

发起调用不带结果直接返回,时间片继续执行,要获取数据,还需要通过文件描述符再次尝试读取。

非阻塞调用发起后,CPU 可以执行其他的任务,随之而来的问题是,之前的操作不是一次完整的 IO,返回得到的不是期望得到的业务数据,仅仅是返回了异步调用状态(pending)。

为了获取返回值,应用程序需要反复调用 IO 来确认操作是否完成——轮询

# 忙轮询

机械地检查 IO,有无数据报返回

# IO 复用模型

在 I/O 复用模型中,会用到 Select 或 Poll 函数或 Epoll 函数(Linux 2.6 以后的内核开始支持),这两个函数也会使进程阻塞,但是和阻塞 I/O 有所不同。

这三个函数可以同时阻塞多个 I/O 操作,而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。

三种 I/O 复用机制的区别如下

  • select

由于 select 采用 1024 长度的数组来存储文件状态,因此最多可以同时检测 1024 个文件描述符

  • poll

相比 select 略有改进,采用链表避免了 1024 的长度限制,并且能避免不需要的遍历检查,相比 select 性能稍有改善

  • epoll/kqueue

是 linux 下效率最高的 I/O 事件通知机制,轮询时如果没有检测到 I/O 事件,将会进行休眠,直到事件发生将线程唤醒。它是真正利用了事件通知,执行回调,而不是遍历(文件描述符)查询,因此不会浪费 CPU

本质上说,轮询仍然是一种同步操作,因为应用程序仍然在等待 I/O 完全返回,等待期间要么遍历文件描述状态,要么休眠等待事件的发生。

# 信号驱动式 I/O 模型

在信号驱动式 I/O 模型中,应用程序使用信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。

但是在数据从内核复制到用户空间过程中依然是阻塞的,并不能算是一场彻底的革命(异步)

# 理想中的(Node)非阻塞异步 I/O

我们理想中的异步 I/O 应该是应用程序发起非阻塞调用,无需通过轮询的方式进行数据获取,更没有必要在数据拷贝阶段进行无谓的等待,而是能够在 I/O 完成之后,通过信号或者回调函数的方式传递给应用程序,在此期间应用程序可以执行其他业务逻辑。

# 实际的异步 I/O

实际上,linux 平台下原生支持了异步 I/O(AIO),但是目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 I/O 复用模型为主。

而 Windows 下通过 IOCP 实现了真正的异步 I/O。

# 多线程模拟异步 I/O

linux 平台下,Node 利用线程池,通过让部分线程进行阻塞 I/O 或者非阻塞 I/O+轮询的方式完成数据获取,让某一个单独的线程进行计算,通过线程之间的通信,将 I/O 结果进行传递,这样便实现了异步 I/O 的模拟。

其实 Windows 平台下的 IOCP 异步异步方案底层也是采用线程池的方式实现的,所不同的是,后者的线程池是由系统内核进行托管的。

我们常说Node是单线程的,但其实只能说是JS 执行在单线程中,无论是 linux 还是 windows 平台,底层都是利用线程池来完成 I/O 操作。

# libuv

nodejs 中的异步 I/O 的操作是通过 libuv 这个库来实现的,包含了 window 和 linux 下面的异步 I/O 实现。


# 感谢巨人

  1. JDK、JRE (opens new window)
  2. JDK、JRE、JVM (opens new window)
  3. 朴灵评注:JavaScript 运行机制 (opens new window)
  4. Node 的异步模型 (opens new window)
  5. nodejs 异步 I/O 和事件驱动 (opens new window)
  6. 浏览器与 Node 的事件循环 (opens new window)
  7. 深入事件循环和渲染机制 (opens new window)