Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active July 3, 2023 02:59
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mizchi/a4509ffe1c7c7f8d047b840de0a5b970 to your computer and use it in GitHub Desktop.
Save mizchi/a4509ffe1c7c7f8d047b840de0a5b970 to your computer and use it in GitHub Desktop.

Deno の コードリーディング

動機

cloudflare worker, deno deploy 等で v8 isolates を前提にしたホスティングプラットフォームが増えている。これを自分で実装してみたいと思った。

denoland/rusty_v8 を読んだが、SnapshotCreator の使い方がよくわからなかった。これを deno 本体を読んで使い方を調べた。

ついでに、rusty_v8、というか v8 にはメインループやスケジューラが含まれていないので、 Tokio のメインループで、どのように deno が非同期メインループを実装しているか調べた。

現状の理解

まず公式の説明は rusty_v8 導入以前のもので、現状とやや乖離している。

前提として、v8 自体ではイベントループが回らない。それを deno がどのように実行しているか。

denoland/rusty_v8

v8 へのバインディング。deno 本体からリポジトリごと切り出されている。

本当はこれを使いたいだけだったのだが、example が少なく、その実例として deno の大部分を読む羽目になった。

deno/serde_v8

v8 の内部状態を serde でシリアライズできるようにするやつらしい。実験的なモジュールで、まだ全部で使われてるわけではないので存在を知っておけば十分。

これで http-server の実装がメチャクチャ速くなったみたいなのは読んだ。

Deno 1.9 previews native HTTP/2 server | InfoWorld

deno/core

rusty_v8 で V8 Isolate (軽量プロセスのようなもの) を管理して、V8 Snapshot の生成、読み込み、実行状態(JsRuntimeState)の管理をしている crate。 runtime::JsRuntime がメイン。

V8 には Isolate の実行状態をシリアライズしてバイナリとして吐ける機能がある。これを使って、 Deno のグローバル変数を読み込み終わった状態を作って吐き出しておく。deno 起動時はこの snapshot から起動するので高速に立ち上がる。

core/bindings.rs で V8 と Rust のブリッジが実装されている。これらは Deno.core.opSync("op_print", "Hello World\n"); のように、JS 側から直接叩くことができる。Deno で実装されてる部分は v8 isolate としてシリアライズされないが、rusty_v8 の snapshot を起動する際に、 ExternalReferences という参照を渡すことで、rust <=> js 間をブリッジしている。(rusty_v8 からみると、ここが全くドキュメントになってない)

実際に起動用 snapshot を作るのは runtime/build.rs なのだが、 runtime/extensions/*extensions/* も一緒に実行されている。これらは ↑ の ops の API を使って、 js で console, fetch, webgpu といった API をウェブ標準に合わせて実装している。つまり、 console.log(...) の実体は op_print のラッパーになっている。

ESM の import や dynamic import のモジュール解決もここで扱う。

注意するべきは deno/core にイベントループは含まれておらず、イベントループに実行キューを詰む処理はあるが、そのループを実際に動かす処理は deno/runtime にある。

deno/runtime

Tokio のイベントループで deno/core の JsRuntime を回す。OS のプロセス管理を WebWorker の API として実装している。 メインプロセスが、 deno/runtime/worker.rs で、 サブプロセスが deno/runtime/web_worker.rs

deno 特有の permissions 管理もここ。

deno/cli

CLI として、TypeScript 周りを処理して、その結果を runtime::MainWorker に渡す。Language Server Protocol も cli の中で実装してる。

基本的に、TypeScript 周りを扱う処理は全部 CLI 層にある。他のは v8 を叩くのが主なので、特に出てこない。


以下これらを理解するためのコードリーディングのログ。

最初に読んだのは runtime/examples/hello_runtime.rs

Worker を作って event_loop を回している

  let mut worker =
    MainWorker::from_options(main_module.clone(), permissions, &options);
  worker.bootstrap(&options);
  worker.execute_module(&main_module).await?;
  worker.run_event_loop().await?;
  Ok(())

runtime/worker.rs

worker::bootstrap

  pub fn bootstrap(&mut self, options: &WorkerOptions) {
    let runtime_options = json!({
      "args": options.args,
      "applySourceMaps": options.apply_source_maps,
      "debugFlag": options.debug_flag,
      "denoVersion": options.runtime_version,
      "noColor": options.no_color,
      "pid": std::process::id(),
      "ppid": ops::runtime::ppid(),
      "target": env!("TARGET"),
      "tsVersion": options.ts_version,
      "unstableFlag": options.unstable,
      "v8Version": deno_core::v8_version(),
      "location": options.location,
    });

    let script = format!(
      "bootstrap.mainRuntime({})",
      serde_json::to_string_pretty(&runtime_options).unwrap()
    );
    self
      .execute(&script)
      .expect("Failed to execute bootstrap script");
  }

  /// Same as execute2() but the filename defaults to "$CWD/__anonymous__".
  pub fn execute(&mut self, js_source: &str) -> Result<(), AnyError> {
    let path = env::current_dir()
      .context("Failed to get current working directory")?
      .join("__anonymous__");
    let url = Url::from_file_path(path).unwrap();
    self.js_runtime.execute(url.as_str(), js_source)
  }

bootstrap.mainRuntime というメソッドを引数をシリアライズしながら呼んでいる。

worker::execute_module

  /// Loads, instantiates and executes specified JavaScript module.
  pub async fn execute_module(
    &mut self,
    module_specifier: &ModuleSpecifier,
  ) -> Result<(), AnyError> {
    let id = self.preload_module(module_specifier).await?;
    self.wait_for_inspector_session();
    let mut receiver = self.js_runtime.mod_evaluate(id);
    tokio::select! {
      maybe_result = receiver.next() => {
        debug!("received module evaluate {:#?}", maybe_result);
        let result = maybe_result.expect("Module evaluation result not provided.");
        return result;
      }

      event_loop_result = self.run_event_loop() => {
        event_loop_result?;
        let maybe_result = receiver.next().await;
        let result = maybe_result.expect("Module evaluation result not provided.");
        return result;
      }
    }
  }

まず preload_modules する

  /// Loads and instantiates specified JavaScript module.
  pub async fn preload_module(
    &mut self,
    module_specifier: &ModuleSpecifier,
  ) -> Result<ModuleId, AnyError> {
    self.js_runtime.load_module(module_specifier, None).await
  }

specifier を読みだした tokio の id を返している?

読む順番が違う気がしてきた。先に js_runtime.mod_evaluate(id) というやつを読んだほうが良さそう。

EventLoop

先に run_event_loop が何をやってるかだけみていく

  pub async fn run_event_loop(&mut self) -> Result<(), AnyError> {
    poll_fn(|cx| self.poll_event_loop(cx)).await
  }

この poll_fnfutures_util::future::poll_fn で、 rust の非同期周りエコシステムこの辺ややこしそうなイメージあるけど、 tokio でも future の API を使うようになったのかな?

(オンマウスで表示される rust_analyzer が超絶便利…あと examples があるのは最高…)

Example

use futures::future::poll_fn;
use futures::task::{Context, Poll};

fn read_line(_cx: &mut Context<'_>) -> Poll<String> {
    Poll::Ready("Hello, World!".into())
}

let read_future = poll_fn(read_line);
assert_eq!(read_future.await, "Hello, World!".to_owned());

関数を投げるとスケジューラが実行するみたいな雑な理解をする。

で、 その結果実行される poll_event_loop

  pub fn poll_event_loop(
    &mut self,
    cx: &mut Context,
  ) -> Poll<Result<(), AnyError>> {
    // We always poll the inspector if it exists.
    let _ = self.inspector.as_mut().map(|i| i.poll_unpin(cx));
    self.js_runtime.poll_event_loop(cx)
  }

js_runtime::poll_event_loop(cx) で結局こいつを読む必要がある。

runtime とはいっても、こいつは deno/runtime ではなく、 deno/core/runtime.rs にある。というわけで deno::core をみていく。

deno::core

とりあえず README.md に deepl 翻訳を掛けて読む。

この Rust クレートには、Deno のコマンドラインインタフェース(Deno CLI)に必要な V8 バインディングが含まれています。 インターフェース(Deno CLI)に必要な V8 バインディングが含まれています。ここでの主な抽象化は、JavaScript を実行する方法を提供する JsRuntime です。 JavaScript を実行する方法を提供する JsRuntime です。

JsRuntime は、実行されたコードのためのイベントループの抽象化を実装しています。 すべての保留中のタスク(非同期処理、動的モジュールのロード)を追跡します。ユーザーの責任は JsRuntime::run_event_loop`メソッドを使用してそのループを駆動するのはユーザーの責任です。 このループは、Rust のフューチャーエグゼキュータ(tokio, smol など)のコンテキストで実行されなければなりません。

Rust の関数を JavaScript にバインドするには、Deno.core.dispatch()という関数を使います。 関数を使って、Rust の "dispatch "コールバックをトリガーしてください。ユーザーは以下のことに責任があります。 リクエストとレスポンスの両方を Uint8Array にエンコードする責任があります。

あと TypeScript は core に含まれてない。CLI 層で処理されるので Runtime には出てこない、みたいなことが書いてある。

JsRuntime がコアっぽい。

js_runtime を読む前に、 deno/runtime/worker の MainWorker がどのように js_runtime を初期化するか確認。

impl MainWorker {
  pub fn from_options(
    main_module: ModuleSpecifier,
    permissions: Permissions,
    options: &WorkerOptions,
  ) -> Self {
    // Permissions: many ops depend on this
    let unstable = options.unstable;
    let perm_ext = Extension::builder()
      .state(move |state| {
        state.put::<Permissions>(permissions.clone());
        state.put(ops::UnstableChecker { unstable });
        Ok(())
      })
      .build();

    // Internal modules
    let extensions: Vec<Extension> = vec![
      // Web APIs
      deno_webidl::init(),
      deno_console::init(),
      deno_url::init(),
      deno_web::init(),
      deno_file::init(options.blob_url_store.clone(), options.location.clone()),
      deno_fetch::init::<Permissions>(
        options.user_agent.clone(),
        options.ca_data.clone(),
      ),
      deno_websocket::init::<Permissions>(
        options.user_agent.clone(),
        options.ca_data.clone(),
      ),
      deno_webstorage::init(options.location_data_dir.clone()),
      deno_crypto::init(options.seed),
      deno_webgpu::init(options.unstable),
      deno_timers::init::<Permissions>(),
      // Metrics
      metrics::init(),
      // Runtime ops
      ops::runtime::init(main_module),
      ops::worker_host::init(options.create_web_worker_cb.clone()),
      ops::fs_events::init(),
      ops::fs::init(),
      ops::http::init(),
      ops::io::init(),
      ops::io::init_stdio(),
      ops::net::init(),
      ops::os::init(),
      ops::permissions::init(),
      ops::plugin::init(),
      ops::process::init(),
      ops::signal::init(),
      ops::tls::init(),
      ops::tty::init(),
      // Permissions ext (worker specific state)
      perm_ext,
    ];

    let mut js_runtime = JsRuntime::new(RuntimeOptions {
      module_loader: Some(options.module_loader.clone()),
      startup_snapshot: Some(js::deno_isolate_init()),
      js_error_create_fn: options.js_error_create_fn.clone(),
      get_error_class_fn: options.get_error_class_fn,
      extensions,
      ..Default::default()
    });

    let inspector = if options.attach_inspector {
      Some(DenoInspector::new(
        &mut js_runtime,
        options.maybe_inspector_server.clone(),
      ))
    } else {
      None
    };

    let should_break_on_first_statement =
      inspector.is_some() && options.should_break_on_first_statement;

    Self {
      inspector,
      js_runtime,
      should_break_on_first_statement,
    }
  }
  // ...

ops::*::init は飛ばす。

知りたかったのはここ。

    let mut js_runtime = JsRuntime::new(RuntimeOptions {
      module_loader: Some(options.module_loader.clone()),
      startup_snapshot: Some(js::deno_isolate_init()),
      js_error_create_fn: options.js_error_create_fn.clone(),
      get_error_class_fn: options.get_error_class_fn,
      extensions,
      ..Default::default()
    });

初期化された snapshot、モジュールローダー等を与えている。 本筋として、 snapshot をどう作っているか知りたかったので、 js::deno_isolate_init() を読んでいく。

runtime::js::deno_isolate_init()

結局 runtime に戻ってきてしまった。snapshot を読み込んでいる部分を追う。

pub fn deno_isolate_init() -> Snapshot {
  debug!("Deno isolate init with snapshots.");
  let data = CLI_SNAPSHOT;
  Snapshot::Static(data)
}
pub static CLI_SNAPSHOT: &[u8] =
  include_bytes!(concat!(env!("OUT_DIR"), "/CLI_SNAPSHOT.bin"));

pub fn deno_isolate_init() -> Snapshot {
  debug!("Deno isolate init with snapshots.");
  let data = CLI_SNAPSHOT;
  Snapshot::Static(data)
}

この CLI_SNAPSHOT.bin の置き場所を知りたいので、 print デバッグしてみる。

pub fn deno_isolate_init() -> Snapshot {
  debug!("Deno isolate init with snapshots.");
  let data = CLI_SNAPSHOT;
  println!("{}", concat!(env!("OUT_DIR"), "/CLI_SNAPSHOT.bin"));
  Snapshot::Static(data)
}
$ cargo run --example hello_runtime
    Blocking waiting for file lock on build directory
   Compiling deno_runtime v0.14.0 (/Users/mizchi/gh/github.com/denoland/deno/runtime)
    Finished dev [unoptimized + debuginfo] target(s) in 28.45s
     Running `/Users/mizchi/gh/github.com/denoland/deno/target/debug/examples/hello_runtime`
/Users/mizchi/gh/github.com/denoland/deno/target/debug/build/deno_runtime-4b7d49413e4a5776/out/CLI_SNAPSHOT.bin

CLI_SNAPSHOT.bin で grep すると、 runtime/build.rs が引っかかる。

fn main() {
  // Skip building from docs.rs.
  if env::var_os("DOCS_RS").is_some() {
    return;
  }

  // To debug snapshot issues uncomment:
  // op_fetch_asset::trace_serializer();

  println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap());
  println!("cargo:rustc-env=PROFILE={}", env::var("PROFILE").unwrap());
  let o = PathBuf::from(env::var_os("OUT_DIR").unwrap());

  // Main snapshot
  let runtime_snapshot_path = o.join("CLI_SNAPSHOT.bin");

  let js_files = get_js_files("js");
  create_runtime_snapshot(&runtime_snapshot_path, js_files);
}

その create_runtime_snapshot

fn create_runtime_snapshot(snapshot_path: &Path, files: Vec<PathBuf>) {
  let extensions: Vec<Extension> = vec![
    deno_webidl::init(),
    deno_console::init(),
    deno_url::init(),
    deno_web::init(),
    deno_file::init(Default::default(), Default::default()),
    deno_fetch::init::<deno_fetch::NoFetchPermissions>("".to_owned(), None),
    deno_websocket::init::<deno_websocket::NoWebSocketPermissions>(
      "".to_owned(),
      None,
    ),
    deno_webstorage::init(None),
    deno_crypto::init(None),
    deno_webgpu::init(false),
    deno_timers::init::<deno_timers::NoTimersPermission>(),
  ];

  let js_runtime = JsRuntime::new(RuntimeOptions {
    will_snapshot: true,
    extensions,
    ..Default::default()
  });
  create_snapshot(js_runtime, snapshot_path, files);
}

JsRuntime に will_snapshot フラグを付けて runtime を作っている。

// TODO(bartlomieju): this module contains a lot of duplicated
// logic with `cli/build.rs`, factor out to `deno_core`.
fn create_snapshot(
  mut js_runtime: JsRuntime,
  snapshot_path: &Path,
  files: Vec<PathBuf>,
) {
  // TODO(nayeemrmn): https://github.com/rust-lang/cargo/issues/3946 to get the
  // workspace root.
  let display_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
  for file in files {
    println!("cargo:rerun-if-changed={}", file.display());
    let display_path = file.strip_prefix(display_root).unwrap();
    let display_path_str = display_path.display().to_string();
    js_runtime
      .execute(
        &("deno:".to_string() + &display_path_str.replace('\\', "/")),
        &std::fs::read_to_string(&file).unwrap(),
      )
      .unwrap();
  }

  let snapshot = js_runtime.snapshot();
  let snapshot_slice: &[u8] = &*snapshot;
  println!("Snapshot size: {}", snapshot_slice.len());
  std::fs::write(&snapshot_path, snapshot_slice).unwrap();
  println!("Snapshot written to: {} ", snapshot_path.display());
}

これらの runtime/js/*.js が順番に execute される。

$ ls js
01_build.js        30_os.js           40_process.js
01_errors.js       40_compiler_api.js 40_read_file.js
01_version.js      40_diagnostics.js  40_signals.js
01_web_util.js     40_error_stack.js  40_testing.js
06_util.js         40_files.js        40_tls.js
11_workers.js      40_fs_events.js    40_tty.js
12_io.js           40_http.js         40_write_file.js
13_buffer.js       40_net_unstable.js 41_prompt.js
30_fs.js           40_performance.js  90_deno_ns.js
30_metrics.js      40_permissions.js  99_main.js
30_net.js          40_plugins.js      README.md

js_runtime::snapshot でバイナリが吐き出されて、それを先程確認したパスに書き込んでいる。

  let snapshot = js_runtime.snapshot();
  let snapshot_slice: &[u8] = &*snapshot;
  println!("Snapshot size: {}", snapshot_slice.len());
  std::fs::write(&snapshot_path, snapshot_slice).unwrap();
  println!("Snapshot written to: {} ", snapshot_path.display());

結局後回しにした js_runtime.snapshot() を読むことになる。

core/runtime.rs

本腰いれて読んでいく。

impl JsRuntime {
  /// Only constructor, configuration is done through `options`.
  pub fn new(mut options: RuntimeOptions) -> Self {
    let v8_platform = options.v8_platform.take();

    static DENO_INIT: Once = Once::new();
    DENO_INIT.call_once(move || v8_init(v8_platform));
    // ...

全体初期化を一回だけやる。

fn v8_init(v8_platform: Option<v8::UniquePtr<v8::Platform>>) {
  // Include 10MB ICU data file.
  #[repr(C, align(16))]
  struct IcuData([u8; 10413584]);
  static ICU_DATA: IcuData = IcuData(*include_bytes!("icudtl.dat"));
  v8::icu::set_common_data(&ICU_DATA.0).unwrap();

  let v8_platform = v8_platform
    .unwrap_or_else(v8::new_default_platform)
    .unwrap();
  v8::V8::initialize_platform(v8_platform);
  v8::V8::initialize();

  let flags = concat!(
    // TODO(ry) This makes WASM compile synchronously. Eventually we should
    // remove this to make it work asynchronously too. But that requires getting
    // PumpMessageLoop and RunMicrotasks setup correctly.
    // See https://github.com/denoland/deno/issues/2544
    " --experimental-wasm-threads",
    " --no-wasm-async-compilation",
    " --harmony-top-level-await",
    " --harmony-import-assertions",
    " --no-validate-asm",
  );
  v8::V8::set_flags_from_string(flags);
}

ICU という概念があるらしいが、Chrome と v8 のプロセス間通信のファイルソケットかなにかだっけ?ググっても引っかからないが自明なものとして扱われてる…

https://chromium.googlesource.com/chromium/deps/icu46/

ry が wasm の初期化は async にしたいねみたいなコメントを残してる。

ICU のことは一旦忘れて、 v8 が起動した前提で読みすすめる

    let has_startup_snapshot = options.startup_snapshot.is_some();

will_snapshot: true のときの処理

    let (mut isolate, maybe_snapshot_creator) = if options.will_snapshot {
      // TODO(ry) Support loading snapshots before snapshotting.
      assert!(options.startup_snapshot.is_none());
      let mut creator =
        v8::SnapshotCreator::new(Some(&bindings::EXTERNAL_REFERENCES));
      let isolate = unsafe { creator.get_owned_isolate() };
      let mut isolate = JsRuntime::setup_isolate(isolate);
      {
        let scope = &mut v8::HandleScope::new(&mut isolate);
        let context = bindings::initialize_context(scope);
        global_context = v8::Global::new(scope, context);
        creator.set_default_context(context);
      }
      (isolate, Some(creator))
    } else {

bindings::EXTERNAL_REFERENCES を引数に、v8::SnapshotCreator を初期化。

これは snapshot 外の API を予約するみたいなやつかな。

lazy_static::lazy_static! {
  pub static ref EXTERNAL_REFERENCES: v8::ExternalReferences =
    v8::ExternalReferences::new(&[
      v8::ExternalReference {
        function: opcall.map_fn_to()
      },
      v8::ExternalReference {
        function: set_macrotask_callback.map_fn_to()
      },
      v8::ExternalReference {
        function: eval_context.map_fn_to()
      },
      v8::ExternalReference {
        function: queue_microtask.map_fn_to()
      },
      v8::ExternalReference {
        function: encode.map_fn_to()
      },
      v8::ExternalReference {
        function: decode.map_fn_to()
      },
      v8::ExternalReference {
        function: serialize.map_fn_to()
      },
      v8::ExternalReference {
        function: deserialize.map_fn_to()
      },
      v8::ExternalReference {
        function: get_promise_details.map_fn_to()
      },
      v8::ExternalReference {
        function: get_proxy_details.map_fn_to()
      },
      v8::ExternalReference {
        function: memory_usage.map_fn_to(),
      },
    ]);
}

(この後色々やったがログに残ってない)

最初に書きたかったスナップショットの生成と初期化はどうなったか

これで動いた。

use rusty_v8 as v8;
use std::mem::forget;

fn main() {
    let platform = v8::new_default_platform().unwrap();
    v8::V8::initialize_platform(platform);
    v8::V8::initialize();
    let snapshot;
    // --- create snapshot ---
    {
        let mut creator = v8::SnapshotCreator::new(None);
        let mut isolate = unsafe { creator.get_owned_isolate() };
        {
            let scope = &mut v8::HandleScope::new(&mut isolate);
            let context = v8::Context::new(scope);
            let src = r#"
                function add1(n) { return n + 1; }
                x = 1;
                y = 2;
                add1(x + y);
            "#;
            {
                let scope = &mut v8::ContextScope::new(scope, context);
                let code = v8::String::new(scope, src).unwrap();
                let script = v8::Script::compile(scope, code, None).unwrap();
                let result = script.run(scope).unwrap();
                assert_eq!(result.to_rust_string_lossy(scope), "4");
            }
            creator.set_default_context(context);
        }

        snapshot = creator.create_blob(v8::FunctionCodeHandling::Keep).unwrap();
        let snapshot_slice: &[u8] = &*snapshot;
        println!("Snapshot size: {} kb", snapshot_slice.len() / 1024);
        forget(isolate);
    }

    // --- restore snapshot ---
    {
        let mut isolate = v8::Isolate::new(v8::Isolate::create_params().snapshot_blob(snapshot));
        let scope = &mut v8::HandleScope::new(&mut isolate);
        let context = v8::Context::new(scope);
        {
            let scope = &mut v8::ContextScope::new(scope, context);
            let code = v8::String::new(scope, "add1(x + y)").unwrap();
            let script = v8::Script::compile(scope, code, None).unwrap();
            let result = script.run(scope).unwrap();
            assert_eq!(result.to_rust_string_lossy(scope), "4");
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment