1.1 Node.js简介

Node.js是基于事件驱动的单进程单线程应用,单线程具体体现在Node.js在单个线程中维护了一系列任务,然后在事件循环中不断消费任务队列中的节点,又不断产生新的任务,在任务的产生和消费中不断驱动着Node.js的执行。从另外一个角度来说,Node.js又可以说是多线程的,因为Node.js底层也维护了一个线程池,该线程池主要用于处理一些文件IO、DNS、CPU计算等任务。

Node.js主要由V8、Libuv,还有一些其它的第三方模块组成(cares异步DNS解析库、HTTP解析器、HTTP2解析器,压缩库、加解密库等)。Node.js源码分为三层,分别是JS、C++、C,Libuv是使用C语言编写,C层主要是通过V8为JS层提供和底层交互的能力,C层也实现了部分功能,JS层是面向用户的,为用户提供调用底层的接口。

1.1.1 JS引擎V8

Node.js是基于V8的JS运行时,它利用V8提供的能力,极大地拓展了JS的能力。这种拓展不是为JS增加了新的语言特性,而是拓展了功能模块,比如在前端,我们可以使用Date这个函数,但是我们不能使用TCP这个函数,因为JS中并没有内置这个函数。而在Node.js中,我们可以使用TCP,这就是Node.js做的事情,让用户可以使用JS中本来不存在的功能,比如文件、网络。Node.js中最核心的部分是Libuv和V8,V8不仅负责执行JS,还支持自定义的拓展,实现了JS调用C和C调用JS的能力。比如我们可以写一个C模块,然后在JS调用,Node.js正是利用了这个能力,完成了功能的拓展。JS层调用的所有C、C模块都是通过V8来完成的。

1.1.2 Libuv

Libuv是Node.js底层的异步IO库,但它提供的功能不仅仅是IO,还包括进程、线程、信号、定时器、进程间通信等,而且Libuv抹平了各个操作系统之间的差异。Libuv提供的功能大概如下
• Full-featured event loop backed by epoll, kqueue, IOCP, event ports.
• Asynchronous TCP and UDP sockets
• Asynchronous DNS resolution
• Asynchronous file and file system operations
• File system events
• ANSI escape code controlled TTY
• IPC with socket sharing, using Unix domain sockets or named pipes (Windows)
• Child processes
• Thread pool
• Signal handling
• High resolution clock
• Threading and synchronization primitives

Libuv的实现是一个经典的生产者-消费者模型。Libuv在整个生命周期中,每一轮循环都会处理每个阶段(phase)维护的任务队列,然后逐个执行任务队列中节点的回调,在回调中,不断生产新的任务,从而不断驱动Libuv。下是Libuv的整体执行流程

01-Node.js组成和原理 - 图1

从上图中我们大致了解到,Libuv分为几个阶段,然后在一个循环里不断执行每个阶段里的任务。下面我们具体看一下每个阶段

  1. 更新当前时间,在每次事件循环开始的时候,Libuv会更新当前时间到变量中,这一轮循环的剩下操作可以使用这个变量获取当前时间,避免过多的系统调用影响性能,额外的影响就是时间不是那么精确。但是在一轮事件循环中,Libuv在必要的时候,会主动更新这个时间,比如在epoll中阻塞了timeout时间后返回时,会再次更新当前时间变量。
  2. 如果事件循环是处于alive状态,则开始处理事件循环的每个阶段,否则退出这个事件循环。alive状态是什么意思呢?如果有active和ref状态的handle,active状态的request或者closing状态的handle则认为事件循环是alive(具体的后续会讲到)。
  3. timer阶段:判断最小堆中的节点哪个节点超时了,执行它的回调。
  4. pending阶段:执行pending回调。一般来说,所有的IO回调(网络,文件,DNS)都会在Poll IO阶段执行,但是有的情况下,Poll IO阶段的回调会延迟到下一次循环执行,那么这种回调就是在pending阶段执行的,比如IO回调里出现了错误或写数据成功等等都会在下一个事件循环的pending阶段执行回调。
  5. idle阶段:每次事件循环都会被执行(idle不是说事件循环空闲的时候才执行)。
  6. prepare阶段:和idle阶段类似。
  7. Poll IO阶段:调用各平台提供的IO多路复用接口(比如Linux下就是epoll模式),最多等待timeout时间,返回的时候,执行对应的回调。timeout的计算规则:
    1 如果时间循环是以UV_RUN_NOWAIT模式运行的,则timeout是0。
    2 如果时间循环即将退出(调用了uv_stop),则timeout是0。
    3 如果没有active状态的handle或者request,timeout是0。
    4 如果有idle阶段的队列里有节点,则timeout是0。
    5 如果有handle等待被关闭的(即调了uv_close),timeout是0。
    6 如果上面的都不满足,则取timer阶段中最快超时的节点作为timeout。
    7 如果上面的都不满足则timeout等于-1,即一直阻塞,直到满足条件。
  8. check阶段:和idle、prepare一样。
  9. closing阶段:执行调用uv_close函数时传入的回调。
  10. 如果Libuv是以UV_RUN_ONCE模式运行的,那事件循环即将退出。但是有一种情况是,Poll IO阶段的timeout的值是timer阶段的节点的值,并且Poll IO阶段是因为超时返回的,即没有任何事件发生,也没有执行任何IO回调,这时候需要在执行一次timer阶段。因为有节点超时了。
  11. 一轮事件循环结束,如果Libuv以UV_RUN_NOWAIT 或 UV_RUN_ONCE模式运行的,则退出事件循环,如果是以UV_RUN_DEFAULT模式运行的并且状态是alive,则开始下一轮循环。否则退出事件循环。

下面我能通过一个例子来了解libuv的基本原理。

  1. 1. #include <stdio.h>
  2. 2. #include <uv.h>
  3. 3.
  4. 4. int64_t counter = 0;
  5. 5.
  6. 6. void wait_for_a_while(uv_idle_t* handle) {
  7. 7. counter++;
  8. 8. if (counter >= 10e6)
  9. 9. uv_idle_stop(handle);
  10. 10. }
  11. 11.
  12. 12. int main() {
  13. 13. uv_idle_t idler;
  14. 14. // 获取事件循环的核心结构体。并初始化一个idle
  15. 15. uv_idle_init(uv_default_loop(), &idler);
  16. 16. // 往事件循环的idle阶段插入一个任务
  17. 17. uv_idle_start(&idler, wait_for_a_while);
  18. 18. // 启动事件循环
  19. 19. uv_run(uv_default_loop(), UV_RUN_DEFAULT);
  20. 20. // 销毁libuv的相关数据
  21. 21. uv_loop_close(uv_default_loop());
  22. 22. return 0;
  23. 23. }

使用Libuv,我们首先需要获取Libuv的核心结构体uv_loop_t,uv_loop_t是一个非常大的结构体,里面记录了Libuv整个生命周期的数据。uv_default_loop为我们提供了一个默认已经初始化了的uv_loop_t结构体,当然我们也可以自己去分配一个,自己初始化。

  1. 1. uv_loop_t* uv_default_loop(void) {
  2. 2. // 缓存
  3. 3. if (default_loop_ptr != NULL)
  4. 4. return default_loop_ptr;
  5. 5.
  6. 6. if (uv_loop_init(&default_loop_struct))
  7. 7. return NULL;
  8. 8.
  9. 9. default_loop_ptr = &default_loop_struct;
  10. 10. return default_loop_ptr;
  11. 11. }

Libuv维护了一个全局的uvloop_t结构体,使用uv_loop_init进行初始化,不打算展开讲解uv_loop_init函数,w因为它大概就是对uv_loop_t结构体各个字段进行初始化。接着我们看一下uv_idle*系列的函数。

1 uv_idle_init

  1. 1. int uv_idle_init(uv_loop_t* loop, uv_idle_t* handle) {
  2. 2. /*
  3. 3. 初始化handle的类型,所属loop,打上UV_HANDLE_REF,
  4. 4. 并且把handle插入loop->handle_queue队列的队尾
  5. 5. */
  6. 6. uv__handle_init(loop, (uv_handle_t*)handle, UV_IDLE);
  7. 7. handle->idle_cb = NULL;
  8. 8. return 0;
  9. 9. }

执行uv_idle_init函数后,Libuv的内存视图如下图所示

01-Node.js组成和原理 - 图2

2 uv_idle_start

  1. 1. int uv_idle_start(uv_idle_t* handle, uv_idle_cb cb) {
  2. 2. // 如果已经执行过start函数则直接返回
  3. 3. if (uv__is_active(handle)) return 0;
  4. 4. // 把handle插入loop中idle的队列
  5. 5. QUEUE_INSERT_HEAD(&handle->loop->idle_handles, &handle->queue);
  6. 6. // 挂载回调,下一轮循环的时候被执行
  7. 7. handle->idle_cb = cb;
  8. 8. /*
  9. 9. 设置UV_HANDLE_ACTIVE标记位,并且loop中的handle数加一,
  10. 10. init的时候只是把handle挂载到loop,start的时候handle才
  11. 11. 处于激活态
  12. 12. */
  13. 13. uv__handle_start(handle);
  14. 14. return 0;
  15. 15. }

执行完uv_idle_start的内存视图如下图所示。

01-Node.js组成和原理 - 图3

然后执行uv_run进入Libuv的事件循环。

  1. 1. int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  2. 2. int timeout;
  3. 3. int r;
  4. 4. int ran_pending;
  5. 5. // 在uv_run之前要先提交任务到loop
  6. 6. r = uv__loop_alive(loop);
  7. 7. // 没有任务需要处理或者调用了uv_stop
  8. 8. while (r != 0 && loop->stop_flag == 0) {
  9. 9. // 处理idle队列
  10. 10. uv__run_idle(loop);
  11. 11. }
  12. 12.
  13. 13. // 是因为调用了uv_stop退出的,重置flag
  14. 14. if (loop->stop_flag != 0)
  15. 15. loop->stop_flag = 0;
  16. 16. /*
  17. 17. 返回是否还有活跃的任务(handle或request),
  18. 18. 业务代表可以再次执行uv_run
  19. 19. */
  20. 20. return r;
  21. 21. }

我们看到有一个函数是uv__run_idle,这就是处理idle阶段的函数。我们看一下它的实现。

  1. 1. // 在每一轮循环中执行该函数,执行时机见uv_run
  2. 2. void uv__run_idle(uv_loop_t* loop) {
  3. 3. uv_idle_t* h;
  4. 4. QUEUE queue;
  5. 5. QUEUE* q;
  6. 6. /*
  7. 7. 把该类型对应的队列中所有节点摘下来挂载到queue变量,
  8. 8. 变量回调里不断插入新节点,导致死循环
  9. 9. */
  10. 10. QUEUE_MOVE(&loop->idle_handles, &queue);
  11. 11. // 遍历队列,执行每个节点里面的函数
  12. 12. while (!QUEUE_EMPTY(&queue)) {
  13. 13. // 取下当前待处理的节点
  14. 14. q = QUEUE_HEAD(&queue);
  15. 15. // 取得该节点对应的整个结构体的基地址
  16. 16. h = QUEUE_DATA(q, uv_idle_t, queue);
  17. 17. // 把该节点移出当前队列,否则循环不会结束
  18. 18. QUEUE_REMOVE(q);
  19. 19. // 重新插入原来的队列
  20. 20. QUEUE_INSERT_TAIL(&loop->idle_handles, q);
  21. 21. // 执行回调函数
  22. 22. h->idle_cb(h);
  23. 23. }
  24. 24. }

我们看到uv__run_idle的逻辑并不复杂,就是遍历idle_handles队列的节点,然后执行回调,在回调里我们可以插入新的节点(产生新任务),从而不断驱动Libuv的运行。我们看到uv_run退出循环的条件下面的代码为false。

  1. 1. r != 0 && loop->stop_flag == 0

stop_flag由用户主动关闭Libuv事件循环。

  1. 1. void uv_stop(uv_loop_t* loop) {
  2. 2. loop->stop_flag = 1;
  3. 3. }

r是代表事件循环是否还存活,这个判断的标准是由uv__loop_alive提供

  1. 1. static int uv__loop_alive(const uv_loop_t* loop) {
  2. 2. return loop->active_handles > 0 ||
  3. 3. loop->active_reqs.count > 0 ||
  4. 4. loop->closing_handles != NULL;
  5. 5. }

这时候我们有一个actived handles,所以Libuv不会退出。当我们调用uv_idle_stop函数把idle节点移出handle队列的时候,Libuv就会退出。后面我们会具体分析Libuv事件循环的原理。

1.1.3 其它第三方库

Node.js中第三方库包括异步DNS解析(cares)、HTTP解析器(旧版使用http_parser,新版使用llhttp)、HTTP2解析器(nghttp2)、解压压缩库(zlib)、加密解密库(openssl)等等,不一一介绍。

1.2 Node.js工作原理

1.2.1 Node.js是如何拓展JS功能的?

V8提供了一套机制,使得我们可以在JS层调用C++、C语言模块提供的功能。Node.js正是通过这套机制,实现了对JS能力的拓展。Node.js在底层做了大量的事情,实现了很多功能,然后在JS层暴露接口给用户使用,降低了用户成本,也提高了开发效率。

1.2.2 如何在V8新增一个自定义的功能?

  1. 1. // C++里定义
  2. 2. Handle<FunctionTemplate> Test = FunctionTemplate::New(cb);
  3. 3. global->Set(String::New(“Test"), Test);
  4. 4. // JS里使用
  5. 5. const test = new Test();

我们先有一个感性的认识,在后面的章节中,会具体讲解如何使用V8拓展JS的功能。

1.2.3 Node.js是如何实现拓展的?

Node.js并不是给每个功能都拓展一个对象,然后挂载到全局变量中,而是拓展一个process对象,再通过process.binding拓展js功能。Node.js定义了一个全局的JS对象process,映射到一个C对象process,底层维护了一个C模块的链表,JS通过调用JS层的process.binding,访问到C的process对象,从而访问C模块(类似访问JS的Object、Date等)。不过Node.js 14版本已经改成internalBinding的方式,通过internalBinding就可以访问C++模块,原理类似。

1.3 Node.js启动过程

下面是Node.js启动的主流程图如图1-4所示。

01-Node.js组成和原理 - 图4

我们从上往下,看一下每个过程都做了些什么事情。

1.3.1 注册C++模块

RegisterBuiltinModules函数(node_binding.cc)的作用是注册C++模块。

  1. 1. void RegisterBuiltinModules() {
  2. 2. #define V(modname) _register_##modname();
  3. 3. NODE_BUILTIN_MODULES(V)
  4. 4. #undef V
  5. 5. }

NODE_BUILTIN_MODULES是一个C语言宏,宏展开后如下(省略类似逻辑)

  1. 1. void RegisterBuiltinModules() {
  2. 2. #define V(modname) _register_##modname();
  3. 3. V(tcp_wrap)
  4. 4. V(timers)
  5. 5. ...其它模块
  6. 6. #undef V
  7. 7. }

再一步展开如下

  1. 1. void RegisterBuiltinModules() {
  2. 2. _register_tcp_wrap();
  3. 3. _register_timers();
  4. 4. }

执行了一系列_register开头的函数,但是我们在Node.js源码里找不到这些函数,因为这些函数是在每个C++模块定义的文件里(.cc文件的最后一行)通过宏定义的。以tcp_wrap模块为例,看看它是怎么做的。文件tcp_wrap.cc的最后一句代码
NODE_MODULE_CONTEXT_AWARE_INTERNAL(tcp_wrap, node::TCPWrap::Initialize) 宏展开是

  1. 1. #define NODE_MODULE_CONTEXT_AWARE_INTERNAL(modname, regfunc) \
  2. 2. NODE_MODULE_CONTEXT_AWARE_CPP(modname,
  3. 3. regfunc,
  4. 4. nullptr,
  5. 5. NM_F_INTERNAL)

继续展开

  1. 6. #define NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, priv, flags\
  2. 7. static node::node_module _module = { \
  3. 8. NODE_MODULE_VERSION, \
  4. 9. flags, \
  5. 10. nullptr, \
  6. 11. __FILE__, \
  7. 12. nullptr, \
  8. 13. (node::addon_context_register_func)(regfunc), \
  9. 14. NODE_STRINGIFY(modname), \
  10. 15. priv, \
  11. 16. nullptr}; \
  12. 17. void _register_tcp_wrap() { node_module_register(&_module); }

我们看到每个C模块底层都定义了一个_register开头的函数,在Node.js启动时,就会把这些函数逐个执行一遍。我们继续看一下这些函数都做了什么,在这之前,我们要先了解一下Node.js中表示C模块的数据结构。

  1. 1. struct node_module {
  2. 2. int nm_version;
  3. 3. unsigned int nm_flags;
  4. 4. void* nm_dso_handle;
  5. 5. const char* nm_filename;
  6. 6. node::addon_register_func nm_register_func;
  7. 7. node::addon_context_register_func nm_context_register_func;
  8. 8. const char* nm_modname;
  9. 9. void* nm_priv;
  10. 10. struct node_module* nm_link;
  11. 11. };

我们看到_register开头的函数调了node_module_register,并传入一个node_module数据结构,所以我们看一下node_module_register的实现

  1. 1. void node_module_register(void* m) {
  2. 2. struct node_module* mp = reinterpret_cast<struct node_module*>(m);
  3. 3. if (mp->nm_flags & NM_F_INTERNAL) {
  4. 4. mp->nm_link = modlist_internal;
  5. 5. modlist_internal = mp;
  6. 6. } else if (!node_is_initialized) {
  7. 7. mp->nm_flags = NM_F_LINKED;
  8. 8. mp->nm_link = modlist_linked;
  9. 9. modlist_linked = mp;
  10. 10. } else {
  11. 11. thread_local_modpending = mp;
  12. 12. }
  13. 13. }

C内置模块的flag是NM_F_INTERNAL,所以会执行第一个if的逻辑,modlist_internal类似一个头指针。if里的逻辑就是头插法建立一个单链表。C内置模块在Node.js里是非常重要的,很多功能都会调用,后续我们会看到。

1.3.2 创建Environment对象

1 CreateMainEnvironment

Node.js中Environment类(env.h)是一个很重要的类,Node.js中,很多数据由Environment对象进行管理。

  1. 1. context = NewContext(isolate_);
  2. 2. std::unique_ptr<Environment> env = std::make_unique<Environment>(
  3. 3. isolate_data_.get(),
  4. 4. context,
  5. 5. args_,
  6. 6. exec_args_,
  7. 7. static_cast<Environment::Flags>(Environment::kIsMainThread |
  8. 8. Environment::kOwnsProcessState | Environment::kOwnsInspector));

Isolate,Context是V8中的概念,Isolate用于隔离实例间的环境,Context用于提供JS执行时的上下文,kIsMainThread说明当前运行的是主线程,用于区分Node.js中的worker_threads子线程。Environment类非常庞大,我们看一下初始化的代码

  1. 1. Environment::Environment(IsolateData* isolate_data,
  2. 2. Local<Context> context,
  3. 3. const std::vector<std::string>& args,
  4. 4. const std::vector<std::string>& exec_args,
  5. 5. Flags flags,
  6. 6. uint64_t thread_id)
  7. 7. : isolate_(context->GetIsolate()),
  8. 8. isolate_data_(isolate_data),
  9. 9. immediate_info_(context->GetIsolate()),
  10. 10. tick_info_(context->GetIsolate()),
  11. 11. timer_base_(uv_now(isolate_data->event_loop())),
  12. 12. exec_argv_(exec_args),
  13. 13. argv_(args),
  14. 14. exec_path_(GetExecPath(args)),
  15. 15. should_abort_on_uncaught_toggle_(isolate_, 1),
  16. 16. stream_base_state_(isolate_, StreamBase::kNumStreamBaseStateFields),
  17. 17. flags_(flags),
  18. 18. thread_id_(thread_id == kNoThreadId ? AllocateThreadId() : thread_id),
  19. 19. fs_stats_field_array_(isolate_, kFsStatsBufferLength),
  20. 20. fs_stats_field_bigint_array_(isolate_, kFsStatsBufferLength),
  21. 21. context_(context->GetIsolate(), context) {
  22. 22. // 进入当前的context
  23. 23. HandleScope handle_scope(isolate());
  24. 24. Context::Scope context_scope(context);
  25. 25. // 保存环境变量
  26. 26. set_env_vars(per_process::system_environment);
  27. 27. // 关联context和env
  28. 28. AssignToContext(context, ContextInfo(""));
  29. 29. // 创建其它对象
  30. 30. CreateProperties();
  31. 31. }

我们只看一下AssignToContext和CreateProperties,set_env_vars会把进程章节讲解。

1.1 AssignToContext

  1. 1. inline void Environment::AssignToContext(v8::Local<v8::Context> context,
  2. 2. const ContextInfo& info) {
  3. 3. // 在context中保存env对象
  4. 4. context->SetAlignedPointerInEmbedderData(ContextEmbedderIndex::kEnvironment, this);
  5. 5. // Used by Environment::GetCurrent to know that we are on a node context.
  6. 6. context->SetAlignedPointerInEmbedderData(ContextEmbedderIndex::kContextTag, Environment::kNodeContextTagPtr);
  7. 7.
  8. 8. }

AssignToContext用于保存context和env的关系。这个逻辑非常重要,因为后续执行代码时,我们会进入V8的领域,这时候,我们只知道Isolate和context。如果不保存context和env的关系,我们就不知道当前所属的env。我们看一下如何获取对应的env。

  1. 1. inline Environment* Environment::GetCurrent(v8::Isolate* isolate) {
  2. 2. v8::HandleScope handle_scope(isolate);
  3. 3. return GetCurrent(isolate->GetCurrentContext());
  4. 4. }
  5. 5.
  6. 6. inline Environment* Environment::GetCurrent(v8::Local<v8::Context> context) {
  7. 7. return static_cast<Environment*>(
  8. 8. context->GetAlignedPointerFromEmbedderData(ContextEmbedderIndex::kEnvironment));
  9. 9. }

1.2 CreateProperties

接着我们看一下CreateProperties中创建process对象的逻辑。

  1. 1. Isolate* isolate = env->isolate();
  2. 2. EscapableHandleScope scope(isolate);
  3. 3. Local<Context> context = env->context();
  4. 4. // 申请一个函数模板
  5. 5. Local<FunctionTemplate> process_template = FunctionTemplate::New(isolate);
  6. 6. process_template->SetClassName(env->process_string());
  7. 7. // 保存函数模板生成的函数
  8. 8. Local<Function> process_ctor;
  9. 9. // 保存函数模块生成的函数所新建出来的对象
  10. 10. Local<Object> process;
  11. 11. if (!process_template->GetFunction(context).ToLocal(&process_ctor)|| !process_ctor->NewInstance(context).ToLocal(&process)) {
  12. 12. return MaybeLocal<Object>();
  13. 13. }

process所保存的对象就是我们在JS层用使用的process对象。Node.js初始化的时候,还挂载了一些属性。

  1. 1. READONLY_PROPERTY(process,
  2. 2. "version",
  3. 3. FIXED_ONE_BYTE_STRING(env->isolate(),
  4. 4. NODE_VERSION));
  5. 5. READONLY_STRING_PROPERTY(process, "arch", per_process::metadata.arch);......

创建完process对象后,Node.js把process保存到env中。

  1. 1. Local<Object> process_object = node::CreateProcessObject(this).FromMaybe(Local<Object>());
  2. 2. set_process_object(process_object)

1.3.3 初始化Libuv任务

  1. InitializeLibuv函数中的逻辑是往Libuv中提交任务。
  2. 1. void Environment::InitializeLibuv(bool start_profiler_idle_notifier) {
  3. 2. HandleScope handle_scope(isolate());
  4. 3. Context::Scope context_scope(context());
  5. 4. CHECK_EQ(0, uv_timer_init(event_loop(), timer_handle()));
  6. 5. uv_unref(reinterpret_cast<uv_handle_t*>(timer_handle()));
  7. 6. uv_check_init(event_loop(), immediate_check_handle());
  8. 7. uv_unref(reinterpret_cast<uv_handle_t*>(immediate_check_handle()));
  9. 8. uv_idle_init(event_loop(), immediate_idle_handle());
  10. 9. uv_check_start(immediate_check_handle(), CheckImmediate);
  11. 10. uv_prepare_init(event_loop(), &idle_prepare_handle_);
  12. 11. uv_check_init(event_loop(), &idle_check_handle_);
  13. 12. uv_async_init(
  14. 13. event_loop(),
  15. 14. &task_queues_async_,
  16. 15. [](uv_async_t* async) {
  17. 16. Environment* env = ContainerOf(
  18. 17. &Environment::task_queues_async_, async);
  19. 18. env->CleanupFinalizationGroups();
  20. 19. env->RunAndClearNativeImmediates();
  21. 20. });
  22. 21. uv_unref(reinterpret_cast<uv_handle_t*>(&idle_prepare_handle_));
  23. 22. uv_unref(reinterpret_cast<uv_handle_t*>(&idle_check_handle_));
  24. 23. uv_unref(reinterpret_cast<uv_handle_t*>(&task_queues_async_));
  25. 24. // …
  26. 25. }

这些函数都是Libuv提供的,分别是往Libuv不同阶段插入任务节点,uv_unref是修改状态。

1 timer_handle是实现Node.js中定时器的数据结构,对应Libuv的time阶段

2 immediate_check_handle是实现Node.js中setImmediate的数据结构,对应Libuv的check阶段。

3 taskqueues_async用于子线程和主线程通信。

1.3.4 初始化Loader和执行上下文

RunBootstrapping里调用了BootstrapInternalLoaders和BootstrapNode函数,我们一个个分析。

1 初始化loader

BootstrapInternalLoaders用于执行internal/bootstrap/loaders.js。我们看一下具体逻辑。首先定义一个变量,该变量是一个字符串数组,用于定义函数的形参列表,一会我们会看到它的作用。

  1. 1. std::vector<Local<String>> loaders_params = {
  2. 2. process_string(),
  3. 3. FIXED_ONE_BYTE_STRING(isolate_, "getLinkedBinding"),
  4. 4. FIXED_ONE_BYTE_STRING(isolate_, "getInternalBinding"),
  5. 5. primordials_string()};

然后再定义一个变量,是一个对象数组,用作执行函数时的实参。

  1. 1. std::vector<Local<Value>> loaders_args = {
  2. 2. process_object(),
  3. 3. NewFunctionTemplate(binding::GetLinkedBinding)
  4. 4. ->GetFunction(context())
  5. 5. .ToLocalChecked(),
  6. 6. NewFunctionTemplate(binding::GetInternalBinding)
  7. 7. ->GetFunction(context())
  8. 8. .ToLocalChecked(),
  9. 9. primordials()};

接着Node.js编译执行internal/bootstrap/loaders.js,这个过程链路非常长,最后到V8层,就不贴出具体的代码,具体的逻辑转成JS如下。

  1. 1. function demo(process,
  2. 2. getLinkedBinding,
  3. 3. getInternalBinding,
  4. 4. primordials) {
  5. 5. // internal/bootstrap/loaders.js 的代码
  6. 6. }
  7. 7. const process = {};
  8. 8. function getLinkedBinding(){}
  9. 9. function getInternalBinding() {}
  10. 10. const primordials = {};
  11. 11. const export = demo(process,
  12. 12. getLinkedBinding,
  13. 13. getInternalBinding,
  14. 14. primordials);

V8把internal/bootstrap/loaders.js用一个函数包裹起来,形参就是loaders_params变量对应的四个字符串。然后执行这个函数,并且传入loaders_args里的那四个对象。internal/bootstrap/loaders.js会导出一个对象。在看internal/bootstrap/loaders.js代码之前,我们先看一下getLinkedBinding, getInternalBinding这两个函数,Node.js在C层对外暴露了AddLinkedBinding方法注册模块,Node.js针对这种类型的模块,维护了一个单独的链表。getLinkedBinding就是根据模块名从这个链表中找到对应的模块,但是我们一般用不到这个,所以就不深入分析。前面我们看到对于C内置模块,Node.js同样维护了一个链表,getInternalBinding就是根据模块名从这个链表中找到对应的模块。现在我们可以具体看一下internal/bootstrap/loaders.js的代码了。

  1. 1. let internalBinding;
  2. 2. {
  3. 3. const bindingObj = ObjectCreate(null);
  4. 4. internalBinding = function internalBinding(module) {
  5. 5. let mod = bindingObj[module];
  6. 6. if (typeof mod !== 'object') {
  7. 7. mod = bindingObj[module] = getInternalBinding(module);
  8. 8. moduleLoadList.push(`Internal Binding ${module}`);
  9. 9. }
  10. 10. return mod;
  11. 11. };
  12. 12. }

Node.js在JS对getInternalBinding进行了一个封装,主要是加了缓存处理。

  1. 1. const internalBindingWhitelist = new SafeSet([,
  2. 2. 'tcp_wrap',
  3. 3. // 一系列C++内置模块名
  4. 4. ]);
  5. 5.
  6. 6. {
  7. 7. const bindingObj = ObjectCreate(null);
  8. 8. process.binding = function binding(module) {
  9. 9. module = String(module);
  10. 10. if (internalBindingWhitelist.has(module)) {
  11. 11. return internalBinding(module);
  12. 12. }
  13. 13. throw new Error(`No such module: ${module}`);
  14. 14. };
  15. 15. }

在process对象(就是我们平时使用的process对象)中挂载binding函数,这个函数主要用于内置的JS模块,后面我们会经常看到。binding的逻辑就是根据模块名查找对应的C模块。上面的处理是为了Node.js能在JS层通过binding函数加载C模块,我们知道Node.js中还有原生的JS模块(lib文件夹下的JS文件)。接下来我们看一下,对于加载原生JS模块的处理。Node.js定义了一个NativeModule类负责原生JS模块的加载。还定义了一个变量保存了原生JS模块的名称列表。

  1. static map = new Map(moduleIds.map((id) => [id, new NativeModule(id)]));

NativeModule主要的逻辑如下

1 原生JS模块的代码是转成字符存在node_javascript.cc文件的,NativeModule负责原生JS模块的加载,即编译和执行。
2 提供一个require函数,加载原生JS模块,对于文件路径以internal开头的模块,是不能被用户require使用的。

这是原生JS模块加载的大概逻辑,具体的我们在Node.js模块加载章节具体分析。执行完internal/bootstrap/loaders.js,最后返回三个变量给C++层。

  1. 1. return {
  2. 2. internalBinding,
  3. 3. NativeModule,
  4. 4. require: nativeModuleRequire
  5. 5. };

C层保存其中两个函数,分别用于加载内置C模块和原生JS模块的函数。

  1. 1. set_internal_binding_loader(internal_binding_loader.As<Function>());
  2. 2. set_native_module_require(require.As<Function>());

至此,internal/bootstrap/loaders.js分析完了。

2 初始化执行上下文

BootstrapNode负责初始化执行上下文,代码如下

  1. 1. EscapableHandleScope scope(isolate_);
  2. 2. // 获取全局变量并设置global属性
  3. 3. Local<Object> global = context()->Global();
  4. 4. global->Set(context(), FIXED_ONE_BYTE_STRING(isolate_, "global"), global).Check();
  5. 5. /*
  6. 6. 执行internal/bootstrap/node.js时的参数
  7. 7. process, require, internalBinding, primordials
  8. 8. */
  9. 9. std::vector<Local<String>> node_params = {
  10. 10. process_string(),
  11. 11. require_string(),
  12. 12. internal_binding_string(),
  13. 13. primordials_string()};
  14. 14. std::vector<Local<Value>> node_args = {
  15. 15. process_object(),
  16. 16. // 原生模块加载器
  17. 17. native_module_require(),
  18. 18. // C++模块加载器
  19. 19. internal_binding_loader(),
  20. 20. primordials()};
  21. 21.
  22. 22. MaybeLocal<Value> result = ExecuteBootstrapper(
  23. 23. this, "internal/bootstrap/node", &node_params, &node_args);

在全局对象上设置一个global属性,这就是我们在Node.js中使用的global对象。接着执行internal/bootstrap/node.js设置一些变量(具体可以参考nternal/bootstrap/node.js)。

  1. 1. process.cpuUsage = wrapped.cpuUsage;
  2. 2. process.resourceUsage = wrapped.resourceUsage;
  3. 3. process.memoryUsage = wrapped.memoryUsage;
  4. 4. process.kill = wrapped.kill;
  5. 5. process.exit = wrapped.exit;

设置全局变量

  1. 1. defineOperation(global, 'clearInterval', timers.clearInterval);
  2. 2. defineOperation(global, 'clearTimeout', timers.clearTimeout);
  3. 3. defineOperation(global, 'setInterval', timers.setInterval);
  4. 4. defineOperation(global, 'setTimeout', timers.setTimeout);
  5. 5. ObjectDefineProperty(global, 'process', {
  6. 6. value: process,
  7. 7. enumerable: false,
  8. 8. writable: true,
  9. 9. configurable: true
  10. 10. });

1.3.5 执行用户JS文件

StartMainThreadExecution进行一些初始化工作,然后执行用户JS代码。

1 给process对象挂载属性

执行patchProcessObject函数(在node_process_methods.cc中导出)给process对象挂载一些列属性,不一一列举。

  1. 1. // process.argv
  2. 2. process->Set(context,
  3. 3. FIXED_ONE_BYTE_STRING(isolate, "argv"),
  4. 4. ToV8Value(context, env->argv()).ToLocalChecked()).Check();
  5. 5.
  6. 6. READONLY_PROPERTY(process,
  7. 7. "pid",
  8. 8. Integer::New(isolate, uv_os_getpid()));

因为Node.js增加了对线程的支持,有些属性需要hack一下,比如在线程里使用process.exit的时候,退出的是单个线程,而不是整个进程,exit等函数需要特殊处理。后面章节会详细讲解。

2 处理进程间通信

  1. 1. function setupChildProcessIpcChannel() {
  2. 2. if (process.env.NODE_CHANNEL_FD) {
  3. 3. const fd = parseInt(process.env.NODE_CHANNEL_FD, 10);
  4. 4. delete process.env.NODE_CHANNEL_FD;
  5. 5. const serializationMode =
  6. 6. process.env.NODE_CHANNEL_SERIALIZATION_MODE || 'json';
  7. 7. delete process.env.NODE_CHANNEL_SERIALIZATION_MODE;
  8. 8. require('child_process')._forkChild(fd, serializationMode);
  9. 9. }
  10. 10. }

环境变量NODE_CHANNEL_FD是在创建子进程的时候设置的,如果有说明当前启动的进程是子进程,则需要处理进程间通信。

3 处理cluster模块的进程间通信

  1. 1. function initializeclusterIPC() {
  2. 2. if (process.argv[1] && process.env.NODE_UNIQUE_ID) {
  3. 3. const cluster = require('cluster');
  4. 4. cluster._setupWorker();
  5. 5. delete process.env.NODE_UNIQUE_ID;
  6. 6. }
  7. 7. }

4 执行用户JS代码

  1. require('internal/modules/cjs/loader').Module.runMain(process.argv[1]);

internal/modules/cjs/loader.js是负责加载用户JS的模块,runMain函数在pre_execution.js被挂载,runMain做的事情是加载用户的JS,然后执行。具体的过程在后面章节详细分析。

1.3.6 进入Libuv事件循环

执行完所有的初始化后,Node.js执行了用户的JS代码,用户的JS代码会往Libuv注册一些任务,比如创建一个服务器,最后Node.js进入Libuv的事件循环中,开始一轮又一轮的事件循环处理。如果没有需要处理的任务,Libuv会退出,从而Node.js退出。

  1. 1. do {
  2. 2. uv_run(env->event_loop(), UV_RUN_DEFAULT);
  3. 3. per_process::v8_platform.DrainVMTasks(isolate_);
  4. 4. more = uv_loop_alive(env->event_loop());
  5. 5. if (more && !env->is_stopping()) continue;
  6. 6. if (!uv_loop_alive(env->event_loop())) {
  7. 7. EmitBeforeExit(env.get());
  8. 8. }
  9. 9. more = uv_loop_alive(env->event_loop());
  10. 10. } while (more == true && !env->is_stopping());

1.4 Node.js和其它服务器的比较

服务器是现代软件中非常重要的一个组成,我们看一下服务器发展的过程中,都有哪些设计架构。一个基于TCP协议的服务器,基本的流程如下(伪代码)。

  1. 1. // 拿到一个socket用于监听
  2. 2. const socketfd = socket(协议类型等配置);
  3. 3. // 监听本机的地址(ip+端口)
  4. 4. bind(socketfd 监听地址)
  5. 5. // 标记该socket是监听型socket
  6. 6. listen(socketfd)

执行完以上步骤,一个服务器正式开始服务。下面我们看一下基于上面的模型,分析各种各样的处理方法。

1.4.1 串行处理请求

  1. 1. while(1) {
  2. 2. const socketForCommunication = accept(socket);
  3. 3. const data = read(socketForCommunication);
  4. 4. handle(data);
  5. 5. write(socketForCommunication, data );
  6. 6. }

我们看看这种模式的处理过程,假设有n个请求到来。那么socket的结构如下图所示。

01-Node.js组成和原理 - 图5

这时候进程从accept中被唤醒。然后拿到一个新的socket用于通信。结构如下图所示。

01-Node.js组成和原理 - 图6

accept就是从已完成三次握手的连接队列里,摘下一个节点。很多同学都了解三次握手是什么,但是可能很少同学会深入思考或者看它的实现,众所周知,一个服务器启动的时候,会监听一个端口,其实就是新建了一个socket。那么如果有一个连接到来的时候,我们通过accept就能拿到这个新连接对应的socket,那这个socket和监听的socket是不是同一个呢?其实socket分为监听型和通信型的,表面上,服务器用一个端口实现了多个连接,但是这个端口是用于监听的,底层用于和客户端通信的其实是另一个socket。所以每一个连接过来,负责监听的socket发现是一个建立连接的包(syn包),它就会生成一个新的socket与之通信(accept的时候返回的那个)。监听socket里只保存了它监听的IP和端口,通信socket首先从监听socket中复制IP和端口,然后把客户端的IP和端口也记录下来,当下次收到一个数据包的时候,操作系统就会根据四元组从socket池子里找到该socket,从而完成数据的处理。

串行这种模式就是从已完成三次握手的队列里摘下一个节点,然后处理。再摘下一个节点,再处理。如果处理的过程中有阻塞式IO,可想而知,效率是有多低。而且并发量比较大的时候,监听socket对应的队列很快就会被占满(已完成连接队列有一个最大长度)。这是最简单的模式,虽然服务器的设计中肯定不会使用这种模式,但是它让我们了解了一个服务器处理请求的整体过程。

1.4.2 多进程模式

串行模式中,所有请求都在一个进程中排队被处理,这是效率低下的原因。这时候我们可以把请求分给多个进程处理来提供效率,因为在串行处理的模式中,如果有阻塞式IO操作,它就会阻塞主进程,从而阻塞后续请求的处理。在多进程的模式下,一个请求如果阻塞了进程,那么操作系统会挂起该进程,接着调度其它进程执行,那么其它进程就可以执行新的任务。多进程模式下分为几种。

1 主进程accept,子进程处理请求
这种模式下,主进程负责摘取已完成连接的节点,然后把这个节点对应的请求交给子进程处理,逻辑如下。

  1. 1. while(1) {
  2. 2. const socketForCommunication = accept(socket);
  3. 3. if (fork() > 0) {
  4. 4. continue;
  5. 5. // 父进程
  6. 6. } else {
  7. 7. // 子进程
  8. 8. handle(socketForCommunication);
  9. 9. }
  10. 10. }

这种模式下,每次来一个请求,就会新建一个进程去处理。这种模式比串行的稍微好了一点,每个请求独立处理,假设a请求阻塞在文件IO,那么不会影响b请求的处理,尽可能地做到了并发。它的瓶颈就是系统的进程数有限,如果有大量的请求,系统无法扛得住,再者,进程的开销很大,对于系统来说是一个沉重的负担。

2 进程池模式
实时创建和销毁进程开销大,效率低,所以衍生了进程池模式,进程池模式就是服务器启动的时候,预先创建一定数量的进程,但是这些进程是worker进程。它不负责accept请求。它只负责处理请求。主进程负责accept,它把accept返回的socket交给worker进程处理,模式如下图所示。

01-Node.js组成和原理 - 图7

但是和1中的模式相比,进程池模式相对比较复杂,因为在模式1中,当主进程收到一个请求的时候,实时fork一个子进程,这时候,这个子进程会继承主进程中新请求对应的fd,所以它可以直接处理该fd对应的请求,在进程池的模式中,子进程是预先创建的,当主进程收到一个请求的时候,子进程中是无法拿得到该请求对应的fd的。这时候,需要主进程使用传递文件描述符的技术把这个请求对应的fd传给子进程。一个进程其实就是一个结构体task_struct,在JS里我们可以说是一个对象,它有一个字段记录了打开的文件描述符,当我们访问一个文件描述符的时候,操作系统就会根据fd的值,从task_struct中找到fd对应的底层资源,所以主进程给子进程传递文件描述符的时候,传递的不仅仅是一个数字fd,因为如果仅仅这样做,在子进程中该fd可能没有对应任何资源,或者对应的资源和主进程中的是不一致的。这其中操作系统帮我们做了很多事情。让我们在子进程中可以通过fd访问到正确的资源,即主进程中收到的请求。

3 子进程accept

这种模式不是等到请求来的时候再创建进程。而是在服务器启动的时候,就会创建多个进程。然后多个进程分别调用accept。这种模式的架构如图1-8所示。

01-Node.js组成和原理 - 图8

  1. 1. const socketfd = socket(协议类型等配置);
  2. 2. bind(socketfd 监听地址)
  3. 3.
  4. 4. for (let i = 0 ; i < 进程个数; i++) {
  5. 5. if (fork() > 0) {
  6. 6. // 父进程负责监控子进程
  7. 7. } else {
  8. 8. // 子进程处理请求
  9. 9. listen(socketfd);
  10. 10. while(1) {
  11. 11. const socketForCommunication = accept(socketfd);
  12. 12. handle(socketForCommunication);
  13. 13. }
  14. 14. }
  15. 15. }

这种模式下多个子进程都阻塞在accept。如果这时候有一个请求到来,那么所有的子进程都会被唤醒,但是首先被调度的子进程会首先摘下这个请求节点,后续的进程被唤醒后可能会遇到已经没有请求可以处理,又进入睡眠,进程被无效唤醒,这是著名的惊群现象。改进方式就是在accpet之前加锁,拿到锁的进程才能进行accept,这样就保证了只有一个进程会阻塞在accept,Nginx解决了这个问题,但是新版操作系统已经在内核层面解决了这个问题。每次只会唤醒一个进程。通常这种模式和事件驱动配合使用。

1.4.3 多线程模式

多线程模式和多进程模式是类似的,也是分为下面几种

1 主进程accept,创建子线程处理

2 子线程accept

3 线程池

前面两种和多进程模式中是一样的,但是第三种比较特别,我们主要介绍第三种。在子进程模式时,每个子进程都有自己的task_struct,这就意味着在fork之后,每个进程负责维护自己的数据,而线程则不一样,线程是共享主线程(主进程)的数据的,当主进程从accept中拿到一个fd的时候,传给线程的话,线程是可以直接操作的。所以在线程池模式时,架构如下图所示。

01-Node.js组成和原理 - 图9

主进程负责accept请求,然后通过互斥的方式插入一个任务到共享队列中,线程池中的子线程同样是通过互斥的方式,从共享队列中摘取节点进行处理。

1.4.4 事件驱动

现在很多服务器(Nginx,Node.js,Redis)都开始使用事件驱动模式去设计。从之前的设计模式中我们知道,为了应对大量的请求,服务器需要大量的进程/线程。这个是个非常大的开销。而事件驱动模式,一般是配合单进程(单线程),再多的请求,也是在一个进程里处理的。但是因为是单进程,所以不适合CPU密集型,因为一个任务一直在占据CPU的话,后续的任务就无法执行了。它更适合IO密集的(一般都会提供一个线程池,负责处理CPU或者阻塞型的任务)。而使用多进程/线程模式的时候,一个进程/线程是无法一直占据CPU的,执行一定时间后,操作系统会执行任务调度。让其它线程也有机会执行,这样就不会前面的任务阻塞后面的任务,出现饥饿情况。大部分操作系统都提供了事件驱动的API。但是事件驱动在不同系统中实现不一样。所以一般都会有一层抽象层抹平这个差异。这里以Linux的epoll为例子。

  1. 1. // 创建一个epoll
  2. 2. var epollFD = epoll_create();
  3. 3. /*
  4. 4. 在epoll给某个文件描述符注册感兴趣的事件,这里是监听的socket,注册可
  5. 5. 读事件,即连接到来
  6. 6. event = {
  7. 7. event: 可读
  8. 8. fd: 监听socket
  9. 9. // 一些上下文
  10. 10. }
  11. 11. */
  12. 12. epoll_ctl(epollFD , EPOLL_CTL_ADD , socket, event);
  13. 13. while(1) {
  14. 14. // 阻塞等待事件就绪,events保存就绪事件的信息,total是个数
  15. 15. var total= epoll_wait(epollFD , 保存就绪事件的结构events, 事件个数, timeout);
  16. 16. for (let i = 0; i < total; i++) {
  17. 17. if (events[i].fd === 监听socket) {
  18. 18. var newSocket = accpet(socket);
  19. 19. /*
  20. 20. 把新的socket也注册到epoll,等待可读,
  21. 21. 即可读取客户端数据
  22. 22. */
  23. 23. epoll_ctl(epollFD,
  24. 24. EPOLL_CTL_ADD,
  25. 25. newSocket,
  26. 26. 可读事件);
  27. 27. } else {
  28. 28. // 从events[i]中拿到一些上下文,执行相应的回调
  29. 29. }
  30. 30. }
  31. 31. }

这就是事件驱动模式的大致过程,本质上是一个订阅/发布模式。服务器通过注册文件描述符和事件到epoll中,epoll开始阻塞,等到epoll返回的时候,它会告诉服务器哪些fd的哪些事件触发了,这时候服务器遍历就绪事件,然后执行对应的回调,在回调里可以再次注册新的事件,就是这样不断驱动着。epoll的原理其实也类似事件驱动,epoll底层维护用户注册的事件和文件描述符,epoll本身也会在文件描述符对应的文件/socket/管道处注册一个回调,然后自身进入阻塞,等到别人通知epoll有事件发生的时候,epoll就会把fd和事件返回给用户。

  1. 1. function epoll_wait() {
  2. 2. for 事件个数
  3. 3. // 调用文件系统的函数判断
  4. 4. if (事件[i]中对应的文件描述符中有某个用户感兴趣的事件发生?) {
  5. 5. 插入就绪事件队列
  6. 6. } else {
  7. 7. /*
  8. 8. 在事件[i]中的文件描述符所对应的文件/socket/管道等indeo节
  9. 9. 点注册回调。即感兴趣的事件触发后回调epoll,回调epoll后,
  10. 10. epoll把该event[i]插入就绪事件队列返回给用户
  11. 11. */
  12. 12. }
  13. 13. }

以上就是服务器设计的一些基本介绍。现在的服务器的设计中还会涉及到协程。不过目前还没有看过具体的实现,所以暂不展开介绍,有兴趣的通信可以看一下协程库libtask了解一下如何使用协程实现一个服务器。
Node.js是基于单进程(单线程)的事件驱动模式。这也是为什么Node.js擅长处理高并发IO型任务而不擅长处理CPU型任务的原因,Nginx、Redis也是这种模式。另外Node.js是一个及web服务器和应用服务器于一身的服务器,像Nginx这种属于web服务器,它们只处理HTTP协议,不具备脚本语言来处理具体的业务逻辑。它需要把请求转发到真正的web服务器中去处理,比如PHP。而Node.js不仅可以解析HTTP协议,还可以处理具体的业务逻辑。