🧵

rult: Rust製 ULT を async ランタイムに載せる(C からも使える)

に公開

はじめに

地面と水平になっているときに思いついた「stackfull ULTもいい感じにやればRust非同期ランタイム上でスケジューリングできるのでは?」というのをを実現するべくAIをしばいてrultを実装した。
x86_64 のユーザー空間コンテキストスイッチを Rust 側で実装し、C は FFI で呼び出せるようにしている。ULTは Futureとして見せるので、普通に await できる。

思いつき

FutureはPendingとReadyの2値を返し、async/awaitブロックはawaitごとに状態を遷移させるステートマシンへとコンパイルされる。このときステートマシンの返り値がstd::task::Pollとなっている。

このようにRustの非同期処理はコンパイラによるコルーチン生成依存になっているため、Rust非同期ランタイムはRustからしか使えないと思っていた。しかし、Futureの返り値さえ満たせはRustはawaitできるのだからFutureの中身をstackfull ULTにして、yieldポイントでPendingを返せばCからRust非同期ランタイムを使えるのではないかと気づいた。

何ができるか

  • x86_64 SysV ABI 上で 手動コンテキストスイッチ(Rust の inline asm)
  • ガード付きスタックの確保と破棄(mmap 前提)
  • C 向けの **FFI API(ヘッダ)**を用意。C から ULT を spawn、yield/await、完了で値返す
  • Rust 側では CAltTaskFuture として扱い、任意の async ランタイム(pluvio 等)でスケジュール
  • XMM レジスタ保存はオプション(高速化と安全側のトレードオフ) (GitHub)

全体像

┌─────────────────────────────────────┐
│  Rust async runtime (pluvio 等)     │
│  ┌───────────────────────────────┐  │
│  │   CAltTaskFuture (impl Future)│  │
│  └───────────┬───────────────────┘  │
│              │  コンテキストスイッチ   │
│  ┌───────────▼───────────────────┐  │
│  │   ULT 実行 (C から呼ぶ)         │  │
│  │   - カスタムスタック             │  │
│  │   - レジスタ保存/復元            │  │
│  │   - yield/await/return        │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

使い方(Rust 側)

CAltTaskFuture にエントリ関数(トランポリン経由で C 側に入る)と引数を渡して spawn。あとは普通の Future と同じ。

use rult::task::CAltTaskFuture;

// ここでは「C 側エントリ(extern "C")」のアドレスを渡す想定
extern "C" fn my_task(arg: usize) -> usize { arg + 42 }

fn main() -> anyhow::Result<()> {
    // スタックはデフォルトサイズ(ガード付き)
    let task = rult::task::CAltTaskFuture::with_default_stack(my_task as usize, 100)?;
    // 生成した Future は pluvio 等のランタイムで poll/await する
    Ok(())
}

README の Quick Start もこの流れ。 (GitHub)

使い方(C 側)

C からは rult.h をincludeして、rult_spawn で ULT を作る。rult_sleep_msrult_return_ok などのプリミティブで、Rust 側に Pending/Ready を伝える。

#include "rult.h"

extern "C" int my_async_task(rult_cx_t* cx, void* data) {
    rult_sleep_ms(cx, 100);     // 100ms 待って Pending に落ちる
    rult_return_ok(cx, 42);     // Ready(42) で復帰
}
...
rult_task_t* t = rult_spawn(my_async_task, NULL);

このサンプルは README の C API 節と同じ構成。 (GitHub)

仕組みの話(実装メモ)

1) コンテキストスイッチ

  • 保存/復元対象は x86_64 SysV の callee-saved レジスタ群と rsp、次回 ripret で戻る)。
  • XMM(xmm6–xmm15)は オプションで保存。数% でも速くしたい場面では外す。
  • スタックは mmap で生やし、下端にガードページを置く。
  • 最初の起動は trampoline(引数パック→C エントリ呼び)経由。

このあたりは rult の「features」として README にまとまっている。 (GitHub)

2) Future 化(CAltTaskFuture)

poll() のたびに ULT を再開→yield/await/return で Rust に戻る。
戻り方は内部的に「理由(yield/park/return)」を TLS に落としておき、Pending/Ready を切り替えるだけ。pluvio の Reactor(タイマ/IO)側と**登録ハンドル(registration)**で噛み合わせている。

3) C API

  • rult_spawn(entry, user_ptr):ULT の生成
  • rult_sleep_ms(cx, ms):タイマ登録→Pending
  • rult_return_ok(cx, val) / rult_return_err(cx, code):即 Ready
  • 登録ハンドルを使う rult_await_reg(cx, reg) みたいな API も設計上は置いてある(IO/UCX 側で割り当てる想定)

実例(最小 ULT:sleep → 完了)

C でこう書くと:

int sleepy(rult_cx_t* cx, void* _) {
    rult_sleep_ms(cx, 10);
    rult_return_ok(cx, 0);
}

Rust 側では普通に await できる:

let fut = rult::spawn_c_ult(sleepy, std::ptr::null_mut(), rult::StackSize::default());
fut.await?; // 10ms 後に Ready

exampleとビルド

  • examples/ に基本的なスワップテストやランタイム統合の雛形がある
    test_basic_swap.rs, test_trampoline.rs, with_pluvio.rs など)
  • ビルドは cargo build && cargo test。Linux/x86-64 前提。Rust 1.70+。 (GitHub)

なぜ ULT を「Future」に寄せたか

  • Rust 側は 状態機械(Future)、C 側は スタック保持(ULT)
  • どちらも「次回再開点」を持つという意味では等価で、反復可能な poll に落とせる。
  • 既存の async エコシステム(タイマ、I/O、トレース)がそのまま使えるメリットが大きい。
  • 逆に「C 側の scheduler を Rust に合わせる」より、Rust 側の executor に取り込むほうが運用が楽。

参考

  • リポジトリ: maetin0324/rult(README に概要・サンプル・要件) (GitHub)

付録(よく聞かれるやつ)

Q. tokio でも動く?

Future なので基本は動く。ただし ULT の設計は 同一スレッド前提。マルチスレッド executor でワークスティーリングされると破綻するので、シングルスレッドで回す。README でも「async runtimes like pluvio」と書いてあり、設計上はその想定。 (GitHub)

Q. どのくらい軽い?

ケース依存。文脈として、ユーザレベルのコンテキストスイッチはカーネルスレッド切替より軽いことが多い、という比較記事/議論は昔からある。 (GitHub)

Discussion