谈谈事件环 – 你不知道的 EventLoop

前言

我们熟读过 Javascript 在 Browers 中的运行机制:事件循环;它允许我们在异步代码中处理多个任务,并且可以确保这些任务按照正确的顺序执行。事件循环本质上是一种机制,用于管理 JavaScript 运行时如何处理事件和响应用户输入。

在JavaScript中,所有的代码都运行在单线程中!? 这也就意味着只有一个命令可以被执行,并且其他命令必须等待当前命令完成后才能开始执行。在 ES6 之前,JavaScript 本身实际上从未有任何直接的异步概念内置在其中。JavaScript 引擎除了在任何给定时刻执行程序的单个块之外,什么也做不了。

也正是因为它的这一特性,使得 Javascript 在对操作的处理上产生了事件环机制。这也是本文的核心, 我们会分别对 Browers 中以及 Node 环境下的执行机制进行概述。

Browsers EventLoop

Javascript 最初是一种单线程同步语言。应该在单个线程上运行的编程语言也就意味着它:一次只能做一件事情。而一次做一件事将要求它遵循某种执行顺序,因此被称为同步

let x = 5;
console.log(x);
let y = 10;
console.log(x + y);

在同步的情况下,JavaScript 代码将逐行执行,所以代码的同步也叫做代码阻塞。 这是因为上一行代码未执行完毕之前,下一行代码会等待。

let x = 5;
let wait = waitTwoMin(x) // 假如这个函数的执行完毕会必须要等待两分钟
let y = 10;
console.log(x + y);

在这个示例中,会有一个需要等待两分钟的处于执行状态的函数,那么将不会转到下一行去执行。这也就导致并发的概念在 JavaScript 最初时,变的尤为重要的原因。但由于 Js 单线程的机制,使得 JavaScript 在处理并发上变的有趣。

JavaScript 中的并发模型:

先来看 非阻塞,简单来说:非阻塞就是程序的执行不会因为一些独立的代码执行时间异常而导致的阻塞。如果当前行执行缓慢,那么则不应该阻止独立于当前行的下一行执行。对于非阻塞,它只适应于两个操作相互独立的情况,任何依赖于上一个代码块操作的结果的后续操作都应该始终等待。

在这里我们可以想到非阻塞的实现依赖于异步的执行。那么什么是异步?异步其实就意味着代码可以立即执行。(面试如果被问到就可以这么说言简易骇) 这与同步代码不同,同步代码会阻塞下一行代码的执行,直到当前行执行完毕。

那什么是并发?其实 多个操作同时发生,就被称为并发。

我们编辑的代码无非就是同步、异步上的问题,在了解了这些前置知识后,我们进入正题:

浏览器中的事件环

谈谈事件环 - 你不知道的 EventLoop

首先我们要知道,浏览器会有通过不同的进程对 Js 代码进行调度执行。例如:网络进程(网络请求)、GPU进程(动画与3D绘制)、插件进程(devtool)。

其中,GUI 渲染线程(渲染页面)和 JS 引擎线程运行互斥,GUI 渲染更新在 JS 引擎任务空闲的时候进行。而这里不得不着重提到的是:Js 内核线程在真正意义上是一个主线程与多个辅线程配合执行的。在执行上会有主线程的同一时间只能做一件事情,通过辅助线程并发的方式(异步的方式)来实现任务的分发。所以说 Js 就只是单线程的说法严格意义上是不完整的,它是因为浏览器中只有一个 JS 引擎(所以有 JS 是单线的说法,但他还有许多辅助线程)。

执行机制以及调用堆栈:

为了维护当前运行的顺序,Javascript 在执行代码时使用堆栈。代码的执行发生在执行上下文中,函数具有局部执行上下文,这些上下文是在调用函数时生成的,其他所有内容都在全局执行上下文中运行。

当前正在执行的代码的执行上下文将位于栈顶。一旦完成执行,执行上下文就会从堆栈中弹出。这种数据结构被称为调用堆栈

而对于执行机制来说,在我们的传统观念中存在着宏任务和微任务的说法,但在 Js 本身中是不存在这种划分的。

在Javascript 中 “做事” 是使用调用堆栈和 JS 引擎实现的。当 JS Engine 执行代码时,调用堆栈维护执行顺序。首先,将调用栈想象成一个包含一些对象的普通栈(数据结构)。堆栈顶部是表示当前执行中的调用的对象,在完成/终止时将被弹出,控制权将传递到下一个调用/执行上下文。我们来看下面这段代码:

1  console.log('start');
2
3  setTimeout(() => {
4      fn()
5  }, 0)
6
7  console.log('end')
8 
9  function fn(){
10     console.log('fn')
11 }

我们来看按照 Js 的执行方式结合图示来看代码是如何执行的:

  1. 创建一个全局执行上下文并立即将其放入先前为空的调用堆栈中。一旦上下文在调用堆栈中,JS 引擎就开始执行代码。这时状态很清晰,调用栈是空的。
  2. 首先代码执行到第一行,此时 console.log('start') 会被添加到调用堆栈,随后被立即执行后弹出,从调用堆栈中删除。接着向下执行;
  3. 执行 setTimeout() 添加到调用堆栈并被立即执行,此时浏览器会创建一个计时器作为 Web API 的一部分,它将会创建一个线程为我们处理倒计时。回调则会被推入调用堆栈(被推入的是回调,而不是整个定时器),而自身完成执行从调用栈中删除。由于异步执行,执行线程作为辅助线程独立于 JavaScript 主线程,所以并不会造成代码执行上的阻塞, 向下执行;
  4. 执行 console.log('end')和之前同理,向下执行;
  5. 等待 setTimeout 的延迟结束,计时器完成并将回调事件放入执行回调队列中。在顺序执行到当前回调后,将其推入执行堆栈,此时回调函数 fn 被执行并添加 console.log('fn')到调用堆栈,执行输出内容:fn,最后被从调用堆栈中删除。

谈谈事件环 - 你不知道的 EventLoop

这才是 Js 真正意义上的执行方式。简单来说,我们理解中的宏任务是指在回调队列中等待被主线程执行的事件;微任务是基于回调队列、Event loop还有执行堆栈而来的。

补充一下案例中没有提到异步 Promise (其实是不想再录制动图了🤣),我们来看代码口述一下:

1  console.log('start');
2
3  setTimeout(() => { // setout1
4      Promise.resolve(1).then()
5  }, 1000)
6  setTimeout(() => { // setout2
7      console.log('聪明勇敢有力气!')
8  }, 1000)
9  console.log('end')

我们发现在定时器任务中掺杂了 Promise 的异步任务,那么 Js 机制是如何执行的呢?

其实也好理解,这和我们在上边的执行方式是一样的只是需要注意 .then() 的执行。setout1 和 setout2 会顺序执行,作为回调的函数则会被分配对应的处理线程在定时器的延时结束之后,在回调队列中等待被主线程执行。

🔔 前提需知:ES6 中引入了一个名为“作业队列” Job Queue 的新概念。它位于事件循环队列之上。 有些懵么?其实就是我们所说的微任务队列。Job Queue 是一个附加到 Event Loop 队列中每个 tick (在事件循环中,每进行一次循环操作称为tick)末尾的队列。在事件循环的一个 tick 期间可能发生的某些异步操作不会导致将一个全新的事件添加到事件循环队列中,而是将一个项目(也称为 Job)添加到当前 tick 的 Job 队列的末尾。

现在我们可以回到对 setout1 内 promise.then() 的处理上,也知晓了它们共用一个回调队列,处理它们之间的调度的是 Event loop。

我们需要提一句的是:

定时器任务在不同的浏览器的最小延迟是不同的,但同样的他们都存在最低时间设定。以 Chrome 为例是不小于 4ms。由于在定时器之前的同步任务在执行完毕的时间上并不是可控的,所以可能会出现延时已经结束但之前的任务处于未执行完毕的状态而导致的超时现象。

🌈 对于 Web Apis ,在本质上我们是无法对其进行访问的,它们是并发启动的浏览器部分,我们只能调用他们。

🌈 对于 Eventloop 事件循环来说: 它有一项简单的工作——监控调用堆栈和回调队列。如果调用堆栈为空,事件循环将从队列中取出第一个事件并将其推送到调用堆栈,从而有效地运行它。

为了方便大家理解 Web Apis 的执行顺序,这里通过大家熟悉的宏任务和微任务的执行方式来剖析它们。

宏任务 MacroTask

宏任务是指回调队列中的等待被主线程执行的事件。
按照之前说到的,宏任务执行时会有独立的辅助线程来处理回调函数,在宏任务执行结束时,线程会随之销毁。以下是常见的宏任务,我们会对其中必要内容着重说明:script 、 UI 渲染 、 setTimeout \ setInterval 、 setImmediate
messageChannelrequestAnimationFrame 、 用户交互事件;

setImmediate

该特性是非标准的,目前只在 IE新版本/Edge/Node 0.10+ 中兼容。

我们来看Node Event Loop 中对于它的描述:

The main advantage to using setImmediate() over setTimeout() is setImmediate() will always be executed before any timers if scheduled within an I/O cycle, independently of how many timers are present.

相对于 setTimeout () ,如果在一个 I/O 周期内调度任何计时器,那么使用 setImmediate () 的主要优势在于 setImmediate () 将始终在任何计时器之前执行,这与存在多少计时器无关。

例如,如果在非 I/O 的执行周期内,则两个计时器的执行顺序是不确定的

setTimeout(() => {
  console.log("timeout");
}, 0 /** 10 */);

setImmediate(() => {
  console.log("immediate");
});

谈谈事件环 - 你不知道的 EventLoop

我们可以看到由于 setTimeout() 的延迟时间的不确定性,两者的输出顺序是不同的。这里我们一定要知道的是并不是延迟的时间导致的。如果两者存在像宏任务和微任务一样的在执行上有绝对的执行区别,那么也就不会有存在时间上的因素影响。

再者说,如果两者的执行是在同一个 I/O 的执行周期内的,那么 setImmediate() 绝对优先于 setTimeout():

const fs = require("fs");

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log("timeout");
  }, 0);
  setImmediate(() => {
    console.log("immediate");
  });
});

谈谈事件环 - 你不知道的 EventLoop

requestAnimationFrame

该回调函数执行次数通常是每秒 60 次,回调函数执行次数通常与浏览器屏幕刷新次数相匹配的,也就是大约为 16.67刷新一次。

Tip: 人眼舒适放松时可视帧数是每秒24帧,集中精神时最高不超过30帧。

关于 requestAnimationFrame() , 其调用的是系统的时间,setInterval/setTimeout 是宏任务会由于事件的执行而滞后,是不准确的。来看两者在不同维度上的比较:

布局绘制的逻辑不同:

setInterval 回调中有关 dom 的操作,会因为其的执行时机而多次计算、重绘
requestAnimationFrame 会将所有的dom操作集中,而后一次性的进行统一的计算并进行统一的绘制

执行差异:

requestAnimationFrame 会因为窗口的最小化而暂停回调的执行,在页面恢复时再继续在暂停位置开始执行;

无意义的执行,setInterval 会因为计时时间小于浏览器的刷新时间,而导致很多无意义的回调调用。

由于我们只需要理解它在 Js 中的执行方式且介绍它并非我们的重点,所以如果大家感兴趣可以留言,我会单独写一篇。

messageChannel

避免跑题,我们这里不讲它的用法,大家可以自行导航 [MDN: messageChannel]

🌈 所以这里就作为彩蛋提一下:不知道大家是否了解过在 Vue.js1.X 中,关于 nestTick() 钩子的实现。对的!它的核心便是我们看到的当前方法。

模拟一下:

// v1 nextTick.html
<body> 
    <h1>Title</h1>
    <script type="module">
        import port2 from "./v1nextTick.js";
        (function () {
            port2.postMessage('new Title')

            port2.onmessage = function (e) {
                console.log(e.data);
            }
        })()
    </script>
</body>
// v1 nextTick.js
const { port1, port2 } = new MessageChannel(),
  oTitle = document.querySelector("h1");

port1.onmessage = function (ev) {
  oTitle.textContent = ev.data;
  port1.postMessage("Title 已被修改!");
};

export default port2;

谈谈事件环 - 你不知道的 EventLoop

我们通过调用 MessageChannel() 并获取到其的两个端口属性,通过两个端口可以互相传递消息从而实现数据的通信。你会发现通过这个很小的案例做到了在 Dom 的下次更新结束之后调用回调,在页面渲染之前监测数据发生的变化并立即更新数据进行渲染,而 Vue.js 1.X 在核心的实现上也正是这么做的。

MessageChannel() 的两个端口属性是可以进行相互通信的。我们分别调用端口 onmessage 方法来监听对方端口返回的消息数据,可以看到:port2 拿到了 port1 发送的消息并在控制台成功输出了结果;而 port1 也通过赋值的方式将页面的数据修改为了 port2 传递来的数据。

微任务 MicroTask

微任务是基于回调队列、Event loop、UI 主线程还有执行堆栈而来的。 它会在同步任务执行之后,宏任务执行之前被调用。而作为在宏任务内部调用的微任务则会被穿插在本次执行过的宏任务回调之后,下一个宏任务回调之间执行。 也就是我们常常认为的每一个宏任务的执行完毕,都需要清空一次微任务队列。

来看日常中的几个微任务,并再对其中的个别着重说一下:Promise.then() | .catch() | .finally() 、 mutationObserverprocess.nextTick

mutationObserver

提供监视对 DOM 树所做更改的能力。 当 DOM 节点发生变化时,可以通过调用回调函数来对变化做出反应。

什么时候可能会有用?

  1. 用于集成。想象一下,我们大多数情况会通过第三方脚本来做一些我们需要的事情,但是脚本不总是包含这些实用的功能,还会执行一些我们不需要的操作。
  2. 动态代码高亮。我们总会在文档中看到所有的代码块总是高亮显示的,并且适配任何语言。确实这是通过某些语法高亮库来实现的,但是我们总不会去在每一个页面的加载完毕去调用该代码库提供的钩子,尤其是面对当某些内容是三方模块加载,动态显示的。

在这些场景下,我们都可以很幸运的可以使用 MutationObserver 来做处理。我们可以用它来跟踪代码其他部分引入的更改,以及与第三方脚本集成。自动检测何时在页面中插入了代码段并高亮显示它们,我们在一个地方处理高亮显示功能,从而使我们无需集成它。

MutationObserver 可以跟踪任何更改。通过配置项来控制 “要观察的内容”选项用于优化,避免不必要的回调调用以节省资源。

process.nextTick

当你想确保在下一次事件循环迭代中该代码已被执行时,便可以使用 nextTick()

我们来看 Node 官网上的例子:

let bar; 

// this has an asynchronous signature, but calls callback synchronously function 
someAsyncApiCall(callback) {
    callback();
} 

// the callback is called before `someAsyncApiCall` completes. 
someAsyncApiCall(() => { 
    // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value  
    console.log('bar', bar); // undefined 
}); 

bar = 1;

在这个例子中,用户定义的 someAsyncApiCall() 具有异步签名,但它实际上是同步操作的。
当函数被调用时,提供给它的回调在事件循环的同一阶段被调用,可以看到的是:实际上代码块中的内容并没有异步的做任何事情。

结果,回调在尝试引用 bar 时,在作用域上下文中可能还没有该变量,此时代码还没有运行到最后。

然而我们通过将回调函数放在 process.nextTick() 中,代码是能够运行到完成的,且允许所有变量、函数等在回调函数被调用之前初始化。

我们通过 process.nextTick() 来重构前面的这个例子:

let bar;

function someAsyncApiCall(cb){
    process.nextTick(cb)
}

someAsyncApiCall(() => {
    console.log('bar', bar)
})

bar = 1

谈谈事件环 - 你不知道的 EventLoop

这样我们便可以在输出中看到由于 .nextTick() 的缘故,使得输出符合我们期望。

还需要注意的是:process.nextTick() 的优先级仅次于同步任务。因为 在任何时候,只要在给定阶段调用 process.nextTick(),那么传递给 process.nextTick() 的所有回调函数都会在事件循环继续之前被解析。 这意味着 process.nextTick() 基本上确保传递的函数在队列(消息/作业队列)上获得优先级,而不是在调用堆栈内的函数调用之间。

来看代码:

Promise.resolve(1).then(() => {
  console.log("promise.then()");
});

process.nextTick(() => {
  console.log("process.nextTick()");
});

console.log("start");

Promise.resolve(1).then(() => {
  console.log("promise.then()");
});

谈谈事件环 - 你不知道的 EventLoop

在最后的执行结果上可以看到的是它确实仅次于同步任务的执行。

🌈 Tip: 我们来总结一些不同任务在执行上的优先级顺序:同步任务 > process.nextTick > 其他微任务 > GUI渲染 > 宏任务。 上文中关于 ‘‘阶段’’ 在下面的 Node Eventloop 中会讲到。

阅读到目前为止,我们已经学习完了关于浏览器事件环的执行机制。而且在讲解宏任务和微任务上,我们其实已经穿插的讲解了 Node.js 的事件环所需要的知道的前置知识。

Node EventLoop

Node.js 和 JavaScript 一样,他们的单线程指的是主线程是 “单线程” 的。

Node.js 将任务事件分发给多线程去处理,在核心逻辑主内容上交给主线程去处理。 在执行的过程中将事件交给任务队列去执行,线程池中相应的去处理对应事件,这些事件基本上都是异步通过回调的方式去处理的。事件在主线程执行任务的过程中,顺序的执行事件回调而后返回数据,这就是node中的单线程执行规则。

在事件环 Eventloop 上,Node 的核心是基于 libuv 实现的, libuv 在对其自身的定位是这样描述的:

🌈 Asynchronous I/O made simple. libuv is a multi-platform support library with a focus on asynchronous I/O.

一个专注于异步 I/O 的多平台的支持库。

作为整个 Node 事件环执行机制的核心,我们稍后一起来看它在 node 中到底做了哪些事。

Explained

谈谈事件环 - 你不知道的 EventLoop

  1. timers: 此阶段执行由 setTimeout()setInterval() 调度的回调。
  2. pending callback: 执行I/O回调,延迟到下一次循环迭代。仅内部使用。
  3. idle,prepare: 仅内部使用。
  4. poll: 检索新的 I/O 事件;执行与 I/O 相关的回调;节点将在适当的时候阻塞在这里。
  5. check: setimmediation() 回调在这里被调用。
  6. close callbacks: 一些处理关闭机制的回调,eg: socket.('close', ... )。仅内部使用

在每次事件循环运行之间,Node.js 都会检查它是否正在等待任何异步I/O或计时器,如果没有,则会干净地关闭。

Timers: Node 在处理异步任务时,eg:setTimeout() 并不是像在浏览器中处理那样,按序从队列中取出而执行,而是会立即执行之后留下等待执行的回调函数。

Poll:检查新 I/O事件与执行 I/O 回调,其实就是检查 Node 的 api 事件,注册新的事件并执行上一轮遗留的未处理事件。

Libuv

每一个 Node API 都是一个事件,Node 内核 Libuv 会对事件队列(其实 Node 中不存在事件队列)中的事件在线程池中寻找对应的线程,通过线程池的处理将结果返回。

Libuv 将任务分配给工作线程池。但是,任务完成时发生的所有回调都在主线程上执行。
Node.js 实现异步机制的核心便是 libuv,libuv 承担着 Node.js 与文件、网络等异步任务的通信媒介。

libuv 是一个 C 库,最初便是为 Node.js 编写的,用于抽象非阻塞 I/O 操作

  • 集成了事件驱动的异步 I/O 模型。
  • 它允许在执行 I/O 操作的同时同时使用 CPU 和其他资源,从而实现资源和网络的高效利用。
  • 它促进了一种事件驱动的方法,其中 I/O 和其他活动是使用基于回调的通知来执行的。

🌈 如果一个程序正在查询数据库,CPU 处于空闲状态直到查询被处理并且程序处于暂停状态,从而造成系统资源的浪费。为了防止这种情况,在 Node.js 中使用了libuv,它促进了非阻塞 I/O。

🌈 在 Node 10.5 之后,工作线程也可以用于并行执行 JavaScript。Libuv 默认使用 4 个线程,但可以使用UV_THREADPOOL_SIZE 更改

process.env.UV_THREADPOOL_SIZE = 5

Node.js 事件环的执行方式

Node.js 事件环的整个执行周期,会在 Poll 阶段去查看 Check 是否存在被定义待执行的 setImmediate() 事件并去执行它,然后会去查看是否有 Timers setTimeout()、setInterval() 事件回调。

在整个 Poll 阶段还可能会有一个等待的过程,但前提是没有 setImmediate() 。如果有,则需要先执行再去等待 Timers 计时器回调的执行。

需要注意的是:如果 setImmediate()setTimeout()、setInterval() 任务不在 I/O 回调内部,会有一个等待上的执行时机问题:

  1. 如果执行到 Poll 阶段,那么 Poll 阶段会有一个短暂的等待,此时如果 Timers 阶段的计时器延时恰好计时完毕了,I/O 会直接回到 Timers 阶段先去执行回调,然后再回来执行 Check 阶段的 setImmediate() 任务。;
  2. 如果执行到 Poll 阶段, 且在等待阶段中 Timers 的计时器延迟并没有计时完毕,I/O 已经在检查 Check 阶段的事件任务了,那么它会先执行 Check 的阶段任务,而后再回到 Poll 阶段之后才去执行 Timers 阶段中事件回调。

在执行 I/O 回调的机制上,一定是先检查 Check 阶段的任务事件并去执行,然后再去执行 Timers 阶段的定时器回调任务;之后回到 Poll 阶段,进行新事件的检索以及执行 I/O 相关的回调,最后再从 Timers 阶段开始重新一轮的任务轮询,如果在执行在 Check 阶段时再没有需要执行的事件了,此时便会关闭任务轮询。

主执行栈已经执行的过程中就已经将所有的事件放置在事件环各个阶段了,主执行栈中的所有浏览器事件环中的事件执行完毕了便会从 Timers 开始顺序执行。

🌈 Node 的事件环在 0.10及以下的版本是在每一个阶段切换的时候去清空微任务队列,0.11及以上是:每执行一个宏任务便会清空一次微任务。

实战训练

截至目前,我们学习过了关于 Eventloop 在浏览器和服务器上的所有内容。接下来让我们一起看两道经典题目,结束我们本次的快乐时光吧。

document.body.style.backgroundColor = "yellow";

console.log("start");

setTimeout(() => {
  document.body.style.backgroundColor = "red";
  console.log("setout");
}, 1000);

Promise.resolve("resolve").then(() => {
  document.body.style.backgroundColor = "#008c8c";
  console.log("Promise resolve");
});

console.log("end");

谈谈事件环 - 你不知道的 EventLoop

解析:

在执行上我们优先执行同步任务,由于 GUI 渲染是晚于 Promise.then() 的执行的,这也导致了页面的背景色虽然被赋值为了 yellow 是并不会被渲染为黄色。此时控制台会输出:start 和 end

在微任务的执行上,promise.then() 的执行优于定时器任务 setTimeout(),所以也就会先打印出:Promise resolve

在执行 Promise.then() 的回调时,我们在输出之前修改了页面颜色,此时 GUI 的渲染任务之前是没有其他的异步任务的。此时再次修改背景色为马尔斯绿时,页面便会被渲染为新的颜色。最后,在定时器任务中我们又修改了背景色,且输出了 setout

Promise.resolve().then((res) => {
  console.log("0: promise.then");
});

process.nextTick(() => {
  console.log("1: browser process.nextTick");
});

console.log("2: start");

readFile("./t1.txt", "utf8", () => {
  setTimeout(() => {
    console.log("3: setTimeout");
  }, 0);

  process.nextTick(() => {
    console.log("4: I/O process.nextTick");
  });

  setImmediate(() => {
    console.log("5: setImmediate");
  });
});

setTimeout(() => {
  console.log("6: !I/O setTimers");
}, 0); // delay: 100 

setImmediate(() => {
  console.log("7: !I/O Check");
});

console.log("8: sync log");

解析:

在解析上,只是这么去带大家理解,并不是在执行机制上完全就是这样的。我已经习惯了看的方式去梳理它们之间的执行顺序,大家还是要按照上边讲解的一步一步的来。

首先在执行上,我们总是优先执行同步任务,所以会先输出:2、8
其次,我们发现在执行上下文中存在 process.nextTick(),通过优先级的判断我们很清楚它的执行在同步之后,所以此时会输出:1;再接着我们会看到在一开始便等待执行的 promise.then() 回调,执行输出:0。之后便是队列中等待的 Timers 和 Check 回调;

❗注意,上边我们特地说明了在非 I/O 的环境中 setImmediate() 和 setTimeout() 在执行上的先后是具有不确定性的。所以这里在输出顺序上可能存在两种情况:6 和 7(这里是这种情况) 或者 76 之前,后者这种情况还需要稍后再进一步说明一下。

在 reaFile() 的 I/O 的输出环境中,按照我们的执行逻辑自然而然的会输出:4 5 3

当非 I/O 环境下,这时的 setTimeout 的阈值如果为 0ms 时:

谈谈事件环 - 你不知道的 EventLoop

如果为 100ms 时会发现有明显的差别,这也是我们上面说要再进一步说明的地方。先来看结果:

谈谈事件环 - 你不知道的 EventLoop

6: !I/O setTimes 的输出前后位置怎么会这么大?

记得我们说过:Times 的计时器任务是会立即执行的,留在队列中等待的内部的回调。且我们剖析了 poll 阶段在关于 times 和 check 两个阶段的两种给处理情况。如果 setImmediate() 和 setTimeout()、setInterval() 任务不在 I/O 回调内部,会有一个等待上的执行时机问题:

  1. 如果执行到 Poll 阶段,那么 Poll 阶段会有一个短暂的等待,此时如果 Timers 阶段的计时器延时恰好计时完毕了,I/O 会直接回到 Timers 阶段先去执行回调,然后再回来执行 Check 阶段的 setImmediate() 任务。;
  2. 如果执行到 Poll 阶段, 且在等待阶段中 Timers 的计时器延迟并没有计时完毕,I/O 已经在检查 Check 阶段的事件任务了,那么它会先执行 Check 的阶段任务,而后再回到 Poll 阶段之后才去执行 Timers 阶段中事件回调。

此时这种情况便是第二种,而上边的输出则是第一种。这也就很好解释为什么会出现这种大相径庭的结果了。

process.nextTick(() => {
  console.log(1);
});

console.log("start");

setTimeout(() => {
  console.log(2);
}, 0);

setTimeout(() => {
  console.log(3);
}, 0);

setImmediate(() => {
  console.log(4);
  process.nextTick(() => {
    console.log(5);
    Promise.resolve().then(() => {
      console.log(6);
    });
  });
});
readFile("1.txt", "utf-8", () => {
  process.nextTick(() => {
    console.log(7);
  });
  setTimeout(() => {
    console.log(8);
  }, 0);
  setImmediate(() => {
    console.log(9);
  });
});

readFile("2.txt", "utf-8", () => {
  process.nextTick(() => {
    console.log(10);
  });
  setTimeout(() => {
    console.log(11);
  }, 0);
  setImmediate(() => {
    console.log(12);
  });
});
console.log("end");

这道题就不带着大家看了,按照我们讲到的思路都是很容易的。如果有不清楚的地方,可以在评论区留言。

提示:

Node.js Api 的执行是滞后于 Js APi的。 在执行完主执行栈中的任务后,会有两个 I/O 任务:readFile(),且 Node.js 的事件环是按照阶段进行的。

这里关于两个 I/O 任务的输出上,在你没有理解 poll 阶段的执行机制可能会有疑惑。代码块在主执行栈执行的过程中就已经将所有的事件放置在事件环各个阶段了,poll 阶段会对两个 I/O 任务中的事件进行注册,之后才会按阶段执行。

拓展

单线程 JavaScript 的历史

JavaScript 被认为是一种在浏览器中运行的单线程编程语言。单线程意味着同一进程(在本例中为浏览器,或者现代浏览器中的当前选项卡)在任何时候都只执行一组指令。

这种单线程的开发方式,在心智负担上让开发人员在处理的事情变得更容易,因为 JavaScript 最初是一种只对向网页添加交互、表单验证等有用的语言——不需要多线程的复杂性。

Ryan Dahl 在创建 Node.js 时将这种限制视为一个机会。他想实现一个基于异步 I/O 的服务器端平台,以避免对线程的需求并使事情变得容易得多。这便是 Node 在 Api 的实现上更偏向异步的根本原因。

但是并发性可能是一个很难解决的问题。让许多线程访问同一内存会产生很难重现和修复的竞争条件。

单线程任务的运行规则:

JavaScript 是一种单线程执行的语言,也就是说,JavaScript 引擎在执行时只有一个主线程来处理所有的任务。因此,JavaScript 的任务必须遵循一定的执行规则,以保证程序的正确性和可靠性。 JavaScript 单线程任务的运行规则如下:

  1. 执行任务时,JavaScript 引擎会将任务放入一个任务队列中,按照任务的顺序依次执行。
  2. 如果当前任务执行过程中出现了阻塞(如等待用户输入、网络请求等),那么 JavaScript 引擎会暂停当前任务的执行,将该任务放回任务队列中,并继续执行下一个任务。
  3. 当前任务执行完毕后,JavaScript 引擎会从任务队列中取出下一个任务执行,以此类推,直到任务队列中的所有任务都被执行完毕。
  4. JavaScript 引擎会不断地从任务队列中取出任务执行,直到任务队列为空,此时 JavaScript 引擎会进入等待状态,等待新的任务加入队列。 总之,JavaScript 单线程任务的运行规则保证了任务的执行顺序和可靠性,同时也避免了多线程带来的复杂性和不确定性。

为什么我们永远不会在 JavaScript 中使用多线程

在这个问题上,很多人可能认为我们的解决方案应该是在 Node.js 核心中添加一个新的模块并允许我们创建和同步线程。

但这是不现实的。如果我们向 JavaScript 添加线程,那么我们正在改变语言的本质。我们不能仅仅将线程作为一组新的可用类或函数来添加——我们可能需要更改语言以支持多线程。如果不同步他们的访问,那么我们最终可能有两个线程改变一个变量的值。

这导致的结果将是在两个线程都访问该变量后,一个线程更改了几个字节,另一个线程更改了几个字节——因此,不会产生任何有效值。

分布式:Node.js 在解决前后端分离上的跨域问题

跨域是浏览器的问题,并不是服务端的问题。我们通过 Node 做前后端之间的中间层来避免跨域的问题,由于服务端与服务端之间是不存在跨域的,所以我们经常让 Node 和我们的前端是同源的,通过Node去和不同源的服务端(后端)进行数据交互。

让后端将数据返给中间层 – Node, 再通过Node将数据返给前端。

总结

我们在学习 浏览器事件环 知道了:

Js 内核线程是一个主线程与多个辅线程配合执行。
定时器的延迟时间不是人们期望的执行回调的确切时间,而是在这个时间过后可以执行提供的回调。

Job Queue 是一个附加到 Event Loop 队列中每个 tick (在事件循环中,每进行一次循环操作称为tick)末尾的队列。在事件循环的一个 tick 期间可能发生的某些异步操作不会导致将一个全新的事件添加到事件循环队列中,而是将一个项目(也称为 Job)添加到当前 tick 的 Job 队列的末尾。

🌈 对于 Web Apis ,在本质上我们是无法对其进行访问的,它们是并发启动的浏览器部分,我们只能调用他们。

🌈 对于 Eventloop 事件循环来说: 它有一项简单的工作——监控调用堆栈和回调队列。如果调用堆栈为空,事件循环将从队列中取出第一个事件并将其推送到调用堆栈,从而有效地运行它。

宏任务: 是指在回调队列中等待被主线程执行的事件;微任务: 是基于回调队列、Event loop、UI 主线程还有执行堆栈而来的。

不同任务在执行上的优先级顺序: 同步任务 > process.nextTick > 其他微任务 > GUI渲染 > 宏任务。
关于 Web Apis 在本质上我们是无法对其进行访问的,它们是并发启动的浏览器部分,我们只能调用他们。

Node.js 事件环:

我们通过去理解事件环中各阶段所调用的事件、在执行上的运行时机后,便可以很好的处理 Node.js 任务了。

Node.js 将任务事件分发给多线程去处理,在核心逻辑主内容上交给主线程去处理。

Libuv 将任务分配给工作线程池。但是,任务完成时发生的所有回调都在主线程上执行。

在执行上,主执行栈已经执行的过程中就已经将所有的事件放置在事件环各个阶段了,主执行栈中的所有浏览器事件环中的事件执行完毕了便会从 Timers 开始顺序执行。

本文没有从源码的角度去讲 Node.js 事件环,对于 本文想说的可能会有些太深了。如果大家想看,可以留言,我会在本文中更新补充。

原文链接:https://juejin.cn/post/7213298252534857786 作者:inblossoms

(0)
上一篇 2023年3月22日 下午7:31
下一篇 2023年3月23日 上午11:05

相关推荐

发表回复

登录后才能评论