なにこれ
非同期処理の理解に躓いていて正直ややこしかったけど、
ライブラリの改造をしていたら、なんとなくそのややこしさの理由がわかったのでまとめてみる。
(やってるうちにAPIを眺めているとなんとなくRustの気持ちがわかってきた。という感じなので裏付けがあるわけじゃないです。)
大体はここの焼き増しになっているので、焼き増し元をどうぞ。
非同期処理とマルチスレッドの処理の違い
特にここでは、OSのネイティブスレッドをスレッドといい、それを複数動かすことをマルチスレッドと言います。
さらに、マルチプロセスという場合はOSによってメモリにロードされた複数個のプログラム、特に実行状態であるメモリイメージの集まりを指します。
この記事では、タスクという表現をする場合にはRust上で動作する1つの非同期処理を表す単位とします。
一般に使われているOS上で動作するマルチプロセスを表現するマルチタスクの意味ではありません。
マルチスレッド
OSがプロセスに対して生成する、またはAPIによってアプリからOSに要請してOSの仕組みを使って生成する並列処理を行えるようにすること。
OSに依頼しているので、OSの資源が枯渇したらもはやそこまでです。
よくWebの業界ではC10K(クライアント1万台)問題と言ったりしているようです。
非同期処理
OSに依頼するマルチスレッドではOSの資源枯渇に足を引っ張られてしまいます。
それを解消するために、生成するスレッド数は10個程度(この値は適当です)に固定しておきます。
これら10個のスレッドに対して、100個のタスクが割り当てられて、あたかも同時に動いているかのような錯覚を提供する仕組みが非同期処理の仕組みとなっています。
このように最大10個に限定されたスレッド数でも、10個より多いスレッドが有るかのように処理を行わせる機構を「非同期ランタイム」とRustでは表現しています。
有名所だとこの辺りがあります。
同期APIと非同期APIとMutex
そもそもなんで同期APIと非同期APIの違いが有るのだろうか?
同期処理向けAPIを非同期処理の中で使うとどーなるの?
言語上で非同期処理を実装するために、有限の資源であるスレッドを使い回す仕組みが作られました。
それを非同期ランタイムと言います。
という説明をしました。
でも、そのランタイムを使うコード内で同期処理向けAPI(以下、同期版APIと言います)を使うとどうなるか、ということを考えます。
結論を言うと、同期版APIを呼び出している間はスレッドを専有します。
特に、同期版APIのstd::sync::Mutexなんかを使うと、ロック待機中はスレッドが完全に止まります。
例
すべての非同期タスクから使用される、通信用のソケットがあるとします。
もちろんソケットを使うためには競合防止のために非同期APIのMutexLockで排他します。
その間、ソケットを使う他のタスクはすべて停止します。
でも、止まったのはソケットを使うタスクだけなので、ソケットを使わないタスクはMutexLockの取得操作をしたことで空きが出たスレッドに割り当てられて動き続きます。
ですが、この非同期処理の中で同期版のMutexを使った場合、タスクではなくOSのスレッドがすべて停止することになります。
スレッドが停止するので、ソケットを使わないタスクも動きません。
せっかく非同期ランタイムで使い回せるように成ったのに、ランタイムの意味がなくなりました。
という感じです。
なので、RustではSync/SendといったTraitを実装していようとも、同期版APIを非同期処理に渡そうとすると「Sendが実装されてねーぞ」
っていうエラーを吐くように成っています。
このあたりに書いてあります。
マジで躓いた。
タスクは実行スレッドが固定されない
例えば、スレッド1、スレッド2、スレッド3・・・スレッド10 という10個の名前がついたスレッドがあるとします。
スレッド1:タスク1~タスク10
スレッド2:タスク11~タスク20
スレッド3:タスク21~タスク30
・・・
スレッド10:タスク91~タスク100
という割り振りだったとします。
このように固定されてしまっていると、管理は楽ですが
タスク91~タスク100がバカ重タスクであった場合にスレッド10に空きがなくなります。
つまり有限の資源であるスレッドの使い回しが効かなく成ってしまって、
タスク1~タスク92は全部終わってるのにタスク93~タスク100が終わってない!
けど、スレッド1~スレッド9は何も使ってない!
という状況に陥るかもしれません。
そういう事象を回避するために、Rustは空いたスレッドに割り当てるようになっています。
つまり、タスクを開始したときに最初に使っていたスレッドで最後までタスクが実行されるとは限らないというお話でした。
ここからは勝手な想像に基づく推測みたいな話
このような条件があるので、「最初から最後まで同じスレッドで実行される」という前提の同期版APIの場合は、スレッドローカルなストレージ(Thread Local Storage:TLS)でデータを持っているかもしれません。
もしかすると、そのせいで非同期ランタイムがスレッドを移した瞬間に異なるTLS領域が参照されるのでダングリングポインタを指しているはずです。
ダングリングポインタを指した場合の挙動は未定義なのでSEGVして落ちるか、ACE(任意コード実行)になるかはその時々だと思います。
危ないですね。
というわけで、非同期APIはそのあたりをクリアしたデータの持ち方をしてるんじゃないかなーっていう勝手な想像をしてみました。
おおよそ、そういうのを実装しているのがFutureTraitを実装したランタイムがやっているんだろなーっていう気がします。
非同期処理の実装って面白いなー っていう記事でした。
雑なまとめ
- Rustの非同期処理はOSのリソースに依存しない「ユーザ空間で動作するスレッド機構」を実装したもの。
- OSのプロセスのように、1つのスレッドに対して複数のタスクを同時に走らせることができる。(シングルプロセッサの上でマルチタスクをやるようなイメージ)
- 動作させるランタイムはRustの標準ライブラリではなく外部ライブラリを使うことで実現する。
- ただし、ランタイムを実装するための振る舞いはFuture Traitとして定義されている。
- タスクは常に同じスレッドで動作するとは限らない
- なので、非同期処理内は必ず非同期APIを使おう(非同期処理内で同期処理用のスレッドをサスペンドする系の同期版APIを使おうとすると殆どの場合はコンパイルエラーになって使えないですが)
図解してみる
実行環境
スレッドプールにスレッドが生成されているような状態
(図解のためスレッドを8個にしています。実装がこうなっているわけではないです)
10個生成したタスクが8このスレッドに割り当てられたときの状態
awaitが発生したとき1
awaitが発生したとき2
おまけ
std::sync::Mutexが仮に使えたとしたらどうなるのか?
今日はこんなところで。
脚注っぽいの
※錯覚を提供する仕組み
実際に100個のタスクが並列動作(同じタイミングで同時に100個動く)しているわけではなく、10個のスレッドで細切れに10個のタスクを消化するように動作(平行動作)しているので錯覚という表現を使っています。超高速で動けば残像になる感じです。
Thread Local Storage