では、以下のコードの実装を読んでいきます。
https://github.com/async-rs/async-std/tree/new-scheduler/src/rt/runtime.rs
ちなみにこのコードに関する議論はこの PR で行われています。
https://github.com/async-rs/async-std/pull/631
ここからは Rust のコードがゴリゴリ出てくるので、Rust をやったことがない人にとっては学習コストが上がってくるかと思います。ともに頑張りましょう!
主要なコンポーネントの基本構造
この節では主要なコンポーネントの基本構造を見ていきます。Runtime は主要な3つのコンポーネントを上手く組み合わせて非同期タスクをを実行するのが仕事になります。なので、最初にそれぞれのコンポーネントの基本的な構造や役割を説明していきます。これから説明していくコンポーネントはランタイムを含め次の 4 つです。
- Runtime
- Machine
- Processor
- Reactor
Runtime の基本構造
まずは、今回メインとなる Runtime 型の定義を見ていきます。
Runtime
は次のものを持つことが分かります。
- グローバルな非同期タスクのキュー
- 各
Processor
の持つローカルキューからタスクを盗むためのハンドラー(詳細は後述します!) - リアクター(I/O イベントのキュー)
- スケジューラーの状態
少し、定義時に出てきた型について見ていきましょう。これらはどのようなものなのでしょうか?
Injector
Runtime の定義にInjector
という型がありましたね。Injector
とはなんでしょうか?これは複数のスレッド間で共有できるキューです。実行待ちの非同期タスクを保持するために用いられます。実際にランタイムが非同期タスクが保持したり、取り出したりといった動作は後から見ていきましょう。
Runnable
タスクキューはRunnable
型を保持します。ここではコードは簡略化しますがRunnable
型はrun
メソッドを持ち、これを実行することで非同期タスクを実際に動かすことが出来ます。
Stealer
次にStealer
について見ていきましょう。Stealer
はキューそのものではなく、キューからタスクを取得するときのためのハンドラーです。
詳細はあとから見ていきますが、各プロセッサーが各々で実行待ちのタスクを保持するローカルキューを持っています。そして、自分のローカルキューからタスクをどんどん消費していきます。しかし、この時、自分のローカルキューからタスクが無くなったらどうなるでしょうか?(すべてのタスクを消費した勤勉なプロセッサーが居た場合ですね。) 他のプロセッサーがせこせこ働いているのに自分だけ休むわけには行きませんよね。実行可能なタスクを見つける方法の1つは Runtime が持つグローバルキューからタスクを貰い受けることですね。ではグローバルキューにタスクがない時はどうでしょうか? この時プロセッサーは他のプロセッサーの実行待ちのタスクを盗みます。 このときに別のプロセッサーからタスクを取得するためのハンドラーがStealer
になります。
主な使い方としてはInjector
と変わりませんが一応コード例を紹介しておきます。
Scheduler
これはスケジューラーの状態を持つ型です。次のような定義になっています。詳細はここでは考える必要はありませんが、後々のコードを読んでいくときにどのような状態を持っていくか知っておいたほうが良いので紹介します。
次にRuntime
の定義で出てきた主要な 3 つのコンポーネントを見ていきます。
おさらいすると次の3つでしたね。
- Machine
- Processor
- Reactor
Machine
から見てきましょう。
Machine の基本構造
OS スレッドに付き一つの Machine があります。これはスレッドが起動する時、停止するときも連動して、Machine の生成、破棄が行われます。つまり、OS スレッドの個数分の Machine オブジェクトを Runtime が管理しています。すこしprocessor
の定義について見ていきましょう。processor
はSpinlock
という型でラップされたOption<Processor>
です。 Processor というのはここでは実行権を持つか持たないかを表すものだと考えていいでしょう。Machine に Processor が割り当てられていないとき(つまり processor が None のとき)は Machine は非同期タスクの実行権を持ちません。ランタイムは実行開始時に、いくつかの Processor オブジェクトを持ちます。現状では Processor の数は cpu のコア数分です。この Processor オブジェクトを実行したい Machine に割り当てることによって、cpu のコア数より大幅に大きい数の Machine が走らないように数を制限しています。 Machine は OS スレッドにつき 1 つなので、cpu のコア数より大幅に大きい数の Machine が走らないということは、OS スレッドが多分に作られないということでもあります。
また、progress が false になっている Machine(動作中ではないスレッド)は Processor(実行権) を他の Machine に移譲します。この Processor の移譲処理はランタイムが行っています。
コラム スピンロック(Spinlock)とは
ここからはスピンロックの具体的な実装を見ていきますが、ランタイムの仕組みとは**ほとんど関係ありません!**なので、興味のない人は読み飛ばしても大丈夫です。この説の内容を知っていなくても本書は最後まで読み進められるように設計されているのでご安心を。
スピンロックは名前の通り、ロックの一種です。ロックが獲得できない間、単純に無限ループ(スピン)によってロックの獲得を待つような仕組みです。これは一種のビジーウェイト状態を発生させるため、ロック待ち時間が長くなると CPU を無駄に消費してしまう場合があります。
スピンロックの具体的な実装は次のようになってます(すこし簡略化しています。)
TODO Atomic 変数やメモリ順序についての説明を余裕があったら書く。
Processor の基本構造
それでは本題に戻りましょう。先程までに Machine の基本構造を見ていきましたね。次に Processor の基本構造を見ていきましょう。
グローバルキューだけで非同期タスクを管理するようにしてしまうと、複数の Processor がグローバルキューから非同期タスクを取り出そうとしたときに競合状態が発生してしまいます。そのため、グローバルタスクキューからタスクを取り出す時は一度グローバルタスクキューをロックして他がタスクを取り出せないようにする必要があります。このグローバルタスクキューのロック取得をしなくて済むように各々の Processor が実行すべき非同期タスクをローカルタスクキューに保持していく形となっています。 また、ローカルキューをスキップする最適化として、slot に次に実行する非同期タスクを保持しています。slot に次のタスクを保持しておくことで、ローカルタスクキューやグローバルタスクキューへの毎回問い合わせをすることなくタスクを実行することが出来ます。
Reactor
Reactor は I/O イベントのキューとして作用します。I/O イベントキューとはなんのためにあるのでしょうか?次のようなコードを例に考えてみましょう。
このコードでは udp socket からデータを読み込むまで次の行が実行されることはありません。それではいつになったら処理を再開することが出来るのでしょうか?upd パケットを受信した時このプログラムの動作を再開させることが出来るはずです。しかし、「upd パケットを受信した」というのはどうやって管理するのでしょうか?方法の一つとしては、この非同期タスクが継続可能かどうかを逐一問い合わせる方法があります。しかし、この方法では無駄な問い合わせが発生してしまい処理効率が良くありません。
そこで Reactor(I/O イベントのキュー)が使えます。この例だと、「upd パケットの読み込みイベント」を Reactor に登録しておきます。そして読み込み可能となったときにこの非同期タスクを再開可能として処理を再開させます。