“JS 特色之一是单线程,那所谓的基于事件的异步机制是什么?”
All we know, JS 是一种动态类型、弱类型、基于原型的脚本语言,浏览器上有着 JS 专属的引擎作为其解释器,V8 另外,JS 是单线程语言,为何如此设计呢?其实原因很简单,JS 是被用来设计在浏览器中使用,支持操作页面 dom 元素,假设同时有多个进程同时对同一 dom 元素进行 crud,浏览器如何执行呢?所以这就是原因
另外 JS 虽然是单线程运行,但是在主线程运行之外还是有其他的侦听线程作为辅助的如事件触发线程、Http 请求线程等,所以 JS 所谓的单线程并不孤单。浏览器内核实现允许多个线程异步执行,这些线程在内核制控下相互配合以保持同步
那单线程却能执行异步任务为何?主要是因为 JS 中存在事件循环(Event Loop)和任务队列(Task Queue)也叫事件队列
类似进入一个 while(true)的事件循环,直到没有事件观察者退出,每个异步事件都生成一个事件观察者,如果有事件发生就调用该回调函数
一个浏览器环境只能有一个事件循环,而一个事件循环主要包含 macrotask 和 microtask 两个事件队列(macrotask 和 microtask 是异步任务的两种分类),每个任务都有一个任务源(Task source)。同一个任务队列中的任务必须按先进先出的顺序执行
在挂起任务时,JS 引擎会将所有任务按照类别分到 macrotask 和 microtask 这两个队列中
每一次事件循环,只处理一个 macrotask,待该 macrotask 完成后,所有的 microtask 会在同一次循环中处理。处理这些 microtask 时,还可以将更多的 microtask 入队,它们会一一执行,直到整个 microtask 队列处理完后开始执行下一个 macrotask 开启新一轮事件循环
括号内表示支持的环境
// 全局scripts macrotask
console.log("macrotask scripts start");
// macrotask
setTimeout(() => {
Promise.resolve().then(() => console.log("macrotask 1 inner: microtask"));
console.log("macrotask 1");
}, 0);
// microtask
Promise.resolve().then(() => console.log("microtask 1"));
// microtask
Promise.resolve().then(() => console.log("microtask 2"));
console.log("macrotask scripts end");
// output:
// VM322:1 macrotask scripts start
// VM322:16 macrotask scripts end
// VM322:11 microtask 1
// VM322:14 microtask 2
// VM322:7 macrotask 1
// VM322:5 macrotask 1 inner: microtask
首先进入 全局 scripts macrotask
面试过很多人发现他们在回答相关问题时总会有各种误解:“Promise 是微任务,解析执行代码时遇到 promise 会将其推入微任务队列等”
node 是 js 的一个 runtime,所以事件循环同样是 Node.js 处理非阻塞 I/O 操作的机制。由于大多数内核都是多线程的,node.js 会尽可能将操作装载到系统内核。因此它们可以处理在后台执行的多个操作。当其中一个操作完成时,内核会告诉 Node.js,以便 node.js 可以将相应的回调添加到轮询队列中以最终执行
当 Node.js 启动后,它会初始化事件轮询;处理已提供的输入脚本(或丢入 REPL),它可能会调用一些异步的 API 函数调用,安排任务处理事件,或者调用 process.nextTick,然后开始处理事件循环
与浏览器端的事件循环相比有很大不同,node 的事件循环主要分为六个阶段(Phase),每个阶段都会有一个类似于队列的结构, 存储着该阶段需要处理的回调函数:
事件循环过程如下图示意每个框内代表一个阶段:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段
由于这些操作中的任何一个都可能计划 更多的 操作,并且在 轮询 阶段处理的新事件由内核排队,因此在处理轮询事件时,轮询事件可以排队。因此,长时间运行回调可以允许轮询阶段运行大量长于计时器的阈值
在每次运行的事件循环之间,Node.js 检查它是否在等待任何异步 I/O 或计时器,如果没有的话,则关闭干净
补充:
Links: