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_ms や rult_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、次回rip(retで戻る)。 - 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