Event Loop指的是计算机系统的一种运行机制,在JavaScript中就是采用Event Loop这种机制来解决单线程带来的问题。
这主要和js的用途有关,js是作为浏览器的脚本语言,主要是实现用户与浏览器的交互,以及操作dom;这决定了它只能是单线程,否则会带来很复杂的同步问题。
举个例子:如果js被设计了多线程,假如有一个线程要修改一个dom元素,另一个线程要删除这个dom元素,此时浏览器就会一脸茫然,不知所措。所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
要理解Event Loop,首先要理解程序运行的模式,运行以后的程序叫做进程,一般情况下一个进程一次只能执行一个任务,如果有多个任务需要执行,有三种解决办法:
(1)排队。 因为一个进程一次只能执行一个任务,只好等前面的任务执行完了,再执行后面的任务。
(2)新建进程。 使用fork命令,为每个任务新建一个进程。
(3)新建线程。 因为进程太耗费资源,所以如今的程序往往允许一个进程包含多个线程,由线程去完成任务。
简单点说,进程是车间,线程是打工仔,车间可以容纳多个打工仔,打工仔们共用车间内的资源,一个打工仔一次只能做一件事情。
详情不再展开,关于进程和线程的概念这里推荐阮一峰老师的文章,图文并茂,解释的非常生动有趣:
https://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html
JavaScript是单线程语言,要执行多个任务只能排队,如果前面的同步任务耗时过长,势必会阻塞后面任务的执行。因此JavaScript需要一种异步机制来让处理这些耗时的任务,并且在处理完成后进行通知,Event Loop就设计出来了。
在浏览器端JavaScript主要是利用了浏览器内核多线程实现异步的,浏览器是一个多进程的架构,以开源的Chromium为例,它有五个进程,我们需要关心的是渲染进程,渲染进程属于浏览器核心进程。
以下面这段代码为例,先输出something,1s后再输出timeout,因为定时器是在浏览器的定时触发器线程执行的,console.log在js主线程执行,主线程代码执行完成后空闲了才会去读队列中已完成的任务
setTimeout(() => { console.log('timeout') }, 1000) console.log('something')
在Node端同样也是借助多进程架构来实现异步的,很多人说node.js是单线程的,这个说法比较片面,node.js单线程的原因是因为它接收的任务是单线程的(参考后面的node.js整体运行机制),但是Node本身是一个多进程架构。
以下这些概念不用记,忘记了过来查一下即可,但了解这些概念有助于帮助我们理解Event Loop的运行机制。
1、什么是阻塞和非阻塞?
阻塞和非阻塞是针对于进程在访问数据时,根据IO操作的就绪状态而采取的不同方式,简单来说是一种读取或写入操作函数的实现方式,阻塞方式下读取或写入函数将一直等待。非阻塞方式下,读取和写入函数会立即返回一个状态值。
2、什么是异步和同步?
同步和异步是针对应用程序和内核的交互而言的,同步是指用户进程触发IO操作并等待或轮询的查看IO操作是否就绪,异步是指用户进程触发IO操作以后便开始做自己的事情,当IO操作完成时会得到通知,换句话说异步的特点就是通知。
3、什么是I/O模型?
一般而言,IO模型可以分为四种:同步阻塞、同步非阻塞、异步阻塞、异步非阻塞
再看一张图加深一下理解:
这个问题反映到我们生活中也是一样的道理,你一天要干很多事情,有一些事情又很耗时但是它又没那么重要,你会不会想办法来提高自己的效率好让自己解放出来呢?这样你才能合理利用一天的时间去做更重要的事情。于是人们发明了各种工具,洗衣机、电饭煲等等。你不用关心它是怎么做的,什么时候做完,洗好衣服你去晒,做好饭你去吃,你只需要把任务交给它,处理好了它就会通知你,你什么时候有空了再去处理结果就可以了。
这个例子中,你是一个人,不能同时做很多事情,反映到代码中你是单线程,你把耗时的任务交给这些工具,工具完成后会通知你结果,你就解放出来了可以先去做更重要的事情,这就是Event Loop的作用。
结合以上的知识点,我想你对为什么要设计Event Loop已经有了自己的理解。
阮一峰老师的这篇文章也很好的解释了这个问题:
http://www.ruanyifeng.com/blog/2013/10/event_loop.html
请注意,本节所学习的node版本为v10,在v10版本之后Event Loop的行为已经与浏览器保持一致。
以下引用摘自node中文网
事件循环是 Node.js 处理非阻塞 I/O 操作的机制——尽管 JavaScript 是单线程处理的——当有可能的时候,它们会把操作转移到系统内核中去。
既然目前大多数内核都是多线程的,它们可在后台处理多种操作。当其中的一个操作完成的时候,内核通知 Node.js 将适合的回调函数添加到 轮询(poll) 队列中等待时机执行。
链接:https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/#node-js-process-nexttick
简单来说Event Loop就是一种处理非阻塞I/O操作的机制,借助内核多线程的特点,在后台处理各种各样的操作,处理完成后内核会通知Node.js来进行处理。
就像你去餐厅点餐一样,你不用关心你点的这份餐怎么做出来的,餐做好了就会在大厅叫号牌上显示对应的号码,然后提醒你取餐,你不需要站在那等待出餐这个漫长的过程。
在高性能的I/O设计中,有两个比较著名的模式Reactor和Proactor模式,其中Reactor模式用于同步I/O,Proactor用于异步I/O操作。
node采用了Reactor设计模式。那么什么是Reactor模式?
Reactor模式是处理并发I/O常见的一种模式,用于同步I/O,其中心思想是将所有要处理的I/O事件注册到一个中心I/O多路复用器上,同时主线程阻塞在多路复用器上,一旦有I/O事件到来或是准备就绪,多路复用器将返回并将相应I/O事件分发到对应的处理器中。
Reactor是一种事件驱动机制,和普通函数调用不同的是应用程序不是主动的调用某个API来完成处理,恰恰相反的是Reactor逆置了事件处理流程,应用程序需提供相应的接口并注册到Reactor上,如果有相应的事件发生,Reactor将主动调用应用程序注册的接口(回调函数)。
我们都知道node能够运行在不同的平台上,由于在不同操作系统平台上支持所有类型的非阻塞I/O非常困难和复杂,就需要有一个抽象层来管理这些复杂的,跨平台的东西,这个抽象层就是——libuv。
Event Loop就是由libuv提供的。
以下引用自libuv官方文档
libuv 是一个跨平台的支持库,最初是为Node.js 编写的。它是围绕事件驱动的异步 I/O 模型设计的。
该库提供的不仅仅是针对不同I/O轮询机制的简单抽象,“handles”和“streams”为套接字和其他实体提供了更高级抽象;除此之外,还提供了跨平台文件I/O和线程功能。
对libuv感兴趣请参考链接进行学习:http://docs.libuv.org/en/v1.x/design.html
上图中,用户输入JavaScript代码,由V8引擎进行解析,V8调用Node API然后由libuv进行处理,libuv提供Event Loop来处理各类任务,处理完成后将结果返回给V8,V8再将结果返回给用户。
node.js使用了事件驱动模型,该模型包含一个Event Demultiplexer和一个 Event Queue,所有的I/O请求最终会生成一个completion/failure事件或其他触发器,事件会根据以下算法进行处理:
调度整个机制的程序称为事件循环(Event Loop)。
Event Demultiplexer
Event Demultiplexer 不是真实存在的一个部件,它仅仅是 Reactor Pattern 的一个抽象。在真实环境中,不同的操作系统都会实现自己的 Event demultiplexer 。如 linux上 的 epoll、bsd 系统(macos)上的kqueue、solaris中的event ports、windows中的iocp等。node.js可以通过这些已实现的Event demultiplexer使用无阻塞、异步的I/O。
Event Queue
有4种类型的队列由本机libuv事件循环处理:
还有2个中间队列:(虽然这两个队列不属于libuv,但它们是node.js的一部分)
libuv引擎Event Loop处理这几种队列的循环图如下:
Event Loop启动后,首先处理timers队列,再到I/O事件队列,接着处理immediate队列,最后处理close事件队列,在处理完一个阶段即将要切换到下一个阶段之前,会先处理nextTick队列,清空nextTick队列之后再处理microtask队列,等到microtask队列清空后才会进入下一个阶段。
nextTick队列比microtask队列优先级更高,意味着如果有nextTick队列,会在处理microtask队列之前就把nextTick队列都清空。
node.js启动时会初始化事件循环(Event Loop)机制,每次循环都会包含如下6个阶段,每个阶段都有一个先进先出(FIFO)的用于执行回调的队列,通常事件循环运行到某个阶段时,node.js会先执行该阶段的操作,然后再去执行该阶段队列里的回调,直到队列里的内容耗尽,或者执行的回调数量达到最大(maximum number,最大值由当前机器性能决定)。每一个阶段完成后,事件循环就会检查这两个中间队列中是否有内容,如果有立马执行,直到这两个队列清空为止,等到它们清空,事件循环才会进入下一个阶段,如此往复循环,直到进程结束。
liubv引擎Event Loop的6个阶段:
在node.js里,任何异步方法(除timer,close,setImmediate之外)完成时,都会将其callback加到poll queue里,并立即执行。
通过上面的知识可以总结出:一个阶段执行完毕进入下一个阶段之前,Event Loop会先清空microtask队列的任务(如果有nextTick队列,则先清空nextTick队列然后再清空microtask队列),等到microtask队列清空后再进入下一个阶段,如下图所示:
我们重点看timers、poll、check这3个阶段就好,因为日常开发中的绝大部分异步任务都是在这3个阶段处理的。
timers是事件循环的第一个阶段,当我们使用setTimeout或者setInterval时,node会添加一个timer到timers堆,当事件循环进入到timers阶段时,node会检查timers堆中有无过期的timer,如果有,则依次执行过期timer的回调函数。
需要注意的是,node不能保证到了过期时间就立即执行回调函数,因为它在执行回调前必须先检查timer是否过期,检查的过程是需要消耗时间的,这个时间的长短取决于系统性能,性能越好执行速度越快,另外一点是,如果当前Event Loop中还有别的进程在执行,也会影响timer回调的执行。这与浏览器的Event Loop机制是类似的,浏览器环境中如果定时器在一个非常耗时的for循环之后运行,虽然时间已过期,仍然要等到for循环计算完成才会执行定时器的回调。
在到达过期时间之间的时间称为有效期,定时器能够保证的就是至少在给定的有效期内不会触发定时器回调。
以下引用自node中文网:
计时器指定 可以执行所提供回调 的 阈值,而不是用户希望其执行的确切时间。在指定的一段时间间隔后, 计时器回调将被尽可能早地运行。但是,操作系统调度或其它正在运行的回调可能会延迟它们。
注意:轮询(poll) 阶段 控制何时定时器执行。
也就是说:poll阶段控制timer什么时候执行,而执行的具体位置在timers阶段
示例:创建一个延时1s的setTimeout,记录执行回调花费的时间
// 获取纳秒级计时精度-node才有 // 参考文档:https://www.cnblogs.com/boychenney/p/12195632.html const start = process.hrtime(); setTimeout(() => { const end = process.hrtime(start); console.log(`timeout callback executed after ${end[0]}s and ${end[1]/Math.pow(10,9)}ms`); }, 1000);
多次运行程序,你会发现它每次打印的结果都不同,执行回调的间隔都大于1s
timeout callback executed after 1s and 0.0000775ms timeout callback executed after 1s and 0.0023212ms timeout callback executed after 1s and 0.000102ms ......
在node中,setTimeout和setImmediate在一起使用时也会发生不同的情况,例如:
setTimeout(function timeout () { console.log('timeout'); }, 0); setImmediate(function immediate () { console.log('immediate'); });
上面的代码第一眼看上去肯定总是先打印timeout,再打印immediate,因为setImmediate的回调在check队列中,按照步骤应该是先检查过期timer,然后再到check队列中执行setImmediate的回调才对。
实际结果并不是这样,如下所示,多次运行后会得到不同的输出结果
$ node timeout_vs_immediate.js timeout immediate $ node timeout_vs_immediate.js immediate timeout
原因是setTimeout(fn, 0)无法保证timer回调在0秒后立即被调用,通过前面的学习我们知道当Event Loop启动时会先检查timer是否过期。如果它检查的过程耗时比较长,它可能不会立即看到过期的timer,然后就略过了timer阶段走走走走到了check阶段,看到check队列有一个事件,于是执行输出immediate,之后在下一个时间循环中执行setTimeout的回调。
但是,当二者在异步I/O callback内部调用时,总是先执行setImmediate,再执行setTimeout
var fs = require('fs') fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout') }, 0) setImmediate(() => { console.log('immediate') }) }) // 多次执行结果相同 // immediate // timeout
理解了Event Loop的各阶段顺序这个例子很好理解:
因为fs.readFile callback执行完后,程序设定了timer 和 setImmediate,因此poll阶段不会被阻塞进而进入check阶段先执行setImmediate,后进入timer阶段执行setTimeout。(后面会详细给出运行步骤)
二者非常相似,但是二者区别取决于他们什么时候被调用
其二者的调用顺序取决于当前Event Loop的上下文,如果他们在异步i/o callback之外调用,其执行先后顺序是不确定的,执行的顺序不确定,就是因为每一次loop,最开始和结束时都检查timer的缘故。
poll 阶段主要有2个功能:
如果event loop进入了 poll阶段,且代码未设定timer,将会发生下面情况:
如果poll queue不为空,event loop将同步的执行queue里的callback,直至queue为空,或执行的callback到达系统上限;
如果poll queue为空,将会发生下面情况:
如果event loop进入了 poll阶段,且代码设定了timer:
通过下图再来加深一下理解
node官方提供的一个示例:假设您调度了一个在 100 毫秒后超时的定时器,然后您的脚本开始异步读取会耗费 95 毫秒的文件
const fs = require('fs'); function someAsyncOperation(callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } const timeoutScheduled = Date.now(); setTimeout(() => { const delay = Date.now() - timeoutScheduled; console.log(`${delay}ms have passed since I was scheduled`); }, 100); // do someAsyncOperation which takes 95 ms to complete someAsyncOperation(() => { const startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { // do nothing } });
代码中创建了一个someAsyncOperation异步读文件的方法,然后创建了一个常量timeoutScheduled取当前时间,然后有一个setTimeout,里面计算延时多少毫秒之后打印一句话,最后一段是调用someAsyncOperation这个异步方法,读完文件后执行callback,callback里面执行一个空的while循环,我们可以理解为睡眠10毫秒。
当事件循环进入 轮询 阶段时,它有一个空队列(此时 fs.readFile() 尚未完成),因此它将等待剩下的毫秒数,直到达到最快的一个计时器阈值为止。当它等待 95 毫秒过后时,fs.readFile() 完成读取文件,它的那个需要 10 毫秒才能完成的回调,将被添加到 轮询 队列中并执行。当回调完成时,队列中不再有回调,因此事件循环机制将查看最快到达阈值的计时器,然后将回到 计时器 阶段,以执行定时器的回调。在本示例中,您将看到调度计时器到它的回调被执行之间的总延迟将为 105 毫秒。
这个阶段允许在poll阶段结束后立即执行回调,如果poll阶段空闲并且有被setImmediate设置回调,那么事件循环直接跳到check阶段执行而不是阻塞在poll阶段等待回调被加入。
setImmediate实际上是一个特殊的timer,跑在事件循环中的一个独立的阶段。它使用libuv的API来设定在poll阶段结束后立即执行回调。setImmediate的回调会被加入check队列中, 从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。
再来看一段代码示例:(假设要读取的文件需要100ms)
const fs = require('fs') fs.readFile('test.txt', () => { console.log('readFile') setTimeout(() => { console.log('timeout') }, 0) setImmediate(() => { console.log('immediate') }) }) // 执行结果: // readFile // immediate // timeout
以上代码执行顺序为:
程序启动时,Event Loop初始化:
进入timers阶段,检查有无过期timer,没有(如果有则执行timer queue中的callback),进入下一个阶段
进入I/O阶段,无异步I/O完成(可忽略)
进入idls阶段,啥也没有,进入下一个阶段(可忽略)
进入poll阶段(没有设置timer),现在poll queue是空的(此时fs.readFile尚未完成),因此它进入等待状态,直到有任务加入队列中,当它等到100ms时fs.readFile完成,将callback加入poll queue,并执行callback,callback执行打印readFile并设置了一个setTimeout和一个setImmediate,然后callback执行完成,poll queue清空。(如果没有设置setImmediate的情况下,当callback完成时,Event Loop将检查有没有到时间的timer,有的话会回到timers阶段来执行timer的回调)
因此poll阶段不会被阻塞而是进入check阶段,执行setImmediate的回调函数,打印immediate,check queue清空,进入下一个阶段
进入close阶段,没有任务,一次Tick完成,进入下一次Tick
进入timers阶段,检查timer queue有没有过期的timer,有,执行readFile回调中设置的setTimeout回调,打印timeout,进入下一个阶段
······
通过以下代码来具体观察一下浏览器端和node端的执行过程:
setTimeout(()=>{ console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) setTimeout(()=>{ console.log('timer2') Promise.resolve().then(function() { console.log('promise2') }) }, 0)
浏览器端运行结果:timer1 => promise1 => timer2 => promise2
浏览器Event Loop执行动画示意:
浏览器端的执行过程是:
需要注意的是:浏览器Event Loop的宏任务是一个一个执行的,微任务是一队一队执行的,执行完每个宏任务之后都会检查微任务队列,直到将所有微任务都清空后才会继续下一个宏任务,每次将一队微任务执行完成后就会执行渲染操作更新界面。
以下是浏览器端Event Loop的执行流程图:
node端运行结果:timer1 => timer2 => promise1 => promise2
node的Event Loop执行动画示意:
node端Event Loop执行过程分两种情况:
1、检查过期timer消耗的时间小于阈值:
2、检查过期timer消耗的时间大于阈值
官方是这么描述process.nextTick()的
链接:https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/#node-js-process-nexttick
process.nextTick的回调函数会被添加到nextTickQueue,nextTickQueue比其他microtaskQueue具有更高的优先级。尽管它们都在事件循环的两个阶段之间被处理。这意味着nextTickQueue在开始处理microtaskQueue之前就已经被清空。
nextTickQueue的优先级高于promises ,仅仅适用于 promises 是通过v8解析产生的。如果你用了q或者bluebird ,你会观察到完全不同的结果,因为他们先于 promises 执行,且具有不同的语义。
q和bluebird 在处理promise的方式上是不一样的。
process.nextTick()不在event loop的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行。
示例:
var fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('setTimeout'); }, 0); setImmediate(() => { console.log('setImmediate'); process.nextTick(()=>{ console.log('nextTick3'); }) }); process.nextTick(()=>{ console.log('nextTick1'); }) process.nextTick(()=>{ console.log('nextTick2'); }) }); // 运行结果 // nextTick1 // nextTick2 // setImmediate // nextTick3 // setTimeout
以上代码执行顺序为:
来自官方文档有意思的一句话,从语义角度看,setImmediate() 应该比 process.nextTick() 先执行才对,而事实相反,命名是历史原因也很难再变。
In essence, the names should be swapped. process.nextTick() fires more immediately than setImmediate()
process.nextTick() 会在各个事件阶段之间执行,一旦执行,要直到nextTick队列被清空,才会进入到下一个事件阶段,所以如果递归调用 process.nextTick(),会导致出现I/O starving(饥饿)的问题,比如下面例子的readFile已经完成,但它的回调一直无法执行:
const fs = require('fs') const starttime = Date.now() let endtime fs.readFile('text.txt', () => { endtime = Date.now() console.log('finish reading time: ', endtime - starttime) }) let index = 0 function handler () { if (index++ >= 1000) return console.log(`nextTick ${index}`) process.nextTick(handler) // console.log(`setImmediate ${index}`) // setImmediate(handler) } handler()
process.nextTick()的运行结果:
nextTick 1 nextTick 2 ...... nextTick 999 nextTick 1000 finish reading time: 170
setImmediate(),运行结果:
setImmediate 1 setImmediate 2 finish reading time: 80 ...... setImmediate 999 setImmediate 1000
这是因为嵌套调用的 setImmediate() 回调,被排到了下一次Event Loop才执行,所以不会出现阻塞。
process.nextTick()是node早期版本无setImmediate时的产物,node作者推荐我们尽量使用setImmediate。
以下是来自官方的回答:
链接:https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/#process-nexttick-2
我的理解是:
举个例子,比如我们在执行一个非常耗时的计算函数时,如果同步执行函数,因为单线程的缘故势必会阻塞后面代码的执行,所以我们可以将函数交给process.nextTick(),相当于放开计算函数的使用权,通过process.nextTick()方法将该函数的使用权交给计算机系统,就像在说:“我把使用权交给你,你有空了就帮我计算一下,计算完了通过callback告诉我”。
node.js 的事件循环分为6个阶段
浏览器和Node 环境下,microtask 任务队列的执行时机不同
递归的调用process.nextTick()会导致I/O starving,官方推荐使用setImmediate()