2 ポイント 投稿者 GN⁺ 2023-09-09 | 1件のコメント | WhatsAppで共有
  • Rustのasync/awaitは数万件の接続を扱う大規模な並行性を想定しているが、低レベル制御と静的ライフタイム検証というRustの目標と衝突し、通常のRustとは異なる開発体験を生む
  • スレッドとチャネルは多くのソフトウェアには十分だが、C10Kのような規模では接続ごとにスレッドを割り当てる方式の負担が大きくなり、ユーザー空間タスクとランタイムスケジューリングが必要になる
  • async RustではデータをSendとして移動させるか、'static参照として扱う必要があり、asyncの伝染性によってこうした制約がコード全体で繰り返される
  • Arcはコンパイル上の問題を解決しても、オブジェクトやリソースのライフタイムを曖昧にし、再帰async、futureとtaskの違い、blocking呼び出しがランタイムスレッドを止める落とし穴が続く
  • HaskellやGoでは「async code」が通常のコードのように動作し、ランタイムとGCが違いを隠すため、この種のプログラミングではRustの明示的な制御が純粋な利点としては働かないかもしれない

並行性と並列性が必要な理由

  • 高速なプログラムには2つの要求が同時にある
    • 複数のCPUコアを使ってコンピュータ全体を活用しなければならない
    • インターネットメッセージの送信やファイルを開くといった遅い処理を待つ間も、別の作業を続けなければならない
  • 並列性は複数のCPUでコードを同時に実行する問題である
  • 並行性は問題を独立した部分に分ける方法である
  • 両者は同じではないが、プログラムを並行する断片に分ければ、それらの断片を並列に実行してコアを継続的に忙しくできる

プロセス、スレッド、チャネル

  • 並行システムを作る単純な方法は、コードを複数のプロセスに分けることだ
    • OSスケジューラが、実行可能なプロセスのタイムスライスを利用可能なCPUコアで実行する
    • シェルコマンドをパイプでつなぐ場合にもこのモデルを使う
  • プロセス方式はプロセス間通信のコストが大きい
    • 多くの実装では、データをOSメモリにコピーしてから再び取り出さなければならない
    • 共有メモリでコストを下げられるが、OSがプロセス同士を隔離してくれる利点は弱まる
  • スレッドは同じメモリを共有することでこうしたオーバーヘッドを避けるが、mutex、condition variable、semaphoreのような同期手段を誤って使うと、データ競合やデッドロックが発生しうる
  • Tony HoareのCommunicating Sequential Processesモデルは、スレッドをキューまたはチャネルで接続する
    • スレッドはメモリを共有しないため、プロセスのような隔離性が得られる
    • 各スレッドの入力と出力がチャネルとして表れるため、推論やデバッグがしやすい
    • チャネル自体が同期の役割を果たし、空なら受信側が待ち、満杯なら送信側が待つ
  • Rust標準ライブラリにはstd::sync::mpsc::sync_channelがある
  • 多くのソフトウェアでは、スレッドとチャネル、それにCPU集約ループを並列化するRayonのようなツールの組み合わせで十分である

ユーザー空間の並行性とRust async

  • Webサーバーのように数万人の同時ユーザーが接続するC10K問題では、1接続に1スレッドを割り当てる方式は限界にぶつかる
    • Linuxでは各スレッドが4kBのcontrol blockを持ち、スレッド切り替えにはOSスケジューラに入るcontext switchが必要になる
  • 大規模な並行性のために、一部の言語はユーザー空間でタスクを作成・管理する
    • ランタイムがタスクをOSスレッドプールにスケジューリングする
    • 通常はCPUコアごとに1本のスレッドが配置されるようにプールを構成し、並列性を最大化する
    • この方式はgreen thread、lightweight thread、lightweight process、fiber、coroutineなどと呼ばれる
  • RustはC#やNode.jsで見られるasync/awaitモデルを使っている
    • async fnは直接値を返さず、.awaitで結果を得るfutureまたはpromiseを返す
  • Rustのfutureは協調的スケジューリングとstackless設計のおかげで非常に小さく高速である
  • Rustはfuture抽象化を提供しつつ、プログラマに低レベルの制御を約束しようとする
    • すべてのオブジェクトと参照のライフタイムをコンパイル時に静的に検証しようとする
    • futureはコードと、そのコードが参照するデータを何千もの断片に分割し、実行開始後にしか分からない条件に応じて、いつでも任意のスレッドで実行されうるようにする
    • クライアントデータを読むfutureは、そのソケットに読むべきデータがあるときにだけ実行されるべきだが、lifetime annotationはその時点を教えてくれない
  • Rustはfutureランタイムを言語に組み込まず、Tokioのようなライブラリに委ねている
    • ユーザーは環境に合った代替手段を選ぶ自由を得る
    • しかし、Tokioが言語に組み込まれている世界を想像しても同じルールが適用されるため、この論点では副次的な詳細にすぎない

Send'staticArcが生む圧力

  • コンパイラを納得させるには、データをSendとして移動させるか、'staticライフタイムを持つ参照経由で渡す必要がある
  • asyncコードでは複数のタスクが共通状態を共有することが多いため、コピーなしでデータを移動する方式はしばしば適合しない
  • 参照も難しく、futureのライフタイムを「永遠」より短く制限してくれるthread::scopeのような対応物もない
  • asyncには伝染性があり、async関数を呼ぶ関数もasyncでなければならない
    • そのため、こうしたライフタイムと移動性の問題を一部の関数だけでなく、継続的に解決し続けなければならない
    • ランタイムでfutureの完了をblock_onで待って連鎖を断ち切ることはできるが、この方法は合成しにくく、ネストするとランタイムがpanicすることがある
  • Arcは、動的なライフタイムを複数スレッドにまたがって扱うための道具であり、borrow checkを通してコードをコンパイル可能にしてくれる
  • しかしArcを広範に使うと、オブジェクトやリソースのライフタイムが曖昧になる
    • メモリ、ファイル、ソケットのようなリソースがいつ解放されるのか明確でなくなる
    • 実際のGCが提供するallocation throughput、低いfragmentation、cycle leak回避といった利点なしに、GCに似た不利益を被る

async Rustの追加の落とし穴

  • Rustのcoroutineはstacklessなので、コンパイラは各coroutineを.await地点まで進める状態機械に変換する
    • 再帰async関数は再帰的に定義された型になる
    • 単に自分自身を呼びたいだけのユーザーでも、手動でboxingするか、async-recursionのようなcrateを使わなければならない
  • futureはawaitされるまで何もしない
  • taskはランタイムのスレッドプールで作業を開始し、完了を示すfutureを返す
  • futureの内部でblockingコードを呼べないように防ぐ仕組みはない
    • そのような呼び出しが、自身が載っているランタイムスレッドを塞ぐことも防げない
    • これはasyncを使う主要な目的と衝突する

通常のRust、Haskell、Goの違い

  • async Rustは「通常の」Rustとはかなり感触が違う
    • 落とし穴が多い
    • 理解するのも教えるのも難しい
  • ユーザーは2つの選択肢の間に置かれる
    • 抽象化が実際にどう動いているかを深く理解し、複雑なコードを書く
    • ArcPin'staticのような要素をコードのあちこちにばらまいて、うまくいくことを祈る
  • 経験豊富な開発者チームでも、新規プロジェクトでRustを使おうとしてこうした詳細に足を取られることがある
  • HaskellやGoでは「async code」が通常のコードである
    • 両言語は、分厚いランタイムの背後にblockingコードとnon-blockingコードの違いを隠している
    • ライフタイムの問題はgarbage collectionに任せている
  • この種の大規模な並行ユーザー空間ソフトウェアでは、ランタイムとGCが違いを隠す方式は純粋な利点として働く
  • Rustは大規模な並行ユーザー空間ソフトウェアには良い道具ではないかもしれず、そのような要件のないプロジェクトで使う方がよいかもしれない

1件のコメント

 
GN⁺ 2023-09-09
Hacker News のコメント
  • 高性能なメタバースクライアントを Rust で書いており、現在およそ4万行規模
    デモ動画は https://video.hardlimit.com/w/tp9mLAQoHaFR32YAVKVDrz にある
    まともなメタバースなら、ユーザーが作ったコンテンツをほぼリアルタイムで処理する必要があるため、類似のゲームより VRAM が2〜3倍必要で、サーバーからアセットを読み込むには数百 Mbps の帯域幅と複数の CPU、レンダリングと GPU アップロードを並行して行う Vulkan が必要になる
    これは「Web スケール」の並行性のように、同じアドレス空間で小さなサーバー群が別々に動く構造ではなく、高優先度のレンダースレッド、ネットワークイベント更新スレッド、アセットの読み込み・展開スレッド、移動オブジェクト・LOD・キャッシュ整理などを担当する複数のスレッドが一緒に動く構造
    Rust では、定数以外のグローバル状態なしにかなりのロックを使い、チャネルは適した場所で使い、主要なオブジェクトツリーは単一所有権で更新スレッドが主に扱っている。グラフィックスオブジェクトの接続は Arc の参照カウントで管理し、Rend3/WGPU/Vulkan を通じてメッシュとテクスチャを GPU にアップロードする
    C++ でやっていたらクラッシュと戦い続けていたはずだが、Rust ではメモリ関連のクラッシュは年に1回程度で、それもたいてい他人の unsafe コードだった。自分のコードでは unsafe を禁止しており、コンパイルは難しいものの、いったん通れば「そのまま動く」ことが多いので、並行性のデバッグよりはるかに良いと思っている
    不満もある。Rust はデータ競合には強いがデッドロックは防げないので、呼び出し経路に沿ってロック順序を追跡する静的解析器が必要だ。async は計算中心の作業や複数の優先度スレッドには向いていないのに、依存関係としてしつこく入り込んでくる。単一所有権に逆参照が付くようなよくある構造は RcWeak なしでは難しすぎるし、トレイトシステムも複雑で、オブジェクト指向が自然なアセット処理部分では重複コードが生じる
    グラフィックス中核のクレートもまだ完成度が低い。「Rust にはゲームが5本、ゲームエンジンが50個ある」というのは言語の問題ではなくエコシステムの問題で、https://gamedev.rs/ と比べても、Rust による本格的なゲーム開発はまだ不足しているように見える。スケジュールのあるプロのゲーム開発なら、Rust のゲームエコシステムはまだ準備ができておらず、おおよそ5人があと1年ほど取り組む必要があるレベルだと思う

    • この3年間、ロボットシミュレーターを Rust で作ってきたが、ほぼ同じ経験をしている。3年間で実際のランタイムバグは5件ほどで、Rust と async に問題はあるものの、全体として利点の方がはるかに大きい
    • ロック順序をたどって潜在的なデッドロックを見つけるのは良いアイデアに見える
      Linux の lockdep のように、あるロックを保持した状態で別のロックを取るかどうかを分析し、実際に止まる前でも危険な組み合わせを知らせられる。複雑なロックでは「このロッククラスは常にアドレス順に取る」といった注釈が必要になるだろうが、実装可能に思える
    • MMO でほぼ同じことを Java でやっているが、JDK がとても簡単にしてくれる。ネットワークからモデルを生成し、UI スレッドへオブジェクトを並行キューで移せばよく、かなり退屈なほど単純で、それでいて速い
    • Rust は競合状態がないのではなく、データ競合がないということ
      データアクセスの外側では依然として競合状態は起こり得る: https://news.ycombinator.com/item?id=23599598
    • 優先度の問題は比較的簡単に解ける
      複数のスレッドプールを作って future を適切にルーティングするか、自前のイベントループを書いて優先度の異なる複数のイベントキューから取り出せばよい。2つ目の方式は、タスクの実行時間に上限があるなら、CPU 100% の状況でも低優先度のタスクを進めながら高優先度のタスクにソフトリアルタイム保証を与えられる
  • async Rust については微妙なところにいる
    ArcRwLock、共有状態を大量に使うとごちゃごちゃし、特に 'static があちこちに広がり始めると、色付き関数のようにすべてを感染させるという指摘は正しい。以前は Arc を付けてライフタイム付きの借用を賢く扱おうとして、めちゃくちゃになった
    だが Rust にはチャネルもある。今書いているコードの大半は、いくつかのタスクがチャネルをサービスし、入ってきたメッセージを見て、必要なら別のタスクへ送るメッセージを適切なチャネルに入れる構造だ。オブジェクト共有はしない。複数のタスクが大きなオブジェクトを必要とする場合は、関連するクエリ結果をメッセージとして送るタスクの中に置くか、各タスクがメッセージフローの中で自分用のコピーを作るようにしている
    それでも Arc をどう使うか、ライフタイムをどう扱うかを取り上げる記事が多すぎる。async ランタイムを実装するなら必要だろうが、平均的なライブラリ利用者がなぜそこまでここに集中しなければならないのかはよく分からない

    • 批判が少し妙に感じる。async がただちにマルチスレッドを意味するわけではなく、同じスレッド内の async なら共有がないため、共有対象に魔法のようなキーワードを全部付ける必要もない
      スレッド間を行き来する場合は、共有状態を大量に持つよりチャネルでシグナルを送る。どうしても必要なグローバル状態があるなら、Arc/RwLock のような排他アクセス機構を包んだ小さな構造体を作り、呼び出し側からは単純な関数呼び出しのように見えるようにする
      Send+Sync への心配もよく分からない。経験上、ほとんどは簡単に Send+Sync であり、そうでないものは、そうあるべきでないか、そうなれないものだった。細部を考えずにコードを書きたいときもあるが、効率的な並行性・並列性が必要ならマイクロ秒とスループットが重要になり、そのときは実際のコンピューター向けのコードをきちんと書く必要がある
    • メッセージパッシングのパラダイムは本当に良く、Erlang のような言語が分散システムで優れた選択肢であることを示してきた
      しかし、このようなコーディングは、同期コードにグリーンスレッドを付けたように感じられる async JavaScript とはかなり違う。人々は慣れたやり方でコードを書こうとするため、Rust で ArcRwLock の道に入り込むのだと思う
    • Smalltalk と本物のオブジェクト指向の夢はまだ生きている
    • 大学時代に教授からこうした助言を学んだが、本当に大きな助けになった
      問題をタスク間を流れるデータとして構造化し、キューでつなぎ、共有状態を避けるやり方は、どの言語を使うにせよマルチスレッドを扱うより良い方法だ
    • 賢明なプログラマーが言ったように、「メモリを共有して通信するな。通信を通じてメモリを共有せよ」
  • async は実質的にはるかに難しい Rust であり、実際に必要とするプロジェクトはおそらく 1% ほどのはずなのに、ほとんど全員に強制されるようになってしまったのは残念
    ただし、その 1% では本当に素晴らしい。linkerd や nginx のように大量のネットワーク呼び出しを中核として処理するサービス、ゲームで膨大な数の軽量タスクを動かす場合、組み込みで協調的並行性が必要な場合には、async Rust は強力な武器になる
    ほとんどのシステム/アプリケーションレベルのコードには非同期 I/O は必要ない。REST アプリはスレッドプールで十分で、async が必要だとしてもネットワークのような小さな部分に限定し、残りはスレッドとチャネルでつなぐ 混合モデル がたいてい適している
    Rust コミュニティは async をあまりに無分別にあちこちで使ったため、ユーザー体験のよいブロッキング I/O の Rust がエコシステム内で二級市民になってしまった。Web フレームワークも Axum、Warp のようなよく設計された非同期フレームワークはいくつもある一方で、ブロッキング側は tiny_httprouilleastra のように選択肢がずっと限られている

    • 核心は、Rust が コルーチン の実装を誤ったことにある
      スタックレスコルーチンを選んだことで async/await と色付き関数問題が生まれ、記事で述べられている摩擦が生じた。Go はスタック付きコルーチンを使っているため、こうした問題はない
      Rust も当初はスタック付きコルーチンを検討していたが、コルーチンのプリエンプティブなランタイムが必要でコストが大きいと見て、スタックレスモデルに進んだ。しかし大半の人はランタイムなしの async Rust を使わず Tokio を使っており、Tokio は避けようとしていたランタイムが行う仕事を実質的にほぼすべて担っている
      そのため、多くの async Rust ユーザーは両方の悪いところを抱えることになっている。組み込み方面では非常に薄いランタイムで async Rust を使うこともあるが、その数は少なく、彼らでさえ完全に納得しているわけではない
    • 自分のプログラムに Tokio がまた依存関係として引き込まれているのを見た。直接使ってもいないのに、あるクレートの自分が使っていない関数が reqwest を取り込み、それが h2 を取り込み、さらに tokio を取り込む
    • プラットフォームが 仮想スレッド をサポートしているなら、async を使う理由があるのか気になる
      Java を使う立場としては、非同期パラダイム全体を捨てて、ブロッキングで問題ない仮想スレッド上のブロッキングモデルへコードを書き直そうとしている
  • async があまりに多くのクレートに広がっているため、プログラム全体が async になるか、少なくとも多くのことを Tokio に依存するようになる
    Web サーバーが欲しければ async + tokio でなければお断り、という感じで、SQL コネクタも非同期を望まないなら自分で書くしかないような雰囲気だ。それぞれが async のもたらす問題を別々の方法で解決していて、async クロージャのようなものはコンパイラに地獄の門を開くように感じる
    Rust 自体とコンパイラが問題解決を助けてくれる点はよいが、エコシステムが「async でなければ自作しろ」に近いのは十分ではない

    • 標準ライブラリや futures クレートに、よりよい 非同期プリミティブ があれば、苦痛をかなり減らせたはずだ
      エグゼキュータが実装すべきトレイトや、同期コードから非同期コードを実行するための基本的なブロッキングエグゼキュータのようなものが必要だ。今は複数の非同期ランタイムをサポートするライブラリを作るだけでも苦行なので、結局 Tokio だけをサポートするか、せいぜい async-std を追加する程度になってしまう
  • async Rust の専門家ではないが、今月同期 Rust を数千行書いて感じたのは、rustc があるアプローチを難しくしているときは、たいていそれなりの理由があり、似た結果をよりよい方法で得る道があるということだ
    言語を学んでいる途中なら、まずは普通の 同期コード、ループと条件分岐、借用規則に慣れることを勧める。async は実装だけでなく、「非同期とは何で、ユーザーにどう見えるべきか」という哲学的なレベルでも、まだ大きく発展中だ
    コンパイラはトレイトに大きく依存しているが、トレイトが async を扱う機能は安定化されていない。たとえば https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i... のような取り組みがある
    トレイトの非同期機能が安定化されていないのなら、Rust の非同期コードがまだきれいではないと攻撃するのは、結局、いずれ完成する本の初期草稿を批判するのに近い

    • 「よい 非同期 API 設計」とは何なのか気になる。完全に非同期中心にしつつ、拡張可能で保守可能で理解しやすいサーバーを設計するなら、どのような姿になるのだろうか
      非同期がコードベース全体へ広がるのも、どう防げるのか悩ましい
      現在の構想は、I/O スレッドが liburingepoll のシステムイベントを「submit」と「handle」の 2 段階に分けて別のコンポーネントへ送る構造だ。たとえば tcp-connection を作ると、「書き込み可能」「読み取り可能」のような非同期イベントを購読でき、書き込み可能イベントは通常の mutex で満たされたバッファからデータを取り出して EPOLLOUT/io_uring_prep_writev で送る
      スレッド間のイベント伝達には、LMAX Disruptor パターンの複数生産者・複数消費者リングバッファを使える。アプリケーションスレッドやスレッドプールは、それぞれイベントループを持ってこのリングバッファを処理する
      非同期イベントの発火順序を表現する構文にも取り組んでいて、Bash パイプラインのような見た目で statelines と呼んでいる: initialstate1 initialstate2 = state1 | {state1a state1b state1c} {state2a state2b state2d} | state3
    • 安定化されていないなら、本番環境でも使うべきではない
    • Rust 初心者だと決めつけているコメントが面白い。むしろ筆者の経験のほうが彼らより多いかもしれない
  • Arc の寿命は不明なのではなく、どこでどのように保持されるかで決まる
    この記事とのずれは、筆者が Rust を学んで言語に合わせて作業するというより、ガベージコレクションのような以前の思考モデルを Rust に無理に当てはめようとしているところから来ているように思える。新しい言語を学ぶときによくある落とし穴だが、Rust では特にさらに頻繁につまずきやすい

    • そういう意味なら、ガベージコレクションシステムのオブジェクト寿命にも「参照されている間」という下限はある
      しかしそれは、コンパイル時にオブジェクト寿命を静的に制限しようとする借用チェッカーの目標とはほぼ正反対である
      実際にはほぼ逆だった。C、C++、Rust でシステムプログラミングを約10年やった後、現在の職場で Haskell を多用するようになり、大きな言語ランタイムとガベージコレクションが、ある問題領域では怪物ではないという点にかなり目を開かされた
    • 批判のかなりの部分がそんな感じだ。async 変換が、非同期ではないコードでコンパイラができる最適化をどのように妨げるのかについての記事だと思っていた
      Weak と格闘しているというくだりは、複雑な所有権構造を作ろうとしているように見えるが、これは Rust 全般で簡単なことではない。私は弱いスマートポインタを非常にまれにしか使わない
      チャネルはほとんど言及されていないが、非同期コードや非同期・同期コードの間をつなぐときに、プログラムの異なる部分を通信させる主な道具である。Notify やセマフォのようなシグナル抽象化もある
      mutex は遅く、ボトルネックになりやすく、共有状態はすぐに複雑になる。これは昔から知られている事実だ。問題はそもそも BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORROR のような構造にあるのかもしれない
      非同期コンテキストでブロッキングコードを呼ぶことを防げないという指摘は妥当だが、必要なら tokio::spawn_blocking のようなもので比較的管理可能である
    • 参照カウントもガベージコレクションの一種である https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...
      筆者は Arc が何であり、どのように動作するかを知っている可能性が高く、要点は Rust の async では同期コードよりも通常の RAII の代わりに Arc をはるかに頻繁に使うことになる、という点に近い
      プログラムオブジェクトの90%が参照カウントされるなら、多数の小さなヒープ割り当て・解放とアトミック操作のコストを払うより、トレーシングガベージコレクションを使うほうがよいかもしれない。Tokio のチュートリアル例も似た方向を示している: https://tokio.rs/tokio/tutorial/shared-state
      Rust で実際のトレーシングガベージコレクションが、HTTP サーバーのような一般的な非同期アプリケーションを意味のある形で高速化できるのか気になる: https://manishearth.github.io/blog/2015/09/01/designing-a-gc...
    • Arc の寿命はランダムなのではなく、静的には分からないのである
    • Rust の Arc は移動したり借用したりでき、参照カウントに触れずに使うこともできる
      多くの場合、暗黙的な参照カウント言語のオブジェクトよりはるかに安価である
  • Rust は好きだが、async はめちゃくちゃで、同期コードを書くように非同期コードを書くことはできない
    両者を混ぜるのは悪い考えだという確信が次第に強まっており、すべてを同期式のままにして async チャネルプリミティブだけを提供する Go 的なアプローチが正しいのかもしれない
    今、Future を実装する構造体から同期メソッドを呼び出すようにロジックを配線しているところだが、かなり興味深い挑戦である。ゼロコストの非同期抽象化をユーザーにとってある程度簡単にすることはできても、その苦痛はライブラリ開発者が引き受けることになる

    • 最後の点には同意しない。async はエンドユーザーにとっても間違いなくつらく、Rust の中核機能である寿命と明示的な型が抜けた別言語を使っているような感じで、そこに Pin が大量に振りかけられているように思える
      スコープ付きのファイバーを実行できないため、結局 Arc を大量に付けることになり、Pinunsafe なしでは使いにくく、非同期関数のごく小さな変更がコードベース全体の future を !Send にしてしまうことがある
    • ライブラリ開発者はユーザーよりも複雑さを引き受ける余力が大きい。基盤インフラを作る熟練開発者にそうした作業を任せるのは正しい方向である
    • Rust 用の wasm VM の中に、透過的な M:N スケジューリングのように見えるものを提供している例を見たことがあり、その方式なら async の難しさの大半を解決できそうだ。今後どう発展するか見守る必要がある
  • Async Everything は悪い言語である
    async/await は、JavaScript にまともなブロッキングスレッドがない問題を直そうとしたひどいアイデアで、今ではあらゆる言語に継ぎ足されている。言語とライブラリエコシステムを二分し、今後も長く苦痛を生み続けるだろう
    JavaScript の外でマルチスレッドをやったことがある人なら、アクターや通信する逐次プロセスがマルチスレッドに最適な方式だと知っている
    Joe Armstrong の論文でも、マルチスレッドプログラムを理解する唯一の方法は、各スレッドごとに厳密に逐次的なコードを書き、複数スレッドのコードを一か所に混ぜ込まないことだと説明している。問題領域における実際の並行活動の一つが、プログラミング言語上の並行プロセス一つに正確に対応してこそ、概念上のギャップは最小になる: https://erlang.org/download/armstrong_thesis_2003.pdf
    Java の Project Loom を実装した Ron Pressler による async/await 批判もよい: https://www.youtube.com/watch?v=oNnITaBseYQ

    • JavaScript を嫌うのは楽しいが、Ryan Dahl が Node.js を初めて紹介した発表を見返すと興味深い: https://www.youtube.com/watch?v=EeYvFl7li9E
      彼は JavaScript 自体についてはかなり両義的で、主な目的は epoll() の入出力イベントループを、目を突きたくならずに扱える抽象化を見つけることだった。その前にも多くの別方式を試していた
    • async/await は実際には JavaScript ではなく C# から始まった
      C# の Anders Hejlsberg は TypeScript も作り、TypeScript のクラス、アロー関数、async/await といった機能が結局 ES6+ に取り込まれた
      単一スレッドのイベントループである JS/TS では優れた解決策だったと思う。しかし言語が低レベルになるほど抽象化としては悪くなるため、ここで述べられている async Rust 批判の大半は妥当だ
  • 記事は async Rust の複雑さと難しさをよく説明しているが、Rust の中核的な哲学の一つが 性能を犠牲にしないメモリ安全性 である点も重要だ
    Rust の非同期パターン、とくにコンパイラがデータ安全性を保証できるようにする仕組みは、この哲学をよく示している。複雑さはあるものの、開発者にデータと実行フローを深く考えさせる、より安全な並行性モデルとしての価値がある
    すべての大規模な並行ユーザー空間アプリケーションにとって Rust が正解とは限らないが、堅牢性と安全性が最優先のシステムでは、このトレードオフは正当化され得る。エコシステムが発展するにつれて、こうした苦痛を減らす抽象化やライブラリもさらに出てくる可能性が高い

  • async ベースのロックフリー Rust を大量に書いている。主な問題は Tokio の future が 'static である点で、これは Rust エコシステムの奥深くに埋め込まれた設計ミス、つまり メモリリークは安全である という決定に由来する
    このため、future が適切に片付けられることを静的に保証できない。ある非同期タスクを生成したとき、誰かが std::mem::forget で future を忘れてしまうと、その future が推移的に渡した参照がまだ生きていることを借用チェッカーは認識できない
    Arc をあちこちにばらまくよりは、こうした unsafe クレートを使っている: https://docs.rs/async-scoped/latest/async_scoped/
    これで C++ なら作っていたであろうバグの 99% は捕まえてくれるので、妥当なトレードオフだ。安全な方式で非 \'static future を実装する作業も進行中なので、成功してほしい
    もう一つの大きな問題は、async trait が現状ではボックス化された future を要求するため、関数呼び出し境界ごとに malloc/free が追加されることだが、これは今年の修正ロードマップに入っている
    「単にチャネルを使え」という助言も、大きなコードベースでは制御フローをあちこちに散らしてしまう。チャネルは現代版の GOTO のように感じるし、自分も使いはするが、いくつかの処理を並列に実行して完了を待つだけの場合にはあまり使わない

    • 重要な区別は、Tokio の future 自体が 'static なのではなく、ランタイムの並行性を活用して spawn できる future が 'static のものだけだという点である
      future は poll() されるには Pin されていなければならず、Pin された T: !Unpin は最終的に Drop を呼ばなければならない: https://doc.rust-lang.org/std/pin/#drop-guarantee
      コンパイラの async 機能で生成された future はこうした性質を持ち、手書きの future にも PhantomPinned を入れられる。これにより、poll() された後の mem::forget のいたずらは未定義動作だと仮定でき、侵入型・自己参照 future ライブラリも可能になる: https://docs.rs/futures-intrusive/latest/futures_intrusive/
      future が Arc/Rc によって生き残り続けてリークすることはあり得るが、ライブラリ開発者の立場では、通常の使用と合理的に区別できないか、あまり気にする必要がない
    • メモリリークが安全である点を設計ミスと見るなら、内部可変性をなくすか、Rc をなくすか、あるいは感染性のある unsafe なトレイト境界を付けるほうを好むのか気になる