事件循环

94 阅读9分钟

什么是浏览器的事件循环

浏览器的事件循环是一个在JavaScript引擎和渲染引擎之间协调工作的机制。因为JavaScript是单线程的,所以所有需要被执行的操作都需要通过一定的机制来协调它们有序的进行。事件循环的主要任务是监视调用栈(Call Stack)和任务队列(Task Queue)当调用栈为空时,事件循环会从任务队列中取出任务执行。而JavaScript的代码执行是在一个单独的线程中执行的。这就意味着JavaScript的代码,在同一个时刻只能做一件事。如果这件事是非常耗时的,就意味着当前的JavaScript线程就会被阻塞。因此真正耗时的操作,实际上并不是由JavaScript线程在执行的。浏览器的每个进程是多线程的,那么其他线程就可以来完成这个耗时的操作。比如网络请求、定时器,我们只需要在特定的时候,去执行应该有的回调函数即可。

如果在执行JavaScript代码的过程中,有异步操作,比如如果我们在全局上下文的代码中插入了一个setTimeout函数,会怎么样呢?

image.png

其实这个函数将会放入到浏览器的事件循环队列中,并不会阻塞全局执行上下文的代码执行,后续的全局执行上下文的代码将会继续执行,等待适当的时机浏览器会将这个异步操作从循环队列中取出,并执行,如上例子则是等待1秒后从任务队列中取出setTimeout中的回调函数并执行。

宏任务和微任务

其实浏览器的事件循环队列并不止一个,浏览器把事件循环队列分成了2个,宏任务队列和微任务队列。那什么是宏任务,什么是微任务呢?

宏任务(MacroTasks): 宏任务是一个比较大的任务单位,可以看作是一个独立的工作单元。当一个宏任务执行完毕后,浏览器可以在两个宏任务之间进行页面渲染或处理其他事务(比如执行微任务)。

微任务(MicroTasks): 微任务通常是在当前宏任务完成后立即执行的小任务,它们的执行优先级高于宏任务。微任务的执行会在下一个宏任务开始前完成,即在当前宏任务和下一个宏任务之间。

常见的宏任务包括:

Promise.then(Promise的回调)、Promise.catch和Promise.finally

MutationObserver(监视DOM变更的API,在Vue2源码中也有使用它来实现微任务的调度)

process.nextTick(仅在Node.js中,待会儿Node事件循环详细讲解)

queueMicrotask(显式创建微任务的API)

宏任务和微任务区别

(1)执行顺序

事件循环在执行宏任务队列中的一个宏任务后,会查看微任务队列。如果微任务队列中有任务,事件循环会连续执行所有微任务直到微任务队列为空。

宏任务的执行可能触发更多的微任务(比如在宏任务执行的过程中又向微任务队列加入了微任务)而这些微任务会在任何新的宏任务之前执行,确保微任务可以快速执行。 那为什么会这样设计呢?原因就在于,浏览器把那些比较耗时的任务归类到宏任务,而执行时间比较短的任务归类为微任务,设计者不希望耗时任务阻塞执行时间较短的任务,于是设计成每执行完一个宏任务之后,去检查微任务队列,并且把微任务队列中的回调函数先执行完以后再执行下一个宏任务。

(2)用途不同:

由于微任务具有较高的执行优先级,它们适合用于需要尽快执行的小任务,例如处理异步的状态更新。

宏任务适合用于分割较大的、需要较长时间执行的任务,以避免阻塞UI更新或其他高优先级的操作

Node事件循环

浏览器中的EventLoop是根据HTML5定义的规范来实现的,不同的浏览器可能会有不同的实现。

而Node中的事件循环是由是由libuv实现的,这是一个处理异步事件的C库。

libuv是一个多平台的专注于异步IO的库,它最初是为Node开发的,但是现在也被使用到Luvit、Julia、pyuv等其他地方;

image.png

libuv中主要维护了一个EventLoop和worker threads(线程池)而EventLoop负责调用系统的一些其他操作:文件的IO、Network、child-processes等。

Node.js的事件循环包含几个主要阶段,每个阶段都有自己的特定类型的任务

image.png

以上阶段调用的回调函数,都属于是在宏任务。

timers(定时器): 这一阶段执行setTimeout和setInterval的回调函数。

Pending Callbacks(待定回调): executes I/O callbacks deferred to the next loop iteration(官方的解释)

这意味着在这个阶段,Node处理一些上一轮循环中未完成的I/O任务。具体来说,这些是一些被推迟到下一个事件循环迭代的回调,通常是由于某些操作无法在它们被调度的那一轮事件循环中完成。比如操作系统在连接TCP时,接收到ECONNREFUSED(连接被拒绝)。

idle, prepare(空闲、准备): 只用于系统内部调用。

poll(轮询):检索新的 I/O 事件; 执行与 I/O 相关的回调。

(1)检索新的I/O事件:这一部分,libuv负责检查是否有I/O操作(如文件读写、网络通信)完成,并准备好了相应的回调函数。

(2)执行I/O相关的回调:几乎所有类型的I/O回调都会在这里执行,除了那些特别由timers和setImmediate安排的回调以及某些关闭回调(close callbacks)。

check(检查): setImmediate() 的回调在这个阶段执行。

close callbacks(关闭回调): 如 socket.on('close', ...) 这样的回调在这里执行。

Node宏任务和微任务

我们会发现从一次事件循环的Tick来说,Node的事件循环更复杂,它也分为微任务和宏任务:

这些任务分别有:

宏任务(macrotask):setTimeout、setInterval、IO事件、setImmediate、close事件

微任务(microtask):Promise的then回调、process.nextTick、queueMicrotask;

然而,Node中的事件循环中微任务队列划分的会更加精细,Node把微任务队列又划分成2个队列:分别是nexttick queue和other queue。

process.nextTick将放入nexttick queue,Promise的then回调、queueMicrotask会放入到other queue。

在Node中事件循环的整体执行顺序如下:

1.调用栈执行:Node.js 首先执行全局脚本或模块中的同步代码。这些代码在调用栈中执行,直到栈被清空。

2.处理 process.nextTick() 队列:一旦调用栈为空,Node.js 会首先处理 process.nextTick() 队列中的所有回调。这确保了任何在同步执行期间通过 process.nextTick() 加入的回调都将在进入任何其他阶段之前执行。

3.处理其他微任务:处理完 process.nextTick() 队列后,Node.js 会处理 Promise 等微任务队列。这些微任务包括由 Promise.then()、Promise.catch() 或 Promise.finally() 等加入的回调。

4.开始事件循环的各个阶段(执行宏任务):

<1> timers阶段:处理 setTimeout() 和 setInterval() 回调。

<2> I/O 回调阶段:处理大多数类型的I/O相关回调。

<3> poll阶段:等待新的I/O事件,处理poll队列中的事件。

<4> check阶段:处理 setImmediate() 回调。

<5> close回调阶段:处理如 socket.on('close', ...) 的回调。

这里有一个特别的Node处理:微任务在事件循环过程中的处理

在事件循环的任何阶段之间,以及在上述每个阶段内部的任何单个任务后。Node.js 会再次处理 process.nextTick() 队列和 Promise 微任务队列。这确保了在事件循环的任何时刻,微任务都可以优先和迅速的被处理。

process.nextTick在Node.js中事件循环的执行顺序,以及其与微任务的关系

在 Node.js 中,process.nextTick() 是一个在事件循环的各个阶段/各个宏任务之间允许开发者插入操作的功能:

其特点是具有极高的优先级,可以在当前操作完成后、任何进一步的I/O事件(包括由事件循环管理的其他微任务)处理之前执行。

process.nextTick() 的执行顺序:

  1. 调用栈清空:Node.js 首先执行完当前的调用栈中的所有同步代码。

  2. 执行 process.nextTick() 队列:一旦调用栈为空,Node.js 会检查 process.nextTick() 队列。如果队列中有任务,Node.js 会执行这些任务,即使在当前事件循环的迭代中有其他微任务或宏任务排队等待。

3.处理其他微任务:在 process.nextTick() 队列清空之后,Node.js 会处理由 Promises 等产生的微任务队列。

  1. 继续事件循环:处理完所有微任务后,Node.js 会继续进行到事件循环的下一个阶段或者下一个任务(例如 timers、I/O callbacks、poll 等)。

与其他微任务的关系:

优先级:process.nextTick() 创建的任务和 Promise 优先级是不同的,但它们也是一种微任务,并且在所有微任务中具有最高的执行优先级。

这意味着 process.nextTick() 的回调总是在其他微任务(例如 Promise 回调)之前执行。

微任务队列:在任何事件循环阶段或宏任务之间,以及在宏任务内部可能触发的任何点,Node.js 都可能执行 process.nextTick()。执行完这些

任务后,才会处理 Promise 微任务队列。

但是需要注意的是,如果当前正在处理otherQueue微任务队列,在处理的过程中,otherQueue中某个回调函数,向process.nextTick加入了一个微任务,这时候不会中断当前微任务队列的执行。会把当前微任务队列中的任务执行完,再去检查process.nextTick队列。

代码测试

image.png

image.png

分析:

(1)先执行全局代码:所以先打印"start of script" 和 "End of script"

(2)执行全局代码的过程中,在第2行和14行中向宏任务队列中加入的2个setTimeout中的回调函数。

(3)执行第一个setTimeout回调函数,此时先打印"first setTimeout"。

(4)在打印完"first setTimeout"之后接着先后向2个微任务队列中加入了queueMicrotask和process.nextTick微任务。

(5)执行完第一个宏任务之后,会去检查process.nextTick微任务队列,然后打印"nextTick execution"。

(6)然后继续去检查process.nextTick微任务队列,发现此时process.nextTick微任务队列为空,接着去检查otherQueue微任务队列,发现有一个queueMicroTask,将其取出执行。

(7)之后再次检查process.nextTick微任务队列和otherQueue微任务队列,发现都为空,此时转而去检查宏任务队列,发现还有一个setTimeout。于是取出回调函数并执行,打印“Second setTimeout”。

(8)再次依次检查process.nextTick微任务队列,otherQueue微任务队列,宏任务队列,发现都为空,于是程序执行结束