提到JavaScript,大家都知道它是单线程的,那么在执行异步请求的时候,它内部的事件处理机制是怎样的呢?这里就来谈谈事件循环 —— Event loop.
浏览器端的事件循环
先来看这段经典的代码
1 | setTimeout(function() { |
输出结果:
1 | 2 |
但是其中的原因是什么呢?
先来了解几个概念:
- 执行栈(stack):执行栈是一个栈结构,函数调用会形成一个栈帧,帧中包含了当前执行函数的参数和局部变量等上下文信息,函数执行完后,它的执行上下文会从栈中弹出。栈空才会去读取任务队列。
- 任务队列(task queue):任务队列是一个存储着待执行任务的队列,其中的任务严格按照时间先后顺序执行,排在队头的任务将会率先执行,而排在队尾的任务会最后执行。任务队列每次仅执行一个任务,在该任务执行完毕之后,再执行下一个任务。当执行栈为空时,JS 引擎便检查任务队列,如果不为空的话,任务队列便将第一个任务压入执行栈中运行。
其中任务队列中的任务分为2种:macrotasks
: script(整体代码),setTimeout, setInterval, setImmediate, I/O, UI renderingmicrotasks
: process.nextTick, Promises, Object.observe, MutationObserver
检查执行栈是否为空,以及确定把哪个task加入执行栈的这个过程就是事件循环,而js实现异步的核心就是事件循环。(注意:同步任务总是比异步任务先执行)
事件循环的步骤包括:
- JavaScript引擎首先从macrotask queue中取出第一个任务,即从script(整体代码)开始循环。
- 执行完毕后,将microtask queue中的所有任务取出,按顺序全部执行。
- 然后再从macrotask queue中取下一个任务执行。
- 执行完毕后,再次将microtask queue中的全部取出执行
- UI render(由于浏览器有自己的优化策略,不是每轮事件循环都会执行视图更新)
- 循环往复。直到两个queue中的任务都取完。
简单的说一次事件循环就是:
- 在 macrotask 队列中执行最早的那个 task ,然后移出
- 执行 microtask 队列中所有可用的任务,然后移出
- 下一个循环,执行下一个 macrotask 中的任务
现在再回去看那段代码,就很好理解了。
先执行整体代码(promise的构造executors是同步执行的)依次输出2、3、5,接着检查microtask queue,执行所有的任务,这里是promise的回调函数then,输出4,接着再次检查macrotask queue,执行一个任务,这里是setTimeout的回调函数,于是输出1
Node.js中的事件循环
再来看一段代码:
1 | setTimeout(()=>{ |
实际在node环境下输出的是
1 | timer1 |
根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应到 libuv 源码中的实现,如下图所示
1 | ┌───────────────────────────┐ |
每个阶段的任务:
Phases Overview
- timers: this phase executes callbacks scheduled by
setTimeout()
andsetInterval()
.- pending callbacks: executes I/O callbacks deferred to the next loop iteration.
- idle, prepare: only used internally.
- poll: retrieve new I/O events; execute I/O related callbacks (almost all with the exception of close callbacks, the ones scheduled by timers, and
setImmediate()
); node will block here when appropriate.- check:
setImmediate()
callbacks are invoked here.- close callbacks: some close callbacks, e.g.
socket.on('close', ...)
.
我们重点关注timers, poll 和checktimers
执行setTimeout、setInterval的回调,poll
获取新的I/O事件, 执行与I / O相关的回调(几乎是除了定时器和setImmediate()的close事件回调之外的全部回调),check
执行 setImmediate() 的回调
每个事件循环中的阶段的间隔 ,会处理完所有microtask
1 | ┌───────────────────────────┐ |
关于 setTimeout(), setImmediate(), process.nextTick():
- setTimeout() 在某个时间值过后尽快执行回调函数;
- setImmediate() 。 一旦轮询阶段完成就执行回调函数;
- process.nextTick() 在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前” ;
process.nextTick()
会在各个事件阶段之间执行,一旦执行,要直到nextTick队列被清空,才会进入到下一个事件阶段,所以如果递归调用 process.nextTick(),会导致出现I/O starving(饥饿)的问题。官方推荐用户使用setImmediate()