javascript-事件循环

loading 2022年11月19日 88次浏览

1.线程和进程

1.1 概念和关系

js是一种单线程语言,指的是一个进程里只有一个线程。那么进程和线程该如何理解它们的含义和区别呢:
进程是 CPU资源分配的最小单位;线程是 CPU调度的最小单位。

通俗地讲,进程类似于工厂,有自己专属的有CPU分配的工厂资源;线程类似于工厂中的工人,单个或多个工人在一个工厂中工作,也就是说一个进程由一个或多个线程组成,线程共享进程的资源和空间。

1.2 多进程和多线程

  • 多进程
    同一时间内,计算机允许多个进程处于运行状态,比如听歌软件的进程和游戏的进程,等等。
  • 多线程
    程序中包含多个执行流,一个程序中可以同时运行多个不同的线程来执行不同的任务。

以Chrome浏览器中为例,当打开一个Tab页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS引擎线程、HTTP请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

2.浏览器中的事件循环

2.1 宏任务和微任务

事件循环由宏任务和执行宏任务期间产生的微任务组成,完成当下的宏任务后,会立刻执行所有在此期间入队的微任务。

这种设计是为了给紧急任务一个插队的机会,否则新入队的任务永远被放在队尾。区分了微任务和宏任务后,本轮循环中的微任务实际上就是在插队,这样微任务中所做的状态修改,在下一轮事件循环中也能得到同步。

宏任务队列可以有多个,微任务队列只有一个。

常见的宏任务包括:

  • script(整体代码)
  • setTimeout
  • setInterval
  • setImmediate(node独有)
  • I/O
  • UI render(浏览器独有)
  • requestAnimationFrame(浏览器独有)

常见的微任务包括:

  • new Promise().then()
  • await后产生的微任务
  • process.nextTick(node独有)
  • MutationObserver(html5新特性)

2.2 事件循环流程

2.2.1 流程解析

以这个图为范例理解:

详细流程解释:

(1)一开始执行栈为空,微任务队列为空,宏任务里有且仅有一个script脚本(整体代码)。

(2)script从宏任务队列中被推入执行栈,同步代码开始执行。在执行过程中,js判断某些任务是同步还是异步,执行任务时,会产生新的宏任务和微任务,它们分别被推入到各自的任务队列中。

(3)同步代码执行完毕后,script被移出执行栈,移出宏任务队列,这个过程本质上是宏任务队列的执行和出队的过程。

(4)某个宏任务执行完后,开始处理微任务队列,但是微任务队列和宏任务队列的区别在于:前者一队一队执行,后者一个一个执行。因此处理微任务队列时,会处理掉队里的所有微任务直到队列为空。

(5)执行渲染操作,更新页面,检查是否存在Web worder任务,若有则对其进行处理,随后循环往复上述过程,直到两条队列都被清空。

长话短说解释:

先执行某个宏任务(第一个宏任务就是整体的同步代码),执行完后查看微任务队列中是否有微任务:
若有,将队列中的任务全部执行完毕直到清空(若微任务执行途中又产生微任务,则继续将新的微任务加入队列执行);
若队列为空,继续执行宏任务队列中接下来的宏任务,以此类推。

2.2.2 范例

范例一

        Promise.resolve().then(() =>{
            console.log('promise1');
            setTimeout(() =>{
                console.log('setTimeOut2');
            } , 0)
        })

        setTimeout(() =>{
            console.log('setTimeOut1');
            Promise.resolve().then(() =>{
                console.log('promise2');
            })
        } , 0)
        
        console.log('hello world')
	
	//hello world
	//promise1
	//setTimeOut1
	//promise2
	//setTimeOut2
  • 一开始先执行“script内整体代码”这个宏任务,同步任务执行期间遇到Promise1这个微任务,将其加入微任务队列。随后看到setTimeOut1这个宏任务,将其加入宏任务队列,代码跑到最后一行看到句hello world,将其打印。

  • 第一个宏任务执行完后(同步代码跑完)去查看微任务队列,发现有个刚刚加进去的Promise1,因此先打印出promise1,同时生成一个宏任务setTimeOut2加入宏任务队列。

  • 微任务队列清空后来看宏任务队列,队列中有着最先加入的setTimeOut1和后加入的setTimeOut2,先执行前者。首先打印setTimeOut1,随后将promise2这个微任务加入到微任务队列中。

  • 再次来到微任务队列,执行promise2微任务,打印promise2。

  • 微任务队列清空,再回到宏任务队列,剩个setTimeOut2,打印出来。

这里要注意一个点:Promise不是微任务,Promise.then()/catch()/finally()才是,比如这个例子:

	console.log(1)
        let promise = new Promise(function (resolve, reject) {
            console.log(3)
            resolve(100)
            console.log(4)
        }).then(function (data) {
            console.log(data)
        })
        console.log(2)
        //1 3 4 2 100

如果Promise自己就是微任务,那么答案就应该是1 2 3 4 100了,很显然答案不符。

范例二,加入async/await

async隐式返回Promise作为结果的函数,那么可以简单理解为,await后面的函数执行完毕时,await会产生一个微任务。但是我们要注意这个微任务产生的时机,它是执行完await之后,直接跳出async函数,执行其他代码。其他代码执行完毕后,再回到async函数去执行剩下的代码,然后把await后面的代码注册到微任务队列当中。

来看这个例子:

        console.log('script start')

        async function async1() {
            await async2()
            console.log('async1 end')
        }
        async function async2() {
            console.log('async2 end')
        }
        async1()

        setTimeout(function () {
            console.log('setTimeout')
        }, 0)

        new Promise(resolve => {
            console.log('Promise')
            resolve()
        })
            .then(function () {
                console.log('promise1')
            })
            .then(function () {
                console.log('promise2')
            })

        console.log('script end')
  • 开始执行script宏任务,打印script start

  • 执行async1(),首先调用async2()。进入async2(),打印async2 end。async1中的await等到了结果,但是此时会保留async1()的上下文,并跳出async1函数

  • 代码从第10行async1()开始继续执行,遇到setTimeout,将其添加至宏任务中。

  • 遇到Promise,直接执行构造函数内内容,打印Promise,resolve()后遇到then,将微任务添加至微任务队列。

  • 继续执行完第一个宏任务的同步代码,打印script end

  • 宏任务执行完去找微任务队列,里面装着两个then方法,打印promise1和promise2

  • 微任务队列执行完毕,此时回到async1,然后执行await后面的代码注册到微任务队列中,接着执行该微任务,打印async1 end

  • 最后这一轮的宏任务和随之伴生的微任务都处理完了,执行下一个宏任务,打印setTimeout。

那么按上面的分析来看,结果应该是这个:

// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout

但是事实上结果是这样的:

也就是把await后的执行提前了,先打印了async1 end。

原因是新版的浏览器中优化了await的输出速度,导致结果改变了,可以分两种情况来理解:

(1)await后面跟的是一个变量
这种情况相当于先把await后面的代码注册为一个微任务(对比旧版是先跳出再注册),然后跳出async函数,执行别的代码。因此这种情况下就是先打印async1 end,再打印接下来两个then里的promise1,promise2。

(2)await后面跟的是个异步函数的调用
如果把上面例子中的async2函数改成这样,返回一个promise.then()方法:

        async function async2() {
            console.log('async2 end')
            return Promise.resolve().then(()=>{
                console.log('async2 end2')
            })
        }

结果会变成这样:

此时await后面的代码又变成先跳出后注册了。所以第一轮微任务的实际顺序是:

  • 执行async1(),随后执行async2(),打印语句后将async2中的then方法先推进微任务队列,然后await等到了async2()的执行完毕,跳出async1()

  • 注册下方Promise内的两个then微任务

  • 别的微任务代码运行完后,回到async1,注册await下面的代码进微任务,因此最后打印async1 end

3.Node中的事件循环

3.1 简介

事件循环是node处理非阻塞I/O操作的机制,node中事件循环的实现是依靠的libuv引擎。

3.2 六个阶段

node事件循环分为6个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。 当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

六个阶段分别是

  • timers:这个阶段执行timer(setTimeout、setInterval)的回调

  • I/O callbacks:处理一些上一轮循环中的少数未执行的 I/O 回调

  • idle,prepare:仅node内部使用

  • poll:获取新的I/O事件, 适当的条件下node将阻塞在这里

  • check:执行 setImmediate() 的回调

  • close callbacks:执行 socket 的 close 事件回调

执行顺序大致是这样
外部输入数据->poll->check->......->poll->......

3.3 重要的三个阶段

3.3.1 timers

timers阶段会执行setTimeout和setInterval回调,并且是由 poll 阶段控制的。

3.3.2 poll

poll阶段执行流程如下:

  • 如果当前有定时器且时间到,回到timers阶段执行

  • 如果没有定时器,查看poll队列中是否有回调函数,若有则依次同步执行直至队列为空或者受到系统限制

  • 如果poll队列为空:

    • 如果有setImmediate需要执行,poll阶段会被立刻停止并进入到check阶段进行回调
    • 如果没有setImmediate需要执行,会在设置的超时时间内等待回调被加入到队列中,加入后立即执行回调

3.3.3 check

这个阶段直接执行setImmediate的回调

这里看个例子:

        console.log('start')
        setTimeout(() => {
            console.log('timer1')
            Promise.resolve().then(function () {
                console.log('promise1')
            })
        }, 0)
        setTimeout(() => {
            console.log('timer2')
            Promise.resolve().then(function () {
                console.log('promise2')
            })
        }, 0)
        Promise.resolve().then(function () {
            console.log('promise3')
        })
        console.log('end')

	//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2

如果是浏览器中,顺序应该是(原理就不写了,比较好理解):

// start=>end=>promise3=>timer1=>promise1=>timer2=>promise2

为什么在node中会有不同呢?讲一下Node的执行流程:

  • 一开始都没区别,打印start和end,期间将两个setTimeout依次加入timer队列,然后先去执行微任务,打印出promise3

  • 进入timers阶段,先打印timer1,将promise1放入微任务队列,然后打印timer2(这就是和浏览器端不同之处,timers阶段有几个setTimeout/setInterval回调函数就先执行掉几个,不像浏览器执行掉一个就去执行微任务了)

  • 随后打印promise1,promise2就不再赘述了

3.4 process.nextTick

process.nextTick是一个独立于eventLoop的任务队列。

在每一个eventLoop阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行

        setImmediate(() => {
            console.log('timeout1')
            Promise.resolve().then(() => console.log('promise resolve'))
            process.nextTick(() => console.log('next tick1'))
        });

        setImmediate(() => {
            console.log('timeout2')
            process.nextTick(() => console.log('next tick2'))
        });

        setImmediate(() => console.log('timeout3'));
        setImmediate(() => console.log('timeout4'));

在 node11 之前,因为每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行,因此上述代码是先进入 check 阶段,执行所有 setImmediate,完成之后执行 nextTick 队列,最后执行微任务队列,因此输出为timeout1=>timeout2=>timeout3=>timeout4=>next tick1=>next tick2=>promise resolve

在 node11 之后,process.nextTick 是微任务的一种,因此上述代码是先进入 check 阶段,执行一个 setImmediate 宏任务,然后执行其微任务队列,再执行下一个宏任务及其微任务,因此输出为
timeout1=>next tick1=>promise resolve=>timeout2=>next tick2=>timeout3=>timeout4

3.5 node11前后版本差异

node11的变化总的来说就一句话:一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行对应的微任务队列(有点向浏览器看齐的意思)

3.5.1 timers

        setTimeout(() => {
            console.log('timer1')
            Promise.resolve().then(function () {
                console.log('promise1')
            })
        }, 0)
        setTimeout(() => {
            console.log('timer2')
            Promise.resolve().then(function () {
                console.log('promise2')
            })
        }, 0)
        //node11 timer1=>promise1=>timer2=>promise2

        //node10 第一个定时器执行完 第二个定时器在完成队列中
        //timer1=>timer2=>promise1=>promise2

        //node10 第一个定时器执行完 第二个定时器不在完成队列中
        //timer1=>promise1=>timer2=>promise2

3.5.2 check

        setImmediate(() => console.log('immediate1'));
        setImmediate(() => {
            console.log('immediate2')
            Promise.resolve().then(() => console.log('promise resolve'))
        });
        setImmediate(() => console.log('immediate3'));
        setImmediate(() => console.log('immediate4'));

        //node11 immediate1=>immediate2=>promise resolve=>immediate3=>immediate4
        //node10 immediate1=>immediate2=>immediate3=>immediate4=>promise resolve

3.5.3 nextTick

见3.4中nextTick在版本改变前后的变化

3.5.4 总结

浏览器环境下,微任务的任务队列是每个宏任务执行完之后执行。

Node11之前,微任务会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行微任务队列的任务。

Node11之后,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行对应的微任务队列