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官方依赖页中有一些工具依赖我没有介绍,其中之一就是gyp
。generate 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.py
和library_files
,追踪library_files
可得其是lib
和deps
下的所有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(¶ms,
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;
}
可以看到整个启动流程分为如下几步:
- 调用InitializeOncePerProcess生成一个perprocess实例
- 根据result初始化一个NodeMainInstance,此处注意到同时使用了libuv创建了一个默认event loop并传入,以及一个perprocess中的V8实例。
- 调用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;
}
可以看到主要分为如下几步:
- CreateMainEnvironment创建一个env对象
- 根据env调用LoadEnvironment
- 执行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 --