干货剖析 | 走进Node.js之启动过程

大数据小菜 2019-06-21

作者:正龙 (沪江Web前端开发工程师)
本文原创,转载请注明作者及出处。

随着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实例。具体调用链路如图:

干货剖析 | 走进Node.js之启动过程

Init函数主要是解析Node.js启动参数,并过滤V8选项传给JavaScript引擎。

Node.js的main函数原来这么短,那它应该很快运行完并返回。实际上,命令行窗口会一直等待着,并没有马上退出,这又是怎么回事呢?答案就在StartInstance里。首先它会创建V8执行沙盒,生成并初始化Node.js运行环境对象,然后启动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之启动过程

实际上,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执行流程如图:

干货剖析 | 走进Node.js之启动过程

调用setAutorunMicrotask禁止V8自己消费队列中的任务。SetupProcessObject主要设置process的属性,例如比较重要的binding,还有其它提供给开发者的字段,比如cpuUsage、hrtime、uptime等。binding用于获取C/C++构建的模块,Node.js中的net库就是通过这种方式最终调用到libuv。

binding就是做模块查找,其执行过程如下:

  1. 从Args中获取到模块名称。
  2. 从Binding Cache中看是否能找到模块,如果有直接返回模块的exports。
  3. 3往Module Load List中追加一条模块记录,名称为"binding " + 模块名。
  4. 调用get_builtin_module,参数是模块名,get_builtin_module会从modlist_builtin列表中查找内置模块,所有内置模块和第三方扩展都记录在modlist_builtin列表中。C/C++模块通过NODE_MODULE_CONTEXT_AWARE_BUILTIN注册,第三方扩展模块通过NODE_MODULE注册。最终都会调用node_module_register。node_module结构体包含注册函数、模块名称、文件名称等信息。
  5. 如果查找到,则返回对应模块的exports。
  6. 如果模块名是constants,则调用DefineContstants。
  7. 如果模块名是natives,则调用DefineJavaScript,会返回所有内置模块,它们一般由Javascript实现。这些模块在/lib目录下,会通过js2c.py转成c代码,js2c.py会生成一个临时文件node_natives.h,里面包含了NODE_NATIVES_MAP的定义。
  8. 否则,抛出错误:没有指定名称的模块。

环境对象准备好之后,就开始真正加载Node.js自身提供的JavaScript类库代码。LoadEnvironment执行过程如下:

  1. 调用ExecuteString执行bootstrap_node.js。bootstrap_node.js文件里定义了一个函数它会往Global对象上添加属性,通过internal/module加载Node.js自身提供的JavaScript类库。
  2. 执行上一步返回的函数,并传入env->process_object()对象。

到这儿,我们可以总结2个问题:

  1. Node.js里面自己提供的JavaScript库是怎么实现的?

    通过C/C++代码封装成Node.js内置模块,然后再通过process.binding暴露给JavaScript。
  2. 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的启动过程很简洁,但还是有一些问题可以继续深挖。比如,一个网络请求在Node.js中到底是怎么被处理的呢?希望本文可以抛砖引玉,在入门阶段给大家一点帮助。

干货剖析 | 走进Node.js之启动过程

干货剖析 | 走进Node.js之启动过程

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。

>> 沪江Web前端上海团队招聘【Web前端架构师】,有意者简历至:[email protected] <<

相关推荐