Node 回顾与总结(一)

  Node.js 宣称其目标是“旨在提供一种简单的构建可伸缩网络程序的方法”,诞生自 2009 年,是有史以来发展最快的开发工具,它是运行环境而不是语言。如今 Node 的使用已经较为普遍,所在团队的后端微服务架构中,80% 都是 Node 服务,近一两年做了不少 Node 后端的开发,下一阶段学习计划是 Go 的学习和实践,所以有必要整理下Node的经历和累积。

为了简洁明了更加直观,采用互动性强的 QA 方式汇总。

Node 的历史

Q:请简单说下 Node 的发展历史?

  • Node 的创始人 Ryan Dahl 的工作是用 C/C++ 写高性能 Web 服务。对于高性能,异步IO、事件驱动是基本原则,但是用 C/C++ 写着实痛苦,于是他参考诸多高级语言,终选定 JavaScript,在2009年,Ryan及其团队正式推出了基于 JavaScript 语言和 V8 引擎的开源 Web 服务器项目,命名为 Node.js。

  • Node 第一次把 JavaScript 带入到后端服务器开发,成为了世界上无数 JavaScript 开发人员的宠儿,开始茁壮成长,期间 Node.js 的核心用户 Isaac Z. Schlueter 开发出奠定了 Node.js 如今地位的重要工具——NPM。

  • Node.js 基金会成立后,其生态逐渐扩张,connect, express, socket.io 等开源库层出不穷,一大波以 Node.js 作为运行环境的 CLI 工具涌现,其中不乏有用于加速前端开发的优秀工具,如 less, UglifyJS, browserify, grunt 等等。

  • 大量新功能、新技术、新实践伴随 Node 版本更迭被引入,发布了LTS版本,核心 API 趋于稳定,随著 ES2015 的发展和最终定稿,一大批利用 ES2015 特性开发的新模块出现,如原 express 核心团队所开发的 koa等,Node 生态愈加完善,大型企业级应用的实践愈加成熟。

Node 的特性

Q:Node.js 有哪些特性?

  • 异步事件驱动

  • 非阻塞I/O

  • 轻量、可伸缩,适于实时数据交互应用

  • 单进程,单线程(这里指主线程)

  • 性能出众 (依赖 Chrome V8 引擎解释,是一个多线程平台、运行环境,对 JavaScript 层面的任务处理是单线程的)

多进程与多线程

Q:聊一下你对进程、线程的了解?

  • 进程:资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例。(程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行)

  • 线程:线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位,一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量。(线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理)

Q:线程和进程有什么区别和优劣呢?

  • 概念:进程是资源分配的最小单位,线程是程序执行的最小单位。

  • 资源消耗:进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。

  • 通信方便:进程之间的通信需要以通信的方式(IPC)进行,而线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据。

  • 健壮性:多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。

任务类型(I/O-bound 和 CPU-bound, macrotask 和 microtask)

Q:在高并发、多线程编程中经常会听到两个密集型,请解释下。

  • I/O密集型 (I/O-bound):

    • 主要是进行网络、硬盘、内存的IO操作,执行IO操作的时间较长,导致CPU的利用率不高;
    • 此时系统运作状况为 CPU 在等 I/O (硬盘/内存) 的读/写,此时 CPU Loading 不高,CPU处于空闲状态、消耗很少;
    • 例如数据库增删改查操作、文件读写、访问I/O设备等;
    • 常见的大部分任务都是IO密集型任务,比如Web应用。
  • 计算密集型 (CPU-bound):

    • 主要是执行计算任务,响应很快,CPU一直在运行,这种任务CPU的利用率很高;
    • 此时系统运作状况为 CPU Loading 100%,主要消耗CPU资源,I/O在很短时间就可以完成;
    • 例如斐波那契数列等复杂计算,对数据加解密,数据压缩和解压,长时间循环计算,对视频进行高清解码等;
    • 大部分CPU占用率超高的任务是此类型,这可能因为任务不太需要访问I/O设备,也可能因为程序是多线程实现而屏蔽掉了等待I/O的时间。
  • 适用语言:计算密集型程序适合C语言多线程,I/O密集型适合脚本语言开发的多线程。

Q:浏览器环境中异步任务有哪些宏任务和微任务

  • 宏任务(macrotask): setImmediate、I/O、MessageChannel、postMessage、点击事件的callback、UI渲染、setTimeOut、setInterval等。

    • 优先级:script(全局任务)即主代码 > setImmediate > MessageChannel > setTimeOut/setInterval

    • 特点:宏任务队列每次只会取出一条任务到执行栈中执行。

    • Vue 中对于 macro task 的实现:优先检测是否支持原生 setImmediate,这是一个高版本 IE 和 Edge 才支持的特性,不支持再检测是否支持原生的 MessageChannel,如果也不支持的话就降级使用 setTimeout 0

  • 微任务(microtask):process.nextTick (非浏览器 Node 环境才有) 、Promise 、MutationObserver(HTML5特性)、Object.observer、async(实质上是Promise)、vue.$nextTick

    • 优先级:process.nextTick > Promise > MutationOberser

    • 特点:微任务队列操作,总是会一次性执行直到清空队列。

    • Vue 对于 microTask 的实现:Vue.$nextTick 并不一定是微任务,这取决于这个函数采用 Promise/MutationObserver 还是setTimeout 实现,当前环境若不支持 Promise,然后也不支持MutationObserver(2.5+版本后由于兼容性弃用,使用 MessageChannel 替代),则降级采用 setTimeout 0 实现。

    • 2.4版本前:Vue.$nextTick 几乎都是基于 microTask 实现的,采取的策略是默认走microTask,但是由于microTask的执行优先级非常高,在某些场景下它甚至要比事件冒泡还快,就导致一些诡异的问题;但如果全改成 macroTask,对一些有重绘和动画的场景也会有性能的影响。

    • 2.5+版本后:Vue.$nextTick 采取的策略是默认走 microTask,对于一些DOM的交互事件,如v-on绑定的事件回调处理函数的处理会强制走 macroTask,而 Vue 对于 macro task 的实现,上面已经说过了。

  • EventLoop中分为 内存、执行栈、WebApi、异步回调队列,而异步回调队列则包括微任务队列和宏任务队列。

  • 浏览器的EventLoop执行流程:

    -> JS 内核加载代码到执行栈。
    -> 执行栈依次执行主线程同步任务,并将异步任务加入异步回调队列。(宏任务事件压入宏任务队列、微任务事件压入微任务队列)
    -> 执行栈代码执行完毕,一次性执行当前微任务队列所有回调事件,直到清空队列。
    -> 取出宏任务队列第一条放入执行栈执行,执行过程在遇到宏、微任务各自入队,本轮执行结束,往复循环上一步骤和本步骤,构成事件循环。

Q:为了合理最大限度的使用系统资源同时也要保证的程序的高性能,如何配置CPU密集型任务和IO密集型任务的线程数?

  • IO密集型:线程个数 = CPU核数 * 2。(好处:其中的线程在IO操作的时候,其他线程可以继续用CPU,提高了CPU的利用率)

  • CPU密集型:线程个数 = CPU核数 || CPU核数 + 1。(好处:这些线程可并行执行,不存在线程切换的开销,提高了CPU的利用率的同时也减少了切换线程导致的性能损耗。)

执行方式(串行、并行、并发、同步、异步、阻塞、非阻塞)

Q:如何理解串行、并行、并发的区别?

  • 串行:同步线程的实现方式,多个任务按顺序执行,单个线程只能执行一个任务,一个任务执行完成才能执行下一个。

  • 并行:真正的异步,多核CUP可以同时开启多条线程供多个任务同时执行,互不干扰。

  • 并发:伪异步,在单核CUP中只能有一条线程,但又想执行多个任务,只能在一条线程上不停的切换任务。由于CUP处理速度快,貌似是同时执行,其实在同一时间只会执行单个任务。

Q:如何理解阻塞非阻塞与同步异步的区别?

  • 同步和异步关注点在于消息通信机制。

    同步调用:在发起一个调用后,调用者主动等待这个调用的结果,在没有得到结果前,该调用就不返回,但是一旦调用返回,就得到返回值了。

    异步调用:一个异步调用发出后就直接返回了,但调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

    举例:以人工查话费账单为例,如果是同步通信,小李让客服A帮忙查话费账单,她可能马上查到,也可能很久,然后告诉小李结果。如果是异步,客服A会告诉小李等她查好会发短信通知,然后就挂电话。这里的短信回复便是回调。

  • 阻塞和非阻塞关注点在于程序在等待调用结果(消息,返回值)时的状态.

    阻塞调用:调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。

    非阻塞调用:不能立刻得到结果之前,该调用不会阻塞当前线程。

    举例:还以上述为例,如果阻塞式调用,客服A在帮小李查询到账单之前,小李会被挂起,一直等待结果返回,无法做其他的事情,而非阻塞式调用,小李不用管客服A有没有告诉他,小李可以去做其他的事,只需周期性的检查是否有短信回复。

  • 所谓同步异步,只是对于客服A而言。 所谓阻塞非阻塞,仅仅对于小李而言。

运行机制

Q:JavaScript 运行机制是怎样的?(JavaScript的异步机制)

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

  2. 主线程之外,还存在一个线程处理”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件,只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。

  3. 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”中的事件,并执行事件对应的异步任务(回调函数),该异步任务结束等待状态,进入执行栈,开始执行。

  4. 主线程不断重复上面的第三步。如下图。

  • 任务队列:是一个FIFO的数据结构,排在前面的事件,优先被主线程读取。它一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在”任务队列”中添加一个事件,表示相关的异步任务可以进入”执行栈”了。主线程读取”任务队列”,就是读取里面有哪些事件。

  • 回调函数:指那些会被主线程挂起的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

  • 事件:除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。如果”定时器”存在,主线程可能首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

  • 事件循环:主线程从”任务队列”中读取事件,这个过程是循环不断的,所以这种运行机制就称为 Event Loop。主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在”任务队列”中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取”任务队列”,依次执行那些事件所对应的回调函数。如下图。

Q: Node.js的架构及运行机制是怎样的?

  1. V8引擎解析JavaScript脚本。

  2. 解析后的代码,调用Node API。

  3. libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。

  4. V8引擎再将结果返回给用户。如下图。

  • 图解:

    • 应用层:即 JavaScript 交互层,常见的就是 Node.js 的模块,比如 http,fs,https, util。

    • V8引擎层:即利用 V8 引擎来解析JavaScript 语法,进而和下层 API 交互。

    • NodeAPI层:为上层模块提供系统调用,一般是由 C 语言来实现,和操作系统进行交互, 比如 net, buffer, child_process, file。

    • LIBUV层:是跨平台的底层封装,实现了事件循环、异步IO,如文件操作等,是 Node.js 实现异步的核心。(libuv是一个高性能的,事件驱动的I/O库,并且提供了跨平台(如windows, linux)的API,被很多编程语言引入,如 Rust),他的事件循环中宏、微任务执行机制,如下图。

      图解如下:

      • 上面每个阶段对应着一个事件队列,当EventLoop执行到某阶段时,执行对应的事件队列中的事件,当该队列执行完毕或者执行数量超过上限,EventLoop就会执行下一个阶段。
      • 每当EventLoop切换一个执行队列时,就会去清空microtasks queues,然后再切换到下个队列去执行,反复循环。

Q: Node 事件循环的运行机制是怎样的?

  • libuv :实现 Node.js 事件循环和平台的所有异步行为的 C 函数库

  • 事件循环, 大体上分为6个阶段,执行顺序及内容如下图,建议去通读一下官方文档

    • timers(定时器阶段)

      定时器用途是让指定的回调函数在某个阈值后会被执行,具体的执行时间并不一定是那个精确的阈值。(定时器的回调会在制定的时间过后尽快得到执行,然而,操作系统的计划或者其他回调的执行可能会延迟该回调的执行。)

      定时器阶段会处理setTimeout()和setInterval()的回调函数。进入这个阶段后,主线程会检查一下当前时间,是否满足定时器的条件。如果满足就执行回调函数,否则就离开这个阶段。

      注:从技术上来看,轮询阶段控制了定时器的执行时机。

    • pending callbacks(pending 回调阶段)

      执行延迟到下一个循环迭代的 I/O 回调,除了 setTimeout 和 setInterval 、setImmediate、用于关闭请求的回调函数,如socket.on(‘close’, …),其他的回调函数都在这个阶段执行。

      执行的回调函数:几乎全部发生异常的 close 回调,由定时器和setImmediate()计划的回调,TCP错误之类的系统操作的回调。

    • idle, prepare (空闲,预备阶段)

      该阶段只供 libuv 内部调用,分为 idle handlers 和 prepare handlers 两部分,prepare handlers 会为Poll阶段做些准备工作。

    • Poll(轮询阶段)

      该阶段获取新的 I/O 事件,轮询时间等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。

      该阶段时间较长,Node.js 这时会适当进行阻塞,如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。

      注:为了防止轮询阶段持续时间太长,libuv 会根据操作系统的不同设置一个轮询的上限。

      轮询阶段有两个主要功能或过程:

      1. 执行已经到时的定时器脚本
      2. 处理轮询队列中的事件。

      当EventLoop进入到轮询阶段,却没有发现定时器时:

      • 如果轮询队列非空:

        EventLoop会迭代回调队列并同步执行回调,直到队列空了或者达到了上限(前文说过的根据操作系统的不同而设定的上限)

      • 如果轮询队列是空的:

        • 如果有setImmediate()定义了回调:EventLoop终止轮询阶段,进入check阶段去执行setImmediate回调;

        • 如果没有setImmediate():事件回调会等待回调被加入队列并立即执行。

      总结:一旦轮询队列空了,事件循环会查找已经到时的定时器。如果找到了,事件循环就回到定时器阶段去执行回调。

  • check(检查阶段)

    该阶段执行setImmediate()的回调函数。

    setImmediate() 是个特殊的定时器,被设计成在一个单独的阶段运行, 它使用libuv的 API 来使得一旦轮询阶段完成就执行回调函数。

    事件循环会最终进入到等待状态的poll阶段,可能是等待一个连接、请求等。然而,如果有一个setImmediate() 设置了一个回调并且poll阶段空闲了,那么事件循环会进入到check阶段而不是等待轮询事件.

  • close callbacks

    该阶段执行关闭请求的回调函数,比如socket.on(‘close’, …)。

    如果一个 socket 或句柄(handle)被突然关闭(is closed abruptly),例如 socket.destroy(), ‘close’ 事件会被发出到这个阶段。否则这种事件会通过 process.nextTick() 被发出。

  • 详细图解如下

Q: Node.js的异步调用、线程池、事件循环之间关系是怎样的?(Node 异步 IO 处理流程)

  • 异步机制:Node.js 在主线程里维护了一个事件队列,当接到请求后,就将该请求作为一个事件放入这个队列中,然后继续接收其他请求。当主线程空闲时(没有请求接入时),就开始循环事件队列,检查队列中是否有要处理的事件,这时要分两种情况:如果是非 I/O 任务,就亲自处理,并通过回调函数返回到上层调用;如果是 I/O 任务,就从 线程池 中拿出一个线程来处理这个事件,并指定回调函数,然后继续循环队列中的其他事件。

  • 事件循环:当线程中的 I/O 任务完成以后,就执行指定的回调函数,并把这个完成的事件放到事件队列的尾部,等待事件循环,当主线程再次循环到该事件时,就直接处理并返回给上层调用。

定时器

Q: 说下 Node 的四个定时器?

  1. process.nextTick:在当前”执行栈”的尾部,下一次Event Loop(主线程读取”任务队列”)之前,触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。

  2. setImmediate:在当前”任务队列”的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。

  3. setTimeout

  4. setInterval

Node.js文档中称,setImmediate指定的回调函数,总是排在setTimeout前面。实际上,这种情况只发生在递归调用的时候。

Q:说下定时器的执行效率及区别?

  • 执行效率:process.nextTick > setImmediate > setTimeout/setInterval

  • process.nextTick和setImmediate的一个重要区别:多个process.nextTick语句总是在当前”执行栈”一次执行完,多个setImmediate可能则需要多次loop才能执行完。事实上,这正是Node.js 10.0版添加setImmediate方法的原因,否则像下面这样的递归调用process.nextTick,将会没完没了,主线程根本不会去读取”事件队列”。

1
2
3
process.nextTick(function foo() {
process.nextTick(foo);
});

Q:Node 定时器在事件循环中的执行顺序是怎样的?

  1. IO 事件是异步同时执行的,严格来说不在事件循环当中。

  2. setImmediate、setTimeout、setInterval 都是在当前事件循环中注册,如有延时,比如1000ms, 真正的执行函数 callback, 会在后面第 N 次循环才开始被主线程执行。

  3. 一次循环只执行一个事件。

  4. 延时为0时,setTimeout、setInterval与setImmediate相同,在事件执行后可插入事件。

  5. 下一次循环前可插入一个函数队列,即执行多个 process.nextTick。

定时器在事件循环中的执行顺序如下图:

高并发

Q:请说下现有Web服务器的高并发策略、优劣、工作流程?

  • 经典服务器模型:

    • 每进程/每请求:提供多进程模型,为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多。

    • 每线程/每请求:提供多线程模型,服务器为每个客户端请求分配一个线程,使用同步 I/O,系统通过线程切换来弥补同步 I/O 调用的时间开销, Apache采用这种策略。

  • Node 服务器模型:

    事件驱动的方式处理请求,无须为每个请求创建额外的对应线程,省掉创建和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。这使服务器能够有条不紊地处理请求,即使在大量连接的情况下,也不受线程上下文切换开销的影响,Node 采用这种策略。处理流程如下图。

Node 优劣和适用场景

Q: 请说下 Node 的优劣势?

  • 优点:

    • 简单易学,JavaScript 前后端统一语言。

    • 高性能,避免了频繁的线程切换开销。

    • 线程安全,没有加锁、解锁、死锁这些问题。

    • 占用资源小,因为对 JavaScript 层面的任务处理是单线程的,在大负荷情况下,对内存占用仍然很低。

    • 社区活跃,生态资源丰富。

  • 缺点:

    • 很多包的成熟度不高,代码质量良莠不齐。

    • 可靠性低、单进程,单线程,不能充分的利用多核CPU服务器。(该缺点可以通过代码的健壮性来弥补)

Q: 请说下 Node 的适用和非适用场景?

  • 适用场景:

    • RESTful JSON API

    • 聊天系统、微博系统等准实时应用系统

    • 电子游戏统计数据服务

    • 单页面、多Ajax请求Web应用

    • 基于Node.js开发Unix命令行工具

    • 流式数据——传统的Web应用,通常会将HTTP请求和响应看成是原子事件

  • 不适用场景:

    • 计算密集型应用,如视频编码、人工智能等

    • 流量低、简单物理架构的简单 Web 应用。