乾貨 | 走進Node.js之啟動過程剖析

iKcamp發表於2017-03-24

走進Node.js之啟動過程剖析

作者:正龍 (滬江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前端高效開發實戰》已在亞馬遜、京東、噹噹開售。


乾貨 | 走進Node.js之啟動過程剖析

2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!

相關文章