本文通过阅读Node.js(版本0.11.9)的代码, 试图理解两个问题

  • C++和JS是如何交互的
  • 异步是如何实现的, event loop在其中充当什么角色

目录

两个问题

最大调用栈

如果直接调用一下代码, 会造成调用栈过深

function foo() {
    foo();
}
foo();
// Maximum call stack size exceeded

然而, 将递归调用放到异步回调中, 就避免了调用栈过深

function foo() {
    setTimeout(foo, 0);
}
foo();
// all right. browser never block, code execute normally.

队列优先级

下面是一个文件读取操作, 多次试验, 输出的文件平均读取耗时为40mm

var start = Date.now();

fs.readFile("data.txt", function() {
    console.log(Date.now() - start);
});
// 35, 38, 40, 37, 42

我们设定两个计时器, 一个比文件读取时间短(0), 另一个要长(100), 最后输出结果在注释中

var fs = require('fs');

var start = Date.now();

setTimeout(function() {
    console.log('First timer');
}, 0);

fs.readFile("data.txt", function() {
    console.log('Async Operation', Date.now() - start);
});

setTimeout(function() {
    console.log('Second timer');
}, 100);

// First timer 12
// Async Operation 36
// Second timer 108

这样的结果我们是能够理解的, 第一个计时器最快完成(0mm), 然后文件读取完成(37mm), 最后一个计时器最后完成.

但是, 如果像下面代码, 我们使用循环阻塞整个进程, 直到所有任务都完成后, 在执行回调, 结果是怎样呢?

var fs = require('fs');

var start = Date.now();

setTimeout(function() {
    console.log('First timer', Date.now() - start);
}, 0);

fs.readFile("data.txt", function() {
    console.log('Async Operation', Date.now() - start);
});

setTimeout(function() {
    console.log('Second timer', Date.now() - start);
}, 100);

while(1) {
    if ((Date.now() - start) > 200) {
        break;
    }
}

// First timer 212
// Second timer 213
// Async Operation 238

结果先执行两个计时器, 最后执行IO操作. 下面是一个类似的浏览器上的例子

为了理解这两个问题, 我们需要理解event loop背后的原理, 回答一些问题 — 1) 多线程? 2) 多堆栈? 3) 多队列?

C++和Javascript交互

通过v8源码的示例process.cccount-hosts.js, 我们可以了解C++和Javascript代码是如何进行交互的.

通过在C++代码中使用v8引擎提供的接口, 可以在Javascript运行上下文中插入使用C++定义的变量(或函数); 同时, 也可以取出Javascript在此上下文中定义的变量(或函数等), 在C++代码中执行.

在Javascript代码中使用通过C++定义的函数

首先创建全局对象, 用于存放build-in函数log source

Handle<ObjectTemplate> global = ObjectTemplate::New();
global->Set(String::New("log"), FunctionTemplate::New(LogCallback));

在Javascript中, 就可以使用log函数输出日志 source

log("Processing " + request.host + request.path + " from " + request.referrer + "@" + request.userAgent);

在C++中获得使用Javascript定义的函数

在count-hosts.js中定义全局函数Process

function Process(request) { ... }

在process.cc中, 先取出该函数 source

Handle<String> process_name = String::New("Process");
Handle<Value> process_val = context->Global()->Get(process_name);
Handle<Function> process_fun = Handle<Function>::Cast(process_val);

之后再调用它

const int argc = 1;
Handle<Value> argv[argc] = { request_obj };
v8::Local<v8::Function> process = v8::Local<v8::Function>::New(GetIsolate(), process_);
Handle<Value> result = process->Call(context->Global(), argc, argv);

Node.js初始化

为了理解event loop的实现, 首先要对Node.js初始化和模块有所了解.

Node.js的初始化调用链是这样的, main -> Start -> CreateEnvironment -> Load, Start过程中启用了event loop

int Start(int argc, char** argv) {
    ...
    Environment* env =
        CreateEnvironment(node_isolate, argc, argv, exec_argc, exec_argv);
    ...
*    uv_run(env->event_loop(), UV_RUN_DEFAULT);
    ...
}

node:Load加载了node.js, node.js是第一个被加载的Javascript文件, 它负责初始化Node.js的全局变量和函数, 如setTimeout, nextTick等.

Node.js模块

Node.js中, 模块是通过require来加载的, 其背后实现代码在NativeModule.require中.

NativeModule.require首先检测模块是否在缓存中,

NativeModule.require = function(id) {
    ...
    var cached = NativeModule.getCached(id);
    if (cached) {
        return cached.exports;
    }
    ...
};

如果没有则读取该模块文件内容, 并调用runInThisContext执行Javascript模块代码

NativeModule.require = function(id) {
    ...
    var nativeModule = new NativeModule(id);

    nativeModule.cache();
    nativeModule.compile();

    return nativeModule.exports;
};

NativeModule.prototype.compile = function() {
    var source = NativeModule.getSource(this.id);
    source = NativeModule.wrap(source);

    var fn = runInThisContext(source, { filename: this.filename });
    fn(this.exports, NativeModule.require, this, this.filename);

    this.loaded = true;
};

那么runInThisContext是怎样实现的呢?

var ContextifyScript = process.binding('contextify').ContextifyScript;
function runInThisContext(code, options) {
    var script = new ContextifyScript(code, options);
    return script.runInThisContext();
}

稍后将介绍process.binding的实现. 通过grep代码, 在node_contextify.cc找到了contextify的C++实现.

可以预见, process.binding作为一个桥梁, 使用我们上面介绍C++和Javascript交互的技术, 使得Node.js可以调用C++中实现的代码.

process.binding

我们可以在之前提到的Node.js初始化代码中,找到process.binding的实现.

node:CreateEnvironment过程中, 会初始化process对象, 设置process.binding方法

Environment* CreateEnvironment() {
  ...
  SetupProcessObject(env, argc, argv, exec_argc, exec_argv);
  ...
}

void SetupProcessObject() {
  ...
  NODE_SET_METHOD(process, "binding", Binding);
  ...
}

Binding方法接受参数, 然后通过调用get_buildin_module返回使用C++编写的模块

static void Binding(const FunctionCallbackInfo<Value>& args) {
  ...
  node_module_struct* mod = get_builtin_module(*module_v);
  if (mod != NULL) {
    exports = Object::New();
    // Internal bindings don't have a "module" object, only exports.
    assert(mod->register_func == NULL);
    assert(mod->register_context_func != NULL);
    Local<Value> unused = Undefined(env->isolate());
    mod->register_context_func(exports, unused, env->context());
    cache->Set(module, exports);
  } else if (!strcmp(*module_v, "constants")) {
    exports = Object::New();
    DefineConstants(exports);
    cache->Set(module, exports);
  } else if (!strcmp(*module_v, "natives")) {
    exports = Object::New();
    DefineJavaScript(exports);
    cache->Set(module, exports);
  } else {
    return ThrowError("No such module");
  }

  args.GetReturnValue().Set(exports);
}

get_builtin_module通过事先注册的模块列表node_module_list来加载模块, node_module_list是通过宏实现的.

在src/node_extensions.h中定义宏NODE_EXT_LIST, 其中包含了使用C++编写的模块

在src/node_extensions.cc中, 调用宏, 展开过程中使用到得诸如node_fs_module变量则是在每个C++模块底部定义的

NODE_MODULE_CONTEXT_AWARE(node_contextify, node::InitContextify);

这个宏展开后的结果是

extern "C" {
    node::node_module_struct node_contextify_module = {
        13 , __null , __FILE__ , __null , ( node::InitContextify ), "node_contextify"}
    ;
};

get_builtin_module中获取了C++模块后, 通过使用register_context_func模块自己制定的注册函数完成注册的步骤.

mod->register_context_func(exports, unused, env->context());

模块小结

Node.js使用C++来实现系统调用, 在每个C++模块底部都将该模块注册到一个全局队列中. 当这些模块被require时, 将检索全局队列, 初始化, 导出该模块.

如果你编写过Node.js C++ Addon, 就会发现Addon也需要通过宏来注册自己.

异步实现

追踪fs.readFile回调

为了追查异步调用的实现, 我们先从一个常用的异步方法fs.readFile开始,

fs.readFile使用fs.read来读取数据, 并最终调用binding.read

fs.read = function(fd, buffer, offset, length, position, callback) {
  ...
  binding.read(fd, buffer, offset, length, position, wrapper);
};

其中binding是这样定义的

var binding = process.binding('fs');

根据我们上节讲到得process.binding魔法, node_file.cc为文件操作提供了最终实现.

fs.readnode_file.cc中实现为Read, 这个实现是对read(2)的一个包装.

Read中, 获取了异步调用的回调函数, 并将其传入ASYNC_CALL

static void Read(const FunctionCallbackInfo<Value>& args) {
    ...
    cb = args[5];

    if (cb->IsFunction()) {
        ASYNC_CALL(read, cb, fd, buf, len, pos);
    } else {
        SYNC_CALL(read, 0, fd, buf, len, pos)
        args.GetReturnValue().Set(SYNC_RESULT);
    }
    ...
}

宏展开async

Environment* env = Environment::GetCurrent(args.GetIsolate());
FSReqWrap* req_wrap = new FSReqWrap(env, "read" );
int err = uv_fs_read (env->event_loop(), &req_wrap->req_, fd , buf , len , pos , After);
req_wrap->object()->Set(env->oncomplete_string(), cb );
req_wrap->Dispatched();
if (err < 0) {
    uv_fs_t* req = &req_wrap->req_;
    req->result = err;
    req->path = __null ;
    After(req);
}
args.GetReturnValue().Set(req_wrap->persistent());

在libuv中, uv_fs_read的定义是这样的,

UV_EXTERN int uv_fs_read(uv_loop_t* loop, uv_fs_t* req, uv_file file,void* buf, size_t length, int64_t offset, uv_fs_cb cb);

它使用event loop的核心数据结构loop, 当文件读取操作完成后, 将会调用回调函数cb. 接下来我们来看看 libuv是如何实现完成事件调用函数的功能的.

创建运行event loop

Node.js初始化过程中, CreateEnvironment使用uv_default_loop创建了event loop中使用的核心数据结构loop, 在node:Start中通过uv_run启用event loop(见Node.js初始化)

深入libuv

理解libuv分两条线索, 任务的提交和任务的处理.

任务提交

仍以文件读取为例, 上面已经讲到uv_fs_read会在文件可用时调用回调.

uv_fs_read (env->event_loop(), &req_wrap->req_, fd , buf , len , pos , After);

uv_fs_read是这样定义的(deps/uv/src/unix/fs.c)

int uv_fs_read() {
  ...
  do {
      if ((cb) != ((void*)0) ) {
*          uv__work_submit((loop), &(req)->work_req, uv__fs_work, uv__fs_done);
          return 0;
      }
      else {
          uv__fs_work(&(req)->work_req);
          uv__fs_done(&(req)->work_req, 0);
          return (req)->result;
      }
  }
  ...
}

最后文件读取任务被插入任务队列, 等待线程池中线程空闲后执行,

source

void uv__work_submit() {
  uv_once(&once, init_once);
  w->loop = loop;
  w->work = work;
  w->done = done;
  post(&w->wq);
}

static void post(QUEUE* q) {
  uv_mutex_lock(&mutex);
  QUEUE_INSERT_TAIL(&wq, q);
  uv_cond_signal(&cond);
  uv_mutex_unlock(&mutex);
}

#define QUEUE_INSERT_TAIL(h, q)                                               \
  do {                                                                        \
    QUEUE_NEXT(q) = (h);                                                      \
    QUEUE_PREV(q) = QUEUE_PREV(h);                                            \
    QUEUE_PREV_NEXT(q) = (q);                                                 \
    QUEUE_PREV(h) = (q);                                                      \
  }                                                                           \
  while (0)

任务处理

uv_default_loop创建并初始化了loop对象,

uv_loop_t* uv_default_loop(void) {
  if (default_loop_ptr != NULL)
    return default_loop_ptr;

  if (uv__loop_init(&default_loop_struct, /* default_loop? */ 1))
    return NULL;

  default_loop_ptr = &default_loop_struct;
  return default_loop_ptr;
}

static int uv__loop_init(uv_loop_t* loop, int default_loop) {
  ...
  memset(loop, 0, sizeof(*loop));
  RB_INIT(&loop->timer_handles);
  QUEUE_INIT(&loop->wq);
  QUEUE_INIT(&loop->active_reqs);
  QUEUE_INIT(&loop->idle_handles);
  QUEUE_INIT(&loop->async_handles);
  QUEUE_INIT(&loop->check_handles);
  QUEUE_INIT(&loop->prepare_handles);
  QUEUE_INIT(&loop->handle_queue);
  ...
}

uv_run不断循环检测是否还有待处理任务, 如果有则执行该任务关联的回调; 如果没有待处理的任务, 程序就结束了.

在这里还可以看到, 对于timerio任务队列的处理优先级是不同的.

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  ...
  r = uv__loop_alive(loop);
  while (r != 0 && loop->stop_flag == 0) {
    UV_TICK_START(loop, mode);

    uv__update_time(loop);
    uv__run_timers(loop);
    uv__run_idle(loop);
    uv__run_prepare(loop);
    uv__run_pending(loop);

    timeout = 0;
    if ((mode & UV_RUN_NOWAIT) == 0)
      timeout = uv_backend_timeout(loop);

    uv__io_poll(loop, timeout);
    uv__run_check(loop);
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop);
    UV_TICK_STOP(loop, mode);

    if (mode & (UV_RUN_ONCE | UV_RUN_NOWAIT))
      break;
  }
  ...
}

event loop的伪代码是这样的

while there are still events to process:
    e = get the next event
    if there is a callback associated with e:
        call the callback

异步小结

Javascript的异步IO最终使用libuv, 将任务提交到线程池中进行处理. Javascript代码仍然在一条主线程中, 不需要考虑变量共享和锁的问题.

但是背后有多个工作线程处理异步IO操作, 使得Node.js能高校处理IO操作.

总结

  • C++能够通过v8提供的API获取并修改Javascript执行上下文
  • 暴露在Node.js环境中的系统调用最终是使用C++编写的
  • 在Node.js中调用IO接口后, 会将任务提交到线程池中执行. Node.js程序员看到的是单线程的Javascript代码, 但是最终任务是多线程处理的. thread model

最后解答文章开头两个问题

最大调用栈

使用一部调用进行递归可以避免调用栈过深的原因是, 每次回调函数执行时候, 栈已经被清空; 只有栈清空时, event loop才有机会检测事件队列, 执行回调函数.

队列优先级

在上面已经提到, 不同的异步操作队列是有优先级的, 通常timer会高于IO操作. 当然, 前提当event loop在检测时他们都处于完成状态.

执行Javascript代码的v8引擎和event loop在同一个主线程上, 这导致我们使用while循环执行Javascript代码时, 无法检测操作状态, 直到退出while循环, event loop看到都已经处于完成状态的操作, 按照队列优先级执行这些操作的回调.

相关参考