Last Surprise Lyn
  1. 1 Last Surprise Lyn
  2. 2 Quiet Storm Lyn
  3. 3 The Night We Stood Lyn
  4. 4 Time Bomb Veela
  5. 5 Life Will Change Lyn
  6. 6 Libertus Chen-U
  7. 7 Hypocrite Nush
  8. 8 Warcry mpi
  9. 9 One Last You Jen Bird
  10. 10 Flower Of Life 发热巫女
  11. 11 かかってこいよ NakamuraEmi
2020-02-05 17:55:36

Node源码解析——整体架构

因为接下来分析的stream模块和dns等网络模块等都涉及到很多libuv的知识,这部分不是node内部的JS代码,前端同学不一定熟悉。想了想有必要先谈谈Node的整体架构,毕竟Node内部的构成还是比较复杂且有序的,把这些理清才能更有层次的阅读源码。

常规依赖

Node的官方介绍是"开源的,跨平台的JS运行时环境",实际上我们都知道,Node能跑跨平台的JS本身并不完全是Node的功劳,而是因为其依赖的V8。所以仔细想想Node究竟干了什么还是很抽象。那么就先从依赖开始,层层剥离来看Node的职责。

打开源码的deps文件夹,这里就是Node依赖的三方包,在官网上我们也能看到其部分简单介绍:Dependencies,我这里也根据下文将要提到的内容再做一些补充。

  • acorn: 如果熟悉webpack的同学对这个库应该不陌生,webpack在打包时需要解析源码生成AST提取模块依赖,其中的AST解析器就是这个。Node的REPL和assert模块用到了这个库,acorn-plugins就是其依赖的一些插件了。
  • zlib: 熟知的压缩库,被Node的zlib模块依赖。
  • brotli: C语言版本的Brotili压缩算法实现。同样被Node的zlib模块依赖。
  • cares: 用于异步DNS解析的C语言库。被Node的dns模块依赖。
  • histogram: HDR柱状图的C语言实现。被Node的Performance Hooks模块依赖。
  • icu-small: 提供Unicode和国际化支持的C/C++和Java库集合。同样用于Node内部的国际化支持。
  • llhttp: http解析器,自然用于Node内部的http模块了。但还要多说一点的就是http解析器是干什么的。因为http请求的数据本身是以字符串的形式到达应用层的,应用层并不能识别其符合http协议规定的数据结构,所以需要在TCP服务器和应用层之间架设这样一种解析器处理两者间的数据转换。
  • nghttp2: http2的C语言实现。被Node的http2模块依赖。
  • node-inspect: 新V8版本下提供旧V8版本的断点调试命令。
  • npm: 这个应该是个前端都很熟悉了。
  • openssl: tls和crypto模块都有用到。提供加密功能实现的库。
  • uvwasi: 基于libuv实现的WebAssembly接口,被Node的awsi模块依赖。

到这里,我已经介绍完了deps下所有的依赖,提到的依赖都不影响架构,主要被用于实现Node模块中的功能。还有两个涉及Node整体架构实现的关键模块需要重点介绍——V8和libuv。

V8

看完了dep文件,应该找找整个node的启动入口文件了。在根目录下,除了一些md文件和配置文件外,最容易吸引眼球的应该是以项目名称起名的node.gyp的文件,这里确实包含了大量的信息。不过在讲解内容之前当然有必要先了解gyp后缀的文件是什么?

构建系统

实际上如果你细心的话应该会注意到,上文中提到的node官方依赖页中有一些工具依赖我没有介绍,其中之一就是gypgenerate your projects,官方定义即为跨平台的构建系统。构建系统这个概念对于前端也不陌生,webpack就是干这个事,简单来说,就是根据不同场景下,自动化的调用工具链将源码编译得到一些目标文件。比如前端,生产环境和开发环境要区分,但对于跨平台的node来说,还要区分系统环境,那么可以预见,在node.gyp这个文件下我们是能得到关于整个项目的输出文件信息的。

不过在这之前,还要说说V8和gyp的关系。要知道,gyp并不是node内置的而是V8团队开发的,目前V8已经废弃掉了gyp转向了一个名为Generate Ninjia + Ninja的构建系统,其采用C++编写,相比python写的gyp有着编译速度上的巨大优势,同时语法上也有了更好的维护性。

那么既然目前V8采用的构建系统那么好,node怎么不紧跟潮流呢?当然是一个大家都熟悉的原因:历史遗留问题。。有趣的是gyp也被node之父认为是node设计上的一个巨大失误。

现在应该明白的是,node采用了名为gyp的构建方案,而node依赖的V8开发了这套方案,但V8采用了更优秀的构建方案,在V8文件下也能看到后缀名为.gn的相关配置。接下来就看看node.gyp究竟干了些啥。

语法上是用python写的类JSON格式,我们的目的是找到这个构建任务的核心输出文件,那么我们根据gyp语法查找关键key: 'target_name'可以得到第一个目标:

{
      'target_name': '<(node_core_target_name)',
      'type': 'executable',

      'defines': [
        'NODE_WANT_INTERNALS=1',
      ],

      'includes': [
        'node.gypi'
      ],

      'include_dirs': [
        'src',
        'deps/v8/include'
      ],

      'sources': [
        'src/node_main.cc'
      ],

      'dependencies': [
        'deps/histogram/histogram.gyp:histogram',
        'deps/uvwasi/uvwasi.gyp:uvwasi',
      ],
          // ...
}

这里可以看出最终得到的是名为node的可执行文件,编译来源是src/node_main.cc,也就是node的入口了。

接下来继续查找到第二个目标:

   {
      'target_name': '<(node_lib_target_name)',
      'type': '<(node_intermediate_lib_type)',
      'includes': [
        'node.gypi',
      ],

      'include_dirs': [
        'src',
        '<(SHARED_INTERMEDIATE_DIR)' # for node_natives.h
      ],
      'dependencies': [
        'deps/histogram/histogram.gyp:histogram',
        'deps/uvwasi/uvwasi.gyp:uvwasi',
      ],

      'sources': [
        'src/api/async_resource.cc',
        'src/api/callback.cc',
      // ...
      'actions': [
        {
          'action_name': 'node_js2c',
          'process_outputs_as_sources': 1,
          'inputs': [
            # Put the code first so it's a dependency and can be used for invocation.
            'tools/js2c.py',
            '<@(library_files)',
            'config.gypi'
          ],
          'outputs': [
            '<(SHARED_INTERMEDIATE_DIR)/node_javascript.cc',
          ],
          'action': [
            'python', '<@(_inputs)',
            '--target', '<@(_outputs)',
          ],
        },
      ]
   }

继续追踪变量target_name和type变量可知,这个任务将src下的C++文件编译得到的是名为libnode的静态库文件。不过注意到,新增了一个编译前的action——node_js2c,输入为js2c.pylibrary_files,追踪library_files可得其是libdeps下的所有js文件。再结合action名,这里的目的就很明显了,是将node内部的JS文件编译为node_javascript.cc

现在我们可以知道,经过编译会得到入口文件和库文件,库文件又分为两类:src下的C++写的库和lib下的JS写的库。对于这两个库,node使用者更熟悉的学名是:builtin模块和native模块。到这里,node的可执行代码有了,就可以分析node从入口文件的启动流程了。

启动分析

接下来的源码阅读虽然不是JS,不过我们的目的也不是深究而是搞清楚流程,所以能读懂在干嘛就行。先看node_main.cc。

int main(int argc, char* argv[]) {
#if defined(__POSIX__) && defined(NODE_SHARED_MODE)
  // In node::PlatformInit(), we squash all signal handlers for non-shared lib
  // build. In order to run test cases against shared lib build, we also need
  // to do the same thing for shared lib build here, but only for SIGPIPE for
  // now. If node::PlatformInit() is moved to here, then this section could be
  // removed.
  {
    struct sigaction act;
    memset(&act, 0, sizeof(act));
    act.sa_handler = SIG_IGN;
    sigaction(SIGPIPE, &act, nullptr);
  }
#endif

#if defined(__linux__)
  char** envp = environ;
  while (*envp++ != nullptr) {}
  Elf_auxv_t* auxv = reinterpret_cast<Elf_auxv_t*>(envp);
  for (; auxv->a_type != AT_NULL; auxv++) {
    if (auxv->a_type == AT_SECURE) {
      node::per_process::linux_at_secure = auxv->a_un.a_val;
      break;
    }
  }
#endif
  // Disable stdio buffering, it interacts poorly with printf()
  // calls elsewhere in the program (e.g., any logging from V8.)
  setvbuf(stdout, nullptr, _IONBF, 0);
  setvbuf(stderr, nullptr, _IONBF, 0);
  return node::Start(argc, argv);
}

这个文件入口主要是区分了Windows和Unix环境,这里以Unix为例,做了些变量设置后,最后调用了Start函数,这个函数在src/node.cc中。

int Start(int argc, char** argv) {
  InitializationResult result = InitializeOncePerProcess(argc, argv);
  if (result.early_return) {
    return result.exit_code;
  }

  {
    Isolate::CreateParams params;
    const std::vector<size_t>* indexes = nullptr;
    std::vector<intptr_t> external_references;

    bool force_no_snapshot =
        per_process::cli_options->per_isolate->no_node_snapshot;
    if (!force_no_snapshot) {
      v8::StartupData* blob = NodeMainInstance::GetEmbeddedSnapshotBlob();
      if (blob != nullptr) {
        // TODO(joyeecheung): collect external references and set it in
        // params.external_references.
        external_references.push_back(reinterpret_cast<intptr_t>(nullptr));
        params.external_references = external_references.data();
        params.snapshot_blob = blob;
        indexes = NodeMainInstance::GetIsolateDataIndexes();
      }
    }

    NodeMainInstance main_instance(&params,
                                   uv_default_loop(),
                                   per_process::v8_platform.Platform(),
                                   result.args,
                                   result.exec_args,
                                   indexes);
    result.exit_code = main_instance.Run();
  }

  TearDownOncePerProcess();
  return result.exit_code;
}

可以看到整个启动流程分为如下几步:

  1. 调用InitializeOncePerProcess生成一个perprocess实例
  2. 根据result初始化一个NodeMainInstance,此处注意到同时使用了libuv创建了一个默认event loop并传入,以及一个perprocess中的V8实例。
  3. 调用NodeMainInstance的Run函数

那么很自然的,既然第2步需要传入一个V8实例供我们的node代码在上面跑,第一步主要目的就是初始化一个V8实例了。进去看核心代码:

  InitializeV8Platform(per_process::cli_options->v8_thread_pool_size);
  V8::Initialize();
  performance::performance_v8_start = PERFORMANCE_NOW();
  per_process::v8_initialized = true;
  return result;

没有问题,确实得到了一个V8实例。再进去NodeMainInstance的初始化函数中看,文件在src/node_main_instance.cc

NodeMainInstance::NodeMainInstance(
    Isolate::CreateParams* params,
    uv_loop_t* event_loop,
    MultiIsolatePlatform* platform,
    const std::vector<std::string>& args,
    const std::vector<std::string>& exec_args,
    const std::vector<size_t>* per_isolate_data_indexes)
    : args_(args),
      exec_args_(exec_args),
      array_buffer_allocator_(ArrayBufferAllocator::Create()),
      isolate_(nullptr),
      platform_(platform),
      isolate_data_(nullptr),
      owns_isolate_(true) {
  params->array_buffer_allocator = array_buffer_allocator_.get();
  isolate_ = Isolate::Allocate();
  CHECK_NOT_NULL(isolate_);
  // Register the isolate on the platform before the isolate gets initialized,
  // so that the isolate can access the platform during initialization.
  platform->RegisterIsolate(isolate_, event_loop);
  SetIsolateCreateParamsForNode(params);
  Isolate::Initialize(isolate_, *params);

  deserialize_mode_ = per_isolate_data_indexes != nullptr;
  // If the indexes are not nullptr, we are not deserializing
  CHECK_IMPLIES(deserialize_mode_, params->external_references != nullptr);
  isolate_data_.reset(new IsolateData(isolate_,
                                      event_loop,
                                      platform,
                                      array_buffer_allocator_.get(),
                                      per_isolate_data_indexes));
  IsolateSettings s;
  SetIsolateMiscHandlers(isolate_, s);
  if (!deserialize_mode_) {
    // If in deserialize mode, delay until after the deserialization is
    // complete.
    SetIsolateErrorHandlers(isolate_, s);
  }
}

这里初始化了一个isolate,并设置了一份isolateData数据,可见的存了份event_loop,然后设置了isolateHandle。那么到这里就要介绍一下V8中的基本概念了:

  • isolate: 英文原意是隔离,类似于操作系统中的进程,在V8中不同的isolate实例就是拥有各自的堆栈的虚拟机实例
  • handle: 句柄,也就是指向对象的指针。在V8中所有对象都通过handle引用,可以受益于v8的垃圾回收机制。
  • context: 执行器环境,使用context可以将分离的JS代码在同一个V8实例中运行,类似于一张html中的iframe。
  • scope: 由于释放handle比较麻烦,V8提供了范围性的HandleScope和ContextScope来批量处理,分别用来管理函数和context对象的handle。

到这一步,我们已经准备好了V8环境,理所当然的该调用Run执行我们的JS代码了,进入Run方法。

int NodeMainInstance::Run() {
  Locker locker(isolate_);
  Isolate::Scope isolate_scope(isolate_);
  HandleScope handle_scope(isolate_);

  int exit_code = 0;
  std::unique_ptr<Environment> env = CreateMainEnvironment(&exit_code);

  CHECK_NOT_NULL(env);
  Context::Scope context_scope(env->context());

  if (exit_code == 0) {
    {
      InternalCallbackScope callback_scope(
          env.get(),
          Local<Object>(),
          { 1, 0 },
          InternalCallbackScope::kAllowEmptyResource |
              InternalCallbackScope::kSkipAsyncHooks);
      LoadEnvironment(env.get());
    }

    env->set_trace_sync_io(env->options()->trace_sync_io);

    {
      SealHandleScope seal(isolate_);
      bool more;
      env->performance_state()->Mark(
          node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_START);
      do {
        uv_run(env->event_loop(), UV_RUN_DEFAULT);

        per_process::v8_platform.DrainVMTasks(isolate_);

        more = uv_loop_alive(env->event_loop());
        if (more && !env->is_stopping()) continue;

        if (!uv_loop_alive(env->event_loop())) {
          EmitBeforeExit(env.get());
        }

        // Emit `beforeExit` if the loop became alive either after emitting
        // event, or after running some callbacks.
        more = uv_loop_alive(env->event_loop());
      } while (more == true && !env->is_stopping());
      env->performance_state()->Mark(
          node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_EXIT);
    }

    env->set_trace_sync_io(false);
    exit_code = EmitExit(env.get());
  }

  env->set_can_call_into_js(false);
  env->stop_sub_worker_contexts();
  ResetStdio();
  env->RunCleanup();

  // TODO(addaleax): Neither NODE_SHARED_MODE nor HAVE_INSPECTOR really
  // make sense here.
#if HAVE_INSPECTOR && defined(__POSIX__) && !defined(NODE_SHARED_MODE)
  struct sigaction act;
  memset(&act, 0, sizeof(act));
  for (unsigned nr = 1; nr < kMaxSignal; nr += 1) {
    if (nr == SIGKILL || nr == SIGSTOP || nr == SIGPROF)
      continue;
    act.sa_handler = (nr == SIGPIPE) ? SIG_IGN : SIG_DFL;
    CHECK_EQ(0, sigaction(nr, &act, nullptr));
  }
#endif

  RunAtExit(env.get());

  per_process::v8_platform.DrainVMTasks(isolate_);

#if defined(LEAK_SANITIZER)
  __lsan_do_leak_check();
#endif

  return exit_code;
}

可以看到主要分为如下几步:

  1. CreateMainEnvironment创建一个env对象
  2. 根据env调用LoadEnvironment
  3. 执行event loop

我们知道event loop跑起来后,node就已经正常工作了。所以最关键的内容准备,比如node模块加载,我们的JS编译与执行应该都在这个env里了。限于文章篇幅,这个env相关的内容需要再起一篇文章单独讲,现在只需要搞懂V8在node中的角色即可。那么现在也应该知道了,Node将编译完成的builtin模块和native模块作为资源加载到内存,V8在Node启动时初始化,通过V8作为平台连接模块代码和我们的JS代码,并通过V8暴露出C++接口调用各种模块。在Node中起到的作用可以用下图表示:

libuv

当然还没完,是时候介绍libuv在node的执行过程中起到的作用了。之前已经提到了,node启动过程中会在v8的isolate中传入了libuv的event_loop并在env中执行我们的代码,同时将event loop开启。那么显然,通过传入的这个event_loop提供的接口就能和event loop交互。

那么针对具体的工作,可以先看官方的描述:最初专门为Node设计的跨平台的异步I/O库。核心工作是提供了一个event-loop以及包括定时器,异步文件系统访问等一些核心工具。以及官方提供的一张架构图。

解决的痛点问题就在于,传统的I/O函数都是阻塞式的,而网络读取数据的时间远不如cpu的处理速度,程序性能就受到了阻碍。常规方案是使用多线程,将阻塞的I/O操作分配到其他线程,由cpu调度需要资源的线程,libuv则是使用了前端非常熟悉的异步非阻塞的基于事件通知的方案了。

具体到Node的应用上,比如我们使用Node进行一些libuv支持的例如网络I/O或者文件I/O操作时,就会通过V8执行我们的JS代码时调用到相关built in模块,然后再由相关built in模块调用libuv相关C/C++接口实现这套异步流程。关于libuv的详细内容又需要不少文章才能说完这里就不详述,目前只需要理解它在Node架构中的角色即可。反应到上面的架构图中,就是处于如下位置。

小结

至此我们终于明白了Node的整体架构,以及它和V8,libuv的关系。如果要总结Node的核心工作,我认为用“桥”来比喻是合适的。通过Node连接了V8和libuv,并提供工具函数和native模块的胶水,使得我们的代码能结合JS和C/C++的能力各司其职。当然这篇文章写的过程中,又陆续发现了不少问题,比如Node启动时的env中究竟做了什么事来连接不同语言编写的模块,libuv根据不同类型的I/O进行的具体操作以及如何实现JS到C/C++再回到JS回调的流程。只能说慢慢来解决吧,在做了在做了.jpg。

-- EOF --

添加在分类「 前端开发 」下,并被添加 「Node.js」 标签。