JS异步转同步组件——DeAsync.js原理深入分析

最近在项目中遇到一个问题,需要将一个依赖异步网络通信的功能,封装成同步API供第三方调用。

一般来说,我们在JS项目中遇到了异步过程依赖,只需要构造promise,或是使用async/await语法糖,就可以愉快地解决问题了。但异步语法是会向上传染的,而在我的业务场景里,限定了第三方api的调用形式,必须是var a = b(),b函数的执行又依赖网络返回结果。所以必须要让js线程在网络调用时停下来,等待消息返回后,再继续执行。

经过查阅对比,我选择了deAsync.js github: https://github.com/abbr/deasync

deAsync的使用方法很简单,首先看下面的代码段。

var http = require('http');
 
var result, isReturn = false;
 
http.get(url, function(res){
    console.log('http request return!');
    isReturn = true;
    result = res;
});
 
while(!isReturn){
	//do nothing
}
 
console.log(result);

我们都知道是这一段坏代码,console永远不会被打印,因为js是单线程的,线程无法退出while循环,回调函数永远不会被执行,程序也不会结束。 但我们把这段代码改一下,改成

var http = require('http');
var deasync = require('deasync');
 
var result, isReturn = false;
 
http.get(url, function(res){
    console.log('http request return!');
    isReturn = true;
    result = res;
});
 
while(!isReturn){
    deasync.runLoopOnce();
}
 
console.log(result);

这段代码居然是有效的。result会在return之后顺利打印出来,程序可以正常结束。 除了这种写法之外,deasync库还提供了对函数进行封装的写法

const fakeSyncFunction = deasync((args,cb) => {
        realAsyncFunction(args).then((res)=>{
            cb(null, res);
        });
});

console.log("before");
let a = fakeSyncFunction(args);
console.log("after");

这段代码首先会打印before,在realAsyncFunction的then函数执行,cb被调用之前,js线程就会卡死在原地,不执行后面的代码,直到异步过程返回后,继续打印after。 使用这种语法,我们就可以愉快地封装同步api给第三方使用了。

那么,看似不符合js运行原理的黑科技究竟是怎么实现的呢?我们可以打开上面的github目录,分析一下deasync.js的源代码。 代码结构很简单,包含一个src目录和一个index.js入口,其中在index.js入口里,封装了以上两种调用语法。真正核心的函数只有一个,deasync.run()。

src里有一个c++文件,就实现了这个run方法。它的内容也异常简单,全部贴过来也只有几行

#include <uv.h>
#include <v8.h>
#include <napi.h>
#include <uv.h>

using namespace Napi;

Napi::Value Run(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  Napi::HandleScope scope(env);
  uv_run(uv_default_loop(), UV_RUN_ONCE);
  return env.Undefined();
}

static Napi::Object init(Napi::Env env, Napi::Object exports) {
  exports.Set(Napi::String::New(env, "run"), Napi::Function::New(env, Run));
  return exports;
}

NODE_API_MODULE(deasync, init)

要理解这段代码,需要一点编写nodejs c++ 插件(addon)的基础知识。这个例子是使用N-API开发接口编写的。N-API是从node v8开始支持的一种封装,它把node版本的底层差异抽象化,使我们可以无视nodejs的版本,用统一语法开发插件。

这里做一个简单的解释,最后一句NODE_API_MODULE,把init函数作为deasync模块导出,而前面的代码,给deasync注册了一个run方法。index.js里实现runLoopOnce 和loopWhile,调用的就是run方法里。而在run方法的定义中,真正起作用的是这一句。

uv_run(uv_default_loop(), UV_RUN_ONCE);

如何理解这个语句?简单地说,它就是强制JS引擎执行了一遍事件循环。 事件循环又是什么?此处就要深入分析一下JS引擎的工作原理。

JS引擎的工作原理

我们都知道js是单线程执行的,用单线程配合异步IO,让我们开发者可以很直观地编写业务逻辑,不用担心时序错乱的问题。

下图显示了Nodejs的主体结构,在很多地方都能看到它。

从图上可以看出清晰的模块划分。

Application:应用层,即用户编写的代码。 V8:JS引擎,即利用V8 引擎来解析JavaScript语法,和底层api交互,我们说的单线程执行的就是这个东西,但Nodejs本身并不是单线程的,是可以并发的。 Node.js Bindings:连接上层模块和操作系统,提供系统调用,一般使用C++实现。 LIBUV层:是一个高性能事件驱动的程序库,跨平台封装了对操作系统线程池的调用,实现了计时器,文件IO,网络IO等,它是Nodejs异步调用的基础。 Event Queue:事件队列,又叫任务队列。 Event Loop:事件循环。

如何理解最后两项呢?

用户代码在主线程执行,如果执行过程中,遇到一个异步调用,js引擎就会封装一个请求对象,并且注册到线程池去。操作系统会把不同的异步调用交给不同的处理者,如果是文件IO,交给文件模块,如果是网络,交给网络模块。 操作系统大都是多核的,所以处理这些异步调用的过程,也是真正并行的,时间长短未知,不能够保证先后次序。所以,当操作系统处理完这些调用后,需要一个结构来管理它们,就是事件队列。

处理者把处理结果封装成一个观察者对象,塞进对应的事件队列。 因为异步调用有多种类型,事件队列也可能有多个。

在操作系统进行上述过程的时候,我们的用户代码还在V8引擎里继续执行着,直到执行到末尾,主线程结束,进入事件循环阶段。

事件循环阶段,好比巡逻员巡逻,反复去每个队列里检查观察者对象,每巡逻一圈,称为一个tick。 上面我们看到的,那一句关键起作用的语句,就是强制js引擎执行一个tick。

如果js引擎在一个tick里发现,队列里有任务要执行,就取出一个任务,把回调函数推入主线程执行。这时候用户写在then,timeout里的代码,才会得到执行。

整体的过程可以用下图表示

上面说过,异步调用有多重类型,所以取任务的时候,也是有优先级之分的。

在每次轮训检查中,各观察者的优先级分别是:

idle观察者 > I/O观察者 > check观察者。

idle观察者:process.nextTick I/O观察者:一般性的I/O回调,如网络,文件,数据库I/O等 check观察者:setImmediate,setTimeout

可能在一些地方,看到过宏观任务微观任务的说法。宏观任务就是我们上面说的,事件循环中的task,而微观任务是不属于事件循环的,微观任务主要用来实现Promise的then/reject,本质上它和当前的V8调用栈是同层的,不涉及系统调用。

每次进入事件循环之前,js引擎都会首先处理微观任务队列,处理完所有微观任务后,再进行事件循环,所以promise总是比setTimeout先执行,也是这个原理。

副作用

了解了上面的内容,我们也就清楚deAsync的工作原理了。在正常的js执行过程中,主线程代码在结束之前,任何异步注册的回调都不会执行。但我们通过调用deasync.runLoopOnce(),在主线程代码执行完成前,强行激活了事件循环,事件循环会检查观察者,如果这时异步调用返回了结果,它的回调函数也会被执行。 我们只要把回调函数执行与否作为判断条件,就可以暂时卡住主线程,等返回结果后再继续,从而把异步api转成同步。

但这个方案是有副作用的——除了主进程注册的之外,其余的也观察着也会被检查,如果符合条件,就会执行。

尝试运行下面的例子

var http = require('http');
var deasync = require('deasync');
var reqUrl = 'http://www.qq.com';

var status=0,isReturn = false;

setTimeout(()=>{
	console.log("setTimeout 0 done");
},0)

http.get(reqUrl, function(res){
        console.log('http request return');
        isReturn = true;
        status = res.statusCode;
});
 
while(!isReturn){
    deasync.runLoopOnce();
}
console.log(status);
console.log("exit");

执行结果

setTimeout 0 done
http request return
302
exit

可以看到,setTimeout 0被提前触发了。 如果这里是setTimeout 200,那么它会和http请求竞速,哪个先返回哪个先执行。原理也和上面所说的一致。

一般来说,由于异步注册返回的顺序本来就是不确定的,所以副作用也在可以接受的范围,但如果在同步调用的代码前,使用setTimeout,nextTick等方式制造延迟,可能会得到不符合预期的结果。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券