《深入浅出 node.js》笔记part1

内容摘取自《深入浅出 node.js》- 朴灵
有些地方真的比较 “深入” 鉴于个人水平只记录一些要点

相关链接

第 1 章 node.js 简介

Node 的特点

1.异步 I/O

Node 在底层构建了很多异步 I/O 的 API,从文件读取到网络请求,极大地提升了程序性能。

2.事件与回调函数

配合异步 I/O,将事件点暴露给业务逻辑。这种事件的编程方式具有轻量级、松耦合、只关注事务点等优势。

3.单线程

单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。

但是它也有以下弱点(child process 模块出现后都得到了解决或缓解):

  • 无法利用多核 CPU
  • 错误会引起整个应用退出,应用的健壮性值得考验
  • 大量计算占用 CPU 导致无法继续调用异步 I/O

在浏览器端,Web Workers 能够创建工作线程来进行计算,以解决 JavaScript 大量计算阻塞 UI 渲染的问题,工作线程为了不阻塞主线程,通过消息传递的方式来传递运行结果,这也使得工作线程不能访问到主线程的 UI。

Node 采用了与 Web Worker 相同的思路来解决单线程中大计算量的问题:child process。子进程的出现,意味着 Node 可以从容地应对单线程健壮性和无法利用多核 CPU 方面的问题。通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息来传递结果。

4.跨平台:借助 libuv 实现跨平台运行。

Node 的应用场景

探讨的比较多的主要由 I/O 密级型和 CPU 密级型。

Node 面向网络并且擅长并行 I/O,能够有效地组织起更多的硬件资源,在 I/O 密集型场景表现还是不错的。

CPU 密集型应用给 Node 带来的挑战主要是:由于 JavaScript 单线程的原因,如果有长时间运行的计算(比如大循环),将会导致 CPU 时间片不能释放,使得后续 I/O 无法发起。

Node 虽然没有提供多线程用于计算支持,但是有以下两个方式来充分利用 CPU:

  • 编写 C/C++ 扩展,将一些 V8 不能做到性能极致的地方通过 C/C++ 来实现
  • 通过子进程的方式,将一部分 Node 进程当作常驻服务进程用于计算,然后利用进程间的消息来传递结果,将计算与 I/O 分离

Node 使用者的倚重点

  • 前后端语言环境统一
  • 高性能 I/O 用于实时应用(应用在长连接中,通过 socket.io 实现实时通知的功能)
  • 并行 I/O 使得使用者可以更高效地利用分布式环境
  • 云计算平台提供 Node 支持

经典的服务器模型的比较

  • 同步式:一次只处理一个请求,并且其余请求都处于等待状态
  • 每进程 / 每请求:为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多
  • 每线程 / 每请求:为每个请求启动一个线程来处理。尽管线程比进程要轻量,但是由于每个线程要占用一定的内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢。

Node 通过事件驱动的方式处理请求,无须为每个请求创建额外的线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低,这便是 Node 高性能的一个原因。

第 2 章 Node 的模块实现

CommonJS 的模块规范

CommonJS 对模块的定义主要分为模块引用、模块定义和模块标识 3 个部分
1.模块引用

模块引用的示例代码如下:

const math = require('math')

require() 方法接受模块表示,以此引入一个模块的 API 到当前上下文中

2.模块定义

在模块中,存在一个 module 对象,它代表模块自身,而 exports 是 module 的属性,用于导出当前模块的方法或变量。在 Node 中,一个文件就是一个模块

3.模块标识

模块标识就是传递给 require() 方法的参数,它必须是符合小驼峰命名的字符串,或者以 .、… 开头的相对路径,或者绝对路径,可以没有文件后缀名.js。

定义模块的意义在于:将类聚的方法和变量限定在私有的作用域中,每个模块具有独立的空间,它们互不干扰,在引用时也显得干净利落。
这套导出和引入机制使得用户完全不必考虑变量污染。

在这里插入图片描述

Node 的模块实现

规范中 require 和 exports 使用起来十分方便,但是 Node 在实现它们的过程中经历了什么呢?

在 Node 引入模块,需要经历如下 3 个步骤:

1.路径分析

2.文件定位

3.编译执行

在 Node 中,模块分为两类:一类是由 Node 提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。

  • 核心模块部分在 Node 源代码的编译过程中,编译进了二进制执行文件,在 Node 进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。
  • 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

模块加载过程(浅析)

1.优先从缓存加载

与前端浏览器会缓存静态脚本文件以提高性能一样,Node 对引入过的模块都会进行缓存,以减少二次引入时的开销。不同之处在于,浏览器仅仅缓存文件,而 Node 缓存的是编译和执行之后的对象。

不论是核心模块还是文件模块,require() 方法对相同模块的二次加载都一律采用缓存优先的方式,不同之处在于核心模块的检查先于文件模块的缓存检查。

2.路径分析和文件定位

这一部分偏底层,有兴趣的自行购买阅读…

第 3 章 异步 I/O

Node 实现异步 I/O 的过程可以提取为几个关键词:单线程、事件循环、观察者、I/O 线程池。

这里的单线程和 I/O 线程池之间看起来有些悖论的样子,事实上,在 Node 中,除了 JavaScript 是单线程外,Node 自身其实是多线程的,只是 I/O 线程使用的 CPU 较少。另一个需要重视的观点则是,除了用户代码无法并行执行外,所有的 I/O (磁盘 I/O 和网络 I/O 等)都是可以并行执行的。

事件循环

这里将 Node 的事件循环抽象一下便于理解:

在这里插入图片描述
在进程启动时,Node 便会创建一个类似于 white(true) 的循环,每执行一次循环体的过程我们称为 Tick。

每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。

然后进入下个循环,如果不再有事件处理,就退出流程。

观察者

在每个 Tick 的过程中,如何判断是否有事件需要处理呢?

这里必须要引入的概念是观察者。每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有需要处理的事件。

在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件对应的观察者有文件 I/O 观察者、网络 I/O 观察者等。

事件循环是一个典型的 生产者/消费者模型。异步 I/O、网络请求等是事件的生产者,源源不断地为 Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

在 Windows 下,这个循环基于 IOCP 创建,而在 *nix 下则基于多线程创建。

请求对象

对于 Node 中的异步 I/O 调用而言,回调函数不由开发者来调用,那么我们发出调用后,到回调函数被执行,中间发生了什么呢?

事实上,从 JavaScript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,它叫做请求对象

下面我们以 fs.open() 方法作为例子,来探索 Node 与底层之间是如何执行异步 I/O 调用以及回调函数究竟是如何被调用执行的:

fs.open = function (path, flags, mode, callback) {
  // ... 
  ImageBitmapRenderingContext.open(pathModule._makeLong(path),
                                    stringToFlags(flags),
                                    mode,
                                    callback)
}

fs.open() 的作用是根据指定路径和参数取打开一个文件,从而得到一个文件描述符,这是后续所有 I/O 操作的初始操作。

JavaScript 层面的代码通过调用 C++ 核心模块进行下层的操作,如图所示:

在这里插入图片描述
从 JavaScript 调用 Node 的核心模块,核心模块调用 C++ 内建模块,内建模块通过 libuv 进行系统调用,这是 Node 里最经典的调用方式。

在 uv_fs_open() 的调用过程中,会创建一个 FSReqWrap 请求对象,从 JavaScript 层传入的参数和当前方法都被封装在这个请求对象中,回调函数则被设置在这个对象的 oncomplete_sym 属性上:

req_wrap->object_Set(oncomplete_sym, callback)

对象包装完毕后,在 Windows 下,则调用 QueueUserWorkItem() 方法将这个 FSReqWrap 对象推入线程池中等待执行,该方法的代码如下:

QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT)

第一个参数是要执行的方法的引用,第二个参数是这个方法运行时所需要的参数,第三个参数是执行的标志。

当线程池中有可用线程时,就会调用 us_fs_thread_proc() 方法,该方法会根据传入参数的类型调用相应的底层函数。

至此,JavaScript 调用立即返回,JavaScript 线程可以继续执行当前任务的后续操作。

当前的 I/O 操作在线程池中等待执行,不管它是否阻塞 I/O,都不会影响到 JavaScript 线程的后续执行,如此就达到了异步的目的。

总结:请求对象是异步 I/O 过程中的重要产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及 I/O 操作完毕后的回调处理。

执行回调

线程池中的 I/O 操作调用完毕后,会将获取的结果存储在 req->result 属性上,然后通知 IOCP(Windows),告诉当前对象操作已经完成,并将线程归还线程池。

事件循环中的 I/O 观察者,在每次 Tick 的执行中,会检查线程池中是否有执行完的请求,如果存在,会把请求对象加入到 I/O 观察者的队列中,然后将其当做事件处理。

I/O 观察者回调函数的行为就是取出请求对象的 result 属性作为参数,然后调用执行。

至此,整个异步 I/O 的流程完全结束,如图所示:

在这里插入图片描述

第 5 章 内存控制

V8 的垃圾回收机制与内存限制

在一般的后端开发语言中,基本的内存使用上没有什么限制,然而在 Node 中通过 JavaScript 使用内存时会发现只能使用部分内存(64 位系统下约为 1.4 GB,32 位系统下约为 0.7 GB)。

在这样的限制下,将会导致 Node 无法直接操作大内存对象,比如无法将一个 2GB 的文件读入内存中进行字符串分析处理。(stream 模块解决了这个问题)

造成这个问题的主要原因在于 Node 基于 V8 构建,V8 的内存管理机制在浏览器的应用场景下绰绰有余,但在 Node 中却限制了开发者。所以我们有必要知晓 V8 的内存管理策略。

V8 的对象分配

在 V8 中,所有的 JavaScript 对象(object)都是通过堆来进行分配的,Node 提供了 V8 中内存使用量的查看方式,如下:

process.memoryUsage()
{ rss: 21434368,
  heapTotal: 7159808,
  heapUsed: 4455120,
  external: 8224 }

其中,heapTotal 和 heapUsed 是 V8 的堆内存使用情况,前者是已申请到的堆内存,后者是当前使用的量。

如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过 V8 的限制为止。

至于 V8 为何要限制堆的大小,主要是内存过大会导致垃圾回收引起 JavaScript 线程暂停执行的时间增长,应用的性能和响应会直线下降,
这样的情况不仅仅是后端服务无法接受,前端浏览器也无法接受。因此,在当时的考虑下直接限制堆内存是一个好的选择。

不过 V8 也提供了选项让我们打开这个限制,Node 在启动时可以传递如下的选项:

node --max-old-space-size=1700 test.js // 单位为 MB 设置老生代的内存空间
node --max-new-space-size=1024 test.js // 单位为 KB 设置新生代的内存空间

上述参数在 V8 初始化时生效,一旦生效就不能再改变。

V8 的垃圾回收机制

V8 的垃圾回收策略主要基于分代式垃圾回收机制,在实际应用中,人们发现没有一种垃圾回收算法能够胜任所有的场景,因为对象的生存周期长短不一,
不同的算法只能针对特定情况具有最好的效果。

因此,现代的垃圾回收算法按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。

在 V8 中,主要将内存分为新生代和老生代。新生代的对象为存活时间较短的对象,老生代的对象为存活时间较长或常驻内存的对象。

在这里插入图片描述
Scavenge 算法

在分代的基础上,新生代的对象主要通过 Scavenge 算法进行垃圾回收,在 Scavenge 的具体实现中,主要采用了 Cheney 算法。

Cheney 算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,每一部分空间称为 semispace。

在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。

在这里插入图片描述

当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查 From 空间的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将被释放。
完成复制后,From 空间和 To 空间的角色发生对换。

  • Scavenge 的缺点是只能使用堆内存中的一半
  • Scavenge 是典型的牺牲空间换取时间的算法,适合应用于新生代中,因为新生代中对象的生命周期较短
  • 当一个对象经过多次复制仍然存活时,它将会被认为是生命周期较长的对象,其随后会被移动到老生代中,这一过程称为晋升

Mark-Sweep & Mark-Compact

老生代中的对象生命周期较长,存活对象占较大比重,V8 在老生代主要采用 Mark-Sweep 和 Mark-Compact 相结合的方式进行垃圾回收。

Mark-Sweep:标记清除,其分为标记和清除两个阶段。在标记阶段遍历堆中的所有对象,并标记活着的对象,在清除阶段只清除没有被标记的对象。

Mark-Sweep 最大的问题在于进行一次标记清除回收后,内存空间会出现不连续的状态,内存碎片会对后续的内存分配造成问题,比如碎片空间不足以分配一个大对象导致提前触发垃圾回收。

于是就有了 Mark-Compact:标记整理,简单来说就是标记完成后加一个整理阶段,存活对象往一端移动(合并),整理完成后直接清理掉边界外的内存。

在这里插入图片描述

Incremental Marking

为了避免出现 JavaScript 应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的 3 种基本算法需要将应用逻辑暂停下来,
待执行玩垃圾回收后再恢复执行应用逻辑,这种行为被称为全停顿(stop-the-world)。

对于新生代来说,全停顿的影响不大,但是对于老生代就需要改善。

为了降低全堆垃圾回收带来的停顿时间,V8 采用了增量标记(incremental marking)的技术,
大概是将原本一口气停顿完成的动作拆分为许多小“步进”,
每做完一“步进”就让 JavaScript 应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。

在这里插入图片描述
V8 后续还引入了延迟清理(lazy sweeping)、增量式整理(incremental compaction)、并发标记 等技术,感兴趣的可以自行了解。

查看垃圾回收日志

启动时添加 --trace_gc 参数,这样在进行垃圾回收时,将会从标准输出中打印垃圾回收的日志信息。

下面是一段示例,执行结束后,将会在 gc.log 文件中得到所有垃圾回收信息:

node --trace_gc -e "var a = []; for (var i = 0; i < 1000000; i++) a.push(new Array(100));" > gc.log

通过在 Node 启动时使用 --prof 参数,可以得到 V8 执行时的性能分析数据:

node --prof test.js
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
该资源内项目源码是个人的课程设计、毕业设计,代码都测试ok,都是运行成功后才上传资源,答辩评审平均分达到96分,放心下载使用! ## 项目备注 1、该资源内项目代码都经过测试运行成功,功能ok的情况下才上传的,请放心下载使用! 2、本项目适合计算机相关专业(如计科、人工智能、通信工程、自动化、电子信息等)的在校学生、老师或者企业员工下载学习,也适合小白学习进阶,当然也可作为毕设项目、课程设计、作业、项目初期立项演示等。 3、如果基础还行,也可在此代码基础上进行修改,以实现其他功能,也可用于毕设、课设、作业等。 下载后请首先打开README.md文件(如有),仅供学习参考, 切勿用于商业用途。 该资源内项目源码是个人的课程设计,代码都测试ok,都是运行成功后才上传资源,答辩评审平均分达到96分,放心下载使用! ## 项目备注 1、该资源内项目代码都经过测试运行成功,功能ok的情况下才上传的,请放心下载使用! 2、本项目适合计算机相关专业(如计科、人工智能、通信工程、自动化、电子信息等)的在校学生、老师或者企业员工下载学习,也适合小白学习进阶,当然也可作为毕设项目、课程设计、作业、项目初期立项演示等。 3、如果基础还行,也可在此代码基础上进行修改,以实现其他功能,也可用于毕设、课设、作业等。 下载后请首先打开README.md文件(如有),仅供学习参考, 切勿用于商业用途。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值