node深入浅出精读

1,240 阅读19分钟

node简介

设计初衷

Ryan Dahl最初的目是写一个基于事件驱动、非阻塞(异步)I/O的Web服务器,以达到更高的 性能

除了HTML、WebKit和显卡这些UI相关技术没有支持外,Node的结构与Chrome十分相似。它们都是基于事件驱动的异步架构,浏览器通过事件驱动来服务界面上的交互,Node通过事件驱动来服务I/O。

libuv:系统实现跨平台的基础组件(一开始是为了使node兼容Windows实现的)

node特点

1、基于事件驱动的非阻塞I/O(node事件循环这块)

Node带来的最大特性莫过于基于事件驱动的非阻塞I/O模型,这是它的灵魂所在。在Node中,绝大多数的操作都是以异步的方式进行调用。可以进行并行I/O操作,每个调用之间无需等待之前的I/O调用结束,极大提升了效率

2、单线程

Node保持了js在浏览器中单线程的特点(单线程仅仅只是js执行在单线程中罢了,V8引擎线程)。而且在Node中,js与其余线程是无法共享任何状态的。单线程的最大好处是不用像多线程那样处处在意状态同步问题,这里没有死锁的存在(所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。),也没有线程上下文切换所带来性能上的开销

单线程弱点:

  • 无法利用多核CPU(多个线程同时运行),因为一个进程只能利用一个CPU
  • 一旦程序发生错误,会引起整个应用退出
  • 大量计算占用CPU导致无法继续调用异步I/O(因为只能利用一个 cpu ,一旦 cpu 被某个计算一直占用, cpu 得不到释放,后续的请求就会一直被挂起,直接无响应了)

解决方案:

  • 浏览器:Web Workers(工作线程配合js引擎这个主线程)
  • node:子进程(将计算的任务交给子进程)

3、跨平台

  • 一开始node只支持在Linux端
  • libuv:系统实现跨平台的基础组件(一开始是为了使node兼容Windows实现的),他是用c/c++写的

node应用场景

《什么是CPU密集型、IO密集型》

1、I/O密集型

什么是I/O密集型:IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度),对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。

从单线程角度来说,node擅长处理多个并行I/O,主要是利用了node的事件循环。而不是启动每一个线程为每一个请求服务,资源占用极少

2、CPU密集型(需要借助子进程)

什么是CPU密集型:也叫计算密集型,其特点是要进行大量的计算,主要消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力

node处理cpu密集型的方式:通过创建子进程,将计算的任务交给子进程,然后利用进程间的通信来传递信息。这样的方式将计算与I/O分离,还能充分利用多核cpu

ps:计算密集型程序适合C语言多线程(运行速度极快),I/O密集型适合脚本语言开发的多线程(开发效率高)

3、分布式应用

模块机制

这部分之前总结过了,主要是有关commonJs模块和ES6模块的区别

异步I/O

为什么要异步I/O

1、用户体验

  • 在浏览器中js是单线程运行的,而且它还与UI渲染共用一个线程,这意味着js在执行的时候,UI渲染和响应是处于停滞状态的。
  • 当前端同时发起多个没有相互依赖的请求时,如果采用同步方式,则响应时间为每个请求响应的时间总和。而如果采用异步的方式,则总的响应时间仅仅取决于响应时间最长的那个请求。

2、资源分配

单线程同步编程模型:

  • 不足:会因为阻塞I/O 导致硬件资源内存得不到充分的利用,比如造成CPU等待浪费

多线程编程模型:

  • 不足:编程中的死锁、多个线程之间的状态同步问题

node解决的方案:

  • 利用单线程:解决原来多线程死锁、状态同步问题
  • 利用异步I/O:让单线程原理阻塞、以更充分地利用从CPU

node的异步I/O实现(重)

事件循环、观察者、请求对象、I/O线程池这4者共同构成了Node异步I/O模型的基本要素

  • I/O:网络请求、文件读写等操作
  • 非I/O的异步API:setTimeout()、setInterval()、setImmediate()、process.nextTick()

node事件循环

node是单线程怎么支持高并发呢?核心就要在于 js 引擎的事件循环机制。 nodejs 是异步非阻塞的,所以能扛住高并发

Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现,是 Node.js 实现异步的核心 。

六个阶段:

其中libuv引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,也就是一个阶段执行完毕之后,就会去执行microtask队列的任务。事件循环必须跑完这6个阶段,才算一个轮回。

从上图中,大致看出node中的事件循环的顺序:

外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段(按照该顺序反复运行)...

  • timers 阶段:这个阶段执行timer(setTimeout、setInterval)的回调
  • I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
  • idle, prepare 阶段:仅node内部使用
  • poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
  • check 阶段:执行 setImmediate() 的回调
  • close callbacks 阶段:执行 socket 的 close 事件回调

详细介绍timers、poll、check这3个阶段,因为日常开发中的绝大部分异步任务都是在这3个阶段处理的。

1.timers阶段

  • 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
  • 检查是否有 process.nextTick 任务,如果有,全部执行。
  • 检查是否有其他 microtask,如果有,全部执行。
  • 退出该阶段。

2.poll阶段

这个阶段主要做两件事:回到 timer 阶段执行回调、执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情

  • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
  • 如果 poll 队列为空时,会有两件事发生
    • 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
    • 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。

3.check阶段

  • setImmediate()的回调会被加入check队列中,如果有setImmediate回调,则执行所有setImmediate回调。
  • 检查是否有 process.nextTick 回调,如果有,全部执行。
  • 检查是否有其他 microtask,如果有,全部执行。
  • 退出该阶段。

在事件循环的每一个子阶段退出之前都会按顺序执行如下过程:(这个也是和浏览器事件循环不同的地方)

  • 检查是否有 process.nextTick 回调,如果有,全部执行。
  • 检查是否有其它的 microtaks,如果有,全部执行。
  • 退出当前阶段。

node中的宏任务、微任务

Node端事件循环中的异步队列也是这两种:macro(宏任务)队列和 micro(微任务)队列。

  • 常见的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作等。
  • 常见的 micro-task 比如: process.nextTick、new Promise().then(回调)等。

Node与浏览器的 Event Loop 差异

浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

参考文章

内存控制

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

node也是基于V8引擎构建。因此在node中,操作系统会限制每个node进程的最大可用内存。在浏览器中虽然也有这个限制,但是在浏览器的应用场景下使用起来已经绰绰有余了,足以胜任前端页面中的所有需求。但是在node中却限制了开发者随心所欲使用大内存的想法了。

V8为何要限制内存的用量

  • 表层原因:V8最初是为浏览器设计的,对于网页来说,V8的限制值使用起来已经绰绰有余了
  • 深层原因:V8垃圾回收的限制。因为V8垃圾回收也是需要时间的,垃圾回收期间js线程会暂停执行,所以如果一个进程的内存太大,垃圾回收时间过长,会很影响应用的性能。

不过这个限制值可以根据实际情况来调整的,避免在执行过程中稍微多用一些内存就轻易奔溃

V8垃圾回收机制

《深入理解V8的垃圾回收原理》《js内存深入学习(一)》

V8垃圾收集器会每隔一段时间就执行一次释放操作,找出那些不再继续使用的值,然后释放其占用的内存。

  • 标记清除(Mark-Sweep ):现代浏览器基本都是用此方法
  • 引用计数:低版本的IE使用这种方式;循环引用是致命的问题

V8 引擎的垃圾回收机制(基于分代回收机制):

1、将对象分为新生代、老生代

  • 新生代:存活时间短,只经历过一次垃圾回收就被回收了
  • 老生代:存活时间长,经过多次垃圾回收仍然存活

V8堆的整体大小就是新生代所用内存空间+老生代所用内存空间

2、 新生代被分为 FromTo两个空间

  • To 一般是处于闲置的。
  • From(处于使用中)空间满了的时候会执行 Scavenge (清除)算法进行垃圾回收

3、老生代采用了标记清除法。标记清除法首先会从全局对象开始对内存中存活的对象进行标记,标记结束后清除掉那些没有标记的对象。

4、由于标记清除后会造成很多的内存碎片,不便于后面的内存分配(比如需要申请大块内存)。所以为了解决内存碎片的问题引入了标记压缩法

5、标记压缩:标记清除是对未标记的对象立即进行回收,Mark-Compact则是将标记的对象移动到一边,然后再清理未标记的 ps:==进行垃圾回收的时候会暂停应用的逻辑执行==

内存泄露

哪些操作会引起内存泄露:

  • 意外的全局变量(一直占用内存,直到关闭浏览器或者认为清除)
  • 被遗忘的计时器或回调函数
  • 脱离 DOM 的引用
  • 闭包

如何检测内存泄露:

《内存泄漏及如何避免及检测》

浏览器方法:(可以分别两次记录下快照,再进行对比)

  • 打开开发者工具,选择 Memory选项板;
  • 在右侧的Select profiling type字段里面勾选 timeline
  • 点击左上角的录制按钮;
  • 在页面上进行各种操作,模拟用户的使用情况;
  • 一段时间后,点击左上角的 stop 按钮,面板上就会显示这段时间的内存占用情况。

命令行方法:

  • 使用Node提供的process.memoryUsage方法

解决内存泄露:(解除引用)

一旦数据不再使用,最好通过将其值设为null来释放其引用

进程与线程

《浅析 Node 进程与线程》

概念

进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。

线程是程序执行中一个单一的顺序控制流,它存在于进程之中,是比进程更小的能独立运行的基本单位。

早期在单核 CPU 的系统中,为了实现多任务的运行,引入了进程的概念,不同的程序运行在数据与指令相互隔离的进程中,通过时间片轮转调度执行,由于 CPU 时间片切换与执行很快,所以看上去像是在同一时间运行了多个程序。

由于进程切换时需要保存相关硬件现场、进程控制块等信息,所以系统开销较大。为了进一步提高系统吞吐率,在同一进程执行时更充分的利用 CPU 资源,引入了线程的概念。线程是操作系统调度执行的最小单位,它们依附于进程中,共享同一进程中的资源,基本不拥有或者只拥有少量系统资源,切换开销极小。

单线程?

我们常常听到有开发者说 “ Node.js 是单线程的”,那么 Node 确实是只有一个线程在运行吗?

事实上一个 Node 进程通常包含:

  • 1 个 Javascript 执行主线程;
  • 1 个 watchdog 监控线程用于处理调试信息;
  • 1 个 v8 task scheduler 线程用于调度任务优先级,加速延迟敏感任务执行;
  • 4 个 v8 线程(可参考以下代码),主要用来执行代码调优与 GC 等后台任务;
  • 以及用于异步 I / O 的 libuv 线程池。

子进程

通过事件循环机制,Node 实现了在 I/O 密集型(I/O-Sensitive)场景下的高并发,但是如果代码中遇到 CPU 密集场景(CPU-Sensitive)的场景,那么主线程将长时间阻塞,无法处理额外的请求。为了应对 CPU-Sensitive 场景,以及充分发挥 CPU 多核性能,Node 提供了 child_process 模块进行进程的创建、通信、销毁等等。

1、创建

child_process 模块提供了 4 种异步创建 Node 进程的方法,具体可参考 child_process API,这里做一下简要介绍。

  • spawn 以主命令加参数数组的形式创建一个子进程,子进程以流的形式返回 data 和 error 信息。
  • exec 是对 spawn 的封装,可直接传入命令行执行,以 callback 形式返回 error stdout stderr 信息
  • execFile 类似于 exec 函数,但默认不会创建命令行环境,将直接以传入的文件创建新的进程,性能略微优于 exec
  • fork 是 spawn 的特殊场景,只能用于创建 node 程序的子进程,默认会建立父子进程的 IPC 信道来传递消息

2、通信

在 Linux 系统中,可以通过管道、消息队列、信号量、共享内存、Socket 等手段来实现进程通信。在 Node 中,父子进程可通过 IPC(Inter-Process Communication) 信道收发消息,IPC 由 libuv 通过管道 pipe 实现。一旦子进程被创建,并设置父子进程的通信方式为 IPC(参考 stdio 设置),父子进程即可双向通信。

进程之间通过 process.send 发送消息,通过监听 message 事件接收消息。当一个进程发送消息时,会先序列化为字符串,送入 IPC 信道的一端,另一个进程在另一端接收消息内容,并且反序列化,因此我们可以在进程之间传递对象。

工作线程

在 Node v10 以后,为了减小 CPU 密集型任务计算的系统开销,引入了新的特性:工作线程 worker_threads。通过 worker_threads 可以在进程内创建多个线程,主线程与 worker 线程使用 parentPort 通信,worker 线程之间可通过 MessageChannel 直接通信。

总结

Node.js 本身设计为单线程执行语言,通过 libuv 的线程池实现了高效的非阻塞异步 I/O,保证语言简单的特性,尽量减少编程复杂度。但是也带来了在多核应用以及 CPU 密集场景下的劣势,为了补齐这块短板,Node 可通过内建模块 child_process 创建额外的子进程来发挥多核的能力,以及在不阻塞主进程的前提下处理 CPU 密集任务。

为了简化开发者使用多进程模型以及端口复用,Node 又提供了 cluster 模块实现主-从节点模式的进程管理以及负载调度。由于进程创建、销毁、切换时系统开销较大,worker_threads 模块又随之推出,在保持轻量的前提下,可以利用更少的系统资源高效地处理 进程内 CPU 密集型任务,如数学计算、加解密,进一步提高进程的吞吐率


中间件

中间件概念

在NodeJS中,中间件主要是指封装所有Http请求细节处理的方法。一次Http请求通常包含很多工作,如记录日志、ip过滤、查询字符串、请求体解析、Cookie处理、权限验证、参数验证、异常处理等,但对于Web应用而言,并不希望接触到这么多细节性的处理,因此引入中间件来简化和隔离这些基础设施与业务逻辑之间的细节,让开发者能够关注在业务的开发上,以达到提升开发效率的目的。

中间件的行为比较类似Java中过滤器的工作原理,就是在进入具体的业务处理之前,先让过滤器处理。

express中间件

  • 其实中间件就是一个这样格式的函数,三个参数req, res, next
function loginCheck(req, res, next) {
    console.log('模拟登陆成功')
    next()
}
  • 可以通过app.use()app.get()app.post()注册中间件
  • 可以注册多个中间件,依次执行
  • 通过next()的执行一个一个的往下串联下一个中间件
  • express中间件一个接一个的顺序执行, 通常会将 response 响应写在最后一个中间件中

实现原理思路:

  1. app.use用来注册中间件,先收集起来
  2. 遇到http请求,根据pathmethod判断触发哪些中间件
  3. 实现next()机制,即上一个通过next()触发下一个
// 实现类似 express 的中间件
const http = require('http')
const slice = Array.prototype.slice

class LikeExpress {
    constructor() {
        // 收集存放中间件的列表
        this.routes = {
            all: [],   // app.use(...)
            get: [],   // app.get(...)
            post: []   // app.post(...)
        }
    }

    register(path) {
        const info = {}
        if (typeof path === 'string') {
            info.path = path
            // 从第二个参数开始,转换为数组,存入 stack
            info.stack = slice.call(arguments, 1)
        } else {
            info.path = '/'
            // 从第一个参数开始,转换为数组,存入 stack
            info.stack = slice.call(arguments, 0)
        }
        return info
    }

    // 中间件注册和收集
    use() {
        const info = this.register.apply(this, arguments)
        this.routes.all.push(info)
    }

    get() {
        const info = this.register.apply(this, arguments)
        this.routes.get.push(info)
    }

    post() {
        const info = this.register.apply(this, arguments)
        this.routes.post.push(info)
    }

    // 通过当前 method 和 url 来匹配当前路由可执行的中间件
    match(method, url) {
        let stack = []
        if (url === '/favicon.ico') {
            return stack
        }

        // 获取 routes
        let curRoutes = []
        curRoutes = curRoutes.concat(this.routes.all)
        curRoutes = curRoutes.concat(this.routes[method])

        curRoutes.forEach(routeInfo => {
            if (url.indexOf(routeInfo.path) === 0) {
                // url === '/api/get-cookie' 且 routeInfo.path === '/'
                // url === '/api/get-cookie' 且 routeInfo.path === '/api'
                // url === '/api/get-cookie' 且 routeInfo.path === '/api/get-cookie'
                stack = stack.concat(routeInfo.stack)
            }
        })
        return stack
    }

    // 核心的 next 机制
    handle(req, res, stack) {
        const next = () => {
            // 拿到第一个匹配的中间件
            const middleware = stack.shift()
            if (middleware) {
                // 执行中间件函数
                middleware(req, res, next)
            }
        }
        next()
    }

    callback() {
        return (req, res) => {
            res.json = (data) => {
                res.setHeader('Content-type', 'application/json')
                res.end(
                    JSON.stringify(data)
                )
            }
            const url = req.url
            const method = req.method.toLowerCase()

            const resultList = this.match(method, url)
            this.handle(req, res, resultList)
        }
    }

    listen(...args) {
        const server = http.createServer(this.callback())
        server.listen(...args)
    }
}

// 工厂函数
module.exports = () => {
    return new LikeExpress()
}

koa2中间件

  • koa2中间件其实就是一个async函数,参数为(ctx, next)
  • 中间件执行顺序是“洋葱圈”模型
app.use(async (ctx, next) => {
    await next();
    ctx.body = 'Hello World';
});

实现思路:

  • 也是使用app.use来注册中间件,先收集起来
  • 实现next机制,即上一个通过await next()触发下一个中间件
  • 不涉及methodpath的判断
<!--实现类似 靠中间件-->
const http = require('http')

// 组合中间件
function compose(middlewareList) {
    return function (ctx) {
        function dispatch(i) {
            const fn = middlewareList[i]
            try {
                return Promise.resolve(
                    fn(ctx, dispatch.bind(null, i + 1))  // promise
                )
            } catch (err) {
                return Promise.reject(err)
            }
        }
        return dispatch(0)
    }
}

class LikeKoa2 {
    constructor() {
        this.middlewareList = []
    }

    // 收集中间件列表
    use(fn) {
        this.middlewareList.push(fn)
        return this
    }

    createContext(req, res) {
        const ctx = {
            req,
            res
        }
        ctx.query = req.query
        return ctx
    }

    handleRequest(ctx, fn) {
        return fn(ctx)
    }

    callback() {
        const fn = compose(this.middlewareList)

        return (req, res) => {
            const ctx = this.createContext(req, res)
            return this.handleRequest(ctx, fn)
        }
    }

    listen(...args) {
        const server = http.createServer(this.callback())
        server.listen(...args)
    }
}

module.exports = LikeKoa2

在express框架中,中间件的实现方式为方式一,并且全局中间件和内置路由中间件中根据请求路径定义的中间件共同作用,不过无法在业务处理结束后再调用当前中间件中的代码。koa2框架中中间件的实现方式为方式二,将next()方法返回值封装成一个Promise,便于后续中间件的异步流程控制,实现了koa2框架提出的洋葱圈模型,即每一层中间件相当于一个球面,当贯穿整个模型时,实际上每一个球面会穿透两次。

参考文章