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层是面向用户的,为用户提供调用底层的接口。
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来完成的。
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的整体执行流程
从上图中我们大致了解到,Libuv分为几个阶段,然后在一个循环里不断执行每个阶段里的任务。下面我们具体看一下每个阶段
下面我能通过一个例子来了解libuv的基本原理。
#include <stdio.h> #include <uv.h> int64_t counter = 0; void wait_for_a_while(uv_idle_t* handle) { counter++; if (counter >= 10e6) uv_idle_stop(handle); } int main() { uv_idle_t idler; // 获取事件循环的核心结构体。并初始化一个idle uv_idle_init(uv_default_loop(), &idler); // 往事件循环的idle阶段插入一个任务 uv_idle_start(&idler, wait_for_a_while); // 启动事件循环 uv_run(uv_default_loop(), UV_RUN_DEFAULT); // 销毁libuv的相关数据 uv_loop_close(uv_default_loop()); return 0; }
使用Libuv,我们首先需要获取Libuv的核心结构体uv_loop_t,uv_loop_t是一个非常大的结构体,里面记录了Libuv整个生命周期的数据。uv_default_loop为我们提供了一个默认已经初始化了的uv_loop_t结构体,当然我们也可以自己去分配一个,自己初始化。
uv_loop_t* uv_default_loop(void) { // 缓存 if (default_loop_ptr != NULL) return default_loop_ptr; if (uv_loop_init(&default_loop_struct)) return NULL; default_loop_ptr = &default_loop_struct; return default_loop_ptr; }
Libuv维护了一个全局的uv_loop_t结构体,使用uv_loop_init进行初始化,不打算展开讲解uv_loop_init函数,w因为它大概就是对uv_loop_t结构体各个字段进行初始化。接着我们看一下uvidle*系列的函数。
1 uv_idle_init
int uv_idle_init(uv_loop_t* loop, uv_idle_t* handle) { /* 初始化handle的类型,所属loop,打上UV_HANDLE_REF, 并且把handle插入loop->handle_queue队列的队尾 */ uv__handle_init(loop, (uv_handle_t*)handle, UV_IDLE); handle->idle_cb = NULL; return 0; }
执行uv_idle_init函数后,Libuv的内存视图如下图所示
2 uv_idle_start
int uv_idle_start(uv_idle_t* handle, uv_idle_cb cb) { // 如果已经执行过start函数则直接返回 if (uv__is_active(handle)) return 0; // 把handle插入loop中idle的队列 QUEUE_INSERT_HEAD(&handle->loop->idle_handles, &handle->queue); // 挂载回调,下一轮循环的时候被执行 handle->idle_cb = cb; /* 设置UV_HANDLE_ACTIVE标记位,并且loop中的handle数加一, init的时候只是把handle挂载到loop,start的时候handle才 处于激活态 */ uv__handle_start(handle); return 0; }
执行完uv_idle_start的内存视图如下图所示。
然后执行uv_run进入Libuv的事件循环。
int uv_run(uv_loop_t* loop, uv_run_mode mode) { int timeout; int r; int ran_pending; // 在uv_run之前要先提交任务到loop r = uv__loop_alive(loop); // 没有任务需要处理或者调用了uv_stop while (r != 0 && loop->stop_flag == 0) { // 处理idle队列 uv__run_idle(loop); } // 是因为调用了uv_stop退出的,重置flag if (loop->stop_flag != 0) loop->stop_flag = 0; /* 返回是否还有活跃的任务(handle或request), 业务代表可以再次执行uv_run */ return r; }
我们看到有一个函数是uv__run_idle,这就是处理idle阶段的函数。我们看一下它的实现。
// 在每一轮循环中执行该函数,执行时机见uv_run void uv__run_idle(uv_loop_t* loop) { uv_idle_t* h; QUEUE queue; QUEUE* q; /* 把该类型对应的队列中所有节点摘下来挂载到queue变量, 变量回调里不断插入新节点,导致死循环 */ QUEUE_MOVE(&loop->idle_handles, &queue); // 遍历队列,执行每个节点里面的函数 while (!QUEUE_EMPTY(&queue)) { // 取下当前待处理的节点 q = QUEUE_HEAD(&queue); // 取得该节点对应的整个结构体的基地址 h = QUEUE_DATA(q, uv_idle_t, queue); // 把该节点移出当前队列,否则循环不会结束 QUEUE_REMOVE(q); // 重新插入原来的队列 QUEUE_INSERT_TAIL(&loop->idle_handles, q); // 执行回调函数 h->idle_cb(h); } }
我们看到uv__run_idle的逻辑并不复杂,就是遍历idle_handles队列的节点,然后执行回调,在回调里我们可以插入新的节点(产生新任务),从而不断驱动Libuv的运行。我们看到uv_run退出循环的条件下面的代码为false。
r != 0 && loop->stop_flag == 0
stop_flag由用户主动关闭Libuv事件循环。
void uv_stop(uv_loop_t* loop) { loop->stop_flag = 1; }
r是代表事件循环是否还存活,这个判断的标准是由uv__loop_alive提供
static int uv__loop_alive(const uv_loop_t* loop) { return loop->active_handles > 0 || loop->active_reqs.count > 0 || loop->closing_handles != NULL; }
这时候我们有一个actived handles,所以Libuv不会退出。当我们调用uv_idle_stop函数把idle节点移出handle队列的时候,Libuv就会退出。后面我们会具体分析Libuv事件循环的原理。
Node.js中第三方库包括异步DNS解析(cares)、HTTP解析器(旧版使用http_parser,新版使用llhttp)、HTTP2解析器(nghttp2)、解压压缩库(zlib)、加密解密库(openssl)等等,不一一介绍。
V8提供了一套机制,使得我们可以在JS层调用C++、C语言模块提供的功能。Node.js正是通过这套机制,实现了对JS能力的拓展。Node.js在底层做了大量的事情,实现了很多功能,然后在JS层暴露接口给用户使用,降低了用户成本,也提高了开发效率。
// C++里定义 Handle<FunctionTemplate> Test = FunctionTemplate::New(cb); global->Set(String::New(“Test"), Test); // JS里使用 const test = new Test();
我们先有一个感性的认识,在后面的章节中,会具体讲解如何使用V8拓展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++模块,原理类似。
下面是Node.js启动的主流程图如图1-4所示。
我们从上往下,看一下每个过程都做了些什么事情。
RegisterBuiltinModules函数(node_binding.cc)的作用是注册C++模块。
void RegisterBuiltinModules() { #define V(modname) _register_##modname(); NODE_BUILTIN_MODULES(V) #undef V }
NODE_BUILTIN_MODULES是一个C语言宏,宏展开后如下(省略类似逻辑)
void RegisterBuiltinModules() { #define V(modname) _register_##modname(); V(tcp_wrap) V(timers) ...其它模块 #undef V }
再一步展开如下
void RegisterBuiltinModules() { _register_tcp_wrap(); _register_timers(); }
执行了一系列_register开头的函数,但是我们在Node.js源码里找不到这些函数,因为这些函数是在每个C++模块定义的文件里(.cc文件的最后一行)通过宏定义的。以tcp_wrap模块为例,看看它是怎么做的。文件tcp_wrap.cc的最后一句代码 NODE_MODULE_CONTEXT_AWARE_INTERNAL(tcp_wrap, node::TCPWrap::Initialize) 宏展开是
#define NODE_MODULE_CONTEXT_AWARE_INTERNAL(modname, regfunc) \ NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, nullptr, NM_F_INTERNAL)
继续展开
#define NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, priv, flags\ static node::node_module _module = { \ NODE_MODULE_VERSION, \ flags, \ nullptr, \ __FILE__, \ nullptr, \ (node::addon_context_register_func)(regfunc), \ NODE_STRINGIFY(modname), \ priv, \ nullptr}; \ void _register_tcp_wrap() { node_module_register(&_module); }
我们看到每个C++模块底层都定义了一个_register开头的函数,在Node.js启动时,就会把这些函数逐个执行一遍。我们继续看一下这些函数都做了什么,在这之前,我们要先了解一下Node.js中表示C++模块的数据结构。
struct node_module { int nm_version; unsigned int nm_flags; void* nm_dso_handle; const char* nm_filename; node::addon_register_func nm_register_func; node::addon_context_register_func nm_context_register_func; const char* nm_modname; void* nm_priv; struct node_module* nm_link; };
我们看到_register开头的函数调了node_module_register,并传入一个node_module数据结构,所以我们看一下node_module_register的实现
void node_module_register(void* m) { struct node_module* mp = reinterpret_cast<struct node_module*>(m); if (mp->nm_flags & NM_F_INTERNAL) { mp->nm_link = modlist_internal; modlist_internal = mp; } else if (!node_is_initialized) { mp->nm_flags = NM_F_LINKED; mp->nm_link = modlist_linked; modlist_linked = mp; } else { thread_local_modpending = mp; } }
C++内置模块的flag是NM_F_INTERNAL,所以会执行第一个if的逻辑,modlist_internal类似一个头指针。if里的逻辑就是头插法建立一个单链表。C++内置模块在Node.js里是非常重要的,很多功能都会调用,后续我们会看到。
1 CreateMainEnvironment
Node.js中Environment类(env.h)是一个很重要的类,Node.js中,很多数据由Environment对象进行管理。
context = NewContext(isolate_); std::unique_ptr<Environment> env = std::make_unique<Environment>( isolate_data_.get(), context, args_, exec_args_, static_cast<Environment::Flags>(Environment::kIsMainThread | Environment::kOwnsProcessState | Environment::kOwnsInspector));
Isolate,Context是V8中的概念,Isolate用于隔离实例间的环境,Context用于提供JS执行时的上下文,kIsMainThread说明当前运行的是主线程,用于区分Node.js中的worker_threads子线程。Environment类非常庞大,我们看一下初始化的代码
Environment::Environment(IsolateData* isolate_data, Local<Context> context, const std::vector<std::string>& args, const std::vector<std::string>& exec_args, Flags flags, uint64_t thread_id) : isolate_(context->GetIsolate()), isolate_data_(isolate_data), immediate_info_(context->GetIsolate()), tick_info_(context->GetIsolate()), timer_base_(uv_now(isolate_data->event_loop())), exec_argv_(exec_args), argv_(args), exec_path_(GetExecPath(args)), should_abort_on_uncaught_toggle_(isolate_, 1), stream_base_state_(isolate_, StreamBase::kNumStreamBaseStateFields), flags_(flags), thread_id_(thread_id == kNoThreadId ? AllocateThreadId() : thread_id), fs_stats_field_array_(isolate_, kFsStatsBufferLength), fs_stats_field_bigint_array_(isolate_, kFsStatsBufferLength), context_(context->GetIsolate(), context) { // 进入当前的context HandleScope handle_scope(isolate()); Context::Scope context_scope(context); // 保存环境变量 set_env_vars(per_process::system_environment); // 关联context和env AssignToContext(context, ContextInfo("")); // 创建其它对象 CreateProperties(); }
我们只看一下AssignToContext和CreateProperties,set_env_vars会把进程章节讲解。
1.1 AssignToContext
inline void Environment::AssignToContext(v8::Local<v8::Context> context, const ContextInfo& info) { // 在context中保存env对象 context->SetAlignedPointerInEmbedderData(ContextEmbedderIndex::kEnvironment, this); // Used by Environment::GetCurrent to know that we are on a node context. context->SetAlignedPointerInEmbedderData(ContextEmbedderIndex::kContextTag, Environment::kNodeContextTagPtr); }
AssignToContext用于保存context和env的关系。这个逻辑非常重要,因为后续执行代码时,我们会进入V8的领域,这时候,我们只知道Isolate和context。如果不保存context和env的关系,我们就不知道当前所属的env。我们看一下如何获取对应的env。
inline Environment* Environment::GetCurrent(v8::Isolate* isolate) { v8::HandleScope handle_scope(isolate); return GetCurrent(isolate->GetCurrentContext()); } inline Environment* Environment::GetCurrent(v8::Local<v8::Context> context) { return static_cast<Environment*>( context->GetAlignedPointerFromEmbedderData(ContextEmbedderIndex::kEnvironment)); }
1.2 CreateProperties
接着我们看一下CreateProperties中创建process对象的逻辑。
Isolate* isolate = env->isolate(); EscapableHandleScope scope(isolate); Local<Context> context = env->context(); // 申请一个函数模板 Local<FunctionTemplate> process_template = FunctionTemplate::New(isolate); process_template->SetClassName(env->process_string()); // 保存函数模板生成的函数 Local<Function> process_ctor; // 保存函数模块生成的函数所新建出来的对象 Local<Object> process; if (!process_template->GetFunction(context).ToLocal(&process_ctor)|| !process_ctor->NewInstance(context).ToLocal(&process)) { return MaybeLocal<Object>(); }
process所保存的对象就是我们在JS层用使用的process对象。Node.js初始化的时候,还挂载了一些属性。
READONLY_PROPERTY(process, "version", FIXED_ONE_BYTE_STRING(env->isolate(), NODE_VERSION)); READONLY_STRING_PROPERTY(process, "arch", per_process::metadata.arch);......
创建完process对象后,Node.js把process保存到env中。
Local<Object> process_object = node::CreateProcessObject(this).FromMaybe(Local<Object>()); set_process_object(process_object)
InitializeLibuv函数中的逻辑是往Libuv中提交任务。 void Environment::InitializeLibuv(bool start_profiler_idle_notifier) { HandleScope handle_scope(isolate()); Context::Scope context_scope(context()); CHECK_EQ(0, uv_timer_init(event_loop(), timer_handle())); uv_unref(reinterpret_cast<uv_handle_t*>(timer_handle())); uv_check_init(event_loop(), immediate_check_handle()); uv_unref(reinterpret_cast<uv_handle_t*>(immediate_check_handle())); uv_idle_init(event_loop(), immediate_idle_handle()); uv_check_start(immediate_check_handle(), CheckImmediate); uv_prepare_init(event_loop(), &idle_prepare_handle_); uv_check_init(event_loop(), &idle_check_handle_); uv_async_init( event_loop(), &task_queues_async_, [](uv_async_t* async) { Environment* env = ContainerOf( &Environment::task_queues_async_, async); env->CleanupFinalizationGroups(); env->RunAndClearNativeImmediates(); }); uv_unref(reinterpret_cast<uv_handle_t*>(&idle_prepare_handle_)); uv_unref(reinterpret_cast<uv_handle_t*>(&idle_check_handle_)); uv_unref(reinterpret_cast<uv_handle_t*>(&task_queues_async_)); // … }
这些函数都是Libuv提供的,分别是往Libuv不同阶段插入任务节点,uv_unref是修改状态。
1 timer_handle是实现Node.js中定时器的数据结构,对应Libuv的time阶段
2 immediate_check_handle是实现Node.js中setImmediate的数据结构,对应Libuv的check阶段。
3 task_queues_async_用于子线程和主线程通信。
RunBootstrapping里调用了BootstrapInternalLoaders和BootstrapNode函数,我们一个个分析。
1 初始化loader
BootstrapInternalLoaders用于执行internal/bootstrap/loaders.js。我们看一下具体逻辑。首先定义一个变量,该变量是一个字符串数组,用于定义函数的形参列表,一会我们会看到它的作用。
std::vector<Local<String>> loaders_params = { process_string(), FIXED_ONE_BYTE_STRING(isolate_, "getLinkedBinding"), FIXED_ONE_BYTE_STRING(isolate_, "getInternalBinding"), primordials_string()};
然后再定义一个变量,是一个对象数组,用作执行函数时的实参。
std::vector<Local<Value>> loaders_args = { process_object(), NewFunctionTemplate(binding::GetLinkedBinding) ->GetFunction(context()) .ToLocalChecked(), NewFunctionTemplate(binding::GetInternalBinding) ->GetFunction(context()) .ToLocalChecked(), primordials()};
接着Node.js编译执行internal/bootstrap/loaders.js,这个过程链路非常长,最后到V8层,就不贴出具体的代码,具体的逻辑转成JS如下。
function demo(process, getLinkedBinding, getInternalBinding, primordials) { // internal/bootstrap/loaders.js 的代码 } const process = {}; function getLinkedBinding(){} function getInternalBinding() {} const primordials = {}; const export = demo(process, getLinkedBinding, getInternalBinding, 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的代码了。
let internalBinding; { const bindingObj = ObjectCreate(null); internalBinding = function internalBinding(module) { let mod = bindingObj[module]; if (typeof mod !== 'object') { mod = bindingObj[module] = getInternalBinding(module); moduleLoadList.push(`Internal Binding ${module}`); } return mod; }; }
Node.js在JS对getInternalBinding进行了一个封装,主要是加了缓存处理。
const internalBindingWhitelist = new SafeSet([, 'tcp_wrap', // 一系列C++内置模块名 ]); { const bindingObj = ObjectCreate(null); process.binding = function binding(module) { module = String(module); if (internalBindingWhitelist.has(module)) { return internalBinding(module); } throw new Error(`No such module: ${module}`); }; }
在process对象(就是我们平时使用的process对象)中挂载binding函数,这个函数主要用于内置的JS模块,后面我们会经常看到。binding的逻辑就是根据模块名查找对应的C++模块。上面的处理是为了Node.js能在JS层通过binding函数加载C++模块,我们知道Node.js中还有原生的JS模块(lib文件夹下的JS文件)。接下来我们看一下,对于加载原生JS模块的处理。Node.js定义了一个NativeModule类负责原生JS模块的加载。还定义了一个变量保存了原生JS模块的名称列表。
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++层。
return { internalBinding, NativeModule, require: nativeModuleRequire };
C++层保存其中两个函数,分别用于加载内置C++模块和原生JS模块的函数。
set_internal_binding_loader(internal_binding_loader.As<Function>()); set_native_module_require(require.As<Function>());
至此,internal/bootstrap/loaders.js分析完了。
2 初始化执行上下文
BootstrapNode负责初始化执行上下文,代码如下
EscapableHandleScope scope(isolate_); // 获取全局变量并设置global属性 Local<Object> global = context()->Global(); global->Set(context(), FIXED_ONE_BYTE_STRING(isolate_, "global"), global).Check(); /* 执行internal/bootstrap/node.js时的参数 process, require, internalBinding, primordials */ std::vector<Local<String>> node_params = { process_string(), require_string(), internal_binding_string(), primordials_string()}; std::vector<Local<Value>> node_args = { process_object(), // 原生模块加载器 native_module_require(), // C++模块加载器 internal_binding_loader(), primordials()}; MaybeLocal<Value> result = ExecuteBootstrapper( this, "internal/bootstrap/node", &node_params, &node_args);
在全局对象上设置一个global属性,这就是我们在Node.js中使用的global对象。接着执行internal/bootstrap/node.js设置一些变量(具体可以参考nternal/bootstrap/node.js)。
process.cpuUsage = wrapped.cpuUsage; process.resourceUsage = wrapped.resourceUsage; process.memoryUsage = wrapped.memoryUsage; process.kill = wrapped.kill; process.exit = wrapped.exit;
设置全局变量
defineOperation(global, 'clearInterval', timers.clearInterval); defineOperation(global, 'clearTimeout', timers.clearTimeout); defineOperation(global, 'setInterval', timers.setInterval); defineOperation(global, 'setTimeout', timers.setTimeout); ObjectDefineProperty(global, 'process', { value: process, enumerable: false, writable: true, configurable: true });
StartMainThreadExecution进行一些初始化工作,然后执行用户JS代码。
1 给process对象挂载属性
执行patchProcessObject函数(在node_process_methods.cc中导出)给process对象挂载一些列属性,不一一列举。
// process.argv process->Set(context, FIXED_ONE_BYTE_STRING(isolate, "argv"), ToV8Value(context, env->argv()).ToLocalChecked()).Check(); READONLY_PROPERTY(process, "pid", Integer::New(isolate, uv_os_getpid()));
因为Node.js增加了对线程的支持,有些属性需要hack一下,比如在线程里使用process.exit的时候,退出的是单个线程,而不是整个进程,exit等函数需要特殊处理。后面章节会详细讲解。
2 处理进程间通信
function setupChildProcessIpcChannel() { if (process.env.NODE_CHANNEL_FD) { const fd = parseInt(process.env.NODE_CHANNEL_FD, 10); delete process.env.NODE_CHANNEL_FD; const serializationMode = process.env.NODE_CHANNEL_SERIALIZATION_MODE || 'json'; delete process.env.NODE_CHANNEL_SERIALIZATION_MODE; require('child_process')._forkChild(fd, serializationMode); } }
环境变量NODE_CHANNEL_FD是在创建子进程的时候设置的,如果有说明当前启动的进程是子进程,则需要处理进程间通信。
3 处理cluster模块的进程间通信
function initializeclusterIPC() { if (process.argv[1] && process.env.NODE_UNIQUE_ID) { const cluster = require('cluster'); cluster._setupWorker(); delete process.env.NODE_UNIQUE_ID; } }
4 执行用户JS代码
require('internal/modules/cjs/loader').Module.runMain(process.argv[1]);
internal/modules/cjs/loader.js是负责加载用户JS的模块,runMain函数在pre_execution.js被挂载,runMain做的事情是加载用户的JS,然后执行。具体的过程在后面章节详细分析。
执行完所有的初始化后,Node.js执行了用户的JS代码,用户的JS代码会往Libuv注册一些任务,比如创建一个服务器,最后Node.js进入Libuv的事件循环中,开始一轮又一轮的事件循环处理。如果没有需要处理的任务,Libuv会退出,从而Node.js退出。
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()); } more = uv_loop_alive(env->event_loop()); } while (more == true && !env->is_stopping());
服务器是现代软件中非常重要的一个组成,我们看一下服务器发展的过程中,都有哪些设计架构。一个基于TCP协议的服务器,基本的流程如下(伪代码)。
// 拿到一个socket用于监听 const socketfd = socket(协议类型等配置); // 监听本机的地址(ip+端口) bind(socketfd, 监听地址) // 标记该socket是监听型socket listen(socketfd)
执行完以上步骤,一个服务器正式开始服务。下面我们看一下基于上面的模型,分析各种各样的处理方法。
while(1) { const socketForCommunication = accept(socket); const data = read(socketForCommunication); handle(data); write(socketForCommunication, data ); }
我们看看这种模式的处理过程,假设有n个请求到来。那么socket的结构如下图所示。
这时候进程从accept中被唤醒。然后拿到一个新的socket用于通信。结构如下图所示。
accept就是从已完成三次握手的连接队列里,摘下一个节点。很多同学都了解三次握手是什么,但是可能很少同学会深入思考或者看它的实现,众所周知,一个服务器启动的时候,会监听一个端口,其实就是新建了一个socket。那么如果有一个连接到来的时候,我们通过accept就能拿到这个新连接对应的socket,那这个socket和监听的socket是不是同一个呢?其实socket分为监听型和通信型的,表面上,服务器用一个端口实现了多个连接,但是这个端口是用于监听的,底层用于和客户端通信的其实是另一个socket。所以每一个连接过来,负责监听的socket发现是一个建立连接的包(syn包),它就会生成一个新的socket与之通信(accept的时候返回的那个)。监听socket里只保存了它监听的IP和端口,通信socket首先从监听socket中复制IP和端口,然后把客户端的IP和端口也记录下来,当下次收到一个数据包的时候,操作系统就会根据四元组从socket池子里找到该socket,从而完成数据的处理。
串行这种模式就是从已完成三次握手的队列里摘下一个节点,然后处理。再摘下一个节点,再处理。如果处理的过程中有阻塞式IO,可想而知,效率是有多低。而且并发量比较大的时候,监听socket对应的队列很快就会被占满(已完成连接队列有一个最大长度)。这是最简单的模式,虽然服务器的设计中肯定不会使用这种模式,但是它让我们了解了一个服务器处理请求的整体过程。
串行模式中,所有请求都在一个进程中排队被处理,这是效率低下的原因。这时候我们可以把请求分给多个进程处理来提供效率,因为在串行处理的模式中,如果有阻塞式IO操作,它就会阻塞主进程,从而阻塞后续请求的处理。在多进程的模式下,一个请求如果阻塞了进程,那么操作系统会挂起该进程,接着调度其它进程执行,那么其它进程就可以执行新的任务。多进程模式下分为几种。
1 主进程accept,子进程处理请求 这种模式下,主进程负责摘取已完成连接的节点,然后把这个节点对应的请求交给子进程处理,逻辑如下。
while(1) { const socketForCommunication = accept(socket); if (fork() > 0) { continue; // 父进程 } else { // 子进程 handle(socketForCommunication); } }
这种模式下,每次来一个请求,就会新建一个进程去处理。这种模式比串行的稍微好了一点,每个请求独立处理,假设a请求阻塞在文件IO,那么不会影响b请求的处理,尽可能地做到了并发。它的瓶颈就是系统的进程数有限,如果有大量的请求,系统无法扛得住,再者,进程的开销很大,对于系统来说是一个沉重的负担。
2 进程池模式 实时创建和销毁进程开销大,效率低,所以衍生了进程池模式,进程池模式就是服务器启动的时候,预先创建一定数量的进程,但是这些进程是worker进程。它不负责accept请求。它只负责处理请求。主进程负责accept,它把accept返回的socket交给worker进程处理,模式如下图所示。
但是和1中的模式相比,进程池模式相对比较复杂,因为在模式1中,当主进程收到一个请求的时候,实时fork一个子进程,这时候,这个子进程会继承主进程中新请求对应的fd,所以它可以直接处理该fd对应的请求,在进程池的模式中,子进程是预先创建的,当主进程收到一个请求的时候,子进程中是无法拿得到该请求对应的fd的。这时候,需要主进程使用传递文件描述符的技术把这个请求对应的fd传给子进程。一个进程其实就是一个结构体task_struct,在JS里我们可以说是一个对象,它有一个字段记录了打开的文件描述符,当我们访问一个文件描述符的时候,操作系统就会根据fd的值,从task_struct中找到fd对应的底层资源,所以主进程给子进程传递文件描述符的时候,传递的不仅仅是一个数字fd,因为如果仅仅这样做,在子进程中该fd可能没有对应任何资源,或者对应的资源和主进程中的是不一致的。这其中操作系统帮我们做了很多事情。让我们在子进程中可以通过fd访问到正确的资源,即主进程中收到的请求。
3 子进程accept
这种模式不是等到请求来的时候再创建进程。而是在服务器启动的时候,就会创建多个进程。然后多个进程分别调用accept。这种模式的架构如图1-8所示。
const socketfd = socket(协议类型等配置); bind(socketfd, 监听地址) for (let i = 0 ; i < 进程个数; i++) { if (fork() > 0) { // 父进程负责监控子进程 } else { // 子进程处理请求 listen(socketfd); while(1) { const socketForCommunication = accept(socketfd); handle(socketForCommunication); } } }
这种模式下多个子进程都阻塞在accept。如果这时候有一个请求到来,那么所有的子进程都会被唤醒,但是首先被调度的子进程会首先摘下这个请求节点,后续的进程被唤醒后可能会遇到已经没有请求可以处理,又进入睡眠,进程被无效唤醒,这是著名的惊群现象。改进方式就是在accpet之前加锁,拿到锁的进程才能进行accept,这样就保证了只有一个进程会阻塞在accept,Nginx解决了这个问题,但是新版操作系统已经在内核层面解决了这个问题。每次只会唤醒一个进程。通常这种模式和事件驱动配合使用。
多线程模式和多进程模式是类似的,也是分为下面几种
1 主进程accept,创建子线程处理
2 子线程accept
3 线程池
前面两种和多进程模式中是一样的,但是第三种比较特别,我们主要介绍第三种。在子进程模式时,每个子进程都有自己的task_struct,这就意味着在fork之后,每个进程负责维护自己的数据,而线程则不一样,线程是共享主线程(主进程)的数据的,当主进程从accept中拿到一个fd的时候,传给线程的话,线程是可以直接操作的。所以在线程池模式时,架构如下图所示。
主进程负责accept请求,然后通过互斥的方式插入一个任务到共享队列中,线程池中的子线程同样是通过互斥的方式,从共享队列中摘取节点进行处理。
现在很多服务器(Nginx,Node.js,Redis)都开始使用事件驱动模式去设计。从之前的设计模式中我们知道,为了应对大量的请求,服务器需要大量的进程/线程。这个是个非常大的开销。而事件驱动模式,一般是配合单进程(单线程),再多的请求,也是在一个进程里处理的。但是因为是单进程,所以不适合CPU密集型,因为一个任务一直在占据CPU的话,后续的任务就无法执行了。它更适合IO密集的(一般都会提供一个线程池,负责处理CPU或者阻塞型的任务)。而使用多进程/线程模式的时候,一个进程/线程是无法一直占据CPU的,执行一定时间后,操作系统会执行任务调度。让其它线程也有机会执行,这样就不会前面的任务阻塞后面的任务,出现饥饿情况。大部分操作系统都提供了事件驱动的API。但是事件驱动在不同系统中实现不一样。所以一般都会有一层抽象层抹平这个差异。这里以Linux的epoll为例子。
// 创建一个epoll var epollFD = epoll_create(); /* 在epoll给某个文件描述符注册感兴趣的事件,这里是监听的socket,注册可 读事件,即连接到来 event = { event: 可读 fd: 监听socket // 一些上下文 } */ epoll_ctl(epollFD , EPOLL_CTL_ADD , socket, event); while(1) { // 阻塞等待事件就绪,events保存就绪事件的信息,total是个数 var total= epoll_wait(epollFD , 保存就绪事件的结构events, 事件个数, timeout); for (let i = 0; i < total; i++) { if (events[i].fd === 监听socket) { var newSocket = accpet(socket); /* 把新的socket也注册到epoll,等待可读, 即可读取客户端数据 */ epoll_ctl(epollFD, EPOLL_CTL_ADD, newSocket, 可读事件); } else { // 从events[i]中拿到一些上下文,执行相应的回调 } } }
这就是事件驱动模式的大致过程,本质上是一个订阅/发布模式。服务器通过注册文件描述符和事件到epoll中,epoll开始阻塞,等到epoll返回的时候,它会告诉服务器哪些fd的哪些事件触发了,这时候服务器遍历就绪事件,然后执行对应的回调,在回调里可以再次注册新的事件,就是这样不断驱动着。epoll的原理其实也类似事件驱动,epoll底层维护用户注册的事件和文件描述符,epoll本身也会在文件描述符对应的文件/socket/管道处注册一个回调,然后自身进入阻塞,等到别人通知epoll有事件发生的时候,epoll就会把fd和事件返回给用户。
function epoll_wait() { for 事件个数 // 调用文件系统的函数判断 if (事件[i]中对应的文件描述符中有某个用户感兴趣的事件发生?) { 插入就绪事件队列 } else { /* 在事件[i]中的文件描述符所对应的文件/socket/管道等indeo节 点注册回调。即感兴趣的事件触发后回调epoll,回调epoll后, epoll把该event[i]插入就绪事件队列返回给用户 */ } }
以上就是服务器设计的一些基本介绍。现在的服务器的设计中还会涉及到协程。不过目前还没有看过具体的实现,所以暂不展开介绍,有兴趣的通信可以看一下协程库libtask了解一下如何使用协程实现一个服务器。 Node.js是基于单进程(单线程)的事件驱动模式。这也是为什么Node.js擅长处理高并发IO型任务而不擅长处理CPU型任务的原因,Nginx、Redis也是这种模式。另外Node.js是一个及web服务器和应用服务器于一身的服务器,像Nginx这种属于web服务器,它们只处理HTTP协议,不具备脚本语言来处理具体的业务逻辑。它需要把请求转发到真正的web服务器中去处理,比如PHP。而Node.js不仅可以解析HTTP协议,还可以处理具体的业务逻辑。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8