JS事件循环,简单的来说就是所写的JS代码的执行顺序的一个循环机制。
浏览器中的事件循环
浏览器中的JS主要是运行在渲染进程中的JS引擎线程。 (JS引擎其实就是js的虚拟机,类似jvm的功能。)
在渲染进程中除了JS引擎线程,还有其他线程,共同协作负责起整个页面的渲染展示以及交互。
在渲染进程中会影响JS代码的执行的有GUI渲染线程,计时器线程,事件处理线程。
除了渲染进程,在浏览器中的网络进程也会影响JS代码的执行顺序。
GUI渲染线程
作用: GUI渲染线程主要是进行HTML,CSS解析,DOM树的构建,将它们渲染到整个页面中。重绘和重排利用的也是这个线程。
影响: 此线程与JS引擎线程是互斥的。当GUI渲染线程执行的时候,JS引擎线程是挂起的状态。反之,JS引擎线程执行的时候,GUI线程也是挂起的状态。原因是因为,如果渲染的时候,JS代码改变的dom,那么此时该以哪个为准呢?会产生视图不同步的情况。同理,JS一开始设计为单线程也是这样的考虑。
结论:渲染过程是不执行JS代码,执行JS代码的时候,是不进行渲染。
解释一些现象: JS阻止DOM解析,link的CSS文件后有JS代码,会等待link的CSS加载完成后再执行JS代码。
除了GUI渲染线程会直接阻止JS代码的执行以外,其他部分影响JS代码的执行都遵循事件循环。
浏览器事件循环
JS的解释器会为JS函数的执行创建一个调用栈。 每次执行一个函数,就为当前函数创建相应的内存空间用于存储局部变量以及执行语句并且立即执行。如果调用函数调用了其他的函数,那么就依次创建相应的空间,并加入调用栈。一旦函数调用完成,调用栈创建的栈空间自动销毁。表现为入栈与出栈。
- 例子1:
function a() {
console.log("a")
}
function b() {
console.log("b")
a()
}
b();
- b()入栈。创建相应的临时栈空间
- console.log("b")入栈,执行,出栈。
- a()入栈。创建相应的临时栈空间。
- console.log("a")入栈,执行,出栈。
- a()出栈。
- b()出栈。
以上例子全是同步的。但是这就是JS引擎调用主栈代码的执行过程。
JS中所有的异步,都是通过利用其它线程或者进程实现的。例如:异步请求是利用了网络进程;定时器是利用的计时器线程等等。
- 通过一个例子来说明异步的时候,JS的执行过程。
function a() {
console.log("a");
}
setTimeout(() => {
console.log("setTimeout");
}, 200);
Promise.resolve().then(() => {
console.log("promise");
});
axios.get(xxx).then(() => {
console.log("axios");
});
a();
console.log("over");
- 定时器setTimeout交给定时器线程。等待200ms加入宏队列等待执行。等待的过程中,不影响后序代码,因为负责计时不是一个线程了。
- promsie执行,then执行,里面的回调交给微队列任务等待执行。
- axios.get()入栈执行,交给网络进程处理,等待网络进程处理完成,加入宏队列任务。返回Promise对象,然后then执行。这里不会将then的回调加入微队列(因为,网络请求还没有完成)。
- a()入栈。创建相应栈空间。执行同步代码,console.log("a")入栈,执行,出栈。
- console.log("over")入栈,执行,出栈。
- 调用栈为空
- 有个Event loop轮询处理一直观察当前调用栈是否为空。如果为空,就把之前,加入微队列和宏队列的拿出来执行。顺序就是先执行微队列,再执行宏队列。
- console.log("promise")所在函数入栈,执行,出栈。
- 这个时候,执行的是axios的回调,还是setTimout取决了网络情况。
- 假如是setTimout先执行。(假如是axios先回来,那么先打印axios,然后setTimeout)
- setTimout回调函数入栈,执行(console.log("setTimeout")),出栈。
- 然后,网络请求回来了,相关的回调加入宏队列,这时候执行栈为空,执行宏队列,改变了promise状态。将then回调加入了微任务队列。
- 当前微队列里还有一个任务。入栈,执行(console.log("axios")),出栈。
- 执行完毕。
上图这个循环往调用栈添加执行代码,往宏队列或者微队列中添加任务的过程,就是事件循环。
我们就能解决一些疑问:
- 为什么setTimeout不准确。
-
- 因为:setTimeou本质是往计时线程中添加等待时间,并不执行代码。等时间到后,往宏队列里面添加回调任务,加入调用栈,再执行代码。这个时候,如果setTiemout中代码执行时间过长,肯定是会超过设置的时间。如果设置的时间过短,那么这个加入队列,加入执行栈,过程所花费时间可能要比设置时间长,也是不准确的。至少会有一个最短时间。
- promsie只有状态改变后,才把相应的回调加入微队列中。
- 在宏队列代码中也可能会存在会加入微队列中的代码。同理微队列也是。
回顾题:
console.log('1');
async function async1() {
console.log('2');
await async2();
console.log('3');
}
async function async2() {
console.log('4');
}
setTimeout(function() {
console.log('6');
new Promise(function(resolve) {
console.log('8');
resolve();
}).then(function() {
console.log('9')
})
})
async1();
new Promise(function(resolve) {
console.log('10');
resolve();
}).then(function() {
console.log('11');
});
console.log('12');
Node端事件循环
\
Node基于chrome的V8引擎。基本的事件循环的流程跟浏览器差不多。
node每一次事件循环会经历上面6个阶段。主要的是timers,poll,check。
pending callback:此阶段对某些系统操作(如 TCP 错误类型)执行回调。例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED,则某些 *nix 的系统希望等待报告错误。这将被排队以在 挂起的回调 阶段执行。
idle prepare: 系统调度。执行与系统有关。windows,linux等。
close callbacks:一些关闭的回调函数,如:socket.on('close', ...)。
这三个阶段,很大程度上受外界和系统影响,与我们本身的代码关系不是特别的强烈。只需要我们平时稍微注意下特殊的就行。
每一个阶段都会维护一个队列。所以,至少有6个队列。这与浏览器端的微队列和宏队列只有两个不太一样。
每一个队列里面都是回调函数,当队列中的所有回调函数执行完毕之后,进入下一个阶段。
在每次运行的事件循环之间,Node.js 检查它是否在等待任何异步 I/O 或计时器,如果没有的话,则完全关闭。
timers阶段:存放计时器的回调函数。
poll阶段:轮询队列。除了timers,checks两个阶段的回调,绝大部分回调都会放入该队列。比如:文件的读取,监听用户请求。都会进入这个队列。
-
- 运作方式:如果poll队列中有回调,依次执行回调,直到清空队列。如果poll中没有回调,等待其他队列中出现回调,结束该阶段,进入下一个阶段。如果其他队列也没有回调,持续等待,直到出现回调为止。
-
-
- 注意:为了防止 轮询 阶段饿死事件循环,libuv(实现 Node.js 事件循环和平台的所有异步行为的 C 函数库),在停止轮询以获得更多事件之前,还有一个硬性最大值(依赖于系统)。
-
-
- 下面是一个例子:hello word输出的时间远远大于200ms,至少是300ms以上。
const start = Date.now();
setTimeout(function f1() {
console.log("hello word", Date.now() - start);
}, 200);
const fs = require("fs");
fs.readFile("../web/GUI线程与JS引擎互斥.html", "utf-8", (err, data) => {
console.log("readFile");
const start = Date.now();
while (Date.now() - start < 300) {}
});
上面的例子进行解释:
- 首先主调用栈的代码执行完毕后,有相应的上下文了。
- 然后会判断当前是否有事件循环相关需要操作。发现有计时线程和文件读取异步回调需要执行。那么进入事件循环的流程。
- 进入timers, 这个时候,计时还没有到,还没有向timers队列中添加回调,进入下一个阶段。
- 中间暂时省略,进入poll阶段,在这个阶段中,遵循poll阶段的执行规则。这个时候发现整个事件循环中都没有回调需要执行,就在这个阶段中卡着,一直轮询。
- 这个时候,文件读取回调加入了poll队列,执行相关的poll队列的回调,在这里卡了300ms。这个过程中,timers中也加入了相应的队列。
- 读取文件的回调完成之后,poll阶段发现其他队列中出现了回调,那么就进入了下一个阶段,check, 一个事件循环完毕,然后,event loop,发现还有未完成的回调,进入事件循环。
- 进入timers阶段,执行清空timers中的回调。
- 然后进入poll,这个时候所有的异步都已经完成了。自动进入下一个阶段,直到整个循环完成,事件循环结束。
check阶段:使用setImmediate的回调会直接进入这个队列。
-
- 在真实的底层中,在timers中是检查计时器线程的时间。时间到达了就执行相应的回调。其实真实的不是什么队列,但是平时我们当成队列来理解没什么错误。
- check阶段,中setImmediate是真实的是一个队列。我们执行setImmediate,就直接会把它的回调放到check阶段维护的队列。它的效率高很多。原因就是:timers阶段每次都会检查所有的计时器线程,一个一个拿出来看时间是否到了。这个效率就慢了很多。
- 第一个例子:
let i = 0;
console.time();
function test() {
i++;
if (i < 1000) {
setTimeout(test, 0);
// setImmediate(test);
} else {
console.timeEnd();
}
}
test();
使用setTimeout,所花费的时间是:1s以上,每次都会和计时器线程进行时间的比较,效率慢了很多。如果是使用setImmediate,那么时间只会是20几ms。因为每次不用比较,不会和其他线程进行同步。
- 第二个例子:
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
上面的两种打印的情况是不一定的,因为setTimeout肯定是取不到0的,至少也是1ms以上。如果这个时候,计算及卡了一下,那么到达事件轮询的时候,timers阶段有回调,就先执行的setTimeout,但是如果计算机没卡,就很快执行了,那么setImmediate先执行,然后第二圈的事件循环的时候,才打印setTimeout
- 第三个例子
const fs = require("fs");
fs.readFile("../web/GUI线程与JS引擎互斥.html", (err, data) => {
setTimeout(() => {
console.log("1")
setImmediate(() => console.log("3"));
},0);
setImmediate(() => console.log("2"));
});
- 进入事件循环,timers阶段没有可执行的回调,到达
poll阶段。 - poll阶段发现其他阶段没有可执行的回调并且,还是有事件在等待。那么就在
poll阶段不停轮询等待。
- 直到文件读取完成,执行相应的回调。然后,开启计时器线程,并把
setImmediate回调加入到check队列中,poll阶段结束。 - 进入
check阶段,执行相应的回调。打印2.
- 然后事件循环结束,发现还有回调未执行,进入
timers阶段。打印1. - 然后走到
check,打印3。
- 所有事件完成。
nextTick和Promsie
\
在刚才事件循环中执行的所有回调,相当于就是浏览器中代表的宏队列的任务。
nextTick和promise相当于就是微队列。
都有回调的情况,优先级是nextTick。
事件循环中每次打算执行一个回调之前,必须先清空nextTick和promise队列。
- 第一个例子
setImmediate(() => {
console.log(1);
});
process.nextTick(() => {
console.log(2);
process.nextTick(() => {
console.log(6);
});
});
console.log(3);
Promise.resolve().then(() => {
console.log(4);
process.nextTick(() => {
console.log(5);
});
});
- 打印3.
- 进入事件队列,首先清空nextTick和promise。打印2。往nextTick又添加一个,输出6.
- 然后promsie执行,打印4,然后又出现了nextTick,输出5。
- 然后最后打印1。
所以,能够最快执行的异步回调就是nextTick。执行任何一个异步回调之前都要先执行它。
\
- 第二个例子
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(() => {
console.log("setTimeout0");
}, 0);
setTimeout(() => {
console.log("setTimeout3");
}, 3);
setImmediate(() => {
console.log("setImmediate");
});
process.nextTick(() => {
console.log("nextTick");
});
async1();
new Promise((resolve) => {
console.log("promise1");
resolve();
});
- 打印
console.log("script start") - 打印,console.log("async1 start") ,await async2(),打印async2。 这个时候async2返回的promsie的状态变了,加入promsie的微队列。
- 执行new Promise()的代码,打印 console.log("promise1")。 然后所有的同步代码执行完毕。
- 执行nextTick。打印
nextTick。
- 执行promsie的回调,打印async1 end
- 然后进入事件循环队列。
- 进入timers阶段,这里说不准是否0ms或者3ms是否到了。所以,这里和setImmediate的顺序说不准。
-
- 取决于进入事件循环之前,代码执行所花费的时间。
为什么有这么多不同的异步呢?
原因是因为:一开始Node想要提供一个api,在事件循环过程中,马上执行一些东西,于是提供了setImmediate。但是后面发现不能解决一些poll阶段的等待过程中的问题。如下:
发现了浏览器有了微队列这个东西,Node也想搞一个东西出来,但是setImmediate这个名字被用了,也不可能改了,只能用nextTick这个名字了。
为什么要使用 process.nextTick()?
有两个主要原因:
- 允许用户处理错误,清理任何不需要的资源,或者在事件循环继续之前重试请求。
- 有时有让回调在栈展开后,但在事件循环继续之前运行的必要。(如下例子)
以下是一个符合用户预期的简单示例:
const server = net.createServer();
server.on('connection', (conn) => { });
server.listen(8080);
server.on('listening', () => { });
\
假设 listen() 在事件循环开始时运行,但 listening 的回调被放置在 setImmediate() 中。除非传递过主机名,才会立即绑定到端口。为使事件循环继续进行,它必须命中 轮询 阶段,这意味着有可能已经接收了一个连接,并在侦听事件之前触发了连接事件。
\
总结
- 浏览器端的事件循环相比于Node端要简单一些。
- Node端最重要的事件循环的阶段就是
poll阶段,这个阶段作为轮询,一直监听这Node所有事件循环阶段是否有相应回调执行。才能继续走下一步。在网络请求中,额外重要。相当于作为网络请求和后序代码执行的一个结点。