非同期Rust:スケジューラはどこにある?

非同期Rustに関する多くの不満は、結局のところ文法そのものではなく、厄介な並行処理の問題に由来している。この記事では、スケジューラが一体どこに位置するのか、そしてなぜその位置がどの問題を解決できるかを決めるのかを探る。

日本語
コピー
文章配图:async Rust 里任务调度器所在的位置示意

async Rust のいくつかの問題は、突き詰めれば厄介な並行処理の問題が下流に流れ込んだ結果にすぎない。

作者:Mond

DnB 音楽税:[🎵 Dom Valentino / I Am The Black Gold Of The Sun

OriginalMix

🎵](https://www.youtube.com/watch?v=gnksArfIxiU)

認識論的状態:本当は愚痴記事を書くつもりだった、誓って。かなり自信あり。

最初は async Rust への不満を短いリストにして冒頭に置くつもりだった。ネットでよく見かけるやつだ。筋が通っていようがいまいが。

でも気づいた。async Rust をネタにするだけでビンゴカードが一枚埋まる。しかもこの形式のほうがずっと面白い。次に誰かが async Rust で言い争ってるのを見かけたら、ビンゴが揃うか試してみてほしい。

async Rust に関するよくある不満を集めた 5×5 のビンゴカード。

async Rust に関するよくある不満を集めた 5×5 のビンゴカード。原図/ディザ図を切り替え

抜けがあったら教えてほしい!!

Zig の async 案に触れた人がいたらボーナス点。

ここまで読んでいるなら、そのうちいくつかは(おそらく)すでに聞いたことがあるだろう。残りについては、背景を補うのに役立つ推薦資料を折りたたみでまとめておいた。午後ひとつ使って読むにはちょうどいいが、この記事を読むうえでの必須事項では断じてない。

Async Rust Bingo 早見表

注意: このリストはグリッドの読み順に並んでいる。


  • Scoped task trilemma:おおまかに言えば、並行性・並列性・借用のうち二つしか選べないという話。leakpocalypseの下流にある帰結だ。tl;dr:メモリはリークしうるので、デストラクタが必ず走ることは保証できない。その結果、当初の scoped threads API はデータ競合を引き起こせてしまい、非常にまずかった。

  • 関数の色付け:元ネタは Bob Nystrom の名高い記事。この話題は書き尽くされている。たとえば Without Boats の見解がある。関数の色付けを論じた記事は列挙する気にもならないほど多く、しかも関数の色付けが何を指すかについても、しばしば人によって言うことが違う。

  • Async Closures:要するに、async クロージャが使えるようになるまでおよそ 6 年かかった。短くまとめると「async クロージャは lending である必要がある。返される future がクロージャの捕捉変数を借用しうるからだ」となる。詳しくはこの記事。この件を長く掘り下げていると、高階型を呟く人に出くわすかもしれない。心の準備を。

  • Tokio デフォルト主義:Tokio だけが重要な runtime だという思い込み。自分が言っていることが Tokio にしか当てはまらず、async Rust 全体には当てはまらないと気づいていない人にはボーナス点。概観は Corrode.dev のこのブログ記事を参照。

  • 「スレッドをそのまま使え(tm)」:「async を必要とする人はほとんどいない」として、大多数は「スレッドを大量に立てればいい」という主張。最もよくある変種は、1 万以上の接続を同時にさばくサーバーを書くときだけ async が必要で、それ以外は「スレッドプールをそのまま使えばいい」というもの。

  • 複雑なコンパイラエラー/スタックトレース:async Rust に関する最も一般的な不満のひとつ。

  • Async Drop:Rust Drop trait の性質上、デストラクタはすべて同期的だ。問題は、ブロッキングなクリーンアップ処理を実行しなければならないことがある点にある。非同期の文脈では、どのスレッドもブロックしないよう制御を譲れるようにしたい。

  • Future は遅延評価:JavaScript では、生成された Future はすぐに実行を始める(あちらでは Promise と呼ぶが、内緒にしておこう)。Rust ではまったく違う! future は「ただの」struct だ。.await を呼ぶ(あるいは spawn する)までは前に進まず、やらなければ動きもしない。これを忘れるのは初心者がよくやるミスだ。

  • 「Rust にはかつてグリーンスレッドがあった」/スタックフルコルーチン:グリーンスレッドは Rust が自前のランタイムを持っていた頃のモデルだ。Go の goroutine を思い浮かべればいい。それを削除した RFC はこちら。Rust の生みの親 Graydon Hoare による初期 Rust 構想の振り返りも示唆に富む。(アーカイブリンク。)

  • Future は runtime 非依存ではない:タスクの spawn は runtime に依存し、さまざまな前提が組み込まれている。trait にハードコードされている部分もある。間違えるとこういうエラーが出る。runtime をまたいで使えるライブラリを書くことに関する議論も参照。一般に「エコシステムの分断」と言うときは、こうした問題のひとつを指している。もうひとつは、async が Rust エコシステム全体を非同期ライブラリと非同期でないライブラリに割ってしまったことだ。


  • select!/キャンセル安全性:下にあるよくまとまった講演文字起こし『Cancelling Async Rust』を参照。Tokio の select! マクロは特に悪名高く、ドキュメントにはキャンセル安全性についての節がまるまる一つ設けられている。select! の分岐でうっかり future を drop してしまうのは、async Rust の落とし穴のなかでもおそらく一番の定番だろう。

  • Send + Sync + 'static bound の氾濫:たとえば tokio のドキュメントでは、Send bound のようなものがどうやってプログラムにじわじわ染み込んでくるかが説明されている。あらゆるものにこれらの bound を書かされる、というのが不満の種だ。

  • エルゴノミクス不足(自由記述欄):ノーコメント。2026 年の目標も参照。

  • Futurelock:「ある Future A が保持しているリソースが、別の Future B を進めるために必要であり、しかもその二つの Future を担当する Task がもはや poll A されていない、というデッドロック」。説明させないでほしい!

  • effect system/keyword generics/maybe async:keyword generics の提案を参照。同期版と非同期版の関数を別々に書く代わりに、「maybe async」な関数をひとつだけ書くという発想だ。コンパイラが裏で二つのバージョンを生成し(ジェネリクスと似た仕組み)、呼び出しの文脈に応じて正しいほうをディスパッチする。私はこれにずっと懐疑的だ。

  • APIT、RPIT、TAIT、RPITIT:解説はこちら。いずれも、以前は使えなかった場所でimpl Traitを使えるようにする機能を指す。ほとんどは調べればすぐ出てくるが、かなり技術寄りだ。

  • dyn compatibility、Pin<Box<dyn Future<Output = T> + Send>>:動的ディスパッチを行う能力。いくつかの制約を受け、ボクシングが必要になることがあり、やや面倒でもあるし、おかしいほど長い型が生まれる原因にもなる。

  • io_uring:io_uringは Linux カーネルが2019年に導入した非同期 I/O インターフェース。従来のepollより高性能で、少なくとも必要なシステムコールは少ない。io_uring API では、操作が完了するまでカーネルがバッファを保持する。これは future が drop でキャンセルされうることと衝突する。Tokio でこれを解決するには、バッファをムーブする必要があり、&mut参照を渡すわけにはいかない。つまり Tokio の API を作り直すことになる。tokio-uringの設計はこちら。私の知る限り、Tokio はまだ完全には対応していないio_uring。理由のひとつは API 互換性の保証だ。他のランタイムのいくつかはすでに対応している。

  • 「実は async は組み込み分野で重要だ」:Embassyは組み込み向け async Rust executor の代表例だ。Without Boats の Why Async Rustも参照。ひとつ重要なのは、Rust がランタイムやグリーンスレッドを内蔵できないことだ。Rust が組み込み機器で使われる場面と衝突するからである。この主張は極めて筋が通っているが、async Rust の議論で組み込みプログラミングを持ち出すこと自体、もはやほとんどネタになっている。

  • Box::pin(future)がスタックを食い潰す:Future は、ある async 関数が取りうるすべての状態を記述する状態機械だ。async 関数のネストが深いと、スタックを食い潰すことがある。それをBoxに入れようとして、その場でのヒープ確保の仕組みが欠けていることに気づくと、なおさら笑える。例はこちら。


  • thread per core 対 work stealing:こちらの記事に例がある。Tokio は work stealing を採用しており、長年にわたって議論を呼んできた。work stealing とは、タスクがある CPU コアから別のコアへ移動できる仕組みで、どのスレッドにも常に仕事があるようにできる。ただし、すべてのタスクにSend制約を要求する。Glommioのように、さまざまな理由からこのモデルを捨てた代替ランタイムもある。上記に対する Without Boats の反論も参照。

  • 「標準ライブラリに executor を同梱すべきだ」:おおむね、Rust に何らかのランタイム(Tokio でもいいし、超原始的な executor でもいい)を同梱すべきだという話だ。明らかな問題は、executor ごとに実際の要件が異なることで、標準ライブラリにひとつ押し込むのは、Rust がスリムな標準ライブラリを提供するという目標と衝突する。もっと共感できる言い方をすれば、Rust の標準ライブラリは(何らかの形で)タスク生成の API を標準化し、異なるランタイムがうまく協調できる方向へ進むべきだ、ということになる。これを実現するのは難しい。ランタイムごとに前提が違うからだ。work stealing のランタイム(Tokio など)は Future がスレッド間を移動しうるためSend制約を要求するが、thread per core のランタイムにはその制約がない。

  • ジェネレータ / yield / AsyncIterator / コルーチン:こちらを参照。これらは依然として unstable で、2026年の優先事項でもない。私の知る限り、非同期イテレータという概念をどの trait で符号化すべきかをめぐっては、大きな意見の隔たりがある。しかも lending iterator の問題も絡んでくる。要するに、Python のyieldキーワードブロックのような単純な話ではない。

  • spawn_blocking:要するに「プログラムに厄介事を起こさずにブロッキングタスクをどうディスパッチするか」という話全般だ。具体的な落とし穴はこうである。うっかり Tokio のワーカースレッドをブロックしてしまっても、プログラムはおそらくまだどうにか動き続ける(work stealing のおかげで、そのワーカースレッドに残っていた仕事は他のスレッドに盗まれる)。しかしワーカープールは、その呼び出しがブロックしている間ずっと、ワーカースレッドをひとつ丸ごと失った状態になる。トラフィックが少なければ、これに気づくのは本当に難しい。

  • Pin の問題 + Move trait:まともなMove trait があれば Pinと格闘せずに済むのに、という話は山ほど書かれてきた。いつものことだが、Pinを理解するには Without Boats の記事を読むといい。

何が問題なのか?

async Rust で私が気に入らないのは、ランタイムを必要とすることだ。

なぜ? なぜランタイムが要るのか? Rust の他のものはランタイムを必要としない 1。Rust が唯一誇れるのは、ランタイム(たとえばガベージコレクタ)なしでメモリ安全を提供できることだ。ランタイムベースのモデルに後退するのは、退化のように感じられる。

ランタイムを手にした瞬間、あなたは至るところに散らばった(しかもおそらく全く読めない)優先度とスケジューリングの仕組みに身を委ねることになる。自分の関数を直接実行するのではなく、エンジンに渡し、エンジンが独自のルールで代わりに走らせる——神が望んだようには、一歩ずつコードを進めてくれない。まったくもう。

さらに悪いことに! スケジューリングアルゴリズムは必然的に最適化問題であり、トレードオフの境界上の特定の一点を選ばなければならない! 万能の解など存在しない! なんてこった! Java のガベージコレクタをチューニングするみたいに Tokio ランタイムを調整して、ほんの少し性能を絞り出さなきゃいけないのなら、Rust の性能が世界級だなんてよく言えたものだ。

まさにこれこそ、俺が抜け出したかったものだ!

というわけで。

ここまで読んでタブを閉じ、俺に厳しいメールを書くのをやめなかった人、おめでとう!

要するに、上の愚痴は全部デタラメだ。

説明させてくれ。

これらの問題(ランタイムのように感じること、スケジューラの最適化トレードオフ、複雑さ)は、並行コードに共通する問題だ。コードを並行に動かしたいなら、スケジューリングを担うランタイムが必要になる。これはほぼ並行性の定義の一部だ。

async Rust を一切使わず、スレッドに切り替えたとする。変わるのはただ一点、カーネルが Tokio ではなくあなたのスケジューラになるだけだ。スケジューラは依然として存在し、同じ問題が全部(いや、少なくとも一部は)そのまま残る!

言い換えれば、Rust が複雑さをユーザーであるあなたに露出しているからといって、Rust を責めるな。Rust は低レベル言語だ(同時に高レベルな抽象化も持ち、高性能を追求する野心もある)。こうした複雑さを露出させるのはまさに本職だ。Rust はあなたの代わりにスケジューラを選べない。完璧なスケジューラなど存在しないし、異なるスケジューラは異なる場面に適しているからだ。

Go のような言語はずっと楽だ。Go にベアメタル級の性能を期待する人はいない。ここにはトレードオフがある——利便性のために性能の一部を犠牲にする——と誰もが理解しており、だからこそ Go には goroutines(tm)、つまりグリーンスレッドがある。

この謙虚な Gopher は身の程をわきまえ、慌てず、潤いながら生き、繁栄し、sum types とパターンマッチが自分を呼んでいることに永遠に気づかないままで幸せだ。

Go と違い、Rust の目標はもっと高い。

余談:async Rust の性能

ついでに言うと、async Rust はすでに「これ以上ないほど良い」という見方がある。具体的にどこが良いのかは説明できないが、なんとなくそんな印象だ。事実は違う。もちろん「これ以上ないほど良い」という言い方は曖昧すぎて、真偽を問うことすらできないが、ここには少なくとも一つの本物の誤解がある。(「完璧なスケジューラなど存在しない」という誤解はさておき。)

例えば、よく言われるのが、Rust の future は「すべてのデータをちょうど収められる大きさの、サイズ最適な状態機械にコンパイルされる」というものだ。この話は Aaron Turon の『Zero-cost futures in Rust』と Without Boats の『Why Async Rust?』に由来し、その後ひとりでに広まったのだろう。考え方としては正しいが、長年未解決の問題が山ほどある。例えば引数を yield ポイントをまたいで保持すると、オーバーヘッドが倍になる:

async fn test(arg: [u8; 8192]) {
    wait().await;
    drop(arg);
}

async fn wait() {}

fn main() {
    // Expected: 8194 == 2 + 8192
    // Actual: 16386 == 2 + 2 * 8192
    println!("{}", std::mem::size_of_val(&test([0; 8192])));
}

Rust Playground へのリンク。面白い事実:drop(arg); の呼び出しを削除して、何が起きるか見てみよう。

外から見ると、このオーバーヘッドは「本来あるべき」の倍で、しかも最適化されずに残っている。実に馬鹿げている。もっと不気味なのは、future を引数として渡すと、サイズの膨張が指数的になることだ。(Ding に感謝。彼はこの問題を根本から解決しようと勇敢に戦い続けている。このスレッドを参照。)

では、何が問題なのか?

第一に、並行性(どのような形であれ)は必須だ。

第二に、並行性は常にランタイムを必要とする。ランタイムなしに並行性はあり得ない。ランタイムはどこに住み、スタックは誰が管理するのか?

第三に、ランタイムを避けたいなら、最善の策は自分でイベントループを書くことだ(おめでとう、これで自分でランタイムを保守することになる、よくやった)、あるいは「スレッドを直接使う」ことだ(おめでとう、これでカーネルをランタイムとして使うことになり、しかもカーネルスレッド——Tokio の専用タスクより桁違いに重い——を相手にすることになる、よくやった)。

第四に、Go のように「ランタイムを同梱する」ことはできない。Rust の埋め込みを難しくし、FFI 境界でコストを払うのでなければ。(本当に、あの記事を読めばなぜか分かる。Rust の初期の green threading を削除した理由については RFC 230 を参照。)

そして第五に、すべての選択肢を試した末、今日のこの状況に落ち着いた。ランタイムは一つの crate だ。それを取り込み、初期化し、おそらくあちこちに渡して回り、それを使って future をスケジュールする。

素晴らしい。

最初からあったものを、我々はもう一度発明し直したのだ。

俺はここに関わる設計上の決定を整理するのに多くの時間を費やし、async Rust の粗をできる限り探そうとした。しかしどのステップも「ああ。うん。これは合理的だ。筋が通っている。彼らがこうした理由が分かる」になった。

async Rust の問題の半分を魔法のように消し去る、まだ発見されていない抽象が存在するのかどうか、俺にはまだ分からない。だが、存在しないのではないかと考えている。その多くは並行性に共通する問題だ。スケジューラはどこかに住まなければならず、Rust は性能を利便性と引き換えにできない。

これらを二種類に分けるのは合理的だ。一つは並行性そのものの問題、もう一つは「スケジューラが crate に住む」という決定から生じる問題だ。

crate の話は、要するに Tokio デフォルト主義とランタイム不可知性の欠如の根源だ。Send + 'static bound を扱わなければならない理由でもある。Send bound はジェネリックコードの中で増殖する。言語自身、あなたのランタイムがタスクをスレッド間で移動させるかどうかを知りようがないからだ。

多くの面で、スケジューラがライブラリコードの中に住んでいることは問題ではない。Rust にとっては正しいトレードオフだ。

一つ重要なのは、green threads を捨てた決断が、当時まだ揺れていた Rust の硬い設計原則を事実上確定させたことだ。一時期は、Rust が C++ の代替路線——組み込み no_std 環境で使えて「ゼロコスト抽象」を備える——に進むのかどうか、はっきりしていなかった。

後から見れば、この方向は正しかった。おかげで Rust は C#、D、Swift と並んで立っていられる。C++ を本当に置き換えられて、しかもメモリ安全な言語として。「もし」も「ただし」もない。人目を盗んでガベージコレクションや参照カウントを裏口から忍び込ませる試みもない。ベアメタルで、C++ にできることは全部できて、しかもメモリ安全だ。

もし Rust が green threads に賭けていたら、あのパラレルワールドがどうなっていたかは分からない。だが正直に言って、それは Rust が Linux カーネルに入る世界ではなかったと思う。技術的にも政治的にも、ありそうにない。

じゃあ何が残る?

本題の不満に戻ろう。

ユーザーランドスレッド?

俺たちが自分のプログラミング言語の内部で、新しいランタイム(Tokio や Go のやつ)を何度も再発明して議論し続けるのに、どれだけの時間を費やしてきたか。これ、馬鹿げているほどひどい。

一つ議論を呼ぶ意見を言おう。もしかしたら俺たちは「ただ」カーネルスレッドとスケジューリングを直す方法を考えるべきなんじゃないか? あるいは新しい種類のカーネルスレッドを導入して、「スレッドをそのまま使う」のが突然効率的になって、コードを「そのまま」マルチスレッド化できるようにするとか?

上の第三步の前提は、カーネルスレッドが高価で、カーネルスレッドに移ると何かを失うということだ。理想の世界では、そうである必要はないはずだ。どうやるにせよ、カーネルに馬鹿みたいに安い Go 風のスケジューラと、安くて単純なスレッドを用意してほしい。

不可能だと思うかもしれないが、俺たちはより低い抽象レベルにいて、カーネルに近い。言い換えれば、俺たちを縛る制約はより少ないのであって、多いのではない。Goroutine はカーネルの上で動く抽象だ。goroutine を効率的で安く速くできるなら、カーネルも何か効率的で安く速いスレッドの仕組みをサポートできるはずだ。

明らかにできる。何度か試みられている。たいてい「M:N ユーザーランドスレッド」みたいな名前で呼ばれる。今日はそれを調べる元気はないが、どんな種類のプログラムが恩恵を受けて、制約がどこにあるのかを突き止めるのは面白い問題だ。

実際には、カーネルはあなたのコードについて、言語専用ランタイムほど多くを知ることは永遠にできない。これがスレッドをスケジュールする能力を制限し、カーネルが必要とする余分なデータを全部渡す方法を考え出せない限り、言語専用ランタイムがほぼ常に勝つことを意味する。

どちらにせよ、理想的な状況でも、これはみんながスレッドで何もかもやっていた世界に戻るだけだ……性能が高いだけで。それはより良いのか? 分からない。人によるだろう。

Tokio

ああ、忘れないうちに。これももう一つの不満だ。Tokio のあの暗黙のアンビエント実行モデルはちょっと腹立たしいし、Rust の黄金律の精神を損なっている 2。

spawn された Tokio タスクは、環境に潜在するスレッドローカルなコンテキスト/エグゼキュータに魔法のように自分をくっつける。そんなエグゼキュータが存在しなければ、実行時 panic で終わる。例:

fn record_metric(_value: u64) {
    tokio::spawn(async move {
        // do stuff
    });
}

fn checksum(data: &[u8]) -> u64 {
    let sum = data.iter().map(|&b| b as u64).sum();
    record_metric(sum);
    sum
}

#[tokio::main]
async fn main() {
    let data = vec![1u8; 1024];
    checksum(&data); // fine

    let (tx, rx) = tokio::sync::oneshot::channel();
    rayon::spawn(move || {
        let _ = tx.send(checksum(&data)); // panics, process abort
    });
    println!("{:?}", rx.await);
}

実際には、これはすべての Tokio 関数の間で「隠し引数」が受け渡されているようなものだ。ただし、あの呪文を唱え忘れると(つまり Tokio ランタイムの外で Tokio を使おうとすると)、実行時エラーになる。副作用! 隠れたグローバル変数! 隠れた動的スコープ! ひどい! 3

「理想の世界」では(俺みたいな変態以外は多くの人が嫌がるだろうが)、エグゼキュータをコードを通してタスクを spawn する場所まで引き回さなければ、タスクを spawn できない。

もしみんなが本当に引き回さずに Tokio エグゼキュータを手に入れたいなら、それはグローバル変数に置いて、呼び出し地点でそこから取るべきだ。欲しいのは grep できる性質であって、隠すことじゃない。

Zig

(ここで「Zig の話をする」ビンゴの加点をいただく。)

この原則的なやり方が実際にどう見えるかは、Zig を見れば分かる。I/O インターフェースを、それを必要とするすべての関数に渡す。非同期性と I/O は、渡したその具体的な実装に固有の属性になる。

const std = @import("std");
const Io = std.Io;

fn saveData(io: Io, data: []const u8) !void {
    const file = try Io.Dir.cwd().createFile(io, "save.txt", .{});
    defer file.close(io);

    try file.writeAll(io, data);

    const out: Io.File = .stdout();
    try out.writeAll(io, "save complete");
}

Loris Cro のこの話題に関する記事に例がある。

I/O インターフェースが runtime のための契約を定義する。

もし全員が厳密にこのインターフェースを引数として渡せば、原則として三つの魔法のような利点が得られる:

  1. ブロッキング操作が起こりうる場所を正確に追跡できる。
  2. function coloring 4 を避けられる。ただの関数引数だ。
  3. すべてのライブラリコードが executor や scheduler に無知になる。I/O インターフェースの異なる実装をコードに渡せる。エコシステムが分裂しない。

タイトルの問いに戻ろう。scheduler をあちこちで渡す引数の中に置くのは、正しいと感じる。冗長ではあるが、山ほどの問題を解決する。なんと、ビンゴカードの問題の三分の一くらいは消せるだろう。

scheduler はここにいてほしい。カーネルの中でもなく、暗黙の実行コンテキストの中でもなく、引数として渡せて、必要なら差し替えられるものとして。

とはいえ、Zig はまだ安定していないし、async を正しく作るのはめちゃくちゃ難しい。要するに、この道はまだ実戦で試されていない。

Zig チームがそれを成し遂げられるなら、Rust がこの方向に進化する実験にはやる価値があると確信している。

全面的な移行となれば Rust 標準ライブラリの API を書き直すことになる(たとえば std::fs)ので、まず間違いなく選択肢には入らない。だが crate エコシステムはその制約を受けない。ワンチャンあるかもしれない。

Zig チームがこのエキサイティングな時期にふさわしい成功を収められるよう願っている。

ここまで読んだなら、メール通知を購読するか、このブログを RSS リーダーに追加してほしい。


  1. コメント欄には必ず少なくとも一人、Rust の unwind / panic スタックも runtime の一種だと指摘する人が現れる。彼らは正しい。だがそれでもパンツを引っ張られるべきだ。 ↩︎
  2. わかってる、わかってる。Rust に effect system はない。コードが panic するかどうかすら追跡できないのに、副作用の追跡なんてなおさらだ。求めすぎなのは承知だが、コンパイル時にこれを追跡してくれないのが気に入らない。 ↩︎
  3. そう、Tokio には Handle::spawnがある。だがデフォルトなのは暗黙の形式のほうだ。 ↩︎
  4. より正確に言えば、function coloring は特別な言語機能ではなく、関数に渡す引数としてエンコードされている。これが function coloring に当たるのかどうか、結論は永遠に出ない。 ↩︎

出典: Here Comes The Moon← ホームへ戻る