深入理解js 事件循环机制(nodejs篇)

浏览器篇简单补充:(阮一峰 http://www.ruanyifeng.com/blog/2014/10/event-loop.html) 

* 任务队列:

  同步任务和异步任务咋来的?

    - 单线程意味着,所有任务需要排队,任务一个一个执行。

    - 如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

    - js 设计者意识到,这时候主线程完全可以不管 I/O 这个挂起处于等待中的任务,完全可以先运行排在后面的任务,等到IO这边返回了结果再回头把挂起的任务执行。

 于是,所有任务就被分成两种: 一种 同步, 一种 异步。

      * 同步任务: 在主线程上排队执行的任务(这里会形成一个执行栈)

      * 异步任务: 不进入主线程,进入'任务队列'的任务,只有'任务队列'通知主线程,某个异步任务可以执行了,然后该任务在主线程执行完同步任务才会进入执行栈排队,等待被执行。

 具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

        1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

        2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

        3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,就会结束等待状态,进入执行栈,js 开始执行。

        4. 主线程不断重复上面的第三步。

  其实就是只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。

* 事件和回调函数:

  - "任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。

  - "任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。

  - 所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

  - "任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

* Event Loop (事件循环)

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

  为了更好地理解Event Loop,看图:


浏览器 js 主线程运行机制

  上面这张图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码有需要异步处理的(即各种WebAPI),它们在"任务队列"中加入需要在这些api中执行的各种回调事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行这些回调函数。

  即js主线程总是先执行栈中的代码(同步任务),再去读取执行"任务队列"(异步任务)。



Node.js的Event Loop

    

Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。

看图:

node.js 运行机制


根据上图,Node.js的运行机制如下。

  1. V8引擎解析JavaScript脚本。

  2. 解析后的代码,调用Node API。

  3. libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。

  4. V8引擎再将结果返回给用户。

这里我要放上一张关于 浏览器和nodejs 对于 宏任务和微任务 支持与否的区别图:


宏任务macrotask
微任务microtask

很明显,除了setTimeout和setInterval这两个方法,Node.js还提供了另外两个与"任务队列"有关的方法:process.nextTick(微任务)和setImmediate(宏任务)。它们可以帮助我们加深对"任务队列"的理解。

  * process.nextTick方法可以在当前"执行栈"的尾部----下一次Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。[微任务(=浏览器支持的promise.then),一定是在同步任务执行完之后执行,然后才是异步任务队列]

  * setImmediate方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。[宏任务(=等同于setTimeout(fn,0)),是异步任务,会在同步任务和微任务执行完后执行]

  1. 来吧,先看看process.nextTick例子:

      process.nextTick(function A() {

      console.log(1);

        process.nextTick(function B(){console.log(2);});

      });

      setTimeout(function timeout() {

        console.log('TIMEOUT FIRED');

      }, 0)

      // 1

      // 2

      // TIMEOUT FIRED


  上面代码中,由于process.nextTick方法指定的回调函数,总是在当前"执行栈"的尾部触发,所以不仅函数A比setTimeout指定的回调函数timeout先执行,而且函数B也比timeout先执行。这说明,如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行。

process.nextTick

就像上边说的,这个可以认为是一个类似于Promise和MutationObserver的微任务实现,在代码执行的过程中可以随时插入nextTick,并且会保证在下一个宏任务开始之前所执行。

    - 像下面这样的递归调用process.nextTick,将会没完没了,主线程根本不会去读取"事件队列"!

        process.nextTick(function foo() {

          process.nextTick(foo);

        });

    - 由于process.nextTick指定的回调函数是在本次"事件循环"触发,而setImmediate指定的是在下次"事件循环"触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查"任务队列")。


  2. 现在,再看setImmediate。

    - code1:

        setImmediate(function A() {

          console.log(1);

          setImmediate(function B(){console.log(2);});

        });

        setTimeout(function timeout() {

          console.log('TIMEOUT FIRED');

        }, 0);

        //node1 输出答案不一

            // 1

            // TIMEOUT FIRED

            // 2

            // TIMEOUT FIRED

            // 1

            // 2

    - code2:

        setImmediate(function (){

          setImmediate(function A() {

            console.log(1);

            setImmediate(function B(){console.log(2);});

          });


          setTimeout(function timeout() {

            console.log('TIMEOUT FIRED');

          }, 0);

        });

        // node2 答案不一

            //   1

            //   TIMEOUT FIRED

            //   2


            //   TIMEOUT FIRED

            //   1

            //   2

* code1 和 code2 的输出结果总是不稳定的问题怎么解决呢?

  1. 方法一:确保这个循环的执行速度会超过定时器的倒计时

        setTimeout(_ => console.log('setTimeout'))

        setImmediate(_ => console.log('setImmediate'))

        let countdown = 1e9;

        while(countdown--) { } // 我们确保这个循环的执行速度会超过定时器的倒计时,导致这轮循环没有结束时,setTimeout已经可以执行回调了,所以会先执行`setTimeout`再结束这一轮循环,也就是说开始执行`setImmediate`

        // 执行结果:一定是先输出setTimeOut

            // setTimeout

            // setImmediate

  2. 方法二: 如果在另一个宏任务中,必然是setImmediate先执行:

        require('fs').readFile(__dirname, _ => {

          setTimeout(_ => console.log('timeout'))

          setImmediate(_ => console.log('immediate'))

        }); 

        // 执行结果:

            // immediate

            // timeout

你可能感兴趣的:(深入理解js 事件循环机制(nodejs篇))