虽然 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 回调队列,当队列耗尽或达到回调上限时,事件循环将进入下一阶段,如此往复。

  1. timers:执行由 setTimeout() 和 setInterval() 安排的回调。在此阶段内部,会维护一个定时器的小顶堆,按到期时间排序,先到期的先运行。
  2. pending callbacks:处理上一轮循环未执行的 I/O 回调,例如网络、I/O 等异常时的回调。
  3. idle,prepare:仅 Node 内部使用。
  4. poll:执行与 I/O 相关的回调,除了关闭回调、定时器调度的回调和 setImmediate() , 适当的条件下 Node 将阻塞在这里。
  5. check:调用 setImmediate() 回调。
  6. 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() 函数,此处不做展开。

参考资料:

Event Loop 事件循环源码 Node.js技术栈

nodejs真的是单线程吗?

Nodejs探秘:深入理解单线程实现高并发原理

什么是CPU密集型、IO密集型? libuv I/O

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

如何实现线程睡眠?

nodejs中的并发编程

Node.js精进(2)——异步编程的更多相关文章

  1. 17.Node.js 回调函数--异步编程

    转自:http://www.runoob.com/nodejs/nodejs-tutorial.html Node.js 异步编程的直接体现就是回调. 异步编程依托于回调来实现,但不能说使用了回调后程 ...

  2. node.js整理 06异步编程

    回调 异步编程依托于回调来实现,但不能说使用了回调后程序就异步化了 function heavyCompute(n, callback) { var count = 0, i, j; for (i = ...

  3. 使用node.js 进行服务器端JavaScript编程

            node.js 入门        node.js 可以运行在 Linux.Windows 和 Macintosh 等主流的操作系统上.在 Windows 平台上运行 node.js ...

  4. 09-Node.js学习笔记-异步编程

    同步API,异步API 同步API:只有当前API执行完成后,才能继续执行下一个API console.log('before'); console.log('after'); 异步API:当前API ...

  5. Node.js精进(8)——错误处理

    在 Node.js 中,提供了 error 模块,并且内置了标准的 JavaScript 错误,常见的有: EvalError:在调用 eval() 函数时出现问题时抛出该错误. SyntaxErro ...

  6. Node.js入门:异步IO

    异步IO     在操作系统中,程序运行的空间分为内核空间和用户空间.我们常常提起的异步I/O,其实质是用户空间中的程序不用依赖内核空间中的I/O操作实际完成,即可进行后续任务. 同步IO的并行模式 ...

  7. promise 的基本概念 和如何解决js中的异步编程问题 对 promis 的 then all ctch 的分析 和 await async 的理解

    * promise承诺 * 解决js中异步编程的问题 * * 异步-同步 * 阻塞-无阻塞 * * 同步和异步的区别? 异步;同步 指的是被请求者 解析:被请求者(该事情的处理者)在处理完事情的时候的 ...

  8. Node.js中的异步I/O是如何进行的?

    Node.js的异步I/O通过事件循环的方式实现.其中异步I/O又分磁盘I/O和网络I/O.在磁盘I/O的调用中,当发起异步调用后,会将异步操作送进libuv提供的队列中,然后返回.当磁盘I/O执行完 ...

  9. 【第三周读书笔记】浅谈node.js中的异步回调和用js-xlsx操作Excel表格

    在初步学习了node.js之后,我发现他的时序问题我一直都很模糊不清,所以我专门学习了一下这一块. 首先我们来形象地理解一下进程和线程: 进程:CPU执行任务的模块.线程:模块中的最小单元. 例如:c ...

随机推荐

  1. centos6的yum源更新版本

    概述 centos6系统从2020年12月1号开始不再维护,官方的yum源不再可用,同时国内的阿里云镜像和163镜像也都不再可用. 但是我们有一些老的服务器仍然在使用centos6系统版本,依赖库的安 ...

  2. APSI - 2

    上一篇 APSI-1 其实就是对开源库README文件的一个翻译加上自己的一点点理解,因为篇幅过大,导致继续编辑有些卡顿,所以新开一篇继续. 前面介绍了APSI的大致技术.优化方法.以及举例说明了主要 ...

  3. Postman+newman+jenkins+git实战

    一.接口分类,流程,用例设计 接口分类: 外部接口:被测系统与外部其他系统之间的接口. 承保系统(被测系统),核算系统. 内部接口:被测系统内部各个子模块之间的接口. 承保系统(A模块,B模块) 测试 ...

  4. jmeter并发设置的原理

    目录 简介 广义并发 绝对并发 简介 ​ 性能测试过程中是否需要进行同步定时器的设置,需要根据实际情况来考虑. ​ 举个栗子来讲是我们的双十一秒杀活动,这时候就必须实现请求数量达到一定数量后同时向服务 ...

  5. GO 前后端分离开源后台管理系统 Gfast v2.0.4 版发布

    更新内容:1.适配插件商城,开发环境从后台直接安装插件功能:2.代码生成细节修复及功能完善(支持生成上传文件.图片及富文本编辑器功能):3.增加swagger接口文档生成:4.更新goframe版本至 ...

  6. 尤娜故事-迷雾-springboot扮酷小技巧

    前情回顾 从前,有一个简单的通道系统叫尤娜-- 尤娜系统的第一次飞行中换引擎的架构垂直拆分改造 四种常用的微服务架构拆分方式 尤娜,我去面试了 正文 我回到日常的尤娜系统建设中,最近事情比较少,总有一 ...

  7. HCNP Routing&Switching之端口隔离

    前文我们了解了组播路由协议稀疏模式中的RP相关话题,回顾请参考https://www.cnblogs.com/qiuhom-1874/p/16154347.html:今天我们来聊一聊二层交换机中有关v ...

  8. [AcWing 51] 数字排列

    点击查看代码 class Solution { public: vector<vector<int>> res; vector<vector<int>> ...

  9. [笔记] 轮廓线 DP

    是状态 DP 的一种,主要是对于网格图状压,实现 \(O(1)\) 转移的一种处理方式. oooo---- ----x - 是状压了信息的位置,x 是当前更新的位置. 应用价值 可以一格一格考虑状态, ...

  10. 《C++Primary》阅读简要总结

    三月份的主要任务之一就是阅读C++Primary这本书,终于在昨天25号下午完成了基础部分的阅读,算是对基础知识整体梳理了一遍,开始看这本书大概可以追溯到去年12月份,在那之前看了C++的入门书籍&l ...