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 的仓库或者去这里下载压缩包,都可以。
  2. (解压之后)进入源码目录,运行

    ./configure
    make -j
    
      -j [N],--jobs[=N] 参数用来提高编译效率,充分利用多核处理器的性能,并行编译,N 为并行的任务数。但它并不是万能的,如果有编译依赖,最好还是用单核编译。
  3. 使用你习惯的 IDE 来进行 debug,笔者使用的是 CLion。Debug 配置如下图所示:
    Debug 配置

    将执行文件指定到 源码目录/out/Release/ 目录下的 node 执行文件,参数为你需要运行的脚本和相应的参数(比如这里我配置了可以手动调用 gc),之后去掉 Before Launch 中的 build。

  4. 然后运行 Debug 模式,就可以通过断点和 LLDB 来调试 Node.js 源码了,如图。
    Debugging

  5. 如果需要更改源码,在更改后再次运行 make 即可。

进入正题

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

File System

File System 相关的方法实现都在 lib/fs.js 和 src/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 #L1052 的 Read 方法中,会根据传入的第六个参数的类型来判断是同步调用还是异步调用。

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_CALL 和 SYNC_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.c 的 POST 宏中可以看到,当传入回调时,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.js 和 process_wrap.cc,我们一点点来看。

在我们调用的 spawn 实际上最终调用的是 internal/child_process.js 中的 ChildProcess.spawn 方法。ChildProcess的实例会被绑定 onexit 方法,在进程使用完毕后被调用,并且具有 EventEmitter 的 onemit 等方法,来让使用者获取相应的数据。它的内部存在一个 _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 回调,在这个回调里面,会根据结果,触发 error 或 exit 事件。而在运行过程中的输出,会通过 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);
   ...
}

async_process

当你明白了上面 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_spawn 和 uv_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;
    }
    ...
}

sync_process

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

后话

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

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

参考资料:

该文章来自于阿里巴巴技术协会( ATA
目录
相关文章
|
9月前
|
Web App开发 JavaScript 前端开发
前端node.js入门
前端node.js入门
|
Web App开发 JavaScript 前端开发
Node.js的基础学习
Node.js的基础学习
101 1
|
Web App开发 资源调度 JavaScript
node.js简史
node.js简史
96 0
node.js简史
|
JavaScript 前端开发
node.js 学习入门(01 - node.js基础)
node.js 学习入门(01 - node.js基础)
node.js 学习入门(01 - node.js基础)
|
JavaScript 网络协议 Windows
【Node.JS 】服务器相关的概念
【Node.JS 】服务器相关的概念
84 0
【Node.JS 】服务器相关的概念
|
缓存 JavaScript 前端开发
Node.js一年开发经验总结
Node.js一年开发经验总结
306 0
Node.js一年开发经验总结
|
缓存 JavaScript 前端开发
「Node.js」“寓教于乐”的学习记录
用技术实现梦想。今天分享Node.js学习中总结的知识点。
178 1
「Node.js」“寓教于乐”的学习记录
|
Web App开发 JavaScript 前端开发
00-Node.js 简介
Node.js 是一个开源与跨平台的 JavaScript 运行时环境。 它是一个可用于几乎任何项目的流行工具!
136 0
00-Node.js 简介
|
数据采集 JavaScript 安全
《Node.js 实战》预售: 实例讲解 Node.js 在实战开发中的应用
CNode 社区的 4 位大牛合力撰写的 《Node.js 实战》一书,现在当当、京东、亚马逊、互动等各大网店火热预售中。
289 0
《Node.js 实战》预售: 实例讲解 Node.js 在实战开发中的应用

热门文章

最新文章