者: 五月君 来源:编程界|
事件循环是一种控制应用程序的运行机制,在不同的运行时环境有不同的实现,上一节讲了浏览器中的事件循环,它们有很多相似的地方,也有着各自的特点,本节讨论下 Node.js 中的事件循环。
Node.js 做为 JavaScript 的服务端运行时,主要与网络、文件打交道,没有了浏览器中事件循环的渲染阶段。
在浏览器中有 HTML 规范来定义事件循环的处理模型,之后由各浏览器厂商实现。Node.js 中事件循环的定义与实现均来自于 Libuv。
Libuv 围绕事件驱动的异步 I/O 模型而设计,最初是为 Node.js 编写的,提供了一个跨平台的支持库。下图展示了它的组成部分,Network I/O 是网络处理相关的部分,右侧还有文件操作、DNS,底部 epoll、kqueue、event ports、IOCP 这些是底层不同操作系统的实现。
图片来源:http://docs.libuv.org/en/v1.x/_images/architecture.png
当 Node.js 启动时,它会初始化事件循环,处理提供的脚本,同步代码入栈直接执行,异步任务(网络请求、文件操作、定时器等)在调用 API 传递回调函数后会把操作转移到后台由系统内核处理。目前大多数内核都是多线程的,当其中一个操作完成时,内核通知 Node.js 将回调函数添加到轮询队列中等待时机执行。
下图左侧是 Node.js 官网对事件循环过程的描述,右侧是 Libuv 官网对 Node.js 的描述,都是对事件循环的介绍,不是所有人上来都能去看源码的,这两个文档通常也是对事件循环更直接的学习参考文档,在 Node.js 官网介绍的也还是挺详细的,可以做为一个参考资料学习。
左侧 Node.js 官网展示的事件循环分为 6 个阶段,每个阶段都有一个 FIFO(先进先出)队列执行回调函数,这几个阶段之间执行的优先级顺序还是明确的。
右侧更详细的描述了,在事件循环迭代前,先去判断循环是否处于活动状态(有等待的异步 I/O、定时器等),如果是活动状态开始迭代,否则循环将立即退出。
下面对每个阶段分别讨论。
首先事件循环进入定时器阶段,该阶段包含两个 API setTimeout(cb, ms)、setInterval(cb, ms) 前一个是仅执行一次,后一个是重复执行。
这个阶段检查是否有到期的定时器函数,如果有则执行到期的定时器回调函数,和浏览器中的一样,定时器函数传入的延迟时间总比我们预期的要晚,它会受到操作系统或其它正在运行的回调函数的影响。
例如,下例我们设置了一个定时器函数,并预期在 1000 毫秒后执行。
const now = Date.now();
setTimeout(function timer1(){
log(`delay ${Date.now() - now} ms`);
}, 1000);
setTimeout(function timer2(){
log(`delay ${Date.now() - now} ms`);
}, 5000);
someOperation();
function someOperation() {
// sync operation...
while (Date.now() - now < 3000) {}
}
当调用 setTimeout 异步函数后,程序紧接着执行了 someOperation() 函数,中间有些耗时操作大约消耗 3000ms,当完成这些同步操作后,进入一次事件循环,首先检查定时器阶段是否有到期的任务,定时器的脚本是按照 delay 时间升序存储在堆内存中,首先取出超时时间最小的定时器函数做检查,如果 **nowTime - timerTaskRegisterTime > delay** 取出回调函数执行,否则继续检查,当检查到一个没有到期的定时器函数或达到系统依赖的最大数量限制后,转移到下一阶段。
在我们这个示例中,假设执行完 someOperation() 函数的当前时间为 T + 3000:
定时器阶段完成后,事件循环进入到 pending callbacks 阶段,在这个阶段执行上一轮事件循环遗留的 I/O 回调。根据 Libuv 文档的描述:大多数情况下,在轮询 I/O 后立即调用所有 I/O 回调,但是,某些情况下,调用此类回调会推迟到下一次循环迭代。听完更像是上一个阶段的遗留。
idle, prepare 阶段是给系统内部使用,idle 这个名字很迷惑,尽管叫空闲,但是在每次的事件循环中都会被调用,当它们处于活动状态时。这一块的资料介绍也不是很多。略...
poll 是一个重要的阶段,这里有一个概念观察者,有文件 I/O 观察者,网络 I/O 观察者等,它会观察是否有新的请求进入,包含读取文件等待响应,等待新的 socket 请求,这个阶段在某些情况下是会阻塞的。
在阻塞 I/O 之前,要计算它应该阻塞多长时间,参考 Libuv 文档上的一些描述,以下这些是它计算超时时间的规则:
如果以上情况都没有,则采用最近定时器的超时时间,或者如果没有活动的定时器,则超时时间为无穷大,poll 阶段会一直阻塞下去。
很简单的一段代码,我们启动一个 Server,现在事件循环的其它阶段没有要处理的任务,它会在这里等待下去,直到有新的请求进来。
const http = require('http');
const server = http.createServer();
server.on('request', req => {
console.log(req.url);
})
server.listen(3000);
结合阶段一的定时器,在看个示例,首先启动 app.js 做为服务端,模拟延迟 3000ms 响应,这个只是为了配合测试。再运行 client.js 看下事件循环的执行过程:
// client.js
const now = Date.now();
setTimeout(() => log(`setTimeout run after ${Date.now() - now} ms`), 1000);
someAsyncOperation();
function someAsyncOperation() {
http.get('http://localhost:3000/api/news', () => {
log(`fetch data success after ${Date.now() - now} ms`);
});
}
// app.js
const http = require('http');
http.createServer((req, res) => {
setTimeout(() => { res.end('OK!') }, 3000);
}).listen(3000);
当 poll 阶段队列为空时,并且脚本被 setImmediate() 调度过,此时,事件循环也会结束 poll 阶段,进入下一个阶段 check。
check 阶段在 poll 阶段之后运行,这个阶段包含一个 API setImmediate(cb) 如果有被 setImmediate 触发的回调函数,就取出执行,直到队列为空或达到系统的最大限制。
拿 setTimeout 和 setImmediate 对比,这是一个常见的例子,基于被调用的时机和定时器可能会受到计算机上其它正在运行的应用程序影响,它们的输出顺序,不总是固定的。
setTimeout(() => log('setTimeout'));
setImmediate(() => log('setImmediate'));
// 第一次运行
setTimeout
setImmediate
// 第二次运行
setImmediate
setTimeout
但是一旦把这两个函数放入一个 I/O 循环内调用,setImmediate 将总是会被优先调用。因为 setImmediate 属于 check 阶段,在事件循环中总是在 poll 阶段结束后运行,这个顺序是确定的。
fs.readFile(__filename, () => {
setTimeout(() => log('setTimeout'));
setImmediate(() => log('setImmediate'));
})
在 Libuv 中,如果调用关闭句柄 uv_close(),它将调用关闭回调,也就是事件循环的最后一个阶段 close callbacks。
这个阶段的工作更像是做一些清理工作,例如,当调用 socket.destroy(),'close' 事件将在这个阶段发出,事件循环在执行完这个阶段队列里的回调函数后,检查循环是否还 alive,如果为 no 退出,否则继续下一次新的事件循环。
在浏览器的事件循环中,把任务划分为 Task、Microtask,在 Node.js 中是按照阶段划分的,上面我们介绍了 Node.js 事件循环的 6 个阶段,给用户使用的主要是 timer、poll、check、close callback 四个阶段,剩下两个由系统内部调度。这些阶段所产生的任务,我们可以看做 Task 任务源,也就是常说的 “Macrotask 宏任务”。
通常我们在谈论一个事件循环时还会包含 Microtask,Node.js 里的微任务有 Promise、还有一个也许很少关注的函数 queueMicrotask,它是在 Node.js v11.0.0 之后被实现的,参见 PR/22951。
Node.js 中的事件循环在每一个阶段执行后,都会检查微任务队列中是否有待执行的任务。
Node.js 在 v11.x 前后,每个阶段如果即存在可执行的 Task 又存在 Microtask 时,会有一些差异,先看一段代码:
setImmediate(() => {
log('setImmediate1');
Promise.resolve('Promise microtask 1')
.then(log);
});
setImmediate(() => {
log('setImmediate2');
Promise.resolve('Promise microtask 2')
.then(log);
});
在 Node.js v11.x 之前,当前阶段如果存在多个可执行的 Task,先执行完毕,再开始执行微任务。基于 v10.22.1 版本运行结果如下:
setImmediate1
setImmediate2
Promise microtask 1
Promise microtask 2
在 Node.js v11.x 之后,当前阶段如果存在多个可执行的 Task,先取出一个 Task 执行,并清空对应的微任务队列,再次取出下一个可执行的任务,继续执行。基于 v14.15.0 版本运行结果如下:
setImmediate1
Promise microtask 1
setImmediate2
Promise microtask 2
在 Node.js v11.x 之前的这个执行顺序问题,被认为是一个应该要修复的 Bug 在 v11.x 之后并修改了它的执行时机,和浏览器保持了一致,详细参见 issues/22257 讨论。
Node.js 中还有一个异步函数 process.nextTick(),从技术上讲它不是事件循环的一部分,它在当前操作完成后处理。如果出现递归的 process.nextTick() 调用,这将会很糟糕,它会阻断事件循环。
如下例所示,展示了一个 process.nextTick() 递归调用示例,目前事件循环位于 I/O 循环内,当同步代码执行完成后 process.nextTick() 会被立即执行,它会陷入无限循环中,与同步的递归不同的是,它不会触碰 v8 最大调用堆栈限制。但是会破坏事件循环调度,setTimeout 将永远得不到执行。
fs.readFile(__filename, () => {
process.nextTick(() => {
log('nextTick');
run();
function run() {
process.nextTick(() => run());
}
});
log('sync run');
setTimeout(() => log('setTimeout'));
});
// 输出
sync run
nextTick
将 process.nextTick 改为 setImmediate 虽然是递归的,但它不会影响事件循环调度,setTimeout 在下一次事件循环中被执行。
fs.readFile(__filename, () => {
process.nextTick(() => {
log('nextTick');
run();
function run() {
setImmediate(() => run());
}
});
log('sync run');
setTimeout(() => log('setTimeout'));
});
// 输出
sync run
nextTick
setTimeout
process.nextTick 是立即执行,setImmediate 是在下一次事件循环的 check 阶段执行。但是,它们的名字着实让人费解,也许会想这两个名字交换下比较好,但它属于遗留问题,也不太可能会改变,因为这会破坏 NPM 上大部分的软件包。
在 Node.js 的文档中也建议开发者尽可能的使用 setImmediate(),也更容易理解。
Node.js 事件循环分为 6 个阶段,每个阶段都有一个 FIFO(先进先出)队列执行回调函数,这几个阶段之间执行的优先级顺序还是明确的。
事件循环的每一个阶段,有时还会伴随着一些微任务而运行,这里以 Node.js v11.x 版本为分界线会有一些差异,文中也都有详细的介绍。
在上一篇介绍了浏览器的事件循环机制,本篇又详细的介绍了 Node.js 中的事件循环机制,留给大家一个思考问题,结合自己的理解,总结下浏览器与 Node.js 中事件循环的一些差异,这个也是常见的一个面试题,欢迎在留言区讨论。
在 Cnode 上看到的两篇事件循环相关文章,推荐给大家,文章很精彩,评论也更加精彩。
Reference
http://docs.libuv.org/en/v1.x/design.html
https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick
avaScript 语言中的 for 循环用于多次执行代码块,它是 JavaScript 中最常用的一个循环工具,还可用于数组的遍历循环等。
我们为什么要使用 for 循环呢?打个比方,例如我们想要控制台输出1到1000之间的所有数字,如果单写输出语句,要写1000句代码,但是如果使用 for 循环,几句代码就能实现。总之,使用 for 循环能够让我们写代码更方便快捷(当然啦,否则要它干嘛)。
语法如下所示:
for(变量初始化; 条件表达式; 变量更新) {
// 条件表达式为true时执行的语句块
}
例如我们在一个HTML文件中,编写如下代码,实现计算1到100的总和:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>JS_侠课岛(9xkd.com)</title>
</head>
<body>
<script>
var result = 0;
for(var i = 1; i <= 100; i++) {
result = result + i;
}
alert(result);
</script>
</body>
</html>
在浏览器中打开这个文件,会弹出一个弹出层,弹出层中显示的是1到100的总和:
上述代码中,我们声明了一个变量 result 并给它赋值为 0,表示初始的总和为 0 。
然后在 for 循环中三个语句:
此时我们可以一点点来看这个 for 循环:
第一次循环: result = 0 + 1 // 此时result值为0, i的值为1
第二次循环: result = 1 + 2 // 此时result值为0+1,i的值为2
第三次循环: result = 3 + 3 // 此时result值为1+2,i的值为3
第四次循环: result = 6 + 4 // 此时result值为3+3,i的值为4
第五次循环: result = 10 + 5 // 此时result值为6+4,i的值为5
...
我们只需要搞清楚 for 循环中的执行原理,不需要手动来计算求和,只要写好代码,执行代码后计算机会很快会告诉我们1到 100 的总和。
再补充一下,上述代码中result = result + i,我们也可以写成 result += i,这是我们之前学过的加赋值运算符,还记得吗?
示例:
再来看一个例子,例如我们可以使用 for 循环来实现数组遍历,首先定义一个数组 lst:
var lst = ["a", "b", "c", "d", "e"];
在写 for 循环时,首先就是要搞清楚小括号里面的三个语句,因为我们可以通过数组中元素的下标索引来获取元素的值,而数组的索引又是从 0 开始,所以变量初始化可以设置为i = 0。第二个条件表达式,因为数组中最后一个索引为 lst.length - 1,所以只要小于等于 lst.length - 1,循环就会一直执行。而i <= lst.length - 1 就相当于 i<lst.length。第三个变量更新,当循环每循环一次,索引值就加一,所以为 i++。
所以循环可以像下面这样写:
for(i = 0; i<lst.length; i++){
console.log(lst[i]); // 输出数组中的元素值,从索引为0的值开始输出,每次加1,一直到lst.length-1
}
输出:
a
b
c
d
e
其实遍历数组还有一种更好的方法,就是使用 for...in 循环语句来遍历数组。
for...in 循环主要用于遍历数组或对象属性,对数组或对象的属性进行循环操作。for...in 循环中的代码每执行一次,就会对数组的元素或者对象的属性进行一次操作。
语法如下:
for (变量 in 对象) {
// 代码块
}
for 循环括号内的变量是用来指定变量,指定的可以是数组对象或者是对象属性。
示例:
使用 for...in 循环遍历我们定义好的 lst 数组:
var lst = ["a", "b", "c", "d", "e"];
for(var l in lst){
console.log(lst[l]);
}
输出:
a
b
c
d
e
除了数组,for...in 循环还可以遍历对象,例如我们遍历 侠侠 的个人基本信息:
var object = {
姓名:'侠侠',
年龄:'22',
性别:'男',
出生日期:'1997-08-05',
职业:'程序员',
特长:'跳舞'
}
for(var i in object) {
console.log(i + ":" + object[i]);
}
输出:
姓名: 侠侠
年龄: 22
性别: 男
出生日期: 1997-08-05
职业:程序员
特长:跳舞
avaScript 的 Event Loop(事件循环)是 JavaScript 运行时环境(如浏览器和 Node.js)的核心机制之一,它使得 JavaScript 能够处理异步操作而不会阻塞程序的执行。了解 Event Loop 对于理解 JavaScript 的非阻塞行为和编写高效的异步代码至关重要。
首先,重要的是要理解 JavaScript 是一种单线程的语言。这意味着 JavaScript 在同一时间内只能执行一个任务。然而,JavaScript 需要能够处理各种异步操作(如 AJAX 请求、文件读取、用户交互等),这些操作可能会花费很长时间完成。为了解决这个问题,JavaScript 采用了 Event Loop 和 Callback Queues(回调队列)。
调用栈是 JavaScript 代码执行时的数据结构,用于存储函数调用和返回地址。每当一个函数被调用时,它就会被推入调用栈,并在函数执行完毕后从栈中弹出。如果调用栈满了(即达到了最大调用深度),则会发生栈溢出错误。
堆是用于存储对象、数组等引用类型的内存区域。与调用栈不同,堆是动态分配的,并且其大小不是固定的。
Web APIs 是浏览器提供的一组与浏览器功能交互的接口,如 DOM 操作、网络请求等。这些 API 通常是异步的,并且它们有自己的线程或进程来处理请求。
当异步操作完成时(如 AJAX 请求、setTimeout、Promise 解决等),相应的回调函数会被放入任务队列(或称为宏任务队列)或微任务队列中。任务队列中的任务在当前的执行栈清空后才会被执行,而微任务队列中的任务会在当前执行栈清空后、但下一个宏任务执行前立即执行。
Event Loop 的工作流程可以概括为以下几个步骤:
console.log('1');
setTimeout(() => {
console.log('setTimeout 宏任务队列');
}, 0);
new Promise((resolve) => {
console.log('Promise 立即执行');
resolve();
}).then(() => {
console.log('then 微任务队列');
});
console.log('2');
//输出顺序
1
Promise 立即执行
2
then 微任务队列
setTimeout 宏任务队列
解释:
console.log('1');
setTimeout(() => {
console.log('setTimeout 宏任务队列1');
new Promise((resolve) => {
console.log('Promise in setTimeout');
resolve();
}).then(() => {
console.log('then in setTimeout');
});
setTimeout(() => {
console.log('setTimeout 宏任务队列2');
}, 0);
}, 0);
new Promise((resolve) => {
console.log('Promise 立即执行1');
resolve();
}).then(() => {
console.log('then 微任务队列1');
new Promise((resolve) => {
console.log('Promise 立即执行2');
resolve();
}).then(() => {
console.log('then 微任务队列2');
});
});
console.log('2');
//输出顺序
1
Promise 立即执行1
2
then 微任务队列1
Promise 立即执行2
then 微任务队列2
setTimeout 宏任务队列1
Promise in setTimeout
then in setTimeout
setTimeout 宏任务队列2
解释:
const async1= async () => {
console.log('async1 1');
await async2();
console.log('async1 2');
}
const async2= async () => {
console.log('async2');
}
console.log('1');
setTimeout(() => {
console.log('setTimeout 宏任务队列');
}, 0);
async1();
new Promise((resolve) => {
console.log('promise 立即执行');
resolve();
}).then(() => {
console.log('then 微任务队列');
});
console.log('2');
//输出顺序
1
async1 1
async2
promise 立即执行
2
async1 2
then 微任务队列
setTimeout 宏任务队列
解释:
Event Loop 是 JavaScript 异步编程的基石,它使得 JavaScript 能够在不阻塞主线程的情况下处理各种异步操作。通过理解 Event Loop 的工作原理,我们可以更加高效地编写异步代码,避免潜在的错误和性能问题。
*请认真填写需求信息,我们会在24小时内与您取得联系。