前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JS异步转同步组件——DeAsync.js原理深入分析

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

原创
作者头像
WendyGrandOrder
修改2019-04-29 20:25:56
6.9K0
修改2019-04-29 20:25:56
举报
文章被收录于专栏:RESTART POiNTERRESTART POiNTER

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

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

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

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

代码语言:javascript
复制
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循环,回调函数永远不会被执行,程序也不会结束。 但我们把这段代码改一下,改成

代码语言:javascript
复制
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库还提供了对函数进行封装的写法

代码语言:javascript
复制
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方法。它的内容也异常简单,全部贴过来也只有几行

代码语言:javascript
复制
#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转成同步。

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

尝试运行下面的例子

代码语言:javascript
复制
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");

执行结果

代码语言:javascript
复制
setTimeout 0 done
http request return
302
exit

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

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

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • JS引擎的工作原理
  • 副作用
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档