打造健壮的跨平台 JS 引擎
前文 介绍过基于 Lua 的脚本引擎实现,其实如果不是游戏或渲染开发,JS/TS 才是更合适的选择:强类型、更新迭代更快、第三方库更丰富、对前端开发者更友好。
JS 的引擎不少,考虑到性能、跨平台移植、移动端友好等因素,我们跳过 V8 和 Hermes,选择了 QuickJS,具体评测对比过程不展开,有兴趣可移步这里。
QuickJS 的作者 Fabrice Bellard,同时还是 ffmpeg 等知名项目的作者,因此代码质量是有保障的。
不过 GitHub 官方镜像项目不太活跃,这里我们选择了另一个版本 QuickJS-NG,它在原有项目基础上新增了不少 Feature,如直接编译生成二进制文件(而不是 .c)。
话不多说,下面我们直奔主题:打造基于 QuickJS 的 JS 脚本引擎。
搭建基本的 JS 引擎
跟之前 Lua 脚本引擎一样,我们还是先实现基本的功能。
VM 实例管理
相比 Lua 核心的就一个 lua_state
走天下,JS 要略显复杂,涉及到两个概念:JSRuntime
和 JSContext
:
JSContext
类似 OpenGL 的GLContext
,可以理解为一个状态机,且和线程一一对应(Worker 需要单独的 context);JSRuntime
就是一个 JS VM 实例,内部可以包含多个 context。
下面我们看代码:
JsBridge()
{
this->runtime = JS_NewRuntime();
ctx = JS_NewContext(rt);
js_std_add_helpers(ctx, 0, NULL);
js_init_module_std(ctx, "qjs:std");
js_init_module_os(ctx, "qjs:os");
}
~JSBridge()
{
JS_FreeContext(this->context);
js_std_free_handlers(this->runtime);
JS_FreeRuntime(this->runtime);
}
首先创建
JSRuntime
,然后通过它创建JSContext
;加载帮助类和内置库;
析构时释放
JSContext
和JSRuntime
。
JS 异常栈处理
QuickJS 内部是有异常处理机制的,但因为 Android/HarmonyOS 等平台有自己的应用层日志接口,所以 C/C++ 层的异常信息需要转发出来。
因为后续的很多操作都可能出异常,所以这里提前处理,我们参考 js_std_dump_error
的相关实现:
static void _xxx_js_print_err(JSContext *ctx, JSValueConst val)
{
auto errMsg = JS_ToCString(ctx, val);
if (errMsg)
{
//TODO: 转发到统一的的日志模块
JS_FreeCString(ctx, errMsg);
}
}
static void _xxx_js_dump_err(JSContext *ctx)
{
auto exception_val = JS_GetException(ctx);
_xxx_js_print_err(ctx, exception_val);
if (JS_IsError(ctx, exception_val))
{
auto val = JS_GetPropertyStr(ctx, exception_val, "stack");
if (!JS_IsUndefined(val))
{
_xxx_js_print_err(ctx, val);
}
JS_FreeValue(ctx, val);
}
JS_FreeValue(ctx, exception_val);
}
将 C 接口导出到 JS 环境
前文在介绍打造 Lua 脚本引擎的时候我们有提到:native 函数绑定的本质就是建立 C 函数指针和脚本接口函数签名字符串的映射;对于 QuickJS,这个工作也是类似的。
定义 C 函数入口
首先需要定义类似如下签名的 C 函数入口:
JSValue func(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv);
前两个参数跟 JNI 很类似,后续操作基本都需要用到
JSContext
对象;第三个参数为 JS 层传过来的参数列表,跟 Lua 引擎一样,为了代码复用,我们采用 JSON 协议,参数都是字符串,可通过
JS_ToCString
接口读取;JSValue
是 C 封装的 JS 对象,对于临时对象,一般建议通过JS_FreeXXX
释放。
为了方便定义返回各种类型的函数,我们同样定义了一系列宏:
#define DEF_JS_FUNC_VOID(fJ, fS) \
static JSValue fJ(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) \
{ \
auto json = JS_ToCString(ctx, argv[0]); \
fS(json); \
JS_FreeCString(ctx, json); \
return JS_UNDEFINED; \
}
#define DEF_JS_FUNC_STRING(fJ, fS) \
static JSValue fJ(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) \
{ \
auto json = JS_ToCString(ctx, argv[0]); \
const std::string res = fS(json); \
JS_FreeCString(ctx, json); \
return JS_NewString(ctx, res.c_str()); \
}
#define DEF_JS_FUNC_BOOL(fJ, fS) \
static JSValue fJ(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) \
{ \
auto json = JS_ToCString(ctx, argv[0]); \
auto res = fS(json); \
JS_FreeCString(ctx, json); \
return JS_NewBool(ctx, res); \
}
#define DEF_JS_FUNC_INT32(fJ, fS) \
static JSValue fJ(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) \
{ \
auto json = JS_ToCString(ctx, argv[0]); \
auto res = fS(json); \
JS_FreeCString(ctx, json); \
return JS_NewInt32(ctx, res); \
}
#define DEF_JS_FUNC_INT64(fJ, fS) \
static JSValue fJ(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) \
{ \
auto json = JS_ToCString(ctx, argv[0]); \
auto res = fS(json); \
JS_FreeCString(ctx, json); \
return JS_NewInt64(ctx, res); \
}
#define DEF_JS_FUNC_FLOAT(fJ, fS) \
static JSValue fJ(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) \
{ \
auto json = JS_ToCString(ctx, argv[0]); \
auto res = fS(json); \
JS_FreeCString(ctx, json); \
return JS_NewFloat64(ctx, res); \
}
函数绑定
QuickJS 同样提供了基于模块整体批量注册和单个函数注册的接口,这里我们仅介绍单个函数注册的:
bool JsBridge::bindFunc(const std::string &funcJ, JSCFunction *funcC)
{
auto res = true;
auto jFunc = JS_NewCFunction(this->context, funcC, funcJ.c_str(), 1);
if (JS_IsException(jFunc))
{
_xxx_js_dump_err(this->context);
res = false;
}
else
{
if (!JS_DefinePropertyValueStr(this->context, JS_GetGlobalObject(this->context), funcJ.c_str(), jFunc, JS_PROP_WRITABLE | JS_PROP_CONFIGURABLE))
{
_xxx_js_dump_err(this->context);
res = false;
}
}
this->jValues.push_back(jFunc); // Can not free here, will be called in future
return res;
}
JS_NewCFunction
用于创建 C 函数对象;JS_DefinePropertyValueStr
用于保存配置:由于这里我们注册的都是 global 的接口,所以第二个参数传通过
JS_GetGlobalObject()
获取到的 JS global 对象;因为函数对象是可执行的,所以传递了
JS_PROP_WRITABLE | JS_PROP_CONFIGURABLE
;
这里注册的函数后续还要被 JS 调用,显然不能用
JS_FreeValue
立即释放,而需要临时保存;
QuickJS 是支持以
Class
形式组织 C 接口的,同样为了最大限度(跟 Lua)复用代码,我们全都注册为全局 JS 函数。
加载 JS
QuickJS 相比 Lua 的一大特点就是支持二进制编译和加载,下面我们分别讨论。
加载 JS 脚本
先上代码,后解释:
bool _xxx_js_loadScript(JSContext *ctx, const std::string &script, const std::string &name, const bool isModule)
{
auto res = true;
auto flags = isModule ? (JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY) : JS_EVAL_TYPE_GLOBAL;
auto jEvalRet = JS_Eval(ctx, script.c_str(), script.length(), name.c_str(), flags);
if (JS_IsException(jEvalRet))
{
_xxx_js_dump_err(ctx);
return false;
}
if (isModule)
{
if (JS_VALUE_GET_TAG(jEvalRet) != JS_TAG_MODULE)
{ // Check whether it's a JS Module or not,or QJS may crash
JS_FreeValue(ctx, jEvalRet);
return false;
}
js_module_set_import_meta(ctx, jEvalRet, false, true);
auto jEvalFuncRet = JS_EvalFunction(ctx, jEvalRet);
JS_FreeValue(ctx, jEvalFuncRet);
// this->jValues.push_back(jEvalRet);//Can not free here, or QJS may crash
}
else
{
JS_FreeValue(ctx, jEvalRet);
}
return res;
}
JS_Eval
用于加载并解析 JS 脚本(转为二进制),由于 QuickJS 支持 Module 加载,所以这里flags
参数需要判断:如果是 Module,传
JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY
,表示仅仅编译,等着被后续调用;否则传
JS_EVAL_TYPE_GLOBAL
,表示是普通的可以被全局调用的 JS 函数代码;
如果是 Module,还要调用
js_module_set_import_meta
和JS_EvalFunction
,前者用于处理 Module 之间的依赖关系,自动递归加载。
加载 JS 二进制
二进制编译
QuickJS 内部有一个 QJSC 模块用于编译 JS,如果需要可执行文件,需要修改 CMakeLists.txt 中的 BUILD_EXE
开关,这里不再赘述。
二进制文件不仅更小,而且运行时效率更高(前置了编译解释这一步)。
原始 QuickJS 的版本默认会将二进制存储到 .c
文本文件中的一个数组,但对于一个跨平台 JS 业务引擎来说,需要的是纯二进制文件。
所以原本是自己做了魔改(其实改动也不大,只需要修改写文件的地方即可),后来发现 NG 这个仓库已经解决了这个问题,qjsc 指定 -b
参数即可:
./qjsc -bss xxx.js
如果是 JS module,要么将 JS 后缀名设置为
.mjs
,要么在上述命令后指定-m
参数。
二进制加载
如前文所述,由于 qjsc 已经预编译,这一步直接调用 js_std_eval_binary
即可加载 JS 二进制。
目前 NG 仓库的版本貌似不支持二进制形式的 JS Module,具体原因尚不明确(也可能是我打开方式不对)。
二进制版本
如果使用二进制,一定要注意版本,否则可能运行直接 crash:
SyntaxError: invalid version (2 expected=67)
看源码可知,内部定义了一个二进制版本的宏:
#define BC_VERSION 19
编译时会写入:
static int JS_WriteObjectAtoms(BCWriterState *s)
{
//...
bc_put_u8(s, BC_VERSION);
//...
}
运行时会校验:
static int JS_ReadObjectAtoms(BCReaderState *s)
{
//...
if (bc_get_u8(s, &v8))
return -1;
if (v8 != BC_VERSION) {
JS_ThrowSyntaxError(s->ctx, "invalid version (%d expected=%d)", v8, BC_VERSION);
return -1;
}
//...
}
二进制加密
虽然前面已经编译为二进制,但强行通过文本编辑器打开,仍然能看到不少 ASCII 字符。
这里可以利用上面提到的二进制版本配置 BC_VERSION
,通过二进制异或实现简单的加解密。
在编译时对原始编译产物二进制做一次异或再写入:
static void output_object_code(JSContext *ctx, FILE *fo, JSValue obj, const char *c_name, BOOL load_only)
{
uint8_t *out_buf;
size_t out_buf_len;
//...
uint8_t xbuf[out_buf_len];
memset(xbuf, 0, out_buf_len);
for (int i = 0; i < out_buf_len; i++)
xbuf[i] = out_buf[i] ^ BC_VERSION;
if (output_type == OUTPUT_RAW) {
fwrite(xbuf, sizeof(uint8_t), out_buf_len, fo);
}
//...
}
加载二进制时再做一次异或即可恢复原始数据:
bool js_std_eval_binary(JSContext *ctx, const uint8_t *buf, size_t buf_len, int load_only)
{
//...
uint8_t xbuf[buf_len];
memset(xbuf, 0, buf_len);
for (int i = 0; i < buf_len; i++)
xbuf[i] = buf[i] ^ BC_VERSION;
obj = JS_ReadObject(ctx, xbuf, buf_len, JS_READ_OBJ_BYTECODE);
//...
}
原生调用 JS 接口
仍然先上代码再解释:
static std::recursive_mutex *_xxx_js_mutex = nullptr;
static inline const std::string _xxx_js_jstr2stdstr(JSContext *ctx, JSValue jstr)
{
auto c = JS_ToCString(ctx, jstr);
auto s = std::string(c ?: "");
JS_FreeCString(ctx, c);
return s;
}
const std::string JsBridge::callFunc(const std::string &func, const std::string ¶ms, const bool await)
{
_xxx_js_mutex->lock();
std::string s;
auto jFunc = JS_GetPropertyStr(this->context, JS_GetGlobalObject(this->context), func.c_str());
if (JS_IsFunction(this->context, jFunc))
{
auto jParams = JS_NewString(this->context, params.c_str());
JSValue argv[] = {jParams};
auto jRes = JS_Call(this->context, jFunc, JS_GetGlobalObject(this->context), sizeof(argv), argv);
_xxx_js_mutex->unlock();
if (JS_IsException(jRes))
{
_xxx_js_dump_err(this->context);
}
else
{
if (await)
{
jRes = js_std_await(this->context, jRes); // Handle promise if needed
}
s = _xxx_js_jstr2stdstr(this->context, jRes);
}
JS_FreeValue(this->context, jRes);
JS_FreeValue(this->context, jParams);
}
else
{
_xxx_js_mutex->unlock();
}
return s;
}
跟 Lua 引擎一样,基于状态机的都需要保证线程安全,且这里可能涉及到 Native 和 JS 嵌套递归调用的问题,所以也是直接使用
std::recursive_mutex
;通过
JS_GetPropertyStr
获取 JS 函数对象;通过
JS_Call
调用 JS 接口;如果该 JS 函数是返回
Promise
,则需要调用js_std_await
等待异步执行完毕返回结果。
实际落地后遇到的坑
上面只是基本的 JS 引擎框架,实际投入业务开发,踩了很多坑。
各种 crash
由于 QuickJS 是基于状态机的,极度依赖线程安全,否则会出现状态不同步导致的各种堆栈不带重样的 crash;
具体原因可参考 GitHub 上的这个 issue:API to support multi-threaded usage #141。
解决方案也很简单,就是在所有可能读写 JS 状态机的地方都加锁(而且是递归锁):
加载 JS;
执行 JS 方法;
Promise
和 Timer 处理流程;
事件循环阻塞
既然采用 JS/TS 去实现业务,就得不仅完美支持各种 JS 异步编程范式,还要保证耗时操作不阻塞 JS 事件循环。
问题排查
事件调度死循环
起初探索阶段参考 demo,执行完 JS 函数直接调用 js_std_loop
,发现直接就卡死了。
我们看下底层干了啥:
/* main loop which calls the user JS callbacks */
JSValue js_std_loop(JSContext *ctx)
{
JSRuntime *rt = JS_GetRuntime(ctx);
JSThreadState *ts = js_get_thread_state(rt);
JSContext *ctx1;
JSValue ret;
int err;
for(;;) {
/* execute the pending jobs */
for(;;) {
err = JS_ExecutePendingJob(JS_GetRuntime(ctx), &ctx1);
if (err <= 0) {
if (err < 0) {
ts->exc = JS_GetException(ctx1);
goto done;
}
break;
}
}
if (!ts->can_js_os_poll || js_os_poll(ctx))
break;
}
done:
ret = ts->exc;
ts->exc = JS_UNDEFINED;
return ret;
}
可以看到,它实际上就是个死循环,不断通过 JS_ExecutePendingJob
处理 Promise
,然后通过 js_os_poll
处理 Timer 等信号量。
死等 Timer 超时
继续看 js_os_poll
源码:
static int js_os_poll(JSContext *ctx)
{
//...
if (js_os_run_timers(rt, ctx, ts, &min_delay))
return -1;
if (min_delay == 0)
return 0; // expired timer
if (min_delay < 0)
if (list_empty(&ts->os_rw_handlers) && list_empty(&ts->port_list))
return -1; /* no more events */
tvp = NULL;
if (min_delay >= 0) {
tv.tv_sec = min_delay / 1000;
tv.tv_usec = (min_delay % 1000) * 1000;
tvp = &tv;
}
//...
ret = select(fd_max + 1, &rfds, &wfds, NULL, tvp);
//...
}
可以看到,内部通过 select
同步等待 Timer 超时;
如果某个 Timer 超时设置很长,显然会影响后续事件的处理。
js_std_await “夹带私货”
前面我们在调用 JS 函数时直接通过 js_std_await
等待 Promise
结果,我们看看内部干了啥:
/* Wait for a promise and execute pending jobs while waiting for
it. Return the promise result or JS_EXCEPTION in case of promise
rejection. */
JSValue js_std_await(JSContext *ctx, JSValue obj)
{
//...
for(;;) {
state = JS_PromiseState(ctx, obj);
if (state == JS_PROMISE_FULFILLED) {
ret = JS_PromiseResult(ctx, obj);
JS_FreeValue(ctx, obj);
break;
} else if (state == JS_PROMISE_REJECTED) {
ret = JS_Throw(ctx, JS_PromiseResult(ctx, obj));
JS_FreeValue(ctx, obj);
break;
} else if (state == JS_PROMISE_PENDING) {
//...
err = JS_ExecutePendingJob(JS_GetRuntime(ctx), &ctx1);
//...
if (ts->can_js_os_poll)
js_os_poll(ctx);
} else {
/* not a promise */
ret = obj;
break;
}
}
return ret;
}
可见他内部会通过 js_os_poll
再次拉起事件循环,如果外面已经调用 js_std_loop
,两边就会竞争。
C 接口阻塞 JS 事件调度
前面我们注册给 JS 的 C 接口都是原地同步执行立即返回结果,如果是 IO 耗时操作,肯定会阻塞 JS 事件循环,因此需要支持 C 函数返回 Promise
异步回调 JS。
解决方案
拆分事件调度
像他那样直接一个死循环调度所有事件,显然过于简单粗暴,我们首先将 js_std_loop
拆分:
JSValue js_std_loop_promise(JSContext *ctx)
{
JSRuntime *rt = JS_GetRuntime(ctx);
JSThreadState *ts = js_get_thread_state(rt);
JSContext *ctx1;
/* execute the pending jobs */
if (ts->can_js_os_poll)
JS_ExecutePendingJob(JS_GetRuntime(ctx), &ctx1);
return ts->exc;
}
JSValue js_std_loop_timer(JSContext *ctx)
{
JSRuntime *rt = JS_GetRuntime(ctx);
JSThreadState *ts = js_get_thread_state(rt);
/* execute the pending timers */
if (ts->can_js_os_poll)
js_os_poll(ctx);
return ts->exc;
}
void js_std_loop_cancel(JSRuntime *rt)
{
JSThreadState *ts = JS_GetRuntimeOpaque(rt);
ts->can_js_os_poll = false;
}
不仅要拆分,我们还额外提供了退出循环的接口(充分利用原有的 can_js_os_poll
字段)。
libuv 驱动事件调度
前面 Lua 引擎我们有提到通过 libuv 异步驱动事件调度流程,这里也可以采用:
constexpr size_t XXXJsSleepMilliSecs = 1;
static uv_loop_t *_xxx_js_uv_loop_p = nullptr;
static uv_loop_t *_xxx_js_uv_loop_t = nullptr;
static uv_timer_t *_xxx_js_uv_timer_p = nullptr;
static uv_timer_t *_xxx_js_uv_timer_t = nullptr;
static std::recursive_mutex *_xxx_js_mutex = nullptr;
static void _xxx_js_uv_timer_cb_p(uv_timer_t *timer)
{
auto ctx = reinterpret_cast<JSContext *>(timer->data);
/// Do not force to acquire the lock, to avoid blocking the JS event loop.
if (_xxx_js_mutex->try_lock())
{
js_std_loop_promise(ctx);
_xxx_js_mutex->unlock();
}
}
static void _xxx_js_uv_timer_cb_t(uv_timer_t *timer)
{
auto ctx = reinterpret_cast<JSContext *>(timer->data);
/// Do not force to acquire the lock, to avoid blocking the JS event loop.
if (_xxx_js_mutex->try_lock())
{
js_std_loop_timer(ctx);
_xxx_js_mutex->unlock();
}
}
static void _xxx_js_uv_loop_start(JSContext *ctx, uv_loop_t *uv_loop, uv_timer_t *uv_timer, uv_timer_cb cb)
{
if (uv_loop == nullptr)
{
uv_loop = reinterpret_cast<uv_loop_t *>(std::malloc(sizeof(uv_loop_t)));
uv_loop_init(uv_loop);
}
else
{
if (uv_loop_alive(uv_loop))
{
return;
}
}
if (uv_timer == nullptr)
{
uv_timer = reinterpret_cast<uv_timer_t *>(std::malloc(sizeof(uv_timer_t)));
uv_timer_init(uv_loop, uv_timer);
uv_timer->data = ctx;
uv_timer_start(uv_timer, cb, XXXJsSleepMilliSecs, XXXJsSleepMilliSecs);
}
else
{
uv_timer_stop(uv_timer);
uv_timer_again(uv_timer);
}
uv_run(uv_loop, UV_RUN_DEFAULT);
}
static void _xxx_js_uv_loop_stop(uv_loop_t *uv_loop, uv_timer_t *uv_timer)
{
if (uv_loop == nullptr || uv_timer == nullptr || !uv_loop_alive(uv_loop))
{
return;
}
uv_timer_stop(uv_timer);
std::free(uv_timer);
uv_timer = nullptr;
uv_stop(uv_loop);
uv_loop_close(uv_loop);
std::free(uv_loop);
uv_loop = nullptr;
}
static void _xxx_js_loop_startP(JSContext *ctx)
{
_xxx_js_uv_loop_start(ctx, _xxx_js_uv_loop_p, _xxx_js_uv_timer_p, _xxx_js_uv_timer_cb_p);
}
static void _xxx_js_loop_startT(JSContext *ctx)
{
_xxx_js_uv_loop_start(ctx, _xxx_js_uv_loop_t, _xxx_js_uv_timer_t, _xxx_js_uv_timer_cb_t);
}
static void _xxx_js_loop_stop(JSRuntime *rt)
{
_xxx_js_uv_loop_stop(_xxx_js_uv_loop_p, _xxx_js_uv_timer_p);
_xxx_js_uv_loop_stop(_xxx_js_uv_loop_t, _xxx_js_uv_timer_t);
js_std_loop_cancel(rt);
}
通过 libuv 串联好事件调度流程以后,就可以在引擎初始化时将其丢到后台线程:
JsBridge::JsBridge()
{
_xxx_js_mutex = new std::recursive_mutex();
//...
std::thread([&ctx = this->context]()
{
_xxx_js_loop_startP(ctx);
}).detach();
std::thread([&ctx = this->context]()
{
_xxx_js_loop_startT(ctx);
}).detach();
}
代码看起来不少,实际主要是 C 接口的回调参数比较多,而且我们要搞两个循环分别调度 Promise
和 Timer;
关于 libuv 的使用,前文 Lua 引擎已介绍过,这里不再赘述。
唯一需要注意的就是,这里在处理两种事件时,需要加锁,但不能强制加锁,失败就等下一趟,绝不阻塞。
Timer 处理流程优化
不能傻乎乎死等 Timer 超时,我们设置一个最大等待时间,没触发就直接返回(下次再来):
#define MAX_TIMER_DELAY 1
static int js_os_run_timers(JSRuntime *rt, JSContext *ctx, JSThreadState *ts, int *min_delay)
{
//...
list_for_each(el, &ts->os_timers) {
th = list_entry(el, JSOSTimer, link);
delay = th->timeout - cur_time;
if (delay > 0) {
/// Can not use the Timer timeout directly, or the `select()` will block the event loop!!!
*min_delay = min_int(min_int(*min_delay, delay), MAX_TIMER_DELAY);
} else {
//...
}
}
return 0;
}
await 处理流程优化
这里我们模仿原有 js_std_await
逻辑魔改一下:
static inline void sleepForMilliSecs(size_t milliSecs)
{
std::this_thread::sleep_for(milliseconds(milliSecs));
}
JSValue _xxx_js_await(JSContext *ctx, JSValue obj)
{
auto ret = JS_UNDEFINED;
for (;;)
{
/// Do not force to acquire the lock, to avoid blocking the JS event loop.
if (!_xxx_js_mutex->try_lock())
{
sleepForMilliSecs(XXXJsSleepMilliSecs);
continue;
}
auto state = JS_PromiseState(ctx, obj);
if (state == JS_PROMISE_FULFILLED)
{
ret = JS_PromiseResult(ctx, obj);
JS_FreeValue(ctx, obj);
break;
}
else if (state == JS_PROMISE_REJECTED)
{
ret = JS_Throw(ctx, JS_PromiseResult(ctx, obj));
JS_FreeValue(ctx, obj);
break;
}
else if (state == JS_PROMISE_PENDING)
{
/// Promise is executing: release the lock, sleep for a while. To avoid blocking the js event loop, or overloading CPU.
_xxx_js_mutex->unlock();
sleepForMilliSecs(XXXJsSleepMilliSecs);
}
else
{
/// Not a Promise: release the lock, return the result immediately.
ret = obj;
break;
}
}
_xxx_js_mutex->unlock();
return ret;
}
不强制加锁,失败就休眠,下次再来;
若
Promise
还没执行完,立即释放缩放,休眠一段时间再来;不用额外拉起任何事件循环。
支持 C 异步回调 JS Promise
前面我们给 JS 注册的 C 都是同步返回 JSValue
,如果是耗时操作,我们期望能先返回一个 Promise
,再异步回调。
不过官方并没有提供类似的 demo,网上也鲜有相关的资料。
考虑到前面注册 C 函数我们调用的是 JS_NewCFunction
,通过搜索 JS_NewPromise
关键字,果然搜到了 JS_NewPromiseCapability
这个接口,参考引擎内部调用该接口的一些逻辑,最终实现了这一目标。
我们知道,JS 中的 Promise
有 resolve
和 reject
两个回调;同样地,上面这个接口也需要传递一个 JSValue
数组作为参数来对应着两个回调。
因此我们定义一个数据结构来持有相关数据:
typedef struct
{
JSValue p;
JSValue f[2];
} XXX_JS_Promise;
然后再封装下创建 JS Promise
的流程:
XXX_JS_Promise *_xxx_js_promise_new(JSContext *ctx)
{
auto jPromise = new XXX_JS_Promise();
JSValue funcs[2];
jPromise->p = JS_NewPromiseCapability(ctx, jPromise->f);
if (JS_IsException(jPromise->p))
{
_xxx_js_dump_err(ctx);
JS_FreeValue(ctx, jPromise->p);
return nullptr;
}
return jPromise;
}
为了防止后续异步任务执行完回调 JS 时,相关对象不被提前释放,我们创建了堆对象。
创建好 JS Promise 对应的 JSValue
,我们就可以将它返回给 JS,然后开线程异步执行耗时任务:
JSValue JsBridge::newPromise(const std::function<JSValue()> &jf)
{
auto jPromise = _xxx_js_promise_new(this->context);
if (jPromise == nullptr)
{
return JS_EXCEPTION;
}
std::thread([&ctx = this->context, jPro = jPromise, cb = jf]() {
const std::lock_guard<std::recursive_mutex> lock(*_xxx_js_mutex);
auto jRet = cb();
_xxx_js_promise_callback(ctx, jPro, jRet);
}).detach();
return jPromise->p;
}
注意这里也使用了锁。
任务执行完后,调用 JS_Call
回调堆上保存的 JS 函数:
void _xxx_js_promise_callback(JSContext *ctx, XXX_JS_Promise *jPromise, JSValue jRet)
{
auto jCallRet = JS_Call(ctx, jPromise->f[0], JS_UNDEFINED, 1, &jRet);
if (JS_IsException(jCallRet))
{
_xxx_js_dump_err(ctx);
}
JS_FreeValue(ctx, jCallRet);
JS_FreeValue(ctx, jRet);
JS_FreeValue(ctx, jPromise->f[0]);
JS_FreeValue(ctx, jPromise->f[1]);
delete jPromise;
}
回调完后,释放前面堆上分配的对象。
JS worker 的支持
我们知道,JS 默认是单线程的,但 JS 也支持通过 Worker 创建后台线程。
QuickJS 本身也是支持 Worker 的,不要要注意的是:
- Worker 位于单独的线程,对应单独的
JSContext
;因此前面初始化流程需要做下调整:
/// A JS Worker created a all new independent `JSContext`,so we should load the js files and modules again.
/// By default, we just load the built-in modules.
static JSContext *_xxx_js_newContext(JSRuntime *rt)
{
auto ctx = JS_NewContext(rt);
if (!ctx)
{
return NULL;
}
js_std_add_helpers(ctx, 0, NULL);
js_init_module_std(ctx, "qjs:std");
js_init_module_os(ctx, "qjs:os");
_xxx_js_loadScript(ctx, IMPORT_STD_OS_JS, "import-std-os.js", true);
return ctx;
}
JsBridge::JsBridge()
{
_xxx_js_mutex = new std::recursive_mutex();
this->runtime = JS_NewRuntime();
js_std_init_handlers(this->runtime);
JS_SetModuleLoaderFunc(this->runtime, NULL, js_module_loader, NULL);
js_std_set_worker_new_context_func(_xxx_js_newContext);
this->context = _xxx_js_newContext(this->runtime);
//...
}
主要是通过 js_std_set_worker_new_context_func
指定了创建 Worker 对应 JSContext
的回调。
- Worker 不能直接和主线程的代码相互调用,只能通过类似 Android 的
Handler
机制异步处理消息:
/// worker_client.js
function worker_client_main() {
var worker = new os.Worker("./worker_server.js");
worker.onmessage = function (msg) {
var data = msg.data;
print("WorkerClient recv:", JSON.stringify(data));
switch(data.type) {
case "report":
print(`Main Thread received report request: ${data.payload}`);
jTestNetHttpReqPro('https://rinc.xyz')
.then(function (res) {
worker.postMessage({ type: "reportResult", payload: res });
}, function (err) {
print(`${err}`);
});
break;
}
};
}
////////////////////////////////////////////////////////////////////////////////
/// worker_server.js
var server = os.Worker.parent;
function handle_msg(msg) {
var data = msg.data;
print("WorkerServer recv:", JSON.stringify(data));
switch(data.type) {
case "reportResult":
print(`WorkerServer received report result: ${data.payload}`);
break;
}
}
function worker_server_main() {
server.onmessage = handle_msg;
var i = 0;
os.setInterval(()=>{
print(`WorkerServer Timer triggered report -> ${i++}`);
server.postMessage({ type: "report", payload: i });
}, 3000);
}
worker_server_main();
Android 上的 “栈溢出”
在 Android 平台,如果通过 coroutine 异步调用 JS 接口,可能出现栈溢出报错:
Maximum call stack size exceeded
通过搜索关键字,我们找到相关的方法:
static JSValue JS_ThrowStackOverflow(JSContext *ctx)
{
return JS_ThrowRangeError(ctx, "Maximum call stack size exceeded");
}
//...
if (js_check_stack_overflow(s->ctx->rt, 1000)) {
JS_ThrowStackOverflow(s->ctx);
return -1;
}
//...
我们进一步看看它到底是怎么检查栈溢出的:
/* Note: OS and CPU dependent */
static inline uintptr_t js_get_stack_pointer(void)
{
return (uintptr_t)__builtin_frame_address(0);
}
static inline BOOL js_check_stack_overflow(JSRuntime *rt, size_t alloca_size)
{
uintptr_t sp;
sp = js_get_stack_pointer() - alloca_size;
return unlikely(sp < rt->stack_limit);
}
可以看到他通过 __builtin_frame_address()
获取当前函数所在栈帧地址,跟栈顶地址比较,看是否超过阈值。
而在创建 JSRuntime
时会更新栈顶地址:
static void update_stack_limit(JSRuntime *rt)
{
#if defined(__wasi__) || (defined(__ASAN__) && !defined(NDEBUG))
rt->stack_limit = 0; /* no limit */
#else
if (rt->stack_size == 0) {
rt->stack_limit = 0; /* no limit */
} else {
rt->stack_limit = rt->stack_top - rt->stack_size;
}
#endif
}
void JS_UpdateStackTop(JSRuntime *rt)
{
rt->stack_top = js_get_stack_pointer();
update_stack_limit(rt);
}
JSRuntime *JS_NewRuntime2(const JSMallocFunctions *mf, void *opaque)
{
//...
JS_UpdateStackTop(rt);
//...
}
调试发现,iOS 没有问题,唯独 Android 有问题(地址确实相差很大),猜想可能的原因:
我们创建
JSRuntime
实在主线程,栈顶地址也是在主线程;实际业务可能存在很多不同线程执行和回调,例如 Timer 在 libuv 线程,从 C 回调
Promise
在 C++ 线程,再叠加 JNI 和 coroutine,情况就更复杂(可能确实存在栈帧地址“异常”)。
目前暂时没有优雅的解决方案,只能先在 Android 屏蔽掉这个检测逻辑(事实上通过 Android Studio Profiler 也确实未发现内存问题)。
后续如果有进一步突破,再另外发文分析。