详谈javascript和node的事件循环

801 阅读6分钟

Javascript中的事件循环

javascript是一门单线程的非阻塞的脚本语言。单线程,即js代码在执行的任何时候,都只有一个主线程来处理所有任务。非阻塞,只要指的是执行异步任务(如I/O事件)时,主线程会挂起这个任务,然后在异步任务返回结果的时候再按照一定规则执行相应的回调。

Web worker 技术所实现的多线程技术也存在诸多限制。如,所有新线程都受到主线程的完全控制,不能独立执行。这意味着这些‘线程’实际上是主线程的子线程。另外,这些子线程没有执行I/O操作的权限,只能为主线程分担一些如计算等任务。所以严格来讲,web worker并没有改变javascript的单线程本质。

  1. 执行栈和同步执行

执行栈与存储对象指针和基础类型变量的栈是不同的。执行栈是指,当调用一个方法时,js会生成与这个方法对应的一个执行环境(context),即执行上下文。这个执行环境中包含:这个执行环境的私有作用域、上层作用域的指向,方法的参数,私有变量以及该作用域的this指向。因为js是单线程的,同一时间只能执行一个方法,也就是说,当一个方法被执行的时候,其他方法会被排队到一个单独的地方,即执行栈。

当一个脚本第一次执行的时候,js引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈,然后从头开始执行。当执行一个方法时,js会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码执行完毕并返回结果后,js会退出当前执行环境并撤销该环境,回到上一个方法的执行环境,这个过程反复执行,知道执行栈中的代码全部执行完毕。

案列1:

function Func1 () {

    console.log(1)

    function Func2 () {

        console.log(2)

        function Func3 () {

            console.log(3)

        }

        Func3()

      }

      Func2()

}

Func1()

// 1 2 3

同步执行遵循先进后出的规则,在执行Func1时,会向执行栈加入该方法的执行环境,输出1,然后解析了Func2,执行时加入了Func2的执行环境,输出2,然后解析Func3并执行,输出3,Func3执行完毕后会撤销Func3的执行环境,接着是Func2执行完毕并撤销Func2的执行环境,最后撤销Func1的执行环境。该过程若没有终止,会无限进行直到栈溢出。

  1. 异步执行

方法执行时,异步执行事件挂起加入与执行栈不同的另一个队列,即事件队列中,并继续执行执行栈中的其他任务。被放入事件队列不会立即执行其回调,而是等待当前执行栈中的所有任务执行完毕,在主线程出于闲置状态时,主线程会查找事件队列是否有任务。如果有,则会取第一个事件并将该事件的回调放入执行栈中执行,然后执行其中的同步代码,如此反复就是事件循环。

异步任务因为各任务的不同和执行优先级的区别,分为 宏任务 (macro task) 和 微任务 (micro task)

属于宏任务的事件:setTimeout(), setInterval()

属于微任务的事件:new Promise(), new MutaionObserver()(已废除)

当执行栈为空时,主线程会优先查看微任务是否有事件。如果没有,就会执行宏任务中的第一个事件并将对应的回调加入当前执行栈中;如果有,就会依次执行微任务中事件对应的回调,直到微任务队列为空,然后再执行宏任务中的第一个事件对应的回调,如此反复,进入循环。同一次事件循环中,微任务永远优先宏任务执行。

案列2:

setTimeout(function () {
    console.log(1);
});

new Promise(function(resolve,reject){
    console.log(2)
    resolve(3)
}).then(function(val){
    console.log(val);
})

// 2 3 1

node环境下的事件循环

在node中,事件循环与浏览器中的略有不同。node中的事件循环的实现是依靠的libuv引擎。node选用chrome的v8引擎作为解释器,v8引擎将js代码解析后会调用node api,而api则是由libuv引擎驱动,因此node中的事件循环是在libuv引擎中执行。

node中,同步代码执行完,会先清空微任务队列,轮询时会清空当前队列所有任务,才会切换到下一个队列,在切换下一个队列之前也会先清空微任务队列。

  1. 事件循环模型


(来自:node官网)


  1. 事件循环说明

node的事件循环顺序:

外部输入数据—>poll阶段—>检查阶段(check)—>关闭事件回调阶段(close callback)—>定时器检测执行阶段(timers)—>I/O事件回调阶段(I/O callbacks)—>idle,prepare—>poll…

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

setImmediate(() => {console.log('immediate')})

默认情况下setTimeout()和 setImmediate()不知道哪一个会先执行,node执行也需要准备时间。setTimeout()延迟时间设置为0,实际还是有4ms的延迟,假设node准备时间在4ms内,定时器没有执行,poll阶段没有执行setTimeout(),会先执行check中的setImmediate(),等到下一轮询进入时,poll检测到定时器已到时,再执行timer中的setTimeout()

队列中有一个特殊的推迟任务执行的方法process.nextTick再此执行。我们知道,每一次事件循环都是从微任务开始的,并且每一阶段都是按照事件循环顺序进行执行。而在每一次的队列切换之前,都会检查nextTick queue中是否有事件,若有则优先执行。

案列3:

setImmediate(() => {

    console.log("setImmediate1");

    setTimeout(() => {

        console.log("setTimeout1");

    }, 0);

});

setTimeout(() => {

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

    console.log("setTimeout2");

    setImmediate(() => {

        console.log("setImmediate2");

    });

}, 0);

// 结果一

// setImmediate1, setTimeout2, setTimeout1, nextTick, setImmediate2

// 结果二

// setTimeout2, nextTick, setImmediate1, setImmediate2, setTimeout1

产生上面两种结果的原因,是node准备时间的差异。

案例4:

const fs = require('fs');

fs.readFile(__filename, () => {

    setImmediate(() => {

        console.log("setImmediate1");

        setTimeout(() => {

            console.log("setTimeout1");

        }, 0);

    });

    setTimeout(() => {

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

        console.log("setTimeout2");

        setImmediate(() => {

            console.log("setImmediate2");

        });

      }, 0);

});

// setImmediate1, setTimeout2, setTimeout1, nextTick, setImmediate2

此时只会有一种结果,因为是在一个I/O事件的回调中,node准备已结束,setTimeout执行需要等待4ms,setImmediate则立即执行,又setTimeout2和setTimeout1在同一个timers队列中所以按顺序执行,之后需要切换到check队列执行setImmediate2,在切换之前会先检查nextTick队列并执行,因此最后输出nextTick,setImmediate2

注:欢迎大家监督指导,如有疑问或错误,请留言一起探讨~~