随着Node.js的普及,越来越多的开发者使用Node.js来搭建环境,也有很多公司开始把Web站点迁移到Node.js服务器。Node.js的优势显而易见,本文不再赘述,那么它是如何做到的呢?内部的逻辑又是什么?带着这些问题,笔者开始了研究Node.js的漫漫长征路。今天,笔者将跟大家探讨一下Node.js的启动原理。
Node.js内部主要依赖Google的V8引擎和libuv实现。V8,想必大家会比较熟悉,它首创把JavaScript直接翻译成汇编代码的方式执行,让很多不可能变成了可能,例如Node.js。libuv,是一个跨平台的异步IO库,它所说的IO除了包含本地文件操作,还包含TCP、UDP等网络套接字操作,范围甚至可以扩展到所有流操作(Stream)。所以,我们可以把Node.js理解为添加了网络功能的V8。
为了描述方便,下面提到的环境是基于Windows 7专业版。用MAC的伙伴们也不用慌,内容实质仍然适用,可能具体名词有些区别。另外,伙伴们可以下载一份Node.js的源代码(点此下载),本文用的是6.10.0 LTS。
我们打开Node.js的二进制发布包,里面内容很简单:node.exe、npm和node.h头文件。node.h头文件只有开发Node.js插件时才会用到。当我们启动node.exe时,它到底做了哪些事情?
首先,它是一个EXE可执行文件,那肯定会有一个main函数。Node.js的main函数定义在node_main.cc中,它主要是初始化V8 Platform和v8引擎;然后会启动一个Node.js实例。具体调用链路如图:
Init函数主要是解析Node.js启动参数,并过滤V8选项传给JavaScript引擎。
Node.js的main函数原来这么短,那它应该很快运行完并返回。实际上,命令行窗口会一直等待着,并没有马上退出,这又是怎么回事呢?答案就在StartInstance里。首先它会创建V8执行沙盒,生成并初始化Node.js运行环境对象,然后启动Node.js的循环等待。具体如图:
也就是说Node.js的主线程主要消费来自UV默认事件循环(uv_default_loop)和V8的MainThreadQueue和MainThreadDelayedQueue的任务。uv_run是一个阻塞调用。如果队列中有任务,则执行并返回true,如果没有的话,会阻塞住当前线程;如果返回false,则整个Node.js进程会释放资源并退出。注意参数UV_RUN_ONCE,意思是从队列中只取一个任务执行,不管队列中当前是否有多个任务。
到这儿,大概可以理解到Node.js的“单线程”是怎么回事。那运行的Node.js进程确实只开启了一个线程吗?我们打开任务管理器看看:
实际上,Node.js进程当前有7个线程。查阅文档之后发现,Node.js通过指定参数–v8-pool-size可以设置V8线程池大小。原来V8的字节码编译、优化还有GC都是通过多线程完成;又继续深入调查,发现环境变量UV_THREADPOOL_SIZE会影响libuv的线程池大小。
Node.js目前为止做的事情可以归纳为,初始化V8和libuv。接下来,我们看看Node.js自身运行环境是怎样构建起来的。Node.js自身的运行环境由Environment类表示,我们需要把process对象构建起来。process对象在JavaScript应用代码中是可以访问到,它的文档可以狠戳这儿。注意,process现在还没有赋值给Global对象。CreateEnvironment执行流程如图:
调用setAutorunMicrotask禁止V8自己消费队列中的任务。SetupProcessObject主要设置process的属性,例如比较重要的binding,还有其它提供给开发者的字段,比如cpuUsage、hrtime、uptime等。binding用于获取C/C++构建的模块,Node.js中的net库就是通过这种方式最终调用到libuv。
binding就是做模块查找,其执行过程如下:
- 从Args中获取到模块名称。
- 从Binding Cache中看是否能找到模块,如果有直接返回模块的exports。
- 3往Module Load List中追加一条模块记录,名称为”binding “ + 模块名。
- 调用get_builtin_module,参数是模块名,get_builtin_module会从modlist_builtin列表中查找内置模块,所有内置模块和第三方扩展都记录在modlist_builtin列表中。C/C++模块通过NODE_MODULE_CONTEXT_AWARE_BUILTIN注册,第三方扩展模块通过NODE_MODULE注册。最终都会调用node_module_register。node_module结构体包含注册函数、模块名称、文件名称等信息。
- 如果查找到,则返回对应模块的exports。
- 如果模块名是constants,则调用DefineContstants。
- 如果模块名是natives,则调用DefineJavaScript,会返回所有内置模块,它们一般由Javascript实现。这些模块在/lib目录下,会通过js2c.py转成c代码,js2c.py会生成一个临时文件node_natives.h,里面包含了NODE_NATIVES_MAP的定义。
- 否则,抛出错误:没有指定名称的模块。
环境对象准备好之后,就开始真正加载Node.js自身提供的JavaScript类库代码。LoadEnvironment执行过程如下:
- 调用ExecuteString执行bootstrap_node.js。bootstrap_node.js文件里定义了一个函数它会往Global对象上添加属性,通过internal/module加载Node.js自身提供的JavaScript类库。
- 执行上一步返回的函数,并传入env->process_object()对象。
到这儿,我们可以总结2个问题:
-
Node.js里面自己提供的JavaScript库是怎么实现的?
通过C/C++代码封装成Node.js内置模块,然后再通过process.binding暴露给JavaScript。
-
JavaScript库文件是怎么打包在node.exe中?
Node.js内置的JavaScript文件,通过js2c.py编译生成临时文件node_natives.h。
原理思路基本搞明白之后,下面我们来做个小实例:如何把C++对象暴露给JavaScript。 程序主要是C++和JavaScript的交互,通过Node.js插件的方式运行。所以大家需要先了解下如何编译Node.js插件,官方文档猛戳这儿。
首先定义要导出的C++类,构造器可以传入一个数值;调用成员方法PlusOne,数值自增1并返回当前值。
namespace demo {
class MyObject : public node::ObjectWrap {
public:
static void Init(v8::Local<v8::Object> exports);
static void NewInstance(const v8::FunctionCallbackInfo<v8::Value>& args);
inline double value() const { return _value; }
private:
explicit MyObject(double value = 0);
~MyObject();
static void New(const v8::FunctionCallbackInfo<v8::Value>& args);
static void PlusOne(const v8::FunctionCallbackInfo<v8::Value>& args);
static v8::Persistent<v8::Function> constructor;
double _value;
};
}
实现文件
void MyObject::NewInstance(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
const unsigned argc = 1;
Local<Value> argv[argc] = { args[0] };
Local<Function> cons = Local<Function>::New(isolate, constructor);
Local<Context> context = isolate->GetCurrentContext();
Local<Object> instance = cons->NewInstance(context, argc, argv).ToLocalChecked();
args.GetReturnValue().Set(instance);
}
void MyObject::Init(Local<Object> exports) {
Isolate* isolate = exports->GetIsolate();
Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, New);
tpl->SetClassName(String::NewFromUtf8(isolate, "MyObject"));
tpl->InstanceTemplate()->SetInternalFieldCount(1);
NODE_SET_PROTOTYPE_METHOD(tpl, "plusOne", PlusOne);
constructor.Reset(isolate, tpl->GetFunction());
exports->Set(String::NewFromUtf8(isolate, "MyObject"), tpl->GetFunction());
}
void MyObject::New(const FunctionCallbackInfo<Value>& args) {
double value = args[0]->IsUndefined() ? 0 : args[0]->NumberValue();
MyObject* obj = new MyObject(value);
obj->Wrap(args.This());
args.GetReturnValue().Set(args.This());
}
void MyObject::PlusOne(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
MyObject* obj = ObjectWrap::Unwrap<MyObject>(args.Holder());
obj->_value += 1;
args.GetReturnValue().Set(Number::New(isolate, obj->_value));
}
NODE_MODULE(addon, MyObject::Init)
修改binding.gyp文件
{
"targets": [
{
"target_name": "addon",
"sources": [ "myobject.cc" ]
}
]
}
通过node-gyp build编译成功之后会在build/Release/目录下生成文件addon.node。这样我们就可以在JavaScript中使用MyObject了:
const addon = require('./addon');
let obj = new addon.MyObject();
console.log(obj.plusOne());
console.log(obj.plusOne());
console.log(obj.plusOne());
let obj1 = new addon.MyObject(10);
console.log(obj1.plusOne());
执行结果如下:
虽然Node.js的启动过程很简洁,但还是有一些问题可以继续深挖。比如,一个网络请求在Node.js中到底是怎么被处理的呢?希望本文可以抛砖引玉,在入门阶段给大家一点帮助。