《大前端进阶 Node.js》系列 入门

10,991 阅读13分钟

前言

codeing 应当是一生的事业,而不仅仅是 30 岁的青春饭
本文已收录 Github https://github.com/ponkans/F2E,欢迎 Star,持续更新

这篇是 Node.js 核心基础,接水怪觉得一定要掌握哦~


每篇文章都希望你能收获到东西,这篇是围绕 Node.js 的核心架构与基础进行分析,希望你看完,能够有这些收获:

  • Node.js 架构中各个层的含义及关系
  • Node.js 如何与底层操作系统交互呢,比如读取一个文件的时候,都发生了些啥
  • Node.js 是如何处理高并发请求的,如何使得服务器性能被很好的利用的
  • 事件驱动的优势,以及实现方式

很多前端初学者,特别是在校大学生,遇到的第一个技术瓶颈就是今天要讲的 Node.js,其实主要还是一些重要的概念没有理解,一些基础的知识没有掌握,比如编译原理。

PS:前端小伙伴也要重视计算机基础哦,等你工作越久,体会应该就会越深啦~ 很多看似很复杂的东西,其实追溯到底层,也就是计算机的那点事儿。

比如,现在很流行的跨端框架,其实核心就是 AST 抽象语法树的一个转换~

好了步入正题,下面有请怪怪来跟大家摆摆那些关于 Node.js 的龙门阵~

架构

相信只要你是一名前端,或多或少都能说出一些你对 Node.js 的理解与看法。

我们先来看看浏览器与 Node 的一个对比,毕竟很多前端初学者可能还没有接触过 Node,只是在浏览器里面跑项目。

左图是浏览器的一个简单架构,我们平时写的前端项目无非就是 3 个部分嘛。

HTML 跟 CSS 交给 WebKit 引擎去处理,经过一系列的转换处理,最终呈现到我们的屏幕上,之前有看过 Chrome 团队 Steve Kobes 的一个分享,从最底层出发分析了浏览器的一个渲染过程,后面找时间再跟大家分享。

JavaScript 交给 V8 引擎去处理,解析,关于引擎本文暂时不多讲。

再往下看到中间层,Chrome 中的中间层能力是有限的,因为被限制在了浏览器中,比如我们想在浏览器中操作一些本地的文件, 早些时候是很难的一件事情,不过随着 HTML5 的普及,已经可以实现部分功能了,但是跟 Node 中间层的能力比起来,还差很多。

我们把左图中的红色部分去掉,其实也就是一个简单的 Node 架构了,在 Node 中,我们可以随意的操控文件,甚至搭建各种服务,虽然 Node 不处理 UI 层,但是却与浏览器以相同的机制和原理运行,并且在中间层这里有着自己更加强大的功能。

顺着这个思路,我们再想想,如果我们把 WebKit 引擎也进行抽离,然后再加上 Node,是不是就可以脱离浏览器开发带有 UI 处理的 Node 项目了?想必你已经知道怪怪要说啥了,Electron 其实就是这样做的,也不是啥特别神奇的东西~

所以,简单直观的来讲 Node 就是脱离了浏览器的,但仍然基于 Chrome V8 引擎的一个 JavaScript 的运行环境。

从官网的介绍中也可以看到,其 轻量、高效、事件驱动、非阻塞 I/O 是 Node 几个很重要的特性,接下来,我们将从 Node 的运行机制作为切入点,一步步带大家剖析 Node 单线程如何实现高并发,又是如何充分利用服务器资源的。

上面的 Node 架构图比较简易,下面看看比较完整的。

基础架构可以大致分为下面三层~

上层

这一层是 Node 标准库,其实简单理解就是 JavaScript 代码,可以在编写代码时直接调用相关 API,Node 提供了很多很强大的 API 供我们实现,具体可多在实践中去使用深入,举个很简单的例子,我们可以用 Node 写一个定时脚本,定时给女朋友邮件推送你想对她说的话,女朋友开心了,你也学到技术了,完美~

中层

Node bindings(由 c++ 实现),这一层说白了就是个媒人,牵线搭桥,让 JavaScript 小哥哥能够与下层的一堆小姐姐进行交往,Node 之所有这么强,这一层起了十分关键的作用。

下层

这一层,是 Node.js 运行时的关键,这就有点东西了!我们挨个来说说~

V8,可以简单粗暴归纳为,目前业界最牛🍺的 JavaScrpt 引擎。虽然有人尝试使用 V8 的替代品,比如 node-chakracore 项目 以及 spidernode 项目,但 Node.js 依然默认使用 V8 引擎。

C-ares,一个由 C 语言实现的异步 DNS 请求库;

http_parser、OpenSSL、zlib 等,提供一些其他的基础能力。

libuv 是一个高性能的,事件驱动的 I/O 库,并且提供了跨平台(如 Windows、Linux)的API。它强制使用异步的,事件驱动的编程风格,核心工作就是提供一个 event loop,还有基于 I/O 和其它事件通知的回调函数。并且还提供了一些核心工具,例如定时器,非阻塞的网络支持,异步文件系统访问,子进程等。

有层次

有层次

Node 写一封情书的底层运作

这里参照《深入浅出 Node.js》书中的示例来进行说明

假设我们需要打开一个本地 txt 的文件来给女朋友写封情书,那代码可以写成这样:

let fs = require('fs');
fs.open('./情书.txt'"w"function(err, fd) {
    // Vows of eternal love, never separated(海誓山盟,永不分离)
});

fs.open() 的作用是根据指定路径和参数去打开一个文件,返回一个文件描述符

我们进去 lib/fs.js ,看看底层源码:

async function open(path, flags, mode) {
  mode = modeNum(mode, 0o666);
  path = getPathFromURL(path);
  validatePath(path);
  validateUint32(mode, 'mode');
  return new FileHandle(
    await binding.openFileHandle(pathModule.toNamespacedPath(path),
             stringToFlags(flags),
             mode, kUsePromises));
}

JavaScript 代码通过调用 C++ 核心模块进行下层操作,其调用过程可表示为

从 JavaScript 调用 Node.js 标准库,再由标准库调用 C++ 模块,C++ 模块再通过 libuv 进行系统调用,这一流程即为 Node 中最为常见的调用方式。同时 libuv 还提供了 *UNIX 和 Windows 两个平台的实现,赋予了 Node.js 跨平台的能力。

就这样子,情书搞定,你们感情更上一层楼,从此过上了幸福滴生活~

终究是单线程,我需要一个解释

怪怪我正在写基于 Node.js 的高并发口罩秒杀系统结构分析的文章哦~

解决了第一个问题,我们来看看第二个,Node 既然是单线程,那么是如何应对高并发场景的呢?

其实,Node 除了 JavaScript 的部分是单线程外,很多地方都是多线程的。

从上面写情书的例子就可以看到,Node 的 I/O 操作实际上是交给 libuv 来做的,而 libuv 提供了完整的线程池实现。所以,除了用户的 JavaScript 代码无法并行执行以外,所有的 I/O 操作都是可以并行的。

对什么线程池之类不熟悉的小伙伴,老实跟怪怪说,大学是不是就忙着给女朋友写情书去啦!!!

实际上,操作系统中对于 I/O 只有两种处理方式,即阻塞和非阻塞。

阻塞 I/O 即为调用之后需要等待完成所有操作后,调用才结束,这就造成了 CPU 一直在等待 I/O 结束,处理能力得不到充分利用。

举个例子,你现在变成了一块 CPU,现在你要做两件事,第一件事就是给正在外面逛街的女朋友发消息询问是否回来吃饭(因为你要做饭,哈哈哈),第二件事就是打扫房间。

同步 I/O 的做法:给女朋友发消息,然后一直在线等,直到一个小时之后,女朋友终于回消息了。然后,你再去打扫房间,女朋友回到家,看到你为啥这么久才开始打扫房间,然后 everybody 在你头上暴扣~~

异步 I/O 的做法:给女朋友发完消息,然后直接开始打扫房间,等女朋友回消息之后,房间已经打扫完毕,并且饭也做好了,岂不是美滋滋?~

回到操作系统来讲就是,操作系统提供了非阻塞 I/O 的方法,在调用之后会立即返回,之后 CPU 可以去处理其他事务。但由于 I/O 并没有完成,立即返回的仅仅是调用的状态,为了获取最终结果,应用程序需要充分调用判断操作是否完成,即轮询。

目前常见的轮询技术主要有这么几种:

  1. read

    这是最原始的一种,通过重复调用来读取最终结果,在得到结果之前,CPU 会一直消耗在等待上。

  2. select

    read 基础上做了改进,通过对文件描述符上的事件状态来进行判断,当用户进程调用了select,那么整个进程会被block,而同时,kernel会「监视」所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回,这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。select 和之后的 pollepoll 也称为 I/O 多路复用。select 采用一个长度为 1024 的数组来存储状态,所以最多可以同时检查 1024 个文件描述符。

  3. poll

    poll 采用链表方式避免数组长度的限制,性能有所改善。

  4. epoll

    这是 Linux 下效率最高的 I/O 事件通知机制,在进入轮询时如果没有检查到 I/O 事件,将会进行休眠,直到事件发生将它唤醒,不会浪费 CPU。

  5. kqueue

    实现方式与 epoll 类似,仅在 BSD 系统下存在。

上面这些个轮询的名词,其实就是不同的轮询机制而已,不要被吓到了~~

轮询技术虽然能够实现非阻塞,但实际上还是一种同步调用,Linux 下存在一种方式提供原生的异步 I/O,但应用范围较小,所以,Node 选择了另一种方式来实现完整的异步 I/O。

因此,所谓的 Node 单线程其实只是一个 JavaScript 主线程,那些耗时的异步操作还是线程池完成的,Node 将这些耗时的操作都扔到线程池去处理了,而 Node 自己只需要往返调度,并没有真正的 I/O 操作。

单线程与 CPU 密集

单线程带来了不需要在意状态同步问题的好处,同时也带了几个弱点

  • 无法利用多核 CPU
  • 出现错误会导致整个应用退出
  • CPU 密集型任务会导致异步I/O失效

Node.js 中用来解决单线程中 CPU 密集任务的方法很粗暴,那就是直接开子进程,通过 child_process 将计算任务分发给子进程,再通过进程之间的事件消息来传递结果,也就是进程间通信。(Node 中是采用管道的方式进行通信的哦~)

啥,操作系统又不熟悉,小伙伴看来真的需要去补补基础了哦~ 国庆节就不要出去玩啦,哈哈哈~~

事件驱动

事件驱动的实质就是通过主循环加事件触发的方式来运行程序。

事件循环的职责,就是不断得等待事件的发生,然后将这个事件的所有处理器,以它们订阅这个事件的时间顺序,依次执行。当这个事件的所有处理器都被执行完毕之后,事件循环就会开始继续等待下一个事件的触发,不断往复

事件循环

Node 的事件循环采用了 libuv 的默认事件循环,相应代码可在 src/node.cc 中看到。

创建 Node 运行环境

Environment* env = CreateEnvironment(
        node_isolate,
        uv_default_loop(),
        context,
        argc,
        argv,
        exec_argc,
        exec_argv);

启动事件循环

bool more;
do {
  more = uv_run(env->event_loop(), UV_RUN_ONCE);
  if (more == false) {
    EmitBeforeExit(env);
    // Emit `beforeExit` if the loop became alive either after emitting
    // event, or after running some callbacks.
    more = uv_loop_alive(env->event_loop());
    if (uv_run(env->event_loop(), UV_RUN_NOWAIT) != 0)
      more = true;
  }
} while (more == true);
code = EmitExit(env);
RunAtExit(env);

more 用来标识是否进行下一轮循环。接下来 Node.js 会根据 more 的情况决定下一步操作

  • 如果moretrue,则继续运行下一轮loop
  • 如果morefalse,说明已经没有等待处理的事件了,EmitBeforeExit(env); 触发进程的 beforeExit 事件,检查并处理相应的处理函数,完成后直接跳出循环。

最后触发 exit 事件,执行相应的回调函数,Node.js 运行结束,后面会进行一些资源释放操作。

观察者

每个事件循环中都会有观察者,判断是否有要处理的事件就是向这些观察者询问。在 Node.js 中,事件来源主要有网络请求,文件 I/O 等,这些事件都会对应不同的观察者。

请求对象

想啥呢? 不是那个对象,是这个对象!!

请求对象是 Node 发起调用到内核执行完成 I/O 操作的过渡过程中,产生的一种中间产物。例如,libuv 调用文件 I/O 时,就会立即返回 FSReqWrap 请求对象,JavaScript 传入的参数和当前的方法都被封装在这个请求对象中,同时这个对象也会被推送给内核等待执行。

事件驱动的优势

事件循环、观察者、请求对象、I/O 线程池共同构成了 Node 的事件驱动异步 I/O 模型

Apache 采用每个请求启动一个线程的方式来处理请求,虽然线程比较轻量,但仍需要占用一定内存,当大并发请求来临时,内存占用会非常高,导致服务器缓慢。

Node.js 采用事件驱动的方式处理请求,无须为每个请求创建线程,可以省去很多线程创建、销毁和系统上下文切换的开销,即使在大并发条件下,也能提供良好的性能。Nginx 也和 Node 采用了相同的事件驱动模型,借助优异的性能,Nginx 也在逐渐取代 Apache 成为 Web 服务器的主流。

总结

本文已收录 Github https://github.com/ponkans/F2E,欢迎 Star,持续更新💧

怪怪上面讲的很多地方也不是很深入,但大致框架跟结构大同小异,想深入了解的小伙伴,可以参考《深入浅出Node.js》。