Brief analysis of Deno source code interaction between JS and Rust

Keywords: node.js REST JSON

start

Today, we start to analyze how JS and Rust interact. After all, the performance of JS is still not competent in some scenarios. This is the time when Rust is on the stage. The two complement each other and do no harm!

op

I have always said that op is a plug-in mechanism on deno. All functions on deno are basically based on this plug-in mechanism.

send and recv

In the architecture diagram at the beginning, we can see that the interaction between JS and rust in deno can only be through send and recv methods. The actual principle of calling send is also very simple to call the corresponding rust method according to the opId. If it is a synchronous method, it can be returned directly, but if it is an asynchronous method, it needs to use recv to receive the returned value.

Analyze directly from the 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<number> {
  const mode: number | undefined = options?.mode;
  return sendAsync("op_open", {
    path,
    options,
    mode,
  });
}

Here, the sendSync/sendAsync method is called directly, and then the sendSync and sendAsync are traced down:

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<Ok> {
  const opId = OPS_CACHE[opName];
  util.log("sendAsync", opName, opId);
  const promiseId = nextPromiseId();
  args = Object.assign(args, { promiseId });
  const promise = util.createResolvable<Ok>();

  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 is simpler than sendAsync, and directly from OPS_CACHE gets the corresponding opId, and then converts the parameter to Uint8Array to distribute the call.

sendAsync needs to create an extra promise, and then attach the promise ID to the parameter. After the call is distributed, how can the asynchronous call receive the result from recv method?

Go again core.js When deno calls init, it sets a callback handleasyncmsgfromcust:

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);
  }

What handleasyncmsgfromcust does is to take the asynchronous operation result from SharedQueue and trigger the corresponding asynchronous processor:

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 is essentially a piece of memory that can be accessed by both JS and Rust. SharedQueue also has its own memory layout:

In general, this memory can store up to 100 asynchronous operation results or contents smaller than 128 * 100bit(125kb). Once these settings are exceeded, overflow will be triggered, and JS will be able to process these contents in a timely manner by switching from Rust to JS. Therefore, this SharedQueue is very important and can affect the throughput of the entire application.

Go back to trigger asynchronous processor, but it's not enough to trigger resolve method of promise, so go ahead and start initializing 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);
}

It can be found that in addition to op_write/op_read these two OPS use dispatchMinimal.asyncMsgFromRust Method, the rest are used dispatchJson.asyncMsgFromRust Response callback. And in dispatchJson.asyncMsgFromRust In this method, we can see that it specifically deals with 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);
}

According to the promiseId we passed in before, get the promise and resolve it directly.
So there's a small problem, dispatchMinimal.asyncMsgFromRust and dispatchJson.asyncMsgFromRust What's the area? actually dispatchMinimal.asyncMsgFromRust It is specially used for io reading and writing. Generally, the resource id and a buffer are passed in, waiting for the processing of rust, and then the number of bytes after processing is returned dispatchJson.asyncMsgFromRust Parameters are all passed JSON.stringify Then pass it to the rust side to parse the parameters.

So where are the two methods of send and recv defined?
Go straight to the core/bingding.rs Initialize for_ Context, which is the place where the core method of deno initialization is located (all are hung in the Deno.core Under this object), send and recv are also injected into the JS world here:

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(),
      );
      ...
}

Sort out the pictures of the remnant hands:

Plug in writing

... to be continued

summary

As a whole, the interaction between js and rust of deno is well understood. I feel that I have a little more confidence in the future of deno.

Posted by limao on Mon, 22 Jun 2020 22:36:23 -0700