Event Loop 这个循环你晓得么?(附GIF详解)
前言
我们都知道JavaScript是一门 单线程 、 非阻塞 的脚本语言,目的是为了实现与浏览器交互。
这里我们提到了两点:一是单线程,二是非阻塞。
单线程是指JavaScript在执行的时候,有且只有一个主线程来处理所有的任务。
但是他为什么一定是单线程,而不是多线程的呢?
我们设想一下,如果JavaScript是多线程的,现在我们在浏览器中同时操作一个DOM,一个线程要求浏览器在这个DOM中添加节点,而另一个线程却要求浏览器删掉这个DOM节点,那这个时候浏览器就会很郁闷,他不知道应该以哪个线程为准。所以为了避免此类现象的发生,降低复杂度,JavaScript选择只用一个主线程来执行代码,以此来保证程序执行的一致性。
那非阻塞又是如何实现的呢?这就要说到我们今天的主角 Event Loop。
在了解 Event Loop 之前,我们先来熟悉一下执行栈和事件队列这两个概念。
执行栈
当我们调用一个方法的时候,js会生成一个与这个方法相对应的执行环境,也叫执行上下文,这个执行环境存在着这个方法的私有作用域、参数、this对象等等。因为js是单线程的,同一时间只能执行一个方法,所以当一系列的方法被依次调用的时候,js会先解析这些方法,把其中的 同步任务 按照执行顺序排队到一个地方,这个地方叫做执行栈。
事件队列
当我们发出一个ajax请求,他并不会立刻返回结果,为了防止浏览器出现假死或者空白,主线程会把这个异步任务挂起(pending),继续执行执行栈中的其他任务,等异步任务返回结果后,js会将这个 异步任务 按照执行顺序,加入到与执行栈不同的另一个队列,也就是事件队列。
浏览器中的Event Loop
如图所示:
- 主线程运行的时候会生成堆(heap)和栈(stack);
- js从上到下解析方法,将其中的同步任务按照执行顺序排列到执行栈中;
- 当程序调用外部的API时,比如ajax、setTimeout等,会将此类异步任务挂起,继续执行执行栈中的任务,等异步任务返回结果后,再按照执行顺序排列到事件队列中;
- 主线程先将执行栈中的同步任务清空,然后检查事件队列中是否有任务,如果有,就将第一个事件对应的回调推到执行栈中执行,若在执行过程中遇到异步任务,则继续将这个异步任务排列到事件队列中。
- 主线程每次将执行栈清空后,就去事件队列中检查是否有任务,如果有,就每次取出一个推到执行栈中执行,这个过程是循环往复的... ...,这个过程被称为“Event Loop 事件循环”。
大概知道了 Event Loop 执行时是一个什么样的过程,现在我们通过代码来感受一下。
console.log(1);
setTimeout(() => {
console.log(2);
setTimeout(() => {
console.log(3);
setTimeout(() => {
console.log(4);
}, 0) ;
}, 0) ;
}, 0);
setTimeout(() => {
console.log(5);
setTimeout(() => {
console.log(6);
}, 0);
}, 0);
console.log('ok');
脑阔痛?不要捉急,在此奉上小编贴心准备的GIF。
事实上,定时器的计时功能是在web API中完成的,计时完成后再将其回调函数排列到事件队列中。本文的GIF图旨在方便各位理解,所以省略了这一步,为防止误导,特此强调一下。
注:因为这里setTimeout设置的延迟时间为0,所以并没有直接体现出异步回调执行的先后顺序。实际过程中,异步任务回调执行的顺序,与js执行栈清空完成时间及其设置的延迟时间有关。
以上的事件循环过程只是一个宏观的表述,实际上异步任务之间也不相同,执行优先级也有区别。不同的异步任务被分为两类:宏任务(macro task)和微任务(micro task)。我们将经常遇到的异步任务进行分类如下:
宏任务:setTimeout,setInterval,setImmediate,I/O(磁盘读写或网络通信),UI交互事件
微任务:process.nextTick,Promise.then
前面我们介绍,事件循环会将其中的异步任务按照执行顺序排列到事件队列中。然而,根据异步事件的不同分类,这个事件实际上会被排列到对应的宏任务队列或者微任务队列当中去。
当执行栈中的任务清空,主线程会先检查微任务队列中是否有任务,如果有,就将微任务队列中的任务依次执行,直到微任务队列为空,之后再检查宏任务队列中是否有任务,如果有,则每次取出第一个宏任务加入到执行栈中,之后再清空执行栈,检查微任务,以此循环... ...
同一次事件循环中,微任务永远在宏任务之前执行。
没明白?莫慌,再看个 咯。
console.log(1);
setTimeout(() => {
console.log('setTimeout');
}, 0);
let promise = new Promise(resolve => {
console.log(3);
resolve();
}).then(data => {
console.log(100);
}).then(data => {
console.log(200);
console.log(2);
上面的 ,按照js由上到下的执行顺序,遇到同步任务先输出1。setTimeout是宏任务,会先放到宏任务队列中。而new Promise是立即执行的,所以会先输出3。而Promise.then是微任务,会依次排列到微任务队列中,继续向下执行输出2。现在执行栈中的任务已经清空,再将微任务队列清空,依次输出100和200。之后每次取出一个宏任务,因为现在只有一个宏任务,所以最后输出setTimeout。
同样奉上GIF讲解图一份~~~
Node中的Event Loop
Node是一个基于Chrome V8引擎的javascript运行环境,我们写的js代码会交给V8引擎进行处理;解析后代码调用的Node API,Node会交给libuv库处理,它将不同的任务分配给不同的线程,形成一个 Event Loop,以异步的方式将任务的执行结果返回给V8引擎,再将结果返回给用户。
因为Node环境下的差异性,我们可以理解为将浏览器中的宏任务更加细分为以下六个阶段。
- timers:这个阶段执行定时器队列中的回调如 setTimeout 和 setInterval;
- I/O callbacks:这个阶段执行一些系统操作的回调,比如TCP错误,但不包括close事件、定时器和setImmediate的回调;
- idle, prepare:只在node内部使用;
- poll:等待新的I/O事件,node在一些特殊情况下会阻塞在这里;
- check:setImmediate回调在这个阶段执行;
- close callbacks:只在node内部使用。
这里的特殊情况是指在 UV_RUN_ONCE 模式下造成的阻塞,其实event是由uv_run驱动,而uv_run有 UV_RUN_ONCE 和 UV_RUN_NOWAIT 两种模式,UV_RUN_NOWAIT 模式下 io-poll 不会阻塞,他会立即触发超时并结束当前的event_loop,从而进入下一次循环。
说了这么多,再来看个 。
setImmediate(() => {
console.log(1);
process.nextTick(() => {
console.log(4);
process.nextTick(() => {
console.log(2);