Node.js 技术架构

一、Node.js是什么?

Node.js 最初开始于2009年,让 JavaScript 代码离开浏览器的执行环境也可以执行

可以将Node.js理解为一个将多种技术组合起来的平台,可以使用 JavaScript 调用系统接口

既然说到将多种技术组合起来,那么可以先看看 Node.js 用到了哪些技术

图片是 nodejs v1.0 也就是最早发布的 node 版本下的 deps 文件,也就是 nodejs 所用到的依赖

  • cares:用 C-ares 做域名解析

  • gtest:是 C/C++ 的单元测试框架

  • http-parser:用来解析 HTTP

  • npm:包管理工具

  • openssl:用来解析 HTTPS

  • uv:一个跨平台的异步 I/O

  • v8:google 开发的js引擎,为 js 提供运行环境

  • zlib:用来做加密

那么这些技术又是怎么进行组合的呢,再看看 Node.js 的技术架构

将Node.js分成三层

  • 首先最上层是 Node API,提供 http 模块、流模块、fs文件模块等等,可以使用 js 直接调用

  • 中间层 node bindings 主要是使 js 和 C/C++ 进行通信

  • 最下面这一层是支撑 nodejs 运行的关键,主要由 v8libuvc-ares 等模块组成,向上一层提供 api 服务

相信我们或多或少都接触过第一层 Node API,刚刚也通过 node 的依赖初步了解了最下层的模块具有什么功能,那么中间的这个 Node bindings 又是什么呢?

二、什么是Node bindings?

背景:C/C++ 实现了一个用来解析 HTTP 的库 http-parser,非常高效,可是对于只会写 js 的程序员非常的不友好,因为没有办法直接去调用这个 C/C++ 的库,这两个语言连最基本的数据类型都不一样,还怎么做朋友

结论:js 无法直接调用 C++ 的库,需要一个中间的桥梁(调用途径)

那么 bindings 需要怎么实现呢?

Node.js 的作者 Ryan 做了一个中间层处理

  • Node.js 用 C++ 对 http-parse 进行封装,使它符合某些要求(比如统一数据类型),封装好的文件叫做 http_parse_binding.cpp

  • 用 Node.js 提供的编译工具将其编译为 .node 文件

  • js 代码可以直接通过 require 关键字引入这个 .node 文件

这样 js 就能够调用 C++ 库,这个中间的桥梁就是 bindings,由于 node 提供了很多 binding,所以就叫做 Node bindings

2.1 JS如何与C++通信?

2.1.1 JS调用C++代码

// test.js
const addon = require('./build/Release/addon');

console.log('This should be eight:', addon.add(3, 5));
复制代码

上面是js调用,再来看看C++代码(已被编译)

// addon.cc
#include <node.h>

namespace demo {

using v8::Exception;
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::NewStringType;
using v8::Number;
using v8::Object;
using v8::String;
using v8::Value;

// 这是 "add" 方法的实现。
// 输入参数使用 const FunctionCallbackInfo<Value>& args 结构传入。
void Add(const FunctionCallbackInfo<Value>& args) {
  Isolate* isolate = args.GetIsolate();

  // 检查传入的参数的个数。
  if (args.Length() < 2) {
    // 抛出一个错误并传回到 JavaScript。
    isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, 
                        "参数的数量错误",
                            NewStringType::kNormal).ToLocalChecked()));
    return;
  }

  // 检查参数的类型。
  if (!args[0]->IsNumber() || !args[1]->IsNumber()) {
    isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, 
                        "参数错误",
                            NewStringType::kNormal).ToLocalChecked()));
    return;
  }

  // 执行操作
  double value =
      args[0].As<Number>()->Value() + args[1].As<Number>()->Value();
  Local<Number> num = Number::New(isolate, value);

  // 设置返回值 (使用传入的 FunctionCallbackInfo<Value>&)。
  args.GetReturnValue().Set(num);
}

void Init(Local<Object> exports) {
  NODE_SET_METHOD(exports, "add", Add);
}

NODE_MODULE(NODE
_GYP_MODULE_NAME, Init)

}  // 命名空间示例
复制代码

Nodejs 封装的插件开放一些对象和函数,供运行在 Node.js 中的 js 访问,当 js 调用函数 addon 时,输入参数和返回值与 C/C++ 代码相互映射,统一封装处理。这样就可以直接在 Node.js 中引入并使用

2.1.2 C++调用JS回调

// test.js
const addon = require('./build/Release/addon');
// 传入一个函数
addon((msg) => {
  console.log(msg);
// 打印: 'hello world'
});
复制代码

传入C++并执行

// addon.cc
#include <node.h>

namespace demo {

using v8::Context;
using v8::Function;
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::NewStringType;
using v8::Null;
using v8::Object;
using v8::String;
using v8::Value;

void RunCallback(const FunctionCallbackInfo<Value>& args) {
  Isolate* isolate = args.GetIsolate();
  Local<Context> context = isolate->GetCurrentContext();
  Local<Function> cb = Local<Function>::Cast(args[0]);
  const unsigned argc = 1;
  // 这里有一个c++方法,将args[0]也就是我们传入的函数,转化成c++看得懂的,用cb接收
  Local<Value> argv[argc] = {
      String::NewFromUtf8(isolate,
                          "hello world",
                          NewStringType::kNormal).ToLocalChecked() };
  // 调用一下,传入的函数就被调用了,打印出hello world
  cb->Call(context, Null(isolate), argc, argv).ToLocalChecked();
}

void Init(Local<Object> exports, Local<Object> module) {
  NODE_SET_METHOD(module, "exports", RunCallback);
}

NODE_MODULE(NODE_GYP_MODULE_NAME, Init)

}
复制代码

在这个例子中,回调函数被同步地调用,要知道 C++ 是看不懂 js 的,所以如何做中间层的封装就交给这些 Node 插件去做

有了这些 Node.js 提供的插件(node binding),JS和C++就可以进行交互了,也使JS的能力被大大的扩展了

再回顾一下 Node.js 的技术架构

刚刚详细介绍了什么是 Node bindings,它是如何工作的,接着再来看最下面一层功能模块

三、什么是V8

V8引擎是一个 JavaScript 引擎实现,最初由一些语言方面专家设计,后被谷歌收购,随后谷歌对其进行了开源

V8使用 C++ 开发,在运行 JavaScript 之前,相比其它的 JavaScript 的引擎转换成字节码或解释执行,V8 将其编译成原生机器码

Node.js 为啥选择 V8?JavaScript 程序在 V8 引擎下的运行速度媲美二进制程序,它是现阶段执行 JavaScript 最快的一个引擎

3.1 那么v8的功能有哪些呢

  • 将 JS 源代码变成本地代码并执行

  • 维护调用栈,确保 JS 函数的执行顺序

  • 内存管理,为所有对象分配内存

  • 垃圾回收,重复利用无用的内存

  • 实现 JS 的标准库

需要注意的是:js 是单线程的,而 V8 本身是多线程的,开一个线程执行 js,开一个线程清理内存,然后再处理一些其他别的活儿,线程和线程之间毫无瓜葛

四、什么是libuv

背景:因为各个系统的 I/O 库都不一样,windows 系统有 IOCP,Linux 系统有 epoll。Node.js 的作者Ryan 为了将其整合在一起实现一个跨平台的异步 I/O 库,开始写 libuv

好了,背景说完了,啥是I/O?

例如:

  • 从操作系统写文件到硬盘

  • 访问网络,从操作系统发出数据到别的服务器

  • 打印连接打印机,从操作系统发指令给打印机

以上这些行为都是 I/O,可以理解为系统和外界进行交互的过程都叫 I/O

libuv 会根据你是什么系统,自动的选择当前系统已经实现好了的异步操作(I/O)库,用于TCP/UDP/DNS文件等的异步操作

  • 比如操作 TCP,我们都知道 HTTP 是基于 TCP/IP 的,如果可以操作 TCP 那么,就可以做 HTTP 的服务

  • UDP,用于实时通信,常见的 QQ 聊天

  • 解析 DNS

包括读文件、写文件什么的,libuv 都可以帮你管理。这样 I/O 的部分就全部交给 C 语言去做,js 完全不用管,甩手掌柜,负责调用就行了

_v8_ _libuv_ 在整个 Node.js 架构的底层是最为重要的,其他功能就不做详细介绍了

五、Node.js工作流程

了解了 Node Bindings、v8、还有 libuv 貌似可以把工作流程串一串了

Application 就是咱们写的代码,把它放在 V8 上面去运行。发现需要去读一个文件,这时候 libuv开一个线程去读文件。读完文件,操作系统会返回一个事件给 event loopevent loop 就把文件传回给 V8,再给到代码

Emmm…

还是先了解一下 Event Loop 吧

5.1 Node.js中的Event Loop

"Event Loop是一个程序结构,用于等待和发送消息和事件。(a programming construct that waits for and dispatches events or messages in a program.)"

JavaScript 是单线程的,有了 Event Loop 的加持,Node.js 才可以非阻塞地执行 I/O 操作,把这些操作尽量转移给操作系统来执行

我们知道大部分现代操作系统都是多线程的,这些操作系统可以在后台执行多个操作。当某个操作结束了,操作系统就会通知 Node.js,然后 Node.js 就(可能)会把对应的回调函数添加到 poll(轮询)队列,最终这些回调函数会被执行

Event Loop,是Event和Loop

5.1.1 Event

计时器到期了、文件可以读取了、读取出错了

比如说在 js 里面写一个 setTimeOut,10 秒之后打印一行字,所以当 10 秒钟到了,就会产生一个事件,执行回调

什么时候文件可以读,什么时候文件可以写,或者说读取出错的时候,就需要操作系统生成一个事件(Event)告诉 js,因为 js 也不知道

一般来说事件分两种,内部的和外部事件,比如计时器就是内部事件,文件读取就是外部的,因为文件在硬盘上面,硬盘和操作系统又是分开的

5.1.2 Loop

Loop 就是循环,由于事件分优先级,所以处理起来也是分先后顺序,所以 Node.js 需要按顺序轮询每种事件,轮询是循环的

既然说到事件优先级,举个例子,有三种不同的事件

setTimeout(fn1, 100) // 计时器到期了
fs.readFile(‘/1.txt’, fn2) // 文件可以读了
server.on(‘close’, fn3) // 服务器关闭了
复制代码

以上三种事件如果同时发生,执行顺序是怎么样的?

1. 执行读文件,文件来了立马去读

因为如果文件可以读了现在不读,没准儿过会儿就不能读了

2. 执行服务器事件

用户请求进来,可以稍微等一会儿,但是如果太久了也可能就不请求了

3. 执行定时器的事件

定时器可以拖一下

这个顺序(执行顺序)是人为规定的,循环起来

5.2 Event Loop 各阶段详解

   ┌───────────────────────┐
┌─>│        timers         │检查计时器
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │打扫战场,可以忽略
│  └──────────┬────────────┘      ┌─────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │检查系统事件  <─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └─────────┘
│  │        check          │检查setImmediate回调
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │执行关闭事件的回调函数
   └───────────────────────┘
复制代码

上图其中每个方框都是 Event Loop 中的一个阶段,每个阶段都有一个「先入先出队列」,这个队列存有要执行的回调函数

5.2.1 timer 阶段

先看看有没有计时器,有了执行回调函数

需要注意的是:当计时器指定的时间达到后,回调函数会尽早被执行。如果操作系统很忙,或者 Node.js 正在执行一个耗时的函数,那么计时器的回调函数就会被推迟执行

举个例子,置了一个计时器在 100 毫秒后执行,然后你的脚本用了 95 毫秒来异步读取了一个文件

5.2.2 I/O 阶段

这个阶段会执行一些系统操作的回调函数,还有没有其他没有归类的回调(没有归类的回调:不在 timers 阶段、close callbacks 阶段和 check 阶段这三个阶段执行的回调)

5.2.3 Idle 阶段

空闲一会儿,清理战场

5.2.4 Poll 阶段

轮询阶段,处理大部分的事件(文件可读了?读!http请求来了,处理!)

当 event loop 进入 poll 阶段,如果发现没有计时器,就会:

  1. 如果 poll 队列不是空的,event loop 就会依次执行队列里的回调函数,直到队列被清空或者到达 poll 阶段的时间上限。

  2. 如果 poll 队列是空的,就会:

       a) 如果有 setImmediate() 任务,event loop 就结束 poll 阶段去往 check 阶段。

b) 如果没有 setImmediate() 任务,event loop 就会等待新的回调函数进入 poll 队列,并立即执行它。

一旦 poll 队列为空,event loop 就会检查计时器有没有到期,如果有计时器到期了,event loop 就会回到 timers 阶段执行计时器的回调

5.2.5 Check 阶段

如果 poll 阶段空闲了,同时存在 setImmediate() 任务,event loop 就会进入 check 阶段

setImmediate() 是通过 libuv 里一个能将回调安排在 poll 阶段之后执行的 API 实现的

5.2.6 Close callback 阶段

比如 socket.destroy(),就会有一个 close 事件进入这个阶段

------------循环-----------------

但是 Node.js 不傻,不会一直循环循环,如果发现没什么事儿做,就会停留在 poll(轮询)阶段

轮询的阶段呢,会看看有没有文件可以读,有没有请求可以处理,就等着,时不时的看看有没有新的代码,或者检查一下最近的计时器,看看有没有需要过会儿去执行的 callback

如果计时器事件要处理了,我再从下出发,绕回 timers

Node.js 大部分时间都会停留在 poll 阶段,大部分事件都在 poll 阶段被处理,如文件、网络请求

程序结束时,Node.js 会检查 Event Loop 是否在等待异步 I/O 操作结束,是否在等待计时器触发,如果没有,就会关掉 Event Loop

5.3 回顾一下

相信大家对 Event Loop 有了一个初步的了解和认识,那么看回 Node.js 工作流程

  1. Application就是咱们写的代码,把它放在v8上面去运行
  2. 运行的过程中,发现我们写了个setTimeoutv8就会调用Node.js的bindings,把这个settimeout放进Even loop里面
  3. Event loop就会等待适合的时机去发送一个事件去执行这些 js 代码,接着循环等待,一般停留在 poll阶段久一些
  4. 发现需要去读一个文件,这时候 Event loop 就会通过 libuv 开一个线程去专门做读文件这事儿
  5. 读完文件,操作系统会返回一个事件给 Event loop,Event loop 就把文件传回给v8,最后给到代码

js 从头至尾都不参与读文件这个事情,libuv去读

(可以看到最最重要的部分是 libuv 和 V8,而我们写的代码只占小小的一部分)

一句话就是,代码到 V8,通过Node API 使用libuv 和其他一些 C/C++ 提供的功能去完成用户所需要的功能

Node.js 将这些模块进行整合,所以说 Node.js 不是一门语言,就是一个结合技术的平台

六、总结

  • 用 Node.js 标准库简化 JS 代码

Node 很贴心的给用户准备了很高效的库,比如 httpfs 之类的,大大简化了你的 js 代码

  • 用 V8 运行 JS

接着 Node.js 又引入了v8,让 js 代码离开浏览器的执行环境也能够运行

  • 用 bindings 让JS能和 C/C++ 沟通

咱们再如何使用 js 也使用这些功能呢,这时候 bindings 的价值就体现出来了,让 js 能够直接和 C++ 沟通,直接require一下.node文件

  • 用 C/C++ 库高效处理 DNS/HTTP…

Node.js 还使用一些 C++ 的库,高效的处理了 dns/http 等常用功能,有了这些功能,基本上就可以处理文件,处理网络等一些杂七杂八的事情

  • 用 Event Loop 管理事件处理顺序

基于 libuv,Node.js 又实现了一个 Even Loop 用来管理不同事件的处理顺序

  • 用 libuv 进行异步 I/O 操作

Node.js 是使用 libuv 进行异步 I/O 操作,一般来说读文件是一个同步的动作,这时候有了libuv,Nodejs 就把这活儿交给了 libuv,让 libuv 去读这个文件,读完了发过来一个事件,Node.js 再接手处理,这就是个很重要的异步过程

那么为啥 Node.js 可以高效的处理这些请求呢?因为直接使用 C 语言的代码,要比 js 快

简单来说,js是一种动态类型语言,在编译时并不能准确知道变量的类型,只可以在运行时确定,这就不像 C++ 或者 Java 等静态类型语言,在编译时候就可以确切知道变量的类型。然而,在运行时计算和决定类型,会严重影响语言性能,这也就是 js 运行效率比 C++ 或者 Java 低很多的原因之一

七、参考

JavaScript 运行机制详解:再谈Event Loop - 阮一峰的网络日志

认识 V8 引擎

什么是 Event Loop? - 阮一峰的网络日志

C++ 插件 | Node.js API 文档

GitHub - yjhjstz/deep-into-node: 深入理解Node.js:核心思想与源码分析

掘金

猜你喜欢

转载自juejin.im/post/7081891057918558221