Node.js 探秘:求异存同
作者: 发布于:

前言

Node.js 探秘:初识单线程的 Node.js 中,我们了解到,Node.js 基于 libuv 实现了 I/O 的异步操作。所以,我们经常写类似下面的代码:

fs.readFile('test.txt', function(err, data) {
  if (err) {
    //error handle/
  }

  //do something with data.
});

通过回调函数来获得想要的结果。

在我们实际解决问题的时候,往往需要一组操作是有序的,比如:读取配置文件、编写命令行工具等。如果使用回调的方式,会使用很多的回调嵌套,使代码变得很难看。为了解决这个问题,我们引入 Promise、yield 等概念,但今天我们不讨论这些,我们讨论下最简单的解决办法, 同步执行 以及 Node.js 如何在异步的架构上实现同步的方法。

使用同步方法

翻看下 Node.js 的文档,你会看到类似 **Sync 的方法,这些就是 ** 对应的同步版本。我们先简单看下如何使用这些方法。

一般读取文件内容,我们会像文章开头那样异步的处理。如果使用同步方法,则类似于下面这样:

try {
  var data = fs.readFileSync('test.txt');
  //do something with data.
} catch(e) {
  //error handle.
}

可以看到使用同步方法后,我们需要的数据会在文件操作后直接返回,而不存在 callback 的异步处理。需要注意的是,像上面那样使用同步方法时,异常内容不会在返回值中返回,它会被抛到环境中,需要使用 try...catch 来捕获处理。如果想在返回值中获取异常,可以传入一个 Buffer 实例来储存 raw data,这样返回值就变成了一个数组,第一个元素是字符串形式的结果,第二个元素是 Error 信息。

另外,Node.js 在 v0.12 版本之后,实现了同步进程创建的一系列方法,例如:spawnSyncexecSync 等,示例如下:

//异步版本
child_process.exec('ls', function(err, data) {
  if (err) {
    //error handle
  }

  //do something with data
});

//同步版本
try {
  var data = child_process.execSync('ls');
  //do something with data
} catch(e) {
  //error handle
}

如何调试 Node.js 源码

在分析具体内容之前,我们先来做些准备工作。debug 是我们在开发时常用的手段,如果我们在看源码的时候,也能边看,边运行,然后在自己想要的地方停下来,或者按照自己的理解稍作修改,再运行,那一定会大大提高效率,下面笔者介绍下自己的方案(以  MacOS 为例):

  1. 首先需要有一份 Node.js 的源码,使用 git clone Github 的仓库 或者去 这里 下载压缩包,都可以。
  1. (解压之后)进入源码目录,运行
    ./configure
    make -j
    -j [N],--jobs[=N] 参数用来提高编译效率,充分利用多核处理器的性能,并行编译,N 为并行的任务数。但它并不是万能的,如果有编译依赖,最好还是用单核编译。
  1. 使用你习惯的 IDE 来进行 debug,笔者使用的是 CLion。Debug 配置如下图所示:

    将执行文件指定到 源码目录/out/Release/ 目录下的  node 执行文件,参数为你需要运行的脚本和相应的参数(比如这里我配置了可以手动调用 gc),之后去掉 Before Launch 中的 build。
  1. 然后运行 Debug 模式,就可以通过断点和 LLDB 来调试 Node.js 源码了,如图。
  1. 如果需要更改源码,在更改后再次运行 make 即可。

进入正题

Node.js API 文档中,可以看到,只有 File SystemChild Process 中有同步的方法,但是两个模块的同步方法实现是不一样的,我们分开来说。

File System

File System 相关的方法实现都在 lib/fs.jssrc/node_file.cc 中可以找到。每一个对应的文件操作方法,比如 read、write、link 等等,都有对应的封装。下面以 read 为例进行讲解:

lib/fs.js 中我们可以看到,fs.read(#L587) 和 fs.readSync(#L633) 两个方法实际是很相似的,只不过在调用 C/C++ 层面的 read 方法时,传入的参数不同。

//异步方法
fs.read = function(fd, buffer, offset, length, position, callback) {
  //参数处理
  ...
  var req = new FSReqWrap();
  req.oncomplete = wrapper;

  binding.read(fd, buffer, offset, length, position, req);
}

//同步方法
fs.readSync = function(fd, buffer, offset, length, position, callback) {
  //参数处理
  ...
  var r = binding.read(fd, buffer, offset, length, position);
  ...
}

可以看到,异步的方法创建了一个  FSReqWrap 对象,同时把回调包裹后赋值在它的 oncomplete 属性上。这个对象就是我们之前说到的请求对象,在 Node.js 中几乎所有异步操作都会传入这样一个对象,下文我们还会看到 ProcessWrap,除此之外还有很多其他类型的请求对象。它们的作用就是在和 libuv 相互调用时传递需要的数据。

src/node_file.cc #L1052Read 方法中,会根据传入的第六个参数的类型来判断是同步调用还是异步调用。

static void Read(const FunctionCallbackInfo<Value>& args) {
  //参数处理
  ...
  req = args[5];

  if (req->IsObject()) {
    ASYNC_CALL(read, req, fd, &uvbuf, 1, pos);
  } else {
    SYNC_CALL(read, 0, fd, &uvbuf, 1, pos)
    args.GetReturnValue().Set(SYNC_RESULT); //调用返回值为错误码,没有则为 0
  }
}

可以看到,这里面使用了两个宏定义 ASYNC_CALLSYNC_CALL,它们的具体内容在这里:#L281#L295。对于宏定义,可以理解为代码替换,它会在编译时根据传入的参数生成代码到相应的位置。

//ASYNC_CALL
FSReqWrap* req_wrap = FSReqWrap::New(env, req.As<Object>(), #func, dest);
int err = uv_fs_ ## func(env->event_loop(),
                         &req_wrap->req_,
                         __VA_ARGS__,
                         After);					  
//SYNC_CALL
fs_req_wrap req_wrap;
int err = uv_fs_ ## func(env->event_loop(),
                         &req_wrap.req,
                         __VA_ARGS__,
                         nullptr);

这两个宏定义的区别在于,异步操作时传入的是一个封装好的请求对象( FSReqWrap),而同步操作时传入的是一个自定义的结构体( fs_req_wrap),而且后者在调用 libuv 方法时也没有传入回调函数 After。这里面的缘由,就涉及到 libuv 的具体细节了。

libuv 中的 File System 不同于 socket operations,它内部使用了阻塞函数,然后通过线程池来调用这些函数,并在应用程序需要交互时通知在事件循环中注册的 watcher,所以 File System 本身是支持同步调用和异步调用两种形式的,它们的区别就在于 是否传入了回调函数

libuv/fs.cPOST 宏中可以看到,当传入回调时,libuv 会通过 uv__work_submit 方法把操作提交到队列中等待执行,而没有回调时,它通过 uv__fs_work 方法直接执行对应操作。

#define POST
do {
  if (cb != NULL) {
    uv__work_submit(loop, &req->work_req, uv__fs_work, uv__fs_done);
    return 0;
  } else {
    uv__fs_work(&req->work_req);
    return req->result;
  }
} while (0)

另外,这两种方式都是有返回值的,遵循 libuv error code,但一般在同步调用的形式下不会用到。Node.js 在异步调用时通过判断这个返回值是否小于 0,来传递错误。

if (err < 0) {
  ...
  uv_req->result = err;
  ...
  After(uv_req);
  ...
}

再来说说为什么传入的请求对象不同。同步调用时,把需要的参数传入就可以了。但在运行异步回调的时候,是需要给出一个上下文信息的(比如读取到的数据和回调函数),所以通过一个包装的  FSReqWrap 来把信息放在  uv_fs_t 这类对象上,供 libuv 和程序本身使用。

同步方法和异步方法流程区别主要如下图:


同步文件操作


异步文件操作

Child Process

虽然有 libuv 这样强大的底层库,但是由于它本身的不完善,所以在 v0.12 之后才增加了  sync 相关方法。

因为 execexecFile 都是基于 spawn 这个方法,所以我们以 spawn 方法为例,来看下异步进程创建是如何实现的。

跟异步实现相关的文件有 internal/child_process.jsprocess_wrap.cc,我们一点点来看。

在我们调用的 spawn 实际上最终调用的是 internal/child_process.js 中的 ChildProcess.spawn 方法。ChildProcess 的实例会被绑定 onexit 方法,在进程使用完毕后被调用,并且具有 EventEmitteronemit 等方法,来让使用者获取相应的数据。它的内部存在一个 _handle 对象,它是 ProcessWrap 所对应的 Javascript 对象。

function ChildProcess() {
  ...
  this._handle = new Process(); //Process Req Wraper
  ...
  this._handle.onexit = function(exitCode, signalCode) {
    ...
  }
  ...
}
util.inherits(ChildProcess, EventEmitter);

ProcessWrap 对象中,会处理我们传入的参数以及回调,最终变成一个 options 引用,传入到 uv_spawn 中,最终它来真正完成 spawn 操作。和上面文件操作的异步方法类似(因为都是继承的  AsyncWrap),在执行完成后,会通过 MakeCallback 来执行 onexit 回调,在这个回调里面,会根据结果,触发 errorexit 事件。而在运行过程中的输出,会通过 pipe(默认)等方式传回来,因为是 stream,所以可以通过监听 data 事件来获取。

static void Spawn(const FunctionCallbackInfo<Value>& args) {
  ...
  options.exit_cb = OnExit;
  ...
  // options.stdio
  ParseStdioOptions(env, js_options, &options);
  ...
  int err = uv_spawn(env->event_loop(), &wrap->process_, &options);
  ...
}

当你明白了上面 File System 的异步调用后,Child Porcess 异步的实现会比较容易理解。下面我们来看同步的实现。

我们知道,Node.js 因为有 uv_run 的存在,它会一直循环,处理各种事件。当全部处理完成后,uv_run 结束,Node.js 也随之结束。那么,可以理解为 uv_run 是一个阻塞的方法,会阻塞当前进程的运行。实际上,uv_run 也确实是一个有 while 循环的方法,满足条件(uv__loop_alive && loop -> stop_flag == 0)就会一直执行。Child Porcess 同步方法的实现,也正是基于了这个特性。

在它的底层实现 spawn_sync.cc 中,可以看到,它创建了一个新的 uv_loop_t 结构对象 uv_loop_,并且通过它来运行 uv_spawnuv_run。这个 uv_run 会阻塞当前的运行进程,直到执行完毕或者超时。因为主进程使用的是 default_uv_loop,所以不会干扰主进程事件的处理。

void SyncProcessRunner::TryInitializeAndRunLoop(Local<Value> options) {
  ...
  uv_loop_ = new uv_loop_t;
  ...
  if (timeout_ > 0) {
    ...
    r = uv_timer_init(uv_loop_, &uv_timer_);
    ...
  }
  ...
  uv_process_options_.exit_cb = ExitCallback;
  r = uv_spawn(uv_loop_, &uv_process_, &uv_process_options_);
  ...
  r = uv_run(uv_loop_, UV_RUN_DEFAULT);
}

这其中还有一个特殊处理,在 uv_run 执行完毕后,会给即将关闭的 watchers 时间来关闭。

void SyncProcessRunner::CloseHandlesAndDeleteLoop() {
  ...
  if (uv_loop_ != nullptr) {
    CloseStdioPipes();
    CloseKillTimer();
    ...
    //让正在关闭的 watcher 完成关闭,这样它们会调用 close 回调。
    int r = uv_run(uv_loop_, UV_RUN_DEFAULT);
    ...
    delete uv_loop_;
    uv_loop_ = nullptr;
  }
  ...
}

之后根据执行过程中的输出和执行结果,确定返回给 Javascript 层的结果,经过一系列处理后,返回给调用者,这不是本文重点,就不赘述了。

后话

在 Node.js 的 API 中,异步方法的实现都大同小异,使用  AsyncWrap 的子类来和 libuv 交换数据,然后在调用 libuv 中相应的方法来。但相应的同步方法的实现,确各有不同,用到了很多特性,下了很大的功夫。反观需要这些同步方法的场景,读取配置文件、编写命令行工具等,能否在异步框架下完成呢?改变下我们的思维,或许会有更好的解决办法。

注:以上内容基于 Node.js 5.0 源码。

参考资料: