最近面试了一些 Nodejs 的工作,总结了一些 Node 原理。这里分享一下。
0. Node 是什么?优缺点是什么?
开源的,基于 V8 引擎的,非阻塞式异步 I/O 的 JavaScript 运行环境。
回答点:非阻塞 I/O (回调函数) + 事件队列
优点:
- 高并发场景
- IO 密集型应用 (内存读取)
缺点:
- 不适合 CPU 密集型应用,只在单核 CPU 上运行
- 代码有问题,整个系统会发生崩溃
1. Node 的应用场景
- 后台管理系统,实时交互,高并发
- canvas 绘图,联网 web 游戏,实时交互,高并发
- webSocket, 聊天室
- 利用 db,搭建 json api
- 单页面浏览器应用及创建服务
2. Node 的全局对象
全局对象是指在任何模块中都可以直接访问到的对象,而不需要通过 require 或其他方式进行导入。
global
最基础的全局对象,类似于浏览器环境中的 window 对象。在 Node.js 中,所有全局变量(除了 global 本身以外)都是 global 对象的属性。
process
这是一个提供了与当前 Node.js 进程互动接口的全局对象。通过 process 对象,我们可以获取到当前进程的信息,如 PID(进程 ID)、运行环境等。同时,它也提供了一些方法,如 process.exit()用于退出当前进程。
console
Buffer
在 Node.js 中,Buffer 类是一个全局可用的类型,用于处理二进制数据。它可以用于在 TCP 流、文件系统操作、以及其他上下文中与八位字节流进行交互。
setTimeout/setInterval/clearTimeout/clearInterval
这些全局函数用于处理定时器。例如,setTimeout()函数用于在指定的毫秒数后执行一个函数,而 setInterval()函数则用于定期执行一个函数。
filename
这是一个包含当前正在执行的脚本的文件名的全局变量。它返回的是文件所在位置的绝对路径。
dirname
这是一个包含当前执行脚本所在目录的全局变量。它返回的是目录的绝对路径。
3. 模块加载机制 require()
require 加载模块的机制
- CommonJS 规范:require 是基于 CommonJS 规范的模块加载方式,主要在 Node.js 环境中使用。
- 动态加载:require 可以在代码的任何地方进行调用,允许动态地加载和执行模块。
- 同步加载:require 是同步的,意味着在模块加载完成之前,代码执行会暂停。
- 缓存机制:当使用 require 加载一个模块时,Node.js 会缓存该模块。如果再次尝试加载同一个模块,Node.js 会直接从缓存中取出,而不会重新执行模块的代码。
- 导出方式:使用 module.exports 或 exports 来导出模块成员。
import 模块加载机制
- ES6 (ECMAScript 2015) 规范:import 是基于 ES6 模块的加载方式,旨在提供静态的模块结构。
- 静态加载:import 语句必须位于文件的顶部,不能在代码块内(如 if 语句或函数内)使用。
- 异步与同步:虽然 import 主要是同步的,但 ES6 也引入了动态 import() 函数,它返回一个 Promise,允许异步加载模块。
- 不缓存(默认情况下):与 require 不同,每次使用 import 加载模块时,都会重新执行模块的代码(除非使用了特定的缓存策略或打包工具进行了优化)。
- 导出方式:使用 export 关键字来导出模块成员。
4. 基础模块
基于事件处理,node 内置了不同的模块,如 fs
、http
、path
、os
、util
、events
、stream
等。下面,对相应的基础模块进行举例:
fs
文件读取和写入
1 | const fs = require("fs"); |
path
文件路径处理
1 | const path = require("path"); |
events
事件模块处理
1 | const EventEmitter = require("events"); |
http
HTTP 请求和响应
1 | const http = require("http"); |
url
处理
1 | const url = require("url"); |
querystring
处理
这个模块配合上面的 url 使用
1 | const querystring = require("querystring"); |
events
事件处理
1 | const EventEmitter = require("events"); |
os
操作系统模块
1 | const os = require("os"); |
5. Node 有哪些网络模块
以 demo 代码来说明问题:
http
模块
1 | const http = require("http"); |
https
模块
1 | const https = require("https"); |
net
模块
TCP 模块可以用来创建 TCP 服务器。比起 HTTP 底层
1 | const net = require("net"); |
6. Node 处理高并发任务的原理
非阻塞 I/O:
Node.js 中的 I/O 操作(如文件读写、网络通信等)是非阻塞的,这意味着当一个 I/O 操作被发起时,Node.js 不会等待这个操作完成,而是继续执行后面的代码。当 I/O 操作完成时,会通过回调函数或者 Promise 的方式通知 Node.js,从而进行后续处理。这种方式避免了线程阻塞,使得 Node.js 可以同时处理多个请求。
事件驱动:
Node.js 采用事件驱动模型,通过监听事件来处理各种任务。例如,当一个 HTTP 请求到达时,Node.js 会触发一个 ‘request’ 事件,并将请求对象作为参数传递给事件处理函数。这种模型使得 Node.js 可以轻松地处理大量并发的网络连接。
异步编程:
Node.js 鼓励使用异步编程模式,通过回调函数、Promise、async/await 等技术来处理异步操作。这使得 Node.js 能够在处理一个请求的同时,继续接收和处理其他请求,从而实现高并发。
单线程的优势:
虽然 Node.js 是单线程的,但这意味着它不需要像多线程环境那样处理复杂的线程同步问题,从而降低了编程的复杂性。此外,单线程模型也使得 Node.js 在处理大量并发连接时具有较低的内存占用和上下文切换开销。
利用多核处理器:
尽管 Node.js 本身是单线程的,但你可以通过创建多个 Node.js 进程来利用多核处理器的能力。例如,使用 Node.js 的 cluster 模块可以创建多个工作进程,每个进程运行在不同的 CPU 核心上,从而实现并行处理。此外,还可以使用 PM2 等工具来管理和负载均衡多个 Node.js 进程。
线程池处理 CPU 密集型任务:
对于 CPU 密集型任务(如大量的数学计算或数据处理),Node.js 的单线程模型可能不是最高效的。在这种情况下,可以使用 Node.js 的 worker_threads 模块或其他第三方线程池库(如前面提到的 node-worker-threads-pool)来创建多线程环境,从而提高 CPU 密集型任务的处理能力。
7. Node 的多线程处理模块
Node.js 本身是单线程的,通过事件循环和非阻塞 I/O 操作实现了高并发。然而,有些 CPU 密集型任务可能会阻塞事件循环,这时候可以考虑使用线程池来处理这些任务。
在 Node.js 中,可以使用 worker_threads
模块来创建多线程,但这个模块相对底层,直接使用可能会比较复杂。为了简化线程池的使用,可以使用第三方库,比如 fast-pool
或 node-worker-threads-pool
。
1 | npm install node-worker-threads-pool |
下面是一个使用 node-worker-threads-pool
库创建线程池的简单示例:
1 | const { WorkerPool } = require("node-worker-threads-pool"); |
8. I/O 模型的分类
Node 里经常提到非阻塞 I/O, 那么对于 I/O 模型,也是有多重分类。在电脑中,IO 是处理输入输出操作的不同方式。这些模型主要关注于如何有效地管理数据的读写,特别是在涉及磁盘、网络或其他外部设备时。
阻塞
在这种模型中,当用户空间的应用程序执行一个系统调用进行 IO 操作时,如果该操作不能立即完成,那么应用程序会被阻塞,直到该 IO 操作完成为止。在此期间,应用程序无法执行其他任务。
非阻塞
与阻塞 IO 相反,非阻塞 IO 模型中的系统调用会立即返回,无论 IO 操作是否完成。如果数据还未准备好,系统调用会返回一个错误或者表示数据未准备好的状态。这样,应用程序可以继续执行其他任务,而不会被阻塞。
多路复用
这种模型允许应用程序同时监控多个 IO 通道,例如多个网络连接或文件描述符。通过使用 select、poll 或 epoll 等系统调用,应用程序可以等待多个 IO 事件,而无需为每个通道都使用一个单独的线程或进程。当某个通道准备好进行 IO 操作时,应用程序会收到通知并进行相应的处理。
异步
在这种模型中,当应用程序发起一个 IO 操作后,它不需要等待该操作的完成。相反,当数据准备好时,操作系统会通过某种机制(如回调函数、事件或信号)通知应用程序。这种模型允许应用程序继续执行其他任务,而不需要轮询或等待 IO 操作的完成。
9. Node 的事件循环机制
Node.js 的事件循环是基于 libuv 跨平台的异步 I/O 库(multi-platform async IO)实现的。这个库关联到一个EVENT_QUEUE
先进先出(FIFO)的事件队列,并且该事件循环负责处理所有事件EVENT_LOOP
,包括定时器、文件系统操作等。
- timers 阶段:此阶段执行定时器(setTimeout/setInterval)的回调函数。
- I/O callbacks 阶段:执行除了定时器和 setImmediate 的回调外的 I/O 回调。
- idle 和 prepare 阶段:这两个阶段主要为系统内部使用,可以认为是闲置时间。
- poll 阶段:轮询可用的 I/O 事件,执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,由计时器和 setImmediate 调度的回调函数)。
- check 阶段:在此阶段执行 setImmediate 的回调函数。
- close callbacks 阶段:执行关闭的回调函数,如 socket.on(‘close’, …)。
微任务:
包括process.nextTick()
和
其他微任务队列(例如 Promise 的回调函数)。
宏任务:
timer queue:setTimeout/setInterval
poll queue: IO
check queue: setImmediate
close queue: socket.on(‘end’)
process orders:
- nextTick micro task queue
- other micro task queue
- timer queue
- poll queue
- check queue
- close queue
here is the demo code:
1 | const fs = require("fs"); |
再一个 demo
1 | async function async1() { |
10. Node 的全局异常处理
process
对象来捕获全局异常。process
对象是一个全局变量,提供了与当前 Node.js 进程互动的接口。
最佳实践是尽量避免依赖全局异常处理,而是在代码中尽可能明确地使用 try-catch 语句和 Promise 的错误处理机制来捕获和处理这些异常。
在 process
里,可以监听以下事件来捕获全局异常:
uncaughtException
a. 这种异常通常指的是在同步代码中未被 try-catch 语句捕获的错误,或者在异步回调中未被正确处理的错误。
b. 常见的 JavaScript 错误类型,如 ReferenceError,SyntaxError 等
c. 还包括系统级错误,如尝试打开不存在的文件时抛出的错误。
1 | process.on("uncaughtException", (err) => { |
unhandledRejection
这种异常特指与 Promise 相关的错误。当一个 Promise 被拒绝(rejected),且没有提供 .catch() 处理程序或在 async function 中没有使用 try…catch 语句来捕获错误时,就会触发 unhandledRejection 事件。
1 | process.on("unhandledRejection", (reason, promise) => { |
11. Node 多核处理
需要注意的是,开启多核处理并不是为了解决高并发问题,而主要是为了充分利用多核 CPU 的性能,提高应用程序的处理能力和响应速度。
cluster
模块
demo 代码:
1 | const cluster = require("cluster"); |
child_process
模块
childScript.js 文件:
1 | // |
主进程文件:
1 | const { fork } = require("child_process"); |
第三方模块 workerpool
线程池(Thread Pool)是一种多线程处理形式,它包含了一定数量的线程,这些线程都是处于等待状态,准备接收任务并执行。线程池的主要目的是复用线程,以减少在线程创建和销毁上的开销,并提高系统资源的利用率和响应速度。
1 | npm install workerpool |
1 | const WorkerPool = require("workerpool"); |
12. Node 如何处理监听内存的使用情况
process.memoryUsage()
定期监听:
1 | const { performance: any } = require("perf_hooks"); |
perf_hooks
模块:
1 | const { performance } = require("perf_hooks"); |
13. Node 的垃圾回收机制
Node.js 的垃圾回收机制主要基于 V8 引擎实现,而 V8 引擎的垃圾回收机制又主要基于分代式垃圾回收策略。这种策略根据对象的存活时间将内存分为不同的“代”,主要针对新生代(Young Generation)和老生代(Old Generation)进行不同的垃圾回收处理。
新生代(Young Generation):
新生代是内存管理中的一块区域,专门用于存储生命周期较短的新创建的对象。
新生代内部通常采用复制(Copying)算法进行垃圾回收,如 Scavenge 算法,该算法将新生代内存一分为二,包括 From 空间和 To 空间。
当 From 空间快满时,垃圾回收器会遍历所有对象,将活跃对象从 From 空间复制到 To 空间,然后交换 From 和 To 的角色,准备下一轮的垃圾回收。
如果一个对象在新生代中经历了多次垃圾回收仍然存活,它将被认为是“长寿”对象,并被晋升到老生代中。
老生代(Old Generation):
- 老生代是内存堆中的另一块区域,用于存储那些在多次新生代垃圾回收后仍然存活的对象,即生命周期较长的对象。
- 老生代内的垃圾回收相对不那么频繁,并且采用了不同的垃圾回收算法,如标记-清除(Mark-Sweep)算法。
- 在标记-清除算法中,垃圾回收器会从执行栈和全局对象上找到所有能访问到的对象,将它们标记为活跃对象。标记完成后,进入清除阶段,将没有被标记的对象清除,释放其占用的内存空间。
14. v8 引擎的内存泄露
这个问题和浏览器端 JS 的内存泄露一样,都是由于对象没有被回收导致的。下面举例一些 demo 代码说明:
- 定时器设置后要及时释放:
1 | function leakyTimer() { |
- 闭包
1 | function createLeakyClosure() { |
- 循环引用
1 | function createCircularReference() { |
- 移除 DOM 节点的监听函数
1 | const leakyButton = document.createElement("button"); |
- 全局对象未释放
1 | // 全局对象是指不用const 或 let 声明的变量,而又不释放他 |