ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
[TOC] # 什么是事件循环(Event loop) Event loop是什么? <br> WIKI定义: > In computer science, the**event loop, message dispatcher, message loop, message pump, or run loop**is a programming construct that waits for and dispatches events or messages in a program. Event loop是一种程序结构,是实现异步的一种机制。Event loop可以简单理解为: 1. 所有任务都在主线程上执行,形成一个执行栈(execution context stack)。 2. 主线程之外,还存在一个"任务队列"(task queue)。系统把异步任务放到"任务队列"之中,然后主线程继续执行后续的任务。 3. 一旦"执行栈"中的所有任务执行完毕,系统就会读取"任务队列"。如果这个时候,异步任务已经结束了等待状态,就会从"任务队列"进入执行栈,恢复执行。 4. 主线程不断重复上面的第三步。 对JavaScript而言,Javascript引擎/虚拟机(如V8)之外,JavaScript的运行环境(runtime,如浏览器,node)维护了任务队列,每当JS执行异步操作时,运行环境把异步任务放入任务队列。当执行引擎的线程执行完毕(空闲)时,运行环境就会把任务队列里的(执行完的)任务(的数据和回调函数)交给引擎继续执行,这个过程是一个**不断循环**的过程,称为**事件循环**。 <br> **注意:JavaScript(引擎)是单线程的,Event loop并不属于JavaScript本身,但JavaScript的运行环境是多线程/多进程的,运行环境实现了Event loop。** <br> <br> ## Node.js的Event loop 在node中,事件循环表现出的状态与浏览器中大致相同。不同的是node中有一套自己的模型。node中事件循环的实现是依靠的libuv引擎。我们知道node选择chrome v8引擎作为js解释器,v8引擎将js代码分析后去调用对应的node api,而这些api最后则由libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。 因此实际上node中的事件循环存在于libuv引擎中。 Node.js的运行机制如下: * V8引擎解析JavaScript脚本。 * 解析后的代码,调用Node API。 * libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。 * V8引擎再将结果返回给用户。 ![](https://box.kancloud.cn/b70bb4a1a0746b75115fb35b7e130d37_543x223.png) <br> 下面是node启动的部分相关代码: ~~~c // node.cc { SealHandleScope seal(isolate); bool more; do { v8_platform.PumpMessageLoop(isolate); more = uv_run(env.event_loop(), UV_RUN_ONCE); if (more == false) { v8_platform.PumpMessageLoop(isolate); EmitBeforeExit(&env); // Emit `beforeExit` if the loop became alive either after emitting // event, or after running some callbacks. more = uv_loop_alive(env.event_loop()); if (uv_run(env.event_loop(), UV_RUN_NOWAIT) != 0) more = true; } } while (more == true); } ~~~ <br> <br> # libuv Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。 [libuv/src/win/core.c](https://github.com/libuv/libuv/blob/v1.x/src/win/core.c) <br> <br> # 事件循环模型 ~~~ ┌───────────────────────┐ ┌─>│ timers │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ I/O callbacks │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ idle, prepare │ │ └──────────┬────────────┘ ┌───────────────┐ │ ┌──────────┴────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └──────────┬────────────┘ │ data, etc. │ │ ┌──────────┴────────────┐ └───────────────┘ │ │ check │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ └──┤ close callbacks │ └───────────────────────┘ ~~~ 注:模型中的每一个方块代表事件循环的一个阶段 <br /> 每个阶段都有一个**FIFO**的回调队列(queue)要执行。而每个阶段有自己的特殊之处,简单说,就是当event loop进入某个阶段后,会执行该阶段特定的(任意)操作,然后才会执行这个阶段的队列里的回调。当队列被执行完,或者执行的回调数量达到上限后,event loop会进入下个阶段。 <br /> ## 事件循环各阶段 只要任意两个阶段中有`process.nextTick`及`Promise`,就会优先执行它的回调 * timers:定时器阶段。setTimeout、setInterval的回调函数被执行。对应源码的`uv__run_timers` * I/O callbacks:执行被推迟到下一个iteration的 I/O 回调。对应源码的`uv__run_pending` * idle, prepare: 仅内部使用。 * poll:获取新的I/O事件;node会在适当条件下阻塞在这里。进入poll阶段,如果poll queue是空的,并且没有`setImmediate`添加的回调,event loop会在这里等待I/O callbacks被添加到poll queue,并立即执行。 * check:执行`setImmediate`回调 * close callbacks:执行比如`socket.on('close', ...)`的回调。 **大部分的I/O回调会在poll阶段被执行,但某些系统操作(比如TCP类型错误)执行回调会安排在pending callbacks阶段。** ## 阶段详情 ### timers 一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。 > > 注意:技术上来说,**poll**阶段控制 timers 什么时候执行。 > > <br /> > > 注意:这个下限时间有个范围:`[1, 2147483647]`,如果设定的时间不在这个范围,将被设置为1。 以下是官网文档解释的例子: ~~~js var fs = require('fs'); function someAsyncOperation (callback) { // 假设这个任务要消耗 95ms fs.readFile('/path/to/file', callback); } var timeoutScheduled = Date.now(); setTimeout(function () { var delay = Date.now() - timeoutScheduled; console.log(delay + "ms have passed since I was scheduled"); }, 100); // someAsyncOperation要消耗 95 ms 才能完成 someAsyncOperation(function () { var startCallback = Date.now(); // 消耗 10ms... while (Date.now() - startCallback < 10) { ; // do nothing } }); ~~~ 当event loop进入**poll**阶段,它有个空队列(`fs.readFile()`尚未结束)。所以它会等待剩下的毫秒, 直到最近的timer的下限时间到了。当它等了95ms,`fs.readFile()`首先结束了,然后它的回调被加到**poll** 的队列并执行——这个回调耗时10ms。之后由于没有其它回调在队列里,所以event loop会查看最近达到的timer的 下限时间,然后回到**timers**阶段,执行timer的回调。 <br /> 所以在示例里,回调被设定 和 回调执行间的间隔是105ms。 <br /> ### I/O callbacks 这个阶段执行一些系统操作的回调。比如TCP错误,如一个TCP socket在想要连接时收到`ECONNREFUSED`, 类unix系统会等待以报告错误,这就会放到**I/O callbacks**阶段的队列执行。 <br /> ### poll **poll**阶段有两个主要功能: 1. 执行下限时间已经达到的timers的回调,然后 2. 处理**poll**队列里的事件。 * 当event loop进入**poll**阶段,并且**没有设定的timers(there are no timers scheduled)**,会发生下面两件事之一: * 如果**poll**队列不空,event loop会遍历队列并同步执行回调,直到队列清空或执行的回调数到达系统上限; * 如果**poll**队列为空,则发生以下两件事之一: * 如果代码已经被`setImmediate()`设定了回调, event loop将结束**poll**阶段进入**check**阶段来执行**check**队列(里的回调)。 * 如果代码没有被`setImmediate()`设定回调,event loop将阻塞在该阶段等待回调被加入**poll**队列,并立即执行。 * 当event loop进入**poll**阶段,并且**有设定的timers**,一旦**poll**队列为空(**poll**阶段空闲状态): * event loop将检查timers,如果有1个或多个timers的下限时间已经到达,event loop将绕回 **timers** 阶段,并执行 **timer** 队列。 <br /> ### check 这个阶段允许在**poll**阶段结束后立即执行回调。如果**poll**阶段空闲,并且有被`setImmediate()`设定的回调,event loop会转到**check**阶段而不是继续等待。 <br /> `setImmediate()`实际上是一个特殊的timer,跑在event loop中一个独立的阶段。它使用`libuv`的API 来设定在**poll**阶段结束后立即执行回调。 <br /> 通常上来讲,随着代码执行,event loop终将进入**poll**阶段,在这个阶段等待 incoming connection, request 等等。但是,只要有被`setImmediate()`设定了回调,一旦**poll**阶段空闲,那么程序将结束**poll**阶段并进入**check**阶段,而不是继续等待**poll**事件们 (**poll**events)。 <br /> ### close callbacks 如果一个 socket 或 handle 被突然关掉(比如`socket.destroy()`),close事件将在这个阶段被触发,否则将通过`process.nextTick()`触发。 <br /> <br /> # `setImmediate()`vs`setTimeout()` `setImmediate()`和`setTimeout()`是相似的,区别在于什么时候执行回调: 1. `setImmediate()`被设计在**poll**阶段结束后立即执行回调; 2. `setTimeout()`被设计在指定下限时间到达后执行回调。 ## 外部执行setTimeout和setimmediate 下面看一个例子: ~~~js // timeout_vs_immediate.js setTimeout(function timeout () { console.log('timeout'); },0); setImmediate(function immediate () { console.log('immediate'); }); ~~~ 代码的输出结果是: ~~~shell timeout immediate // 或 immediate timeout ~~~ 是的,你没有看错,输出结果是**不确定**的! 从直觉上来说,`setImmediate()`的回调应该先执行,但为什么结果随机呢? <br /> ## readFile中执行setTimeout和setImmediate ~~~js // timeout_vs_immediate.js var fs = require('fs') fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout') }, 0) setImmediate(() => { console.log('immediate') }) }) ~~~ 结果是: ~~~shell immediate timeout ~~~ 很好,`setImmediate`在这里永远先执行! <br> ## 延时执行setTimeout和setImmediate ~~~ const now = Date.now(); setTimeout(() => console.log('setTimeout'), 0); setImmediate(() => console.log('setImmediate')); // 延迟1秒 while (Date.now() - now < 1000) { } // 改为添加一个nextTick函数也可以让setTimeout先执行 // process.nextTick(function(){ // console.log('nextTick'); // }); ~~~ ~~~ setTimeout setImmediate ~~~ 因为延迟了1秒,进入timer阶段时,已过去1秒,setTimeout会先执行 <br> ## setImmediate中执行setTimeout和setImmediate ~~~ setImmediate(() => { console.log('setImmediate') setTimeout(() => { console.log('setImmediate 里面的 setTimeout') }, 0) setImmediate(() => { console.log('setImmediate 里面的 setImmediate') }) }); // setImmediate // setImmediate 里面的 setTimeout // setImmediate 里面的 setImmediate ~~~ ## setTimeout中执行setTimeout和setImmediate ~~~ setTimeout(() => { console.log('setTimeout') setTimeout(() => { console.log('setTimeout 里面的 setTimeout') }, 0) setImmediate(() => { console.log('setTimeout 里面的 setImmediate') }) }, 0); // setTimeout // setTimeout 里面的 setImmediate // setTimeout 里面的 setTimeout ~~~ <br> 所以,结论是: 1. 如果两者都在主模块(main module)调用,那么执行先后取决于进程性能,即随机。 2. 如果两者都不在主模块调用(即在一个 IO circle 中调用),那么`setImmediate`的回调永远先执行。 <br> 看`int uv_run(uv_loop_t* loop, uv_run_mode mode)`源码(deps/uv/src/unix/core.c#332): ~~~ int uv_run(uv_loop_t* loop, uv_run_mode mode) { int timeout; int r; int ran_pending; r = uv__loop_alive(loop); if (!r) uv__update_time(loop); while (r != 0 && loop->stop_flag == 0) { uv__update_time(loop); //// 1. timer 阶段 uv__run_timers(loop); //// 2. I/O callbacks 阶段 ran_pending = uv__run_pending(loop); //// 3. idle/prepare 阶段 uv__run_idle(loop); uv__run_prepare(loop); // 重新更新timeout,使得 uv__io_poll 有机会跳出 timeout = 0; if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) timeout = uv_backend_timeout(loop); //// 4. poll 阶段 uv__io_poll(loop, timeout); //// 5. check 阶段 uv__run_check(loop); //// 6. close 阶段 uv__run_closing_handles(loop); if (mode == UV_RUN_ONCE) { uv__update_time(loop); // 7. UV_RUN_ONCE 模式下会再次检查timer uv__run_timers(loop); } r = uv__loop_alive(loop); if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) break; } if (loop->stop_flag != 0) loop->stop_flag = 0; return r; } ~~~ <br> 第一个参数为指向 `uv_loop_t` 的指针,是事件循环的结构。每次执行uv_run就是进行事件循环的迭代。 1. 首先进入timer阶段,如果我们的机器性能一般,那么进入timer阶段时,1毫秒可能已经过去了(`setTimeout(fn, 0)`等价于`setTimeout(fn, 1)`),那么`setTimeout`的回调会首先执行。 2. 如果没到一毫秒,那么我们可以知道,在check阶段,`setImmediate`的回调会先执行。 3. 为什么`fs.readFile`回调里设置的,`setImmediate`始终先执行?因为`fs.readFile`的回调执行是在**poll**阶段,所以,接下来的**check**阶段会先执行`setImmediate`的回调。 4. 我们可以注意到,`UV_RUN_ONCE`模式下,event loop会在开始和结束都去执行timer。 <br> <br> # 理解`process.nextTick()` 直到现在,我们才开始解释`process.nextTick()`。因为从技术上来说,它并不是event loop的一部分。相反的,`process.nextTick()`会把回调塞入`nextTickQueue`,`nextTickQueue`将在当前操作完成后处理,不管目前处于event loop的哪个阶段。 看看我们最初给的示意图,`process.nextTick()`不管在任何时候调用,都会在所处的这个阶段最后,在event loop进入下个阶段前,处理完所有`nextTickQueue`里的回调。 ## `process.nextTick()`vs`setImmediate()` 两者看起来也类似,区别如下: 1. `process.nextTick()`立即在本阶段执行回调; 2. `setImmediate()`只能在**check**阶段执行回调。 ## 为什么它会被允许? 为什么node中会有这种东西呢?其中的一部分是设计理念,即 API应该始终是异步操作,即使没必要是。下面是个例子: ~~~js function apiCall(arg, callback) { if (typeof arg !== 'string') return process.nextTick(callback, new TypeError('argument should be string')); } ~~~ 这个小片段做了一个参数校验,如果不正确就会将错误对象传给回调,API最近才更新的允许传参数给process.nextTick()使他能够接收任何在回调函数扩散之后作为回调函数的参数传过来的参数,所以你就用不着进行函数嵌套了。 <br> 我们正在做的就是在允许用户的剩下的代码能继续执行的条件下,传递错误对象回去给用户。通过使用process.nextTick()我们保证那个`APICall()`会一直在用户剩下的代码之后和事件循环被允许之前执行它的回调。为了实现这个目的,JS调用栈被允许来解开,然后立即执行被提供的回调,这允许用户做出递归调用来使用process.nextTick(),而不会报`RangeError: Maximum call stack size exceeded from v8`的错。 <br> 这个理念会导致可能的有问题的情况,就拿下面这段代码来说: ~~~ let bar; // 异步特征的却被同步调用 function someAsyncApiCall(callback) { callback(); } // 在someAsyncApiCall完成之前回调 someAsyncApiCall(() => { // someAsyncApiCall 完成调用,bar 还没赋上值 console.log('bar', bar); // undefined }); bar = 1; ~~~ 用户定了someAsyncApiCall()来获取异步签名,但是实际上却是同步操作的,当被调用时,提供给函数的回调,在同一个事件循环的同一阶段被调用,因为函数没有做任何异步操作。结果,回调试图引用 bar这个变量,在作用域中甚至还没有那个变量,因为脚本还没运行完。 <br> 通过把回调函数放在一个`process.nextTick()`中,代码还有能力继续运行完,允许所有变量,函数,等等在函数回调调用之前进行初始化。它还具有不允许事件循环继续的优点。可以在让用户在事件循环被允许继续之前报出错误帮上忙。 <br> 这里先前的例子使用 process.nextTick: ~~~ let bar; function someAsyncApiCall(callback) { process.nextTick(callback); } someAsyncApiCall(() => { console.log('bar', bar); // 1 }); bar = 1; ~~~ 这里是现实中另一个例子: ~~~ const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {}); ~~~ 当仅传一个端口时,端口立即被绑定。所以,“listening”回调将会被立即调用。问题就是.on('listening')回调那会儿还没设置。 <br> 为了克服这个问题,listening事件被在`nextTick()`中插入队列,这样就允许脚本能运行完。这允许用户设置任何他们想设置的方法。 <br> ## 为什么使用process.nextTick()? 有两个主要原因: * 允许用户解决报错,清理任何将来不需要的资源(垃圾清理)或者是在事件循环继续之前再次发送请求。 * 有时,允许回调函数在调用栈解开之后但是在事件循环继续之前运行时必要的。 <br> 一个满足用户期待的简单例子: ~~~ const server = net.createServer(); server.on('connection', (conn) => { }); server.listen(8080); server.on('listening', () => { }); ~~~ listen() 运行在生命周期的开始,但是监听回调被放在一个setImmediate()立即执行函数里,除非端口名被传进去,否则会立即绑定到端口。为了事件循环执行,他必须进入轮询阶段,也就是意味着有非零的几率,可能会接收到一个在监听事件开始之前就触发的连接事件 另一个例子就是运行一个函数的构造器,也就是说继承一个EventEmitter并且想在构造器里调用事件 ~~~ const EventEmitter = require('events'); const util = require('util'); function MyEmitter() { EventEmitter.call(this); this.emit('event'); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('an event occurred!'); }); ~~~ 你不能在构造器里立即释放一个事件,以为你脚本还没运行到用户为事件定义回调函数的地方。所以在构造函数内部,你可以使用process.nextTick()来在构造器完成之后设置一个回调来释放这个事件,也就是下面这个例子所期望的结果: ~~~ const EventEmitter = require('events'); const util = require('util'); function MyEmitter() { EventEmitter.call(this); // use nextTick to emit the event once a handler is assigned process.nextTick(() => { this.emit('event'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('an event occurred!'); }); ~~~ <br> <br> <br> <br> # readFile执行顺序 ~~~ const fs = require('fs') const now = Date.now(); setTimeout(() => console.log('timer'), 10); fs.readFile(__filename, () => console.log('readfile')); setImmediate(() => console.log('immediate')); while(Date.now() - now < 1000) {} ~~~ 在同一上下文,readFile执行顺序在setImmediate、setTimeout后 ~~~ // 结果 timer immediate readfile ~~~ 分析见 https://github.com/creeperyang/blog/issues/26#issuecomment-370144475 <br> <br> # Node11 改变了宏任务的执行 ## setTimeout ~~~ setTimeout(() => { console.log('timer1'); Promise.resolve().then(function() { console.log('promise1'); }); }, 0); setTimeout(() => { console.log('timer2'); Promise.resolve().then(function() { console.log('promise2'); }); }, 0); ~~~ Node 10以下版本的结果: ~~~ timer1 timer2 promise1 promise2 ~~~ Node 11的结果 ~~~ timer1 promise1 timer2 promise2 ~~~ ## setImmediate 1. 多次调用`setImmediate()`则把回调都放入队列,在 check 阶段都会执行; 2. 但`setImmediate()`回调里调用`setImmediate()`,则放到下次 event loop。 ~~~ setImmediate(function(){ console.log("setImmediate"); setImmediate(function(){ console.log("嵌套setImmediate"); }); process.nextTick(function(){ console.log("nextTick") }) }) setImmediate(function(){ console.log("setImmediate2"); }); // Node 10以下 // setImmediate // setImmediate2(Node 10以下会输出到这里) // nextTick // 嵌套setImmediate // Node 11 // setImmediate // nextTick // setImmediate2(Node 11会输出到这里) // 嵌套setImmediate ~~~ <br> 在node 11.0 的修改日志里面发现了这个: * Timers * Interval timers will be rescheduled even if previous interval threw an error. #20002 * nextTick queue will be run after each immediate and timer. #22842 <br> 然后分别看了20002和22842的PR,发现在[ #22842](https://github.com/nodejs/node/pull/22842) 在lib/timers.js里面有以下增加: ![](https://box.kancloud.cn/7165ee58c3aa9ce4b5fdee1bdb66b1eb_566x289.png) ![](https://box.kancloud.cn/3f450d6877905fa734ed165f0e979607_504x172.png) <br> runNextTicks()就是process.\_tickCallback()。用过的可能知道这个就是除了处理一些异步钩子,然后就是执行微任务队列的。于是我增加了两行process.\_tickCallback()在setTimeout方法尾部,再使用node10运行,效果果然和node11一致,代码如下: ~~~ setTimeout(() => { console.log('timer1'); Promise.resolve().then(function() { console.log('promise1'); }); process._tickCallback(); // 这行是增加的! }, 0); setTimeout(() => { console.log('timer2'); Promise.resolve().then(function() { console.log('promise2'); }); process._tickCallback(); // 这行是增加的! }, 0); ~~~ ## 那么为什么要这么做呢? 当然是为了和浏览器更加趋同。 <br> 了解浏览器的eventloop可能就知道,浏览器的宏任务队列执行了一个,就会执行微任务。 <br> 简单的说,可以把浏览器的宏任务和node10的timers比较,就是node10只有全部执行了timers阶段队列的全部任务才执行微任务队列,而浏览器只要执行了一个宏任务就会执行微任务队列。 <br> 现在node11在timer阶段的setTimeout,setInterval...和在check阶段的immediate都在node11里面都修改为一旦执行一个阶段里的一个任务就立刻执行微任务队列。 # 参考资料 * [Node.js的event loop及timer/setImmediate/nextTick](https://github.com/creeperyang/blog/issues/26)) * [又被node的eventloop坑了,这次是node的锅](https://juejin.im/post/5c3e8d90f265da614274218a) * [nodejs的eventloop,timers和process.nextTick()【译】](https://www.jianshu.com/p/ac64af22d775) * [详解JavaScript中的Event Loop(事件循环)机制](https://zhuanlan.zhihu.com/p/33058983) * [The Node.js Event Loop, Timers, and process.nextTick()](https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/)