Deno源码简析(三)JS与Rust交互

开始

今天开始分析JS与Rust是如何交互的,毕竟JS的性能在某些场景下还是不能胜任,所以现在Rust闪亮登场的时候,Rust拥有媲美C/C++的性能,而且自身也自带强大的基础库,应用场景还是大大的有。

op

之前一直说的op,我个人觉得就是deno上的一个插件机制,deno上所有的功能基本是都是在这个插件机制基础上工作的。

send和recv

在一开始的架构图我们就可以看到,在deno里面JS与Rust的交互只能通过send和recv这两个方法,调用send的实际原理也很简单根据opId去调用对应的rust方法,如果是同步的方法那就可以直接返回,但是如果是异步方法就需要用到recv去接收返回的值。

直接从打开文件open/openAsync这个op开始分析:

export function openSync(path: string, options: OpenOptions): number {
  const mode: number | undefined = options?.mode;
  return sendSync("op_open", { path, options, mode });
}

export function open(path: string, options: OpenOptions): Promise {
  const mode: number | undefined = options?.mode;
  return sendAsync("op_open", {
    path,
    options,
    mode,
  });
}

这里直接调用sendSync/sendAsync方法,然后再跟踪下去sendSync和sendAsync:

export function sendSync(
  opName: string,
  args: object = {},
  zeroCopy?: Uint8Array
): Ok {
  const opId = OPS_CACHE[opName];
  util.log("sendSync", opName, opId);
  const argsUi8 = encode(args);
  const resUi8 = core.dispatch(opId, argsUi8, zeroCopy);
  util.assert(resUi8 != null);

  const res = decode(resUi8);
  util.assert(res.promiseId == null);
  return unwrapResponse(res);
}

export async function sendAsync(
  opName: string,
  args: object = {},
  zeroCopy?: Uint8Array
): Promise {
  const opId = OPS_CACHE[opName];
  util.log("sendAsync", opName, opId);
  const promiseId = nextPromiseId();
  args = Object.assign(args, { promiseId });
  const promise = util.createResolvable();

  const argsUi8 = encode(args);
  const buf = core.dispatch(opId, argsUi8, zeroCopy);
  if (buf) {
    // Sync result.
    const res = decode(buf);
    promise.resolve(res);
  } else {
    // Async result.
    promiseTable[promiseId] = promise;
  }

  const res = await promise;
  return unwrapResponse(res);
}

sendSync相对sendAsync会简单一点,直接从OPS_CACHE拿到对应的opId,然后再把参数转成Uint8Array就可以分发这次调用下去。

而sendAsync则需要多创建一个promise,然后把promiseId附加到参数上,在分发这个次调用下去,那么这次异步调用怎么从recv方法接收结果回来的尼?

再去到core.js,deno在调用init的时候就设置了一个回调handleAsyncMsgFromRust:

function init() {
    const shared = core.shared;
    assert(shared.byteLength > 0);
    assert(sharedBytes == null);
    assert(shared32 == null);
    sharedBytes = new Uint8Array(shared);
    shared32 = new Int32Array(shared);
    asyncHandlers = [];
    // Callers should not call core.recv, use setAsyncHandler.
    recv(handleAsyncMsgFromRust);
  }

而handleAsyncMsgFromRust所做的就是从SharedQueue上取出异步操作结果然后触发相应的异步处理器:

function handleAsyncMsgFromRust(opId, buf) {
    if (buf) {
      // This is the overflow_response case of deno::Isolate::poll().
      asyncHandlers[opId](buf);
    } else {
      while (true) {
        const opIdBuf = shift();
        if (opIdBuf == null) {
          break;
        }
        assert(asyncHandlers[opIdBuf[0]] != null);
        asyncHandlers[opIdBuf[0]](opIdBuf[1]);
      }
    }
  }

SharedQueue本质上是一块JS与Rust都能共同访问的内存,而SharedQueue也有一套自身的内存布局:
截屏2020-06-22 下午12.06.54.png

总的来说这块内存最多能存100条异步操作结果或者少于128 * 100bit(125kb)大小的内容,一旦超过这些设定,就会触发overflow,立马从Rust切回到JS运行,让JS能够及时处理这些内容,所以这个SharedQueue是很重要的,可以影响整个应用的吞吐量。

再回到触发异步处理器,但是没到怎么触发promise的resolve方法,所以继续深入吧,再来到一开始初始化ops的地方:

function getAsyncHandler(opName: string): (msg: Uint8Array) => void {
  switch (opName) {
    case "op_write":
    case "op_read":
      return dispatchMinimal.asyncMsgFromRust;
    default:
      return dispatchJson.asyncMsgFromRust;
  }
}

// TODO(bartlomieju): temporary solution, must be fixed when moving
// dispatches to separate crates
export function initOps(): void {
  OPS_CACHE = core.ops();
  for (const [name, opId] of Object.entries(OPS_CACHE)) {
    core.setAsyncHandler(opId, getAsyncHandler(name));
  }
  core.setMacrotaskCallback(handleTimerMacrotask);
}

可以发现,除了op_write/op_read这两个op使用的是dispatchMinimal.asyncMsgFromRust方法,其余的都是使用dispatchJson.asyncMsgFromRust响应回调。而在dispatchJson.asyncMsgFromRust这个方法里面我们就可以看到它专门对promise做了处理:

export function asyncMsgFromRust(resUi8: Uint8Array): void {
  const res = decode(resUi8);
  util.assert(res.promiseId != null);

  const promise = promiseTable[res.promiseId!];
  util.assert(promise != null);
  delete promiseTable[res.promiseId!];
  promise.resolve(res);
}

根据我们之前传入的promiseId,来获取promise然后直接resolve。
那么还有一个小问题,dispatchMinimal.asyncMsgFromRust和dispatchJson.asyncMsgFromRust有啥区别尼?实际上dispatchMinimal.asyncMsgFromRust是专门处理io的读写的,一般都是传入资源id和一个buffer,等待rust的处理,然后返回处理后的字节数;而dispatchJson.asyncMsgFromRust参数都是经过JSON.stringify然后传给rust那边再解析取参。

那么还有send和recv这两个方法是在哪里定义的尼?
直接来到core/bingding.rs的initialize_context,在这里是deno初始化核心方法的地方(都是挂在Deno.core这个对象下),send和recv也是在这里注入到JS的世界里面:

pub fn initialize_context<'s>(
  scope: &mut impl v8::ToLocal<'s>,
) -> v8::Local<'s, v8::Context> {
    ...
    let mut recv_tmpl = v8::FunctionTemplate::new(scope, recv);
      let recv_val = recv_tmpl.get_function(scope, context).unwrap();
      core_val.set(
        context,
        v8::String::new(scope, "recv").unwrap().into(),
        recv_val.into(),
      );

      let mut send_tmpl = v8::FunctionTemplate::new(scope, send);
      let send_val = send_tmpl.get_function(scope, context).unwrap();
      core_val.set(
        context,
        v8::String::new(scope, "send").unwrap().into(),
        send_val.into(),
      );
      ...
}

手残画张图整理一下:
截屏2020-06-23 下午12.03.58.png

插件编写

2020-06-28补充:

deno的插件当然是用Rust来编写啦,相对于node使用C++,两者入门难度其实都不相上下,对于习惯了js的前端都是一个门槛;不过除了使用语言本身,当然还要看编写的体验怎么样,而deno的项目下其实就有一个示例项目:test_plugin,所以我们就从这个示例项目入手,分析插件编写的各个步骤。

另外提一下,deno的插件接口目前还是unstable状态,所以官方文档目前也是没有的,现在讨论的内容,将来都可能会改变。

首先新建一个目录,执行cargo init命令(还是很有npm风格),初始化项目:

cargo init

这个时候可以看到Cargo.toml文件(跟pacakage.json作用差不多)

[package]
name = "deno-plugin-test"
version = "0.1.0"
authors = ["popewu"]
edition = "2018"

[lib]
crate-type = ["cdylib"]

[dependencies]
serde = { version = "1.0.106", features = ["derive"] }
serde_derive = "1.0.106"
serde_json = { version = "1.0.52", features = [ "preserve_order" ] }
futures = "0.3.4"
deno_core = { path = "../deno/core" }

重点要配置crate-type来确定我们编译的产物类型,deno插件需要的是动态库。

然后依赖还要配置futures库(编写异步接口的时候需要),deno_core(指向本地代码库)。

下面直接开始rust代码:

#[no_mangle]
pub fn deno_plugin_init(interface: &mut dyn Interface) {
    interface.register_op("testSync", test_sync_op);
    interface.register_op("testAsync", test_async_op);
}

声明deno_plugin_init方法,这个方法就是整个插件的入口,方法名也是固定的,上面的no_mangle宏也是必须的,因为rust支持方法重载,不加no_mangle方法名会被编译器改掉的。然后就是调用interface.register_op注册我们的方法:

fn test_sync_op(_interface: &mut dyn Interface, data: &[u8], _: Option) -> Op {
    ...
    Op::Sync(..)
}
fn test_async_op(_interface: &mut dyn Interface, data: &[u8], _: Option) -> Op {
    ...
    Op::Async(...)
}

方法接收的参数都是固定的,从方法签名就可以看到rust从js接收的data就是一个u8数组,所以js传给rust的参数一般都是需要经过一层转换的。

最后打包,执行:

cargo build --release

现在在target/release目录下你可以看到打包的产物。

在js端如何使用?在上一节就提到有sendSync和sendAsync方法帮我们封装好调用插件的一些逻辑,但是我们目前是不能使用这两个非常友好的方法,因为初始化OPS_CACHE的地方只有一个,deno没有暴露出接口让它可以重新初始化,所以在OPS_CACHE上是不会找到我们的注册的方法的,另外,即使我们能在重新初始化OPS_CACHE,如果我们注册的方法覆盖掉deno内部的方法肯定又会导致新的一些问题。综合一下,我们还是得老老实实自己写代码实现相应的逻辑。

const rid = Deno.openPlugin('./target/release/libdeno_plugin_test.dylib');

const {testSync, testAsync} = Deno.core.ops();

首先使用Deno.openPlugin方法来加载组件,deno会调起deno_plugin_init方法让我们能够注册自己的op。

然后我们就能够调用Deno.core.ops(),获取到新的OPS_CACHE。

对于同步方法,我们直接调用Deno.core.dispatch方法就可以了。

但是对于异步方法,我们还需要调用Deno.core.setAsyncHandler,设置相应的回调。当然我们也可以用官方那种套路,封装一下调用逻辑,自己维护一个promise table,然后把promiseId当做额外的参数传给rust,等回调响应的时候,根据promiseId找出对应的promise然后resolve。

最后Deno.close(rid)释放资源。

关于Deno.core.setAsyncHandler设置的回调,有一个点我认为需要值得注意的,回调接收的是一个buf(u8数组),这个数组一般情况下引用的是Deno.core.shared,也就是上一节所说的ShareQueue,所以如果直接把这个buf引用保存起来,在下一个事件循环是很有可能会变化的;但是在另外一种情况下这个buf又是不会变的就是我们的异步方法返回的数据超过ShareQueue的大小,这个时候deno会单独创建一个新的ArrayBuffer实例然后传给回调,这个时候就是不会变化的。
所以建议我们还是直接复制一份会比较靠谱点。

因为从setAynceHandler接收数据有一个不确定的地方,就是它可能会复制到ShareQueue上也可能单独创建一个ArrayBuffer实例,所以如果一些性能敏感的接口,可以使用zeroCopy这个参数,然后在Rust那边直接把数据写到zeroCopy这个ArrayBuffer上,然后自己处理。

好吧,整体deno插件编写的体验还是很流畅的,编写插件的时候最后就需要返回一个u8数组,其实也不用太关心deno那边会做什么操作,这样可以更专注一个功能模块的实现。

总结

整体下来,deno在js与rust的交互方面是挺好理解的,感觉对deno的未来又多加了几分信心了。

你可能感兴趣的:(deno,javascript,node.js)