Node.js精进(2)——异步编程
虽然 Node.js 是单线程的,但是在融合了libuv后,使其有能力非常简单地就构建出高性能和可扩展的网络应用程序。
下图是 Node.js 的简单架构图,基于 V8 和 libuv,其中 Node Bindings 为 JavaScript 和 C++ 搭建了一座沟通的桥梁,使得 JavaScript 可以访问 V8 和 libuv 向上层提供的 API。
本系列所有的示例源码都已上传至Github,点击此处获取。
一、术语解析
接下来会对几个与 Node.js 相关的术语做单独的解析,其中事件循环会单独细讲。
1)libuv
libuv 是一个事件驱动、非阻塞异步的 I/O 库,并且具备跨平台的能力,提供了一套事件循环(Event Loop)机制和一些核心工具,例如定时器、文件访问、线程池等。
2)非阻塞异步的I/O
非阻塞是指线程不会被操作系统挂起,可以处理其他事情。
异步是指调用者发起一个调用后,可以立即返回去做别的事。
I/O(Input/Output)即输入/输出,通常指数据在存储器或其他周边设备之间的输入和输出。
它是信息处理系统(例如计算机)与外部世界(可能是人类或另一信息处理系统)之间的通信。
将这些关键字组合在一起就能理解 Node.js 的高性能有一部分是通过避免等待 I/O(读写数据库、文件访问、网络调用等)响应来实现的。
3)事件驱动
事件驱动是一种异步化的程序设计模型,通过用户动作、操作系统或应用程序产生的事件,来驱动程序完成某个操作。
在 Node.js 中,事件主要来源于网络请求、文件读写等,它们会被事件循环所处理。
在浏览器的 DOM 系统中使用的也非常广泛,例如为按钮绑定 click 事件,在用点击按钮时,弹出提示或提交表单等。
4)单线程
Node.js 的单线程是指运行 JavaScript 代码的主线程,网络请求或异步任务等都交给了底层的线程池中的线程来处理,其处理结果再通过事件循环向主线程告知。
单线程意味着所有任务需要排队有序执行,如果出现一个计算时间很长的任务,那么就会占据主线程,其他任务只能等待,所以说 Node.js 不适合 CPU 密集型的场景。
经过以上术语的分析可知,Node.js 的高性能和高并发离不开异步,所以有必要深入了解一下 Node.js 的异步原理。
二、事件循环
当 Node.js 启动时会初始化事件循环,这是一个无限循环。
下图是事件循环的一张运行机制图,新任务或完成 I/O 任务的回调,都会添加到事件循环中。
下面是按照运行优先级简化后的六个循环阶段。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
每个阶段都有一个 FIFO 回调队列,当队列耗尽或达到回调上限时,事件循环将进入下一阶段,如此往复。
- timers:执行由 setTimeout() 和 setInterval() 安排的回调。在此阶段内部,会维护一个定时器的小顶堆,按到期时间排序,先到期的先运行。
- pending callbacks:处理上一轮循环未执行的 I/O 回调,例如网络、I/O 等异常时的回调。
- idle,prepare:仅 Node 内部使用。
- poll:执行与 I/O 相关的回调,除了关闭回调、定时器调度的回调和 setImmediate() , 适当的条件下 Node 将阻塞在这里。
- check:调用 setImmediate() 回调。
- close callbacks:关闭回调,例如 socket.on("close", callback)。
在deps/uv/src/unix/core.c文件中声明了事件循环的核心代码,旁边还有个 win 目录,应该就是指 Windows 系统中 libuv 相关的处理。
其实事件循环就是一个大的 while 循环 ,具体如下所示。
代码中的 UV_RUN_ONCE 就是上文 poll 阶段中的适当的条件,在每次循环结束前,执行完 close callbacks 阶段后,会再执行一次已到期的定时器。
static int uv__loop_alive(const uv_loop_t* loop) {
return uv__has_active_handles(loop) ||
uv__has_active_reqs(loop) ||
loop->closing_handles != NULL;
}
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
// 检查事件循环中是否还有待处理的handle、request、closing_handles是否为NULL
r = uv__loop_alive(loop);
// 更新事件循环时间戳
if (!r)
uv__update_time(loop);
// 启动事件循环
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop); // timers阶段,执行已到期的定时器
ran_pending = uv__run_pending(loop); // pending阶段
uv__run_idle(loop); // idle阶段
uv__run_prepare(loop);// prepare阶段 timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop); uv__io_poll(loop, timeout); // poll阶段 /* Run one final update on the provider_idle_time in case uv__io_poll
* returned because the timeout expired, but no events were received. This
* call will be ignored if the provider_entry_time was either never set (if
* the timeout == 0) or was already updated b/c an event was received.
*/
uv__metrics_update_idle_time(loop); uv__run_check(loop); // check阶段
uv__run_closing_handles(loop); // close阶段 if (mode == UV_RUN_ONCE) {
/* UV_RUN_ONCE implies forward progress: at least one callback must have
* been invoked when it returns. uv__io_poll() can return without doing
* I/O (meaning: no callbacks) when its timeout expires - which means we
* have pending timers that satisfy the forward progress constraint.
*
* UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
* the check.
*/
uv__update_time(loop);
uv__run_timers(loop); // 执行已到期的定时器
} r = uv__loop_alive(loop);
// 在 UV_RUN_ONCE 和 UV_RUN_NOWAIT 模式中,跳出当前循环
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
} /* The if statement lets gcc compile it to a conditional store. Avoids
* dirtying a cache line.
*/
if (loop->stop_flag != 0)
loop->stop_flag = 0; // 标记当前的 stop_flag 为 0,表示跑完这轮,事件循环就结束了 return r;
}
1)setTimeout 和 setImmediate
setTimeout 会在最前面的 timers 阶段被执行,而 setImmediate 会在 check 阶段被执行。
但在下面的示例中,timeout 和 immediate 的打印顺序是不确定的。
在 setTimeout() 官方文档中曾提到,当延迟时间大于 2147483647(24.8天) 或小于 1 时,将默认被设为 1。
所以下面的 setTimeout(callback, 0) 相当于 setTimeout(callback, 1)。
虽然在源码中会先运行 uv__run_timers(),但是由于上一次的循环耗时可能超过 1ms,也可能小于 1ms,所以定时器有可能还未到期。
如此的话,就会造成打印顺序的不确定性,上述分析过程参考了此处。
setTimeout(() => {
console.log('timeout')
}, 0);
setImmediate(() => {
console.log('immediate')
});
如果将 setTimeout() 和 setImmediate() 注册到 I/O 回调中运行,那么顺序就是确定的,先 immediate 再 timeout。
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
});
这是因为 readFile() 的回调会在 poll 阶段运行,而在 uv__io_poll() 之后,就会立即执行 uv__run_check(),从而就能保证先打印 immediate 。
在自己的日常工作中,曾使用过一个基于 setTimeout() 的定时任务库:node-schedule。
由于延迟时间最长为 24.8 天,所以该库巧妙的运用了一个递归来弥补时间的上限。
Timeout.prototype.start = function() {
if (this.after <= TIMEOUT_MAX) {
this.timeout = setTimeout(this.listener, this.after)
} else {
var self = this
this.timeout = setTimeout(function() {
self.after -= TIMEOUT_MAX
self.start()
}, TIMEOUT_MAX)
}
if (this.unreffed) {
this.timeout.unref()
}
}
2)与浏览器中的事件循环的差异
在浏览器的事件循环中,没有那么细的循环阶段,不过有两个非常重要的概念,那就是宏任务和微任务。
宏任务包括 setTimeout()、setInterval()、requestAnimationFrame、Ajax、fetch()、脚本标签代码等。
微任务包括 Promise.then()、MutationObserver。
在 Node.js 中,process.nextTick()是微任务的一种,setTimeout()、setInterval()、setImmediate() 等都属于宏任务。
在 Node版本 < 11 时,执行完一个阶段的所有任务后,再执行process.nextTick(),最后是其他微任务。
可以这样理解,process.nextTick() 维护了一个独立的队列,不存在于事件循环的任何阶段,而是在各个阶段切换的间隙执行。
即从一个阶段切换到下个阶段前执行,执行时机如下所示。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ nextTickQueue
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ nextTickQueue
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘
nextTickQueue nextTickQueue
│ ┌─────────────┴─────────────┐
│ │ poll │
│ └─────────────┬─────────────┘
│ nextTickQueue
│ ┌─────────────┴─────────────┐
│ │ check │
│ └─────────────┬─────────────┘
│ nextTickQueue
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
但是在 Node 版本 >= 11 之后,会处理的和浏览器一样,也是每执行完一个宏任务,就将其微任务也一并完成。
在下面这个示例中, setTimeout() 内先声明 then(),再声明 process.nextTick(),最后执行一条打印语句。
接着在 setTimeout() 之后再次声明了 process.nextTick()。
// setTimeout
setTimeout(() => {
Promise.resolve().then(function() {
console.log('promise');
});
process.nextTick(() => {
console.log('setTimeout nextTick');
});
console.log('setTimeout');
}, 0);
// nextTick
process.nextTick(() => {
console.log('nextTick');
});
我本地运行的 Node 版本是 16,所以最终的打印顺序如下所示。
nextTick
setTimeout
setTimeout nextTick
promise
外面的 process.nextTick() 要比 setTimeout() 先运行,里面的打印语句最先执行,然后是 process.nextTick(),最后是 then()。
3)sleep()
有一道比较经典的题目是编写一个 sleep() 函数,实现线程睡眠,在日常开发中很容易就会遇到。
搜集了多种实现函数,有些是同步,有些是异步。
第一种是同步函数,创建一个循环,占用主线程,直至循环完毕,这种方式也叫循环空转,比较浪费CPU性能,不推荐。
function sleep(ms) {
var start = Date.now(), expire = start + ms;
while (Date.now() < expire);
}
第二至第四种都是异步函数,本质上线程并没有睡眠,事件循环仍在运行,下面是 Promise + setTimeout() 组合实现的 sleep() 函数。
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
第三种是利用 util 库的promisify()函数,返回一个 Promise 版本的定时器。
function sleep(ms) {
const { promisify } = require('util');
return promisify(setTimeout)(ms);
}
第四种是当 Node 版本 >= 15 时可以使用,在timers库中直接得到一个 Promise 版本的定时器。
function sleep(ms) {
const { setTimeout } = require('timers/promises');
return setTimeout(ms);
}
第五种是同步函数,可利用Atomics.wait阻塞事件循环,直至线程超时,实现细节在此不做说明了。
function sleep(ms) {
const sharedBuf = new SharedArrayBuffer(4);
const sharedArr = new Int32Array(sharedBuf);
return Atomics.wait(sharedArr, 0, 0, ms);
}
还可以编写 C/C++ 插件,直接调用操作系统的 sleep() 函数,此处不做展开。
参考资料:
JavaScript 运行机制详解:再谈Event Loop
Node.js Event Loop 的理解 Timers,process.nextTick()
浏览器与Node的事件循环(Event Loop)有何区别?
Why is the EventLoop for Browsers and Node.js Designed This Way?
Node.js 事件循环 Phases of the Node JS Event Loop
Node.js精进(2)——异步编程的更多相关文章
- 17.Node.js 回调函数--异步编程
转自:http://www.runoob.com/nodejs/nodejs-tutorial.html Node.js 异步编程的直接体现就是回调. 异步编程依托于回调来实现,但不能说使用了回调后程 ...
- node.js整理 06异步编程
回调 异步编程依托于回调来实现,但不能说使用了回调后程序就异步化了 function heavyCompute(n, callback) { var count = 0, i, j; for (i = ...
- 使用node.js 进行服务器端JavaScript编程
node.js 入门 node.js 可以运行在 Linux.Windows 和 Macintosh 等主流的操作系统上.在 Windows 平台上运行 node.js ...
- 09-Node.js学习笔记-异步编程
同步API,异步API 同步API:只有当前API执行完成后,才能继续执行下一个API console.log('before'); console.log('after'); 异步API:当前API ...
- Node.js精进(8)——错误处理
在 Node.js 中,提供了 error 模块,并且内置了标准的 JavaScript 错误,常见的有: EvalError:在调用 eval() 函数时出现问题时抛出该错误. SyntaxErro ...
- Node.js入门:异步IO
异步IO 在操作系统中,程序运行的空间分为内核空间和用户空间.我们常常提起的异步I/O,其实质是用户空间中的程序不用依赖内核空间中的I/O操作实际完成,即可进行后续任务. 同步IO的并行模式 ...
- promise 的基本概念 和如何解决js中的异步编程问题 对 promis 的 then all ctch 的分析 和 await async 的理解
* promise承诺 * 解决js中异步编程的问题 * * 异步-同步 * 阻塞-无阻塞 * * 同步和异步的区别? 异步;同步 指的是被请求者 解析:被请求者(该事情的处理者)在处理完事情的时候的 ...
- Node.js中的异步I/O是如何进行的?
Node.js的异步I/O通过事件循环的方式实现.其中异步I/O又分磁盘I/O和网络I/O.在磁盘I/O的调用中,当发起异步调用后,会将异步操作送进libuv提供的队列中,然后返回.当磁盘I/O执行完 ...
- 【第三周读书笔记】浅谈node.js中的异步回调和用js-xlsx操作Excel表格
在初步学习了node.js之后,我发现他的时序问题我一直都很模糊不清,所以我专门学习了一下这一块. 首先我们来形象地理解一下进程和线程: 进程:CPU执行任务的模块.线程:模块中的最小单元. 例如:c ...
随机推荐
- centos6的yum源更新版本
概述 centos6系统从2020年12月1号开始不再维护,官方的yum源不再可用,同时国内的阿里云镜像和163镜像也都不再可用. 但是我们有一些老的服务器仍然在使用centos6系统版本,依赖库的安 ...
- APSI - 2
上一篇 APSI-1 其实就是对开源库README文件的一个翻译加上自己的一点点理解,因为篇幅过大,导致继续编辑有些卡顿,所以新开一篇继续. 前面介绍了APSI的大致技术.优化方法.以及举例说明了主要 ...
- Postman+newman+jenkins+git实战
一.接口分类,流程,用例设计 接口分类: 外部接口:被测系统与外部其他系统之间的接口. 承保系统(被测系统),核算系统. 内部接口:被测系统内部各个子模块之间的接口. 承保系统(A模块,B模块) 测试 ...
- jmeter并发设置的原理
目录 简介 广义并发 绝对并发 简介 性能测试过程中是否需要进行同步定时器的设置,需要根据实际情况来考虑. 举个栗子来讲是我们的双十一秒杀活动,这时候就必须实现请求数量达到一定数量后同时向服务 ...
- GO 前后端分离开源后台管理系统 Gfast v2.0.4 版发布
更新内容:1.适配插件商城,开发环境从后台直接安装插件功能:2.代码生成细节修复及功能完善(支持生成上传文件.图片及富文本编辑器功能):3.增加swagger接口文档生成:4.更新goframe版本至 ...
- 尤娜故事-迷雾-springboot扮酷小技巧
前情回顾 从前,有一个简单的通道系统叫尤娜-- 尤娜系统的第一次飞行中换引擎的架构垂直拆分改造 四种常用的微服务架构拆分方式 尤娜,我去面试了 正文 我回到日常的尤娜系统建设中,最近事情比较少,总有一 ...
- HCNP Routing&Switching之端口隔离
前文我们了解了组播路由协议稀疏模式中的RP相关话题,回顾请参考https://www.cnblogs.com/qiuhom-1874/p/16154347.html:今天我们来聊一聊二层交换机中有关v ...
- [AcWing 51] 数字排列
点击查看代码 class Solution { public: vector<vector<int>> res; vector<vector<int>> ...
- [笔记] 轮廓线 DP
是状态 DP 的一种,主要是对于网格图状压,实现 \(O(1)\) 转移的一种处理方式. oooo---- ----x - 是状压了信息的位置,x 是当前更新的位置. 应用价值 可以一格一格考虑状态, ...
- 《C++Primary》阅读简要总结
三月份的主要任务之一就是阅读C++Primary这本书,终于在昨天25号下午完成了基础部分的阅读,算是对基础知识整体梳理了一遍,开始看这本书大概可以追溯到去年12月份,在那之前看了C++的入门书籍&l ...