Event Loop 这个循环你晓得么?(附GIF详解)

1,233 阅读7分钟
原文链接: zhuanlan.zhihu.com

前言

我们都知道JavaScript是一门单线程非阻塞的脚本语言,目的是为了实现与浏览器交互。

这里我们提到了两点:一是单线程,二是非阻塞。

单线程是指JavaScript在执行的时候,有且只有一个主线程来处理所有的任务。

但是他为什么一定是单线程,而不是多线程的呢?

我们设想一下,如果JavaScript是多线程的,现在我们在浏览器中同时操作一个DOM,一个线程要求浏览器在这个DOM中添加节点,而另一个线程却要求浏览器删掉这个DOM节点,那这个时候浏览器就会很郁闷,他不知道应该以哪个线程为准。所以为了避免此类现象的发生,降低复杂度,JavaScript选择只用一个主线程来执行代码,以此来保证程序执行的一致性。

那非阻塞又是如何实现的呢?这就要说到我们今天的主角 Event Loop。

在了解 Event Loop 之前,我们先来熟悉一下执行栈和事件队列这两个概念。

执行栈

当我们调用一个方法的时候,js会生成一个与这个方法相对应的执行环境,也叫执行上下文,这个执行环境存在着这个方法的私有作用域、参数、this对象等等。因为js是单线程的,同一时间只能执行一个方法,所以当一系列的方法被依次调用的时候,js会先解析这些方法,把其中的同步任务按照执行顺序排队到一个地方,这个地方叫做执行栈。

事件队列

当我们发出一个ajax请求,他并不会立刻返回结果,为了防止浏览器出现假死或者空白,主线程会把这个异步任务挂起(pending),继续执行执行栈中的其他任务,等异步任务返回结果后,js会将这个异步任务按照执行顺序,加入到与执行栈不同的另一个队列,也就是事件队列。

浏览器中的Event Loop

如图所示:

  1. 主线程运行的时候会生成堆(heap)和栈(stack);
  2. js从上到下解析方法,将其中的同步任务按照执行顺序排列到执行栈中;
  3. 当程序调用外部的API时,比如ajax、setTimeout等,会将此类异步任务按照执行顺序排列到事件队列中;
  4. 主线程先将执行栈中的同步任务清空,然后检查事件队列中是否有任务,如果有,就将第一个事件对应的回调推到执行栈中执行,若在执行过程中遇到异步任务,则继续将这个异步任务排列到事件队列中。
  5. 主线程每次将执行栈清空后,就去事件队列中检查是否有任务,如果有,就每次取出一个推到执行栈中执行,这个过程是循环往复的... ...,这个过程被称为“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。

示例一

以上的事件循环过程只是一个宏观的表述,实际上异步任务之间也不相同,执行优先级也有区别。不同的异步任务被分为两类:宏任务(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:这个阶段执行几乎所有的回调。但是不包括close事件、定时器和setImmediate的回调;
  • idle, prepare:只在node内部使用;
  • poll:等待新的I/O事件,node在一些特殊情况下会阻塞在这里;
  • check:setImmediate回调在这个阶段执行;
  • close callbacks:只在node内部使用。
 setImmediate(() => {
   console.log(1);

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

   setImmediate(() => {
     console.log(3);
   });
 });

js按照从上到下的执行顺序,将setImmediate排列到node事件环的I/O callbacks队列中,process.nextTick排列到微任务队列。首先检查执行栈,发现没有任务,然后检查微任务队列并执行清空,输出2,并将setImmediate排列到I/O callbacks队列,检查timers队列,发现并没有任务,继续检查I/O callbacks队列,发现有任务,执行清空,输出1,将process.nextTick排列到微任务队列,输出2,将setImmediate继续排列,输出3,最后将微任务清空,输出4。

讲道理,这里应该还有一个GIF图。

示例三

总结:

  1. js解析方法时,将同步任务排队到执行栈中,异步任务排队到事件队列中。

2. 事件队列分为:

  • 宏任务:setTimeout,setInterval,setImmediate,I/O,UI交互事件
  • 微任务:process.nextTick,Promise.then

3. 浏览器环境中执行方法时,先将执行栈中的任务清空,再将微任务推到执行栈中并清空,之后检查是否存在宏任务,若存在则取出一个宏任务,执行完成检查是否有微任务,以此循环…

4. Event Loop在浏览器与node环境中的区别:

  • 浏览器环境每次执行一个宏任务,再去检查微任务
  • node会清空当前所处阶段的队列,即执行所有task,再去检查微任务

以上~~~