Skip to content

Instantly share code, notes, and snippets.

@sile
Last active June 2, 2022 15:19
Show Gist options
  • Star 24 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sile/87f0732236e2ebc6d108ac95a2d444c6 to your computer and use it in GitHub Desktop.
Save sile/87f0732236e2ebc6d108ac95a2d444c6 to your computer and use it in GitHub Desktop.
Rustの『RFC 2033: 実験的なコルーチン』の要約メモ

RFC 2033: experimental coroutines

関連: RFC 2394

要約

  • コルーチンをRustに導入するための実験的なRFC
    • 正式なものはまた別のPRで
  • コルーチンに対するアイディアの共有とnightlyで試せるようにするのが目的

動機

Rustの2017年のロードマップの中では頑強でスケーラブルなサーバを書けるべきという項目があった:

問題は、どうやってasync/awaitを実際に実装するか。

次の二つの問題に分割可能:

  • Async/awaitの実際の文法をどうする?
    • 新しいキーワードの追加?
    • それとも既存文法の拡張?
  • Async/awaitが生成したfutureの中断をどう実現する?
    • sub-futureの完了を待っている時に、どうやってfutureのスタックを保存し、後の処理再開をサポートするか

このRFCの主要な関心は二番目。 ただし、まずは文法の話からするよ。

Async/await構文

現時点では、新しいキーワードは導入しない方向:

  • いろいろ理由はあるけど、一番は柔軟性
  • (キーワードでは無い方が)試行錯誤が容易

代わりにマクロで実現:

  • 手続きマクロmacro_rules!の両方を使う
  • 手続きマクロを使えば、キーワード導入に近い自然な知見が得られるけど、nightly限定

それを踏まえると、構文は以下のようになると期待される:

#[async]
fn print_lines() -> io::Result<()> {
    let addr = "127.0.0.1:8080".parse().unwrap();
    let tcp = await!(TcpStream::connect(&addr))?;
    let io = BufReader::new(tcp);

    #[async]
    for line in io.lines() {
        println!("{}", line);
    }

    Ok(())
}

注目すべき個所:

  • #[async]を使って"futureを返す関数"をタグ付け:
    • proc_macro_attributeディレクティブを使えば実装可能
    • 実際の返り値がResultではなく、futureになるように書き換えてしまう
  • await!#[async]関数内で利用可能:
    • あるfuture上のブロックする
    • 上の例だと、
      • await!によりTcpStream:connectを「接続したTCPストリーム」を返すfutureと見做せるようになる
      • TCP接続が利用可能になるまでprint_lined関数はブロック(! スレッドリソースの利用権は別のコルーチンに移る)
    • ?は通常(同期処理)と同じセマンティクスで、エラーを伝搬する
  • #[async] forループ(非同期用糖衣構文の一例):
    • futuresクレートのStreamトレイト上の走査

この文法の意図:

  • Rustの既存文法に可能な限り馴染み深いものにする
    • 制御フローの分断は最小限に
  • そのためにプログラマが行う必要があるのは、以下の二つだけ:
    • ブロックするかもしれない関数を#[async]でタグ付け
    • 実際にブロッキングが必要な個所ではawait!を使用する

その他に重要な点は、async/awaitが公開するAPIが非常に小さい、ということ:

  • このRFCは「実験的なコルーチン」のためのものだけど、この文法ではコルーチンについて全く言及していない
  • これは意図的な設計上の決定:
    • #[async]およびawait!の実装の柔軟性を最大限に高めるため

Async/await内での中断

だいたいの構文は分かったので、次は「どうやってfutureを中断するか」に進む。

上述のfunctionは、次のようにdesugarされる:

fn print_lines() -> impl Future<Item = (), Error = io::Error> {
    // ...
}

つまりFutureを何らかの方法で生成する必要がある。

(futuresクレートの)コンビネータを使えば、以下のように書ける:

fn print_lines() -> impl Future<Item = (), Error = io::Error> {
    lazy(|| {
        let addr = "127.0.0.1:8080".parse().unwrap();
        TcpStream::connect(&addr).and_then(|tcp| {
            let io = BufReader::new(tcp);

            io.lines().for_each(|line| {
                println!("{}", line);
                Ok(())
            })
        })
    })
}

残念なことに、この変換は実際には極めて困難だし、性能的に最適でもない。

ただ上のコードから、期待されるセマンティクスに対する考察は行える:

  • print_lines関数は、呼ばれても実際には何もしない
    • lazyによって生成されたfutureを即座に返すだけ
  • Future::pollは、
    • 初回呼び出し時には、addrを生成してTcpStream::connectを呼ぶ
    • それ以降の呼び出しは、処理がTcpStream::connectが返したfutureへと委譲される
  • TCP接続が完了したら、次のコンビネータ上で処理を続ける (i.e., ソケットから各行を読み込む)

上のdesugarの主要な利点は、隠れたアロケーションが無いこと:

  • コンビネータ(i.e., lazy, and_then, for_each)にはその点でのオーバヘッドがない
  • ただ問題は、ネストした状態機械の束が生成されること(各コンビネータにつき一つの状態機械ができる)
    • 必要な分よりも、若干多めのメモリ表現になる可能性がある
    • 追加の走査コストも必要
      • ! 関数はfuture群から構成される一つの木を生成し、目的のfutureのpollを実行するには、ルートからそこに至る全てのfutureのpollを呼び出す必要がある
  • あとは#[async]が付与された任意の関数から、コンビネータ群を生成するのは困難
    • unusualな制御フローや全てのパラダイムをどうやってサポートする?
    • (! 個々のコンビネータは、ある特定の制御フロー等しか表現できない)

最終的な解法に進む前に、こういった問題に対する一般的な解法を見てみる:

  • グリーンスレッドを使うことで「futureの生成」に纏わる問題を回避(するのが一般的)
  • グリーンスレッドなら、通常のコンテキストスイッチの仕組みを使えば、中断も自然と実現できる
    • 各グリーンスレッド用のスタックを、そのまま退避・復元すれば良い
  • ただし残念ながら、この方法はRustの目的である「ゼロコスト抽象」とは合い入れない
    • グリーンスレッドのスタック管理はコスト高になり得る

この時点で、

  • 「非同期処理用の気の利いた構文」と「#[async]をfutureに変換する(大変だけど)おおまかな方法」が得られた
  • こういった問題に対する伝統的な解法も説明(グリーンスレッド)
    • => ただそれは独自のコストを有する
    • => Rust用には「future用に最適な状態機械を生成するための簡単な方法」が必要

"スタックレスコルーチン"としての状態機械

  • ここまではコルーチンに対する言及なし
    • "なぜコルーチンが必要か?"を述べるための下地は整った
  • コンパイラが"スタックレスコルーチン"というものを使えば、状態機械の生成に纏わる上述の問題は解決するよ!
  • ただコルーチンはfutureよりも低レベル:
    • スタックレスコルーチンは、future以外に、イテレータのような言語プリミティブ用にも使用可能
  • #[async]をどのように実現できるか見てみましょう
    • 注意: 以下のコードは、コルーチンの構文を規定するものではない(あくまでも雰囲気を掴むための例)
fn print_lines() -> impl Future<Item = (), Error = io::Error> {
    CoroutineToFuture(|| {
        let addr = "127.0.0.1:8080".parse().unwrap();
        let tcp = {
            let mut future = TcpStream::connect(&addr);
            loop {
                match future.poll() {
                    Ok(Async::Ready(e)) => break Ok(e),
                    Ok(Async::NotReady) => yield,
                    Err(e) => break Err(e),
                }
            }
        }?;

        let io = BufReader::new(tcp);

        let mut stream = io.lines();
        loop {
            let line = {
                match stream.poll()? {
                    Async::Ready(Some(e)) => e,
                    Async::Ready(None) => break,
                    Async::NotReady => {
                        yield;
                        continue
                    }
                }
            };
            println!("{}", line);
        }

        Ok(())
    })
}
  • yieldキーワードの追加が最も重要:
    • コンパイラに、コルーチンが後の再開のために一時停止する、ことを指示
    • 上の例ではfutureがNotReady状態の時に呼ばれている
  • futureを直接触ることは無い
    • 以下のようなCoroutineToFutureがコルーチンからfutureへの変換を行ってくれる
    • 注意: 詳細は省かれている(コルーチンからfutureへの変換が簡単に行えることを示すことが目的)
struct CoroutineToFuture<T>(T);

impl<T: Coroutine> Future for CoroutineToFuture {
    type Item = T::Item;
    type Error = T::Error;

    fn poll(&mut self) -> Poll<T::Item, T::Error> {
        match Coroutine::resume(&mut self.0) {
            CoroutineStatus::Return(Ok(result)) => Ok(Async::Ready(result)),
            CoroutineStatus::Return(Err(e)) => Err(e),
            CoroutineStatus::Yield => Ok(Async::NotReady),
        }
    }
}

これでコード生成の問題は解決!

ここまでのハイライト:

  • 2017年のロードマップの一つは、Rustをサーバ用途で使えるものにすること
  • このゴールの大きな部分は、Rust (with futures)にasync/await構文を実装すること
  • async/awaitは、手続きマクロを用いた比較的直截的な構文を備える
  • 手続きマクロによる実装は、スタックレスコルーチンを使うことで、最適なfutureを生成可能

別の言い方をすれば、コンパイラがスタックレスコルーチンを実装すれば、もうasync/wait構文は実現している!

スタックレスコルーチンの特徴

サーバや非同期I/O(i.e., 実際の用途)の観点からは、少し離れてスタックレスコルーチンを見てみる。

高レベルでは、コンパイラでのスタックレスコルーチンは、次のように実装されるだろう:

  • 暗黙的なメモリ確保はなし
  • コルーチンは、コンパイラによって内部的に状態機械に変換される
  • 標準ライブラリは、コルーチン言語機能をサポートするために必要なトレイト/型を持つ

これ以外には、現時点ではあまり制約はない:

  • async/awaitの実現には、スタックレスコルーチンの構文は全く重要ではない
  • コルーチンの実装詳細は#[async]await!の外には漏れていない
    • これらはFutureに対する操作のみを要求
  • 今のasync/await構文に合意できれば、もうコルーチンに関する議論を始められる
  • また、コルーチンの実装は、簡単に差し替えられるようにすべき

詳細なデザイン

  • 必要なものは揃ったので、nighlyにasync/awaitを入れて遊び始めることはできる
  • ただしこのRFCは、明確に"実験的"と述べられている:
    • 安定化のためのリファレンスになることは意図していない
    • スタックレスコルーチンも、安定機能になるためには、別のRFCを経るべき
  • コルーチンは大きな機能なので、nightlyでいろいろとテストしたりデータを集めたりすることが必要:
    • コンパイラには何かしら組み込んで使えるようにする必要がある

このRFCは、以前のRFC 1823RFC 1832に比べて、主にジェネレータの実装詳細の記述がない、という点で異なっている。

  • コルーチンに関連する構文の細部についての"自転車置き場の議論"を避けることを意図
  • コルーチンの安定化のためには必要だけど、async/awaitレイヤーでは不要なので、もっと経験を積んだ後に決定すれば良い
  • 別の言い方をすれば、このRFCの意図は「手続きマクロとコルーチンを用いたasync/awaitの追加に焦点を当てている」ことを強調すること

ただし、スタックレスコルーチンの高レベルのデザインゴールに言及しておくことは有用:

  • コルーチンは、libcoreと互換性があるべき:
    • メモリ割当や組み込み関数等による実行時サポートを要求すべきではない
  • 結果として、コルーチンは(再開時に前方に進む)状態機械にコンパイルされる:
    • コルーチンがyieldした時はいつでも、後から再開可能な自身を状態に退避する
  • コルーチンは、クロージャーと同様に機能すべき:
    • 変数の捕捉が可能
    • 動的ディスパッチのコストを押し付けない
    • 各コルーチンは独立してコンパイルできる
  • コルーチンは、外部との通信方法を提供すべき:
    • 例えば、yield時には値を渡せるべき (! for イテレータ的な用途)
    • 再開時に値を受け取れると便利かも

@Zoxcがrustcをフォークして実装したジェネレータは参考になる:

注記:

  • 実験的なRFCを扱った経験がまだ少ない
  • このRFC自体は軽量にしてフットワークを軽くしていきたい
  • ただ、コーナケースを含んだテストを前もって考えておくこと望ましい:
    • コルーチンの初期実装が考慮すべきテストのリストを載せておく
    • 安定化の前に考慮されるべき未解決も問題に関するリストも載せておく

未解決の問題 - コルーチン:

  • コルーチンの正確な構文は?
  • コルーチンは、文法的・機能的にどのように構築されるべきか?
  • コルーチンに関連するトレイトは?
  • "コルーチン"という名前は適切?
  • コルーチンは、イテレータを実装するために十分?
  • "コルーチントレイト"やFutureトレイト、Iteratorはどのように相互作用するか?
    • 一貫性を持たせるために"ラッパー構造体"は必要か?

未解決の問題 - async/await:

  • 構文拡張の多用は、"サブ言語"の作成と見做されるか?
    • async/awaitの使い方は、Rustにとって自然?
  • 非同期関数のシグネチャの正確な書き方は?
    • future的な側面に言及する?
  • Stream実装も似たような構文で生成できる?
    • コルーチンによるasync/awaitは、future向けに特殊化され過ぎていないか?

テスト - 基本的な使い方:

  • yieldを呼ばずに、即座に結果を返すコルーチン
  • yieldが一回呼ばれ、次の結果を返すコルーチン
  • 値を閉じ込め、それを結果として返すコルーチンの生成
  • 一回のyieldの後に、捕捉した値を返す
  • コルーチンの破棄は、閉じ込めた変数群をドロップする
  • コルーチンを生成し、実行はせずにドロップする
  • 捕捉した変数がそうなら、コルーチンもSendかつSync(クロージャーと同様)
  • あるスレッドでコルーチンを生成し、別のスレッドで実行する

テスト - 基本的なコンパイル失敗:

  • コルーチンは、自身が破棄されるより前に破棄されるデータは閉じ込めない
  • non-Sendなデータを閉じ込めるコルーチンは、non-Send

テスト - 興味深い制御構造:

  • 設定された回数分のforループ内でyiled
  • ifの片方の分岐ではyieldし、もう片方ではしない
  • forループ内の、一つのif分岐の中でyield
  • ifの条件式の中でyield

テスト - パニック安全性:

  • コルーチンの中でパニックしても、全てを殺しはしない
  • パニックしたコルーチンからの再開は、メモリ安全
  • パニック時に、ローカル変数は正しくドロップされる

テスト - デバッグ情報:

  • yield点の前後での変数のinspectは動作する
  • yield点の前後でのbreakは動作する

どのように教えるか

  • コルーチンが、このRFCの結果としてstableな言語機能となることはない
  • またasync/await構文を通してのみ利用されることが想定されている
  • => そのため現時点では、Rustでのコルーチンを教育するプランはない(ただ安定化の前には必要)

Nightly用のドキュメントは、unstable-bookで利用可能で、そこにはコルーチンに関する記述も含まれるだろうけど、おそらく学習のために使える網羅的なものにはならない。

欠点

  • コルーチンは、それ自体でコンパイラにとっては重大な機能
  • 設計が難しく、機能が上手く活用されなければメンテナンスコストが増えるだけの可能性はある
  • ただし、以下のように考えている:
    • コルーチンは、futuresと組み合わせて功を奏する可能性が極めて高い
    • async/await記法も、stableな困憊r機能と上手く統合できる可能性が高い

代替

このRFCは実験的なものなので、代替案は"機能に対して"というより"動機に対して"がターゲットとなる。

この話題に関する詳細な議論はオリジナルのRFCスレッドを参照して貰うとして、ここではハイライトだけを記述する:

  • "スタックフルコルーチン" (aka グリーンスレッド):
    • 昔のRustでは探求されていた戦略
    • libgreenというライブラリを持っていたけど、削除することが決まった
    • いろいろと知見は得られたよ!
  • ユーザモードスケジューリング:
    • グリーンスレッド方式の別の可能性
    • 残念ながら、現時点では主要なOS(Linux/Mac/Windows)の全て実装されている訳ではないの、候補からは外れる
  • "再開可能な式(resumable expressions)":
    • C++での提案
    • async/awaitの"ウイルス性"の問題のいくつかに対処するための試み
    • Rustに適用可能かどうかは不明瞭

いろいろな代替案はある:

  • ただし、最も"使える"ということが分かっているのは(既に知見がある)スタックフルコルーチン
  • スタックレスコルーチンは、その次に有力な候補だけど、まだ知見はない
  • => 探求や試行錯誤を行うには今が良い時期でしょう

未解決の質問

  • "実験的なRFC"の位置付けや進め方がまだ固まっていない
  • まあ上手くやっていけるだろうと期待しています
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment