打造健壮的跨平台 JS 引擎

前文 介绍过基于 Lua 的脚本引擎实现,其实如果不是游戏或渲染开发,JS/TS 才是更合适的选择:强类型、更新迭代更快、第三方库更丰富、对前端开发者更友好。

JS 的引擎不少,考虑到性能、跨平台移植、移动端友好等因素,我们跳过 V8Hermes,选择了 QuickJS,具体评测对比过程不展开,有兴趣可移步这里

QuickJS 的作者 Fabrice Bellard,同时还是 ffmpeg 等知名项目的作者,因此代码质量是有保障的。

不过 GitHub 官方镜像项目不太活跃,这里我们选择了另一个版本 QuickJS-NG,它在原有项目基础上新增了不少 Feature,如直接编译生成二进制文件(而不是 .c)。

话不多说,下面我们直奔主题:打造基于 QuickJS 的 JS 脚本引擎。

搭建基本的 JS 引擎

跟之前 Lua 脚本引擎一样,我们还是先实现基本的功能。

VM 实例管理

相比 Lua 核心的就一个 lua_state 走天下,JS 要略显复杂,涉及到两个概念:JSRuntimeJSContext

  • 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

  • 加载帮助类和内置库

  • 析构时释放 JSContextJSRuntime

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_metaJS_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 &params, 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 中的 Promiseresolvereject 两个回调;同样地,上面这个接口也需要传递一个 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 也确实未发现内存问题)。

后续如果有进一步突破,再另外发文分析。