事件循环
# 事件循环
结论: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 过程:
- 当某个宏任务执行完后,会查看是否有微任务队列。
- 如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务。
- 执行宏任务的过程中,遇到微任务,依次加入微任务队列。
- 栈空后,再次读取微任务队列里的任务,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 过程:
- 外部输入数据-->
- 轮询阶段(poll)-->
- 检查阶段(check)-->
- 关闭事件回调阶段(close callback)-->
- 定时器检测阶段(timer)-->
- I/O 事件回调阶段(I/O callbacks)-->
- 闲置阶段(idle, prepare)-->
- 轮询阶段(按照该顺序反复运行)...
# 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 操作过程:
- 遇到异步操作
- 将调用封装成中间对象,交给 event-loop,然后直接返回
- 中间对象进入线程池等待执行
- 执行完成后,将数据放入事件队列,形成事件
- 本次循环继续执行,处理事件。拿到事件的关联函数和数据并执行
- 继续下一个事件,进入循环
准确讲,事件驱动的系统中必然有非常多的事件,不能全部由主线程处理,会导致阻塞。
所以采用多线程模拟异步 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 实现。