コアごとのスレッド(Thread-per-core)
(without.boats)- Rust asyncランタイムをめぐる議論は、「コアごとのOSスレッド」かどうかよりも、**ワークスティーリング(work-stealing)**で負荷を均すのか、share-nothingでデータ移動を減らすのかという選択に近い
- async Rustはすでに、ユーザー空間での並行処理と非同期I/Oというthread-per-coreの中核条件を満たしており、残る論点はコア間での状態共有をどこまで避けられるかにある
- ワークスティーリングは、スレッドの遊休時間を減らしてテールレイテンシ(tail latency)とCPU使用率の改善を目指すが、Rustではyield地点をまたぐ状態がスレッドセーフである必要があるため、
Send制約が伴う - share-nothingは、データを特定コアのキャッシュにとどめてレイテンシを下げられるが、キーバリューストアのように状態分割が容易な場合と、トランザクションや原子的変更が必要な場合とでは難易度が大きく異なる
- Enberg論文はshare-nothingとshared-stateを比較しているが、どちらもワークスティーリングを使っていないため、その結果をRustのワークスティーリング実行器への批判にそのまま結び付けるのは難しい
Rust asyncランタイム論争の核心
- Rustコミュニティでは、tokioのような主要asyncランタイムがデフォルトでマルチスレッド実行器を使い、複数タスクをワークスティーリングで動的に均衡させる選択をめぐって議論が続いている
- 一部のユーザーは、このデフォルトのせいで
Send + 'staticまたはSend + Sync + 'staticの制約がコード作成体験を損ねると批判している - 単一スレッドサーバーを好む場合もあるが、ここではRustシステムが1つ以上のCPUコア時間を使おうとする状況を前提とする
- 代案として挙げられる「thread-per-core」は、より高速で実装も容易だと期待されるが、性能と実装容易性を同時に得るのは難しい
「thread-per-core」という名前が生む混乱
- 従来のマルチスレッドasync実行器も、広い意味ではthread-per-coreに当てはまる
- コアごとにOSスレッドを作り
- コア数を大きく上回るタスクをそのスレッド群の上でスケジューリングする
- Pekka Enbergはthread-per-coreを3つのアイデアの組み合わせとして整理している
- 高コストなカーネルスレッドの代わりにユーザー空間で並行性を処理する
- コアごとのスレッドがブロックされないよう非同期I/Oを使う
- 同期コストとCPUキャッシュ間のデータ移動をなくすため、データをCPUコア間で分割する
- Enbergは、高スループットのシステムを作るには前の2点が重要だが、3点目は非常に大きなマルチコアマシンでのみ必要になる可能性があると見ている
- async Rustを使うなら、ユーザー空間での並行性と非同期I/Oという前の2条件はすでに満たされている
- したがって議論の中心はthread-per-coreそのものではなく、ワークスティーリングとshare-nothingのどちらの最適化を選ぶかにある
ワークスティーリングの目的とコスト
- ワークスティーリングは、すべてのスレッドが継続して処理すべき仕事を持てるようにして、テールレイテンシを下げるための最適化である
- 実際のシステムでは、タスクごとに必要な作業量が異なる
- あるHTTPリクエストは、別のリクエストよりはるかに多くの処理を要求することがある
- 初期段階でスレッドごとの仕事を均等に分けても、予測不能なタスク差により時間がたつと作業量に偏りが生じうる
- 最大負荷時には、あるスレッドは処理可能量を超える仕事を抱え、別のスレッドは遊休状態のままになることがある
- tokio、async-std、smolはいずれも、テールレイテンシ低減とCPU使用率改善を目的としてワークスティーリングを実装している
- コストは、タスクがあるスレッドで停止した後、別のスレッドで再開されうる点にある
- yield地点をまたいで使われる状態はスレッドセーフでなければならない
- Rust APIでは、futureが
Sendでなければならないという形で表れる - システム状態を明確に把握できていないと、どのように
Sendを保証するか判断しづらい
- 状態が別スレッドへ移動すると、同期コストやキャッシュミスが発生し、各CPUが自分の状態だけを扱うshare-nothing原則と衝突する
share-nothingの性能ロジック
- share-nothingは、データを複数コアで共有される遅いキャッシュではなく、特定CPUコアのより高速なキャッシュに保持することでテールレイテンシを下げようとする設計である
- Enbergの論文 The Impact of Thread-Per-Core Architecture on Application Tail Latency は、share-nothing型キーバリューストアとshared-state構造のmemcachedをベンチマークし、テールレイテンシの改善を示している
- Rustコミュニティでこの論文が「71%性能改善」のように単純引用されるやり方は浅く、あまり有益ではない
- Enbergのキーバリューストアは、share-nothing構造のために状態と接続を分割している
- ハッシュ関数でkeyspaceを複数スレッドに分割する
SO_REUSEPORTで入ってくるTCP接続をスレッド間に分配する- 接続を管理するスレッドから、該当keyspaceを管理するスレッドへ、メッセージ受け渡しチャネルでリクエストをルーティングする
- memcachedでは、すべてのスレッドが分割されたkeyspaceの所有権を共有し、各パーティションはmutexで保護される
- Enbergの結果は、mutexよりチャネルを使う構造のほうが、より低いテールレイテンシを達成できることを示している
- 同じパーティションに繰り返しアクセスされ、1つのコアのキャッシュにとどまることで、キャッシュミスが減っていると考えられる
- ただし、高度なカーネル機能と綿密に計画された構造によってデータ移動を避ける設計が、データをmutexで包む方式より実装しやすいとは言いがたい
状態分割が容易な場合と難しい場合
- キーバリューストアは、アプリケーション状態を複数スレッドに分けやすく、share-nothingに適した例である
- より複雑なアプリケーションで、複数パーティションの状態をトランザクション的または原子的に変更する必要があるなら、正しく実装するためにははるかに多くの注意が必要になる
- share-nothing擁護には、過去のeventual consistencyデータベースをめぐる過熱と似た面がある
- 性能は向上しうる
- その一方で、データ不整合から生じるバグを避けるには慎重な設計が必要になる
Enberg論文とRustワークスティーリング論争の距離
- Enbergの実装とmemcachedは、どちらもワークスティーリングを使っていない
- このため、論文の主要な性能結果をRustのワークスティーリングアーキテクチャと直接結び付けるのは難しい
- Enbergの構造にワークスティーリングを加えると、データ移動はある程度増えるかもしれないが、CPU使用率を高める方向に働く可能性もある
- memcachedにワークスティーリングを追加する場合は、役に立たない理由を想像しにくいと見られる
- 論文実装は、keyspaceの均等分割と
SO_REUSEPORTによって、仕事をあらかじめ均等に配分するよう設計されている - 実環境では動的な不均衡が現れうる
- hot keyはより多くの読み書きを受け、そのkeyspaceを管理するスレッドの作業量を増やす
- 一部の接続は他の接続より多くのリクエストを処理し、その接続を管理するスレッドの作業量を増やす
- 論文のベンチマークは、各接続がランダムなキーに対して一定量の作業を行うため、こうした不均衡条件を再現していないと理解される
- hot keyを追加パーティションにキャッシュする方法のように、share-nothingシステムの中でも不均衡を緩和する設計は可能である
- 一部タスクは状態移動を避けるため特定コアに固定しつつ、別種のワークスティーリングを最適化として使うこともできる
実務的な結論
- CPUキャッシュ間のデータ移動を避けるようシステムを慎重に設計すれば、そうしない場合より高い性能を得られることがある
- しかし、
Send制約をgenericsに追加することを最大の不満としている程度なら、その種の細かな性能エンジニアリングをしているとは言いがたい - どうせshared stateを使うシステムであれば、負荷時にワークスティーリングがCPU使用率を改善しないと考えるのは難しい
1件のコメント
Hacker News の意見
個人的には、この記事は大局を見失って細部にこだわっているように感じる
議論の核心は、コアごとのスレッドのワークスティーリング実行器ではなく、Rust において async/await がそのための良い抽象化なのかどうかだ
async コードを多用するほど抽象化が漏れ、相手にしてプログラミングするのが難しいと感じる
人々が望んでいる代替の並行性モデルは、ワークスティーリング実行器の上にあるスタック付きコルーチンと、チャネルによる構造化並行性だ
誰かがそれを実装して async/await と future ベースのモデルと比較するまでは生産的な議論は難しく、async が嫌いな人は避けるだろうし、
Send + Sync + 'staticを気にしない人は使い続けるだろうその規模が必要な仕事は、たいていの場合かなりまれだと思う
こういう記事は「カーネルスレッドは高コストだ」とだけ言って、それが本質的に真であるかのように流しているが、実際にはそうではない
処理が何もしないタスクばかりを延々と生成するのでなければ、「本物のスレッド」のオーバーヘッドは小さい可能性が高く、その代わりに得られる単純さは非常に大きい
withoutboats が扱った引用は、リンク先の記事でデフォルトのマルチスレッドとワークスティーリングを具体的に批判している部分だ
[1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_...
この記事は、私が書いてほしかった議論ではなく、別の議論を扱っているだけだ
変更可能な型を所有してチャネルで通信するErlang 風サーバーを作ることもできるし、
Arcで押し通すこともできるRust はその両方を可能にする力を与えてくれる
スタック付きコルーチンは、その段階なら単に別スレッドを使えばよいので、あまり意味がないように見える
約15年前、コアごとのスレッドモデルが最初に解決しようとしていた本来の問題は、汎用マルチコアサーバーで計算のスケーラビリティと効率を得ることだった
一部の主張とは異なり、コアごとのスレッドは明示的に CPU 中心のワークロードを最適化するためのものであり、後により洗練された I/O 処理が必要にはなったが、高スループットの I/O 中心ワークロードにも優れていることが分かった
こうした記事を見ると、コアごとのスレッドアーキテクチャが導入されたときにあったさまざまなソフトウェア設計上の失敗を、急速に繰り返しているように見える
公平に言えば、コアごとのスレッドに関するコンピューターサイエンスは主に HPC に由来しており、文書化が不十分だ
この記事は、コアごとのスレッドアーキテクチャの難題である、コア間の作業バランスに焦点を当てている
基本モデルはデータ/負荷の push/pull の4つで、ワークスティーリングは本質的に**負荷プル(load pull)モデルだ
この方式は、ほとんど使う必要がないとき、つまり実際の問題ではまれな自然な均衡があるときだけオーバーヘッドが低い
コア間の動的な負荷の偏りがよくある、より興味深いワークロードでは、調整オーバーヘッドのためワークスティーリングが性能上のボトルネックになる
それでも理解しやすいので、適したワークロードでは今でも使われるが、一般化はうまくいかない
記事で触れられていないまれなワークロードの中には、おそらく最善となる場合もある
最近もっとも支持を集めているモデルはデータプッシュ(data push)**のように見える。直感的ではないが、スレッド間の調整がはるかに少なくて済む
このモデルにも適さないワークロードはあるが、一般的なほとんどのワークロードにはうまく一般化できる
コアごとのスレッドアーキテクチャは残り続けるだろう。スケーラビリティと効率では勝てない
ただし、現代的で慣用的なコアごとのスレッド設計がどのようなものかについて、ほとんどのソフトウェアエンジニアには直感が足りず、このテーマを深く扱う記事や論文が少ないことが状況をさらに悪くしている
プロフィールの自己紹介とこの説明を見ると、そうしたアプリケーションが主に扱っている種類ではなさそうだ
関連文献へのリンクがあれば見てみたい
「I/O 中心というのは、実際に Rust で書いたときに単一コアを飽和させるほど作業が多くないという意味だ。なら当然、単一スレッドのシステムを書け」という文には共感する
私が使っているアプリケーションの多くは、バックグラウンドでイベントに反応するデーモンで、単一スレッドにすれば
ArcとMutexのオーバーヘッドをなくせるこのオーバーヘッドはその時点ではほとんど構文上の負担に近いが、デバッグと保守は楽になる
必要なものにだけコストを払うという点が、Rust で気に入っているところだ
この記事が応答している元の記事は、tokio や他の async ライブラリが単純な 単一スレッドアーキテクチャに戻りにくくしていると批判している
誇張はあるが、おおむねその批判に同意する
高スループットのアプリケーションにより適しているという理由でデフォルトをより複雑にするのは、Rust の理想とは逆のように見える
スループットに縛られているのではなく、ほとんどアイドル状態でいて、作業が発生したらシステムリソースの使用を減らすために素早く終わらせる、という形だ
ときどき巨大な作業のスパイクが発生し、そのときレイテンシが非常に重要でない限り、スレッドを 1 つより多く使うのは、利点なしに複雑さとオーバーヘッドを増やすだけだ
オペレーティングシステムでは、すべてのシステムサービスが IPC リクエストを同時に処理する必要があるが、大半は全体の CPU 消費を減らすために単一スレッドで処理する
4 コアのデバイスで数十個のサービスをコアごとのスレッドにすると、CPU と RAM の無駄遣いだ
tokio API では
LocalSetと呼ばれているhttps://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
単一 CPU の単一スレッドで十分でも、それでも 同時性が欲しいことはある
ArcとMutexの代わりにRcとRefCellを使うことになるだろうが、コード面では同じくらい複雑で冗長ではないかと思う効率が低いのは理解しているが、説明されたケースなら、いくつかのアトミック操作の追加コストはどのみち無視できる程度ではないだろうか
引用について言うと、「Rust async プログラミングの原罪はデフォルトをマルチスレッドにしたことだ…
Send + 'static、さらに悪ければSend + Sync + 'staticが Rust を使う楽しさを殺している」という表現は、あまりにメロドラマ調だという点には同意するSend + Syncをなくしても、そこまで大きな違いが出るとは思わないいちばん気になるのは
'staticで、それはワークスティーリングのせいではない欲しいのはスコープ付きの同時性だ
たとえば <https://github.com/tokio-rs/tokio/issues/2596> のようなものだ
今の Rust async でさらに本当に嫌なのは 計測の不足だ
会社では一部のタスクがただ止まってしまう本番環境の問題があり、
gdb; thread apply all btに相当することをしたい少なくとも <https://github.com/tokio-rs/tokio/issues/5638> が入ることを期待している
現在も実験的には存在するが、私の経験ではときどきパニックする
今日、実際に PR を書いて、SIGTERM 時に実験版を使ってみるつもりだ
どうせ終了中なので、クラッシュしても構わないという判断だ
これらの不満のどれも、ワークスティーリングをなくしても解決しない
さらに列挙しても、ワークスティーリングの削除はほとんど何の助けにもならないだろう
デバッガも不要で、JVM に SIGQUIT を送ると全スレッドのスタックトレースを stderr にダンプして、そのまま実行を続ける
各スレッドがどのロックを保持しているか、あるいは待っているかも含まれる
他の言語を使うたびにこの機能が恋しくなる
本番環境での一時的なプロファイリングにも使える
スナップショットを何度か取り、その後
grep/sed/sort/uniqでホットスポットを探せばよいすべての
await地点の前にr.set_location(file!(), line!());を手動で散りばめる必要があるが、システムがなぜ止まっているように見えるのかを説明するのに何度も役立った[1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t... には
set_location()があり、task.rsにはlist_tasks()があるこの問題に正解はなく、すべてはユースケース次第
結局のところ、I/O 中心か CPU 中心かというワークロード、そしてキャッシュの追い出しやロック競合のようなものがどれほど悪影響を及ぼすかの問題である
外部データベースと通信し、その間に軽いビジネスロジックがある HTTP サーバーを共有仮想サーバー上に載せるなら、ワークスティーリングとスレッド再利用は直感的には理にかなっている
もちろん常にベンチマークすべきである
逆に、データベースやそれに近いシステムを作っていて、負荷時に高い並行性と多数のコンテキストスイッチがあちこちでキャッシュの追い出しや競合を引き起こすなら、苦労することになる
その場合はコアあたり 1 スレッドが非常に妥当で、async フレームワーク自体が意味を持たないことすらある
何が「より良い」かについて独断的な正解はない
アプリケーションをプロファイルすべきである
前にも言ったが、Rust 全体の焦点がWeb サービス開発の大規模な流入によって歪められているように感じる
Rust がそうした作業に適した言語なのかはまだ確信していないが、彼らにはかなりうまく機能しているようなので、それはそれとして受け止めている
ただし、言語に関する公開の議論や前面に押し出されるクレートは、今や全般的にこの偏りを反映している
このフォーラムのソフトウェアエンジニアの多くが持つ偏りでもある
ゲームエンジン、暗号化ライブラリ、カーネル、コマンドラインツール、コンパイラなどのシステムアプリケーションは、async に触れなくても問題なく作られている
大規模な暗号化ライブラリを保守しているが、async 周りのことにはまったく影響を受けていない
その結果として Rust のシステムプログラミングにおける有用性が損なわれるなら残念だ
アプリケーション自体はリクエスト外の状態がほとんどなく、ソケットリスナーとデータベース接続はスレッドごとに分けられる
残る状態もリクエスト間では大半が静的である可能性が高く、キャッシュの無効化は頻繁には起きない
共有状態がないので、所有権の扱いも容易なはずだ
それが良い悪いと言いたいわけではないが、今や Rust は品質がまちまちな Web 関連ライブラリやフレームワークの終わりない流れと付き合っていかなければならない
そして中核的な基盤ライブラリやクレートのかなりの部分がasync ファーストのアプローチを採ったため、async は引き続き議論の対象であり続ける
今では一般的な開発者がプロジェクトの主要方針として async を使わないと明言しない限り、ビジネス上の問題のために普通の同期コードを書くことが難しいレベルになっている
エグゼキュータのスレッド間でタスクの移動を許可するために
Send境界を要求するのは、Rust の async システム自体の明確な欠陥だと思うasync Drop の根本問題とあわせて、スコープ付き API の実装を妨げるからである
スレッドと同じように、spawn やチャネル経由のデータ送信のような関数にだけ
Send境界があれば十分なはずだ共有しないアプローチはたいてい、この欠陥を隠すための回避策以上のものではない
タスクを特定のスレッド/コアに選択的に固定することには利点があり、状況によっては本当に有用だが、それはより細かな議論であり、
Sendに関する async ユーザーの不満とはあまり関係がない良い記事で、タイトルだけを見ずにもっと読んでみることを勧める
いちばん気に入った文は「最大の不満が何かのジェネリックに
Send境界を追加することだというなら、その人がそのレベルのエンジニアリングをしているとは信じがたい」だった編集: 「duped」のコメントに全面的に同意する
この議論のより大きな文脈を知らなかったため、この文をあまりに早まって引用してしまったかもしれない
「システムの状態をきちんと見られない人は、future が
Sendになるよう保証する最善の方法を見つけるのが難しいかもしれない」というくだりは、少し傲慢に聞こえる気がする'staticライフタイムとSend/Sync制約の問題は開発者に広く実感されており、彼らが愚かだという印象は受けなかった人々はワークスティーリングをしない方が簡単で速いと言う
私の主張は、そのどちらかだということだ
共有しない方式がより速くなるには、共有状態アーキテクチャをスレッドセーフにするより簡単ではない方法でコードを設計しなければならない
次の段落には「遅い」に対応する並行した文がある
並列・並行 Rust をコンパイルが通るようにするのに苦労している人々が愚かだとは思っていない
ただし、私たちが用意した API が人生を台無しにしたかのように振る舞うのは好まない
どうせやらなければならないことなら、より難しいわけではない
「人々が些細な作業に過剰反応している」というより、async がいつか解くべき問題を早めに解かせるということだ
借用チェッカーに似た感覚である
ときには過度に制限的だが、ときにはすべて問題ないと仮定したときには考慮していなかったコーナーケースが実際に存在していた、ということでもある
Sendかどうかを理解するのに、ときどき確かに苦労するだから引用された文には共感する
これは大局を見落として細部にこだわっている記事だ
すべてのプログラムで最高の性能を得る、常に正しい方法などない
いくらでも議論はできるが、コアあたり 1 スレッドの長所と短所は典型的な「状況による」ものだ
問題はそもそも
asyncの使用自体が早すぎる最適化であることにあるRust プログラムの 99% は redis や linkerd のようなものではない
Python や Ruby で書いても十分に速い CLI ツールや Web アプリである
それなのに、なぜコミュニティはブロッキング I/O の Rust を捨て、すべてが async になり、開発者はデフォルトのようにあらゆるものに
#[tokio::main]を付けているのかと思う速度を出す代わりにプログラミング体験がより良い言語があり、Python がその例である
Rust を使いたいなら、おそらく追加の性能が必要なのだろうし、すでに扱いにくい言語を受け入れている以上、より良い性能のために扱いにくいスタイルも受け入れられるだろう
Rustを使ったことはありませんが、不満は理解できます。
必要でもなく、CPUに十分な余裕がある状況で、単一リクエストのエンドツーエンドのレイテンシをむしろ遅くしかねない負荷の再分配のために、状態をスレッド間で移動できるようコードを特別な書き方にしなければならないなら、いら立つだろうと思います。
移動可能な状態がデフォルトで、ほとんど破綻しないプラットフォームなら、このアプローチにも意味があるかもしれませんが、Rustではそうではないようです。
気になるのは使い心地です。
単に「
Sendという魔法の呪文を追加しないとコードがコンパイルされない」に近いのか、それとも「ワークスティーリング中に状態が壊れ、間欠的でデバッグしにくい形で失敗する」に近いのかが気になります。Serverがあり、serveでメッセージを読み、その後各メッセージハンドラを新しいタスクとしてspawn(async move { ... })するような形です。最初はすべてうまく動きます。
ところがある日、
do_thisの実装を変えてthisの型がもはやSendではなくなると、spawn(...)が、匿名のasync move { }スコープによって作られた型がSendではないため不可だ、というおどろおどろしいコンパイルエラーを受け取ることになります。理由が必ずしも明確ではなく、エラーメッセージも役に立ちません。
thisがSendでなければ、do_that(arg).awaitの.awaitをまたいで保持することはできません。各
.awaitはfutureが譲歩し、実行器によって別スレッドにスケジュールされ得る実行地点だからです。型を
Sendにできるなら問題ありません。しかしそうできない場合も十分にあり、その場合はfutureのスケジューリングを
spawn_localのようなものに変えなければなりません。これを呼ぶには、かなりのボイラープレートを追加しなければならないこともあります。
これが
Sendの問題です。単に型アノテーションを追加する問題ではなく、型が
Sendを実装しているかどうかは常に明確とは限らないため、コードに微妙に染み込み、後になって不明瞭な形で壊れることがあります。SendとSyncかどうかを検出します。プログラムをスレッドセーフに書けば問題ありません。
要点はこれです。
人々はTokio asyncがあちこちで
SendとSyncを要求するので難しいと不満を言いますが、実際にはどんな種類であれ安全な並行コードを書くこと自体が難しいのです。直感的ではなく、問題はasyncがそれを自動的に「処理してくれる」かのように感じさせる点にあります。
しかし実際にはそうではありません。
自分が何をしているのかを理解している必要があり、コンパイラはここで手助けしてくれるだけです。
コアあたりスレッド方式にすると、状況によっては
Sendの必要性を隠せますが、すべてではありません。そして長期的には、アーキテクチャ面でしっぺ返しを受ける可能性があります。
Send/Syncでなければコンパイルされません。間違っているかもしれませんが、これを達成するための怠惰な方法は、たいてい共有され得るものを
ArcやMutexで包むことです。CPU中心なら、ワークスティーリングのほうがほとんどの場合で優れているでしょう。
I/O中心なら、コアあたりスレッドのほうがうまく機能するかもしれませんが、いずれにせよCPUには十分な余裕があるので、性能はそれほど重要ではありません。
私の考えでは、ワークスティーリングのほうが言語APIに入れるデフォルトとして優れています。