構造化並行性
著者メモ (TODO): この章の一部については、もっと早い段階、特に設計原則として本の中で議論したほうがよいかもしれません(最初の導入は guide/intro にあります)。ただし、このトピックをよりよく理解し、何かを書き留めておくために、まずは独立した章として始めています。また、まだ少し粗い状態です。
(注: 最初のいくつかのセクションでは、構造化並行性という抽象的な概念について述べており、Rust や async プログラミングに固有のものではありません(参照: スレッドを使った同期的な並行プログラミング)。ここでは「タスク」を、任意のスレッド、async タスク、またはその他の類似する並行性プリミティブを意味するものとして使います)。
構造化並行性は、並行プログラムを設計するための考え方です。プログラムが構造化並行性の原則に完全に従うには、特定の言語機能やライブラリが必要ですが、そのような機能がなくても、この考え方に従うことで多くの利点を得られます。構造化並行性は、言語や並行性プリミティブ(スレッドか async か、など)から独立しています。多くの人が、async Rust でプログラミングする際に構造化並行性の考え方が有用であると感じています。
構造化並行性の本質的な考え方は、タスクがツリーとして構成されるということです。子タスクは親タスクの後に開始され、常に親タスクより前に終了します。これにより、結果とエラーを常に親タスクへ返せるようになり、親タスクのキャンセルが常に子タスクへ伝播されることが求められます。主に、時間的スコープはレキシカルスコープに従います。つまり、タスクは、それが作成された関数やブロックより長く生存すべきではありません。ただし、より長く生存するタスクがプログラム内で何らかの形で具象化されている限り、これは構造化並行性の要件ではありません(典型的には、親タスク内における子タスクの時間的スコープを表すオブジェクトを使います)。
TODO 図
構造化並行性という名前は、任意のジャンプ(goto)ではなく、関数やループなどを使って制御フローを構造化すべきだという考え方である構造化プログラミングとの類比によるものです。
構造化並行性について考える前に、一般的な並行設計がどのような意味で非構造化されているのかを振り返ると役に立ちます。典型的なパターンは、何らかの spawn 文を使ってタスクを開始するというものです。そのタスクはその後、システム内の他のタスク(それを spawn したタスクを含む)と並行して完了まで実行されます。どのタスクが先に終了するかについて制約はありません。プログラムは本質的に、独立して生存し、いつでも終了し得るタスクの単なる集まりです。タスク間の通信や同期はすべて場当たり的であり、プログラマーは他のどのタスクがまだ実行中であるとも仮定できません。
非構造化並行性の実用上の欠点は、タスクから結果を返すことが、その発生時期や方法に関する言語レベルの保証なしに、言語外的な方法で行われなければならない点です。言語のエラー処理機構は、非構造化並行性の制約のない制御フローには適用できないため、エラーが捕捉されない可能性があります。また、タスクの相対的な状態についても保証がありません。どのタスクも、他のタスクの状態とは無関係に、実行中であったり、正常終了していたり、エラーで終了していたり、外部からキャンセルされていたりする可能性があります1。これらすべてが、並行プログラムを理解し保守することを難しくしています。この構造の欠如は、並行プログラミングが逐次プログラミングより本質的に難しいと考えられる理由の 1 つです。
構造化並行性は、プログラムに制約を課すプログラミング規律であることに注意する価値があります。関数やループが goto より柔軟性に欠けるのと同じように、構造化並行性は単にタスクを spawn するより柔軟性に欠けます。しかし、構造化プログラミングと同様に、構造化並行性が柔軟性の面でもたらすコストは、予測可能性の向上によって補って余りあります。
構造化並行性の原則
構造化並行性の重要な考え方は、すべてのタスク(またはスレッドなど)がツリーとして組織化されるということです。すなわち、各タスク(ルートである main タスクを除く)は単一の親を持ち、親関係に循環はありません。子タスクはその親によって開始され2、親より前に必ず実行を終えなければなりません。兄弟間には制約はありません。タスクの親は変更できません。
構造化並行性を実装するプログラムについて推論する際の重要な新しい事実は、あるタスクが生存しているなら、そのすべての祖先タスクも生存していなければならないということです。これは、それらが良好な状態にあることを保証するものではありません。シャットダウン中であったり、エラー処理中であったりする可能性がありますが、何らかの形で実行中でなければなりません。これは、どのタスク(ルートタスクを除く)についても、結果やエラーを送る先となる生存中のタスクが常に存在することを意味します。実際、理想的なアプローチは、言語のエラー処理を拡張し、エラーが常に親タスクへ伝播されるようにすることです。Rust では、これは Result::Err を返すことと panic の両方に適用されるべきです。
さらに、子タスクのライフタイムは親タスク内で表現できます。一般的なケースでは、タスクのライフタイム(その時間的スコープ)は、それが開始されたレキシカルスコープに結び付けられます。たとえば、関数内で開始されたすべてのタスクは、その関数が return する前に完了すべきです。これは非常に強力な推論の道具です。もちろん、これはすべての場合には制約が強すぎるため、プログラム内のオブジェクト(しばしば「スコープ」または「nursery」と呼ばれます)を使うことで、タスクの時間的スコープをレキシカルスコープの外へ延ばせます。そのようなオブジェクトは渡したり格納したりできるため、任意のライフタイムを持つことができます。それでもなお、重要な推論の道具があります。そのオブジェクトに結び付けられたタスクは、それより長く生存できません(Rust では、この性質によってタスクをライフタイムシステムと統合できます)。
上記は、構造化並行性の別の利点につながります。複数のタスクにまたがるリソース管理について推論できるようになることです。クリーンアップコードは、リソースがもはや使われなくなるときに呼び出されます(たとえば、ファイルハンドルを閉じる場合)。逐次コードでは、いつクリーンアップコードを呼び出すかという問題は、オブジェクトがスコープを抜けるときにデストラクタが呼び出されることを保証することで解決されます。しかし、並行コードでは、オブジェクトが別のタスクによってまだ使われている可能性があるため、いつクリーンアップすべきかは明確ではありません(参照カウントやガベージコレクションは多くの場合の解決策ですが、オブジェクトのライフタイムについての推論を難しくし、エラーにつながる可能性があり、さらにランタイムオーバーヘッドも伴います)。
親タスクがその子より長く生存するという原則は、キャンセルに対して重要な含意を持ちます。あるタスクがキャンセルされた場合、そのすべての子タスクもキャンセルされなければならず、それらのキャンセルは親のキャンセルが完了する前に完了しなければなりません。これはさらに、構造化並行システムにおいてキャンセルをどのように実装できるかに影響します。 タスクがエラーによって早期に完了する場合(Rust では、これは早期 return だけでなく panic を意味することもあります)、そのタスクは return する前に、すべての子タスクが完了するのを待たなければなりません。実際には、早期 return は子タスクのキャンセルをトリガーしなければなりません。これは Rust における panic と類似しています。panic は、スタックを巻き戻す前に現在のスコープ内のデストラクタをトリガーし、プログラムが終了するか panic が捕捉されるまで、各スコープでデストラクタを呼び出します。構造化並行性の下では、早期 return は子タスクのキャンセル(したがって、それらのタスク内のオブジェクトのクリーンアップ)をトリガーし、タスクのツリーを下ってすべての(推移的な)子をキャンセルしなければなりません。
一部の設計は構造化並行性の下で非常に自然に機能します(たとえば、完了すべき単一のジョブを持つワーカータスク)。一方で、あまりうまく適合しない設計もあります。一般に、これらのパターンは、特定のタスクに結び付けられていないこと自体が機能であるものです。たとえば、ワーカープールやバックグラウンドスレッドです。これらのパターンを使用する場合でも、通常、タスクはプログラム全体より長く存続すべきではないため、常に親になれるタスクが 1 つ存在します。
構造化並行性の実装
構造化並行性の代表的な実装は、Python の Trio ライブラリです。Trio は、構造化並行性の概念を中心に設計された、async プログラミングと IO のための汎用ライブラリです。Trio プログラムは async with 構文を使用して、タスクを生成するための字句スコープを定義します。生成されたタスクは nursery オブジェクト(Rust の Scope にいくらか似ています)に関連付けられます。タスクのライフタイムは、その nursery の動的な時間的スコープに結び付けられ、一般的なケースでは async with ブロックの字句スコープに結び付けられます。これにより、タスク間の親子関係、したがって構造化並行性のツリー不変条件が強制されます。
エラーハンドリングには Python の例外が使用され、それらは自動的に親タスクへ伝播されます。
部分的な構造化並行性
多くのプログラミング技法と同様に、構造化並行性の完全な利点は、それ「のみ」を使用することで得られます。すべての並行性が構造化されていれば、プログラム全体の振る舞いについて推論することがはるかに容易になります。しかし、それには言語に対する要件があり、それらを満たすのは容易ではありません。たとえば Rust では、非構造化並行性を行うことは十分に簡単です。しかし、構造化並行性の原則を選択的に適用するだけでも、あるいは構造化並行性の観点で考えるだけでも有用です。
構造化並行性を設計規律として使用できます。プログラムを設計するときは、常にタスク間の親子関係を考慮し、文書化し、子タスクがその親より前に終了することを保証します。これは通常の実行ではたいていかなり容易ですが、キャンセルや panic に直面すると困難になることがあります。
構造化並行性のもう 1 つの要素で、かなり容易に取り入れられるものは、常にエラーを親タスクへ伝播することです。通常のエラーハンドリングと同様に、最善策はエラーを無視することかもしれませんが、その場合でも親タスクのコード内で明示的であるべきです。
構造化並行性から学べるもう 1 つのプログラミング規律は、親タスクをキャンセルする場合に、すべての子タスクをキャンセルすることです。これにより、構造化並行性の保証がはるかに信頼できるものになり、キャンセル全般についての推論が容易になります。
async Rust による実践的な構造化並行性
Rust における並行性(async であれスレッドを使用するものであれ)は、本質的に非構造化です。タスクは任意に生成でき、他のタスクで発生したエラーや panic は無視でき、キャンセルは通常即時であり、他のタスクへ伝播しません(これらの問題を容易に解決できない理由については後述します)。しかし、プログラム内で構造化並行性の利点の一部を得る方法はいくつかあります。
- 構造化並行性に従って、高いレベルでプログラムを設計する。
- 可能な場合は構造化並行性のイディオムに従う(そして非構造化のイディオムを避ける)。
- 構造化並行性をより使いやすく、信頼できるものにするために crate を使用する。
Rust で構造化並行性を使用する際の最も厄介な問題の 1 つは、キャンセルを子 Future/タスクへ伝播することです。Future を使用し、それらを並行に合成している場合、これは唐突ではあるものの自然に発生します(Future を drop すると、それが所有するすべての Future が drop され、それらがキャンセルされます)。しかし、タスクが drop されたとき、そのタスクが生成したタスクへシグナルを送る機会はありません(少なくとも Tokio ではありません3)。
これが意味するのは、「本物の」構造化並行性よりも弱い不変条件しか仮定できないということです。つまり、親タスクが常に生存していると仮定できるのではなく、その親がキャンセルされたか panic した場合を除き、常に生存しているとしか仮定できません。これは最適ではありませんが、それでもプログラミングを単純化できます。なぜなら、通常の実行では、何らかの結果を処理する親が存在しないケースを扱う必要が決してないからです。
TODO
- 所有権/ライフタイムは自然に sc へつながる
- リソースについての推論
async プログラムの設計に構造化並行性を適用する
プログラムの設計という観点では、構造化並行性を適用することにはいくつかの含意があります。
- プログラムの並行性をツリー構造で組織化する。つまり、親タスクと子タスクの観点で考える。
- 時間的スコープは、可能な場合は字句スコープに従うべきです。より具体的には、関数内で開始されたタスクが完了するまで、関数は return すべきではありません(早期 return や panic を含みます)。
- データは一般に子タスクから親タスクへ流れます。もちろん、一部のデータは親から子へ、あるいは他の方法で流れますが、主として、タスクは自分の作業結果を親タスクに渡し、さらに処理してもらいます。これにはエラーも含まれるため、親タスクはその子のエラーを処理すべきです。
ライブラリを書いていて構造化並行性を使用したい場合(または、そのライブラリを並行構造化されたプログラムで使用できるようにしたい場合)、ライブラリコンポーネントのカプセル化に時間的カプセル化が含まれていることが重要です。つまり、API 関数が return した後も実行され続けるタスクを開始しないということです。
Rust は構造化並行性の規則を強制できないため、プログラム(またはコンポーネント)がどのように構造化されているか、そしてどこで構造化並行性の規律に違反しているかを認識し、文書化することが重要です。
有用な妥協パターンの 1 つは、抽象化の最上位レベルでのみ、かつメインタスクの最も外側の関数から生成されるタスクに対してのみ、非構造化並行性を許可することです(理想的には main 関数からのみですが、プログラムにはセットアップや設定コードが含まれることが多いため、プログラムの論理的な「トップレベル」は実際には数関数分深い位置にあることがあります)。このようなパターンでは、通常 main から多数のタスクが生成され、それぞれが異なる責務を持ち、互いのやり取りは限定的です。これらのタスクは再起動されたり、任意の他のタスクによって新しいタスクが開始されたり、クライアントなどに紐づいた限定的なライフタイムを持ったりすることがあります。つまり、それらは並行・非構造化です。これら各タスクの内部では、構造化並行性が厳密に適用されます。
TODO なぜこれが有用なのか?
TODO ここにケーススタディがあると非常によい。
構造化されたイディオムと非構造化のイディオム
このサブセクションでは、並行性に対する構造化されたアプローチとうまく機能する雑多なイディオムと、並行性の構造化をより難しくするいくつかのイディオムを扱います。
構造化並行性に従う最も簡単な方法は、タスクと生成を使うのではなく、future と 並行合成を使うことです。並列性のためにタスクが必要な場合は、JoinHandle または JoinSet を使う必要があります。親タスクが panic したりキャンセルされたりした場合に、子タスクが適切にクリーンアップできるよう注意しなければなりません。子タスク内のエラーが適切に処理されるように、ハンドルのエラーを確認しなければなりません。
キャンセルの伝播がないことを回避する 1 つの方法は、子を持つ可能性のあるタスクを突然キャンセル(drop)しないようにすることです。代わりに、シグナル(例: キャンセルトークン)を使い、そのタスクが終了前に子をキャンセルできるようにします。残念ながら、これは select と互換性がありません。
プログラム(またはコンポーネント)をシャットダウンするには、コンポーネントを drop するのではなく、明示的なシャットダウンメソッドを使います。これにより、シャットダウン関数は子タスクの終了を待機したり、それらをキャンセルしたりできます(drop は async にできないためです)。
いくつかのイディオムは、構造化並行性とうまく噛み合いません。
- join handle 経由で完了を await せずにタスクを生成すること、またはそれらの join handle を drop すること。
- Select または race のマクロ/関数。これらは本質的に構造化されているわけではありませんが、future を突然キャンセルするため、非構造化なキャンセルのよくある原因になります。
- ワーカータスクまたはプール。async タスクでは、タスクの開始/シャットダウンのオーバーヘッドが非常に低いため、タスクのプールを使っても、「データ」のプール、たとえばコネクションプールを使う場合と比べて、得られる利点はほとんどない可能性があります。
- 明確な所有権構造を持たないデータ - これは必ずしも構造化並行性と矛盾するわけではありませんが、多くの場合、設計上の問題につながります。
構造化並行性のためのクレート
TODO
- クレート: moro, async-nursery
- futures-concurrency
関連トピック
このセクションは、async Rust で構造化並行性を使うために知っておく必要はありませんが、興味のある読者のために有用な背景情報として含めています。
スコープ付きスレッド
Rust のスレッドにおける構造化並行性はかなりうまく機能します。スコープなしのライフタイムでスレッドを生成することを防ぐことはできませんが、これは簡単に避けられます。代わりに、スコープ付きスレッドの使用に限定してください。その方法については scope 関数のドキュメントを参照してください。スコープ付きスレッドを使うと、子のライフタイムが制限され、panic が自動的に親スレッドへ伝播されます。ただし、親スレッドはエラーを処理するために子スレッドの結果を確認しなければなりません。Trio の nursery のように Scope オブジェクトを受け渡すことさえできます。通常、Rust のスレッドではキャンセルは問題になりませんが、スレッドキャンセルを利用する場合は、それをスコープ付きスレッドと手動で統合する必要があります。
Rust に固有の点として、スコープ付きスレッドでは子スレッドが親スレッドからデータを借用できます。これは並行・非構造化スレッドでは不可能なことです。これは非常に有用であり、構造化並行性と Rust の所有権スタイルのリソース管理がどれほどうまく連携できるかを示しています。
Async drop とスコープ付きタスク
Rust では、オブジェクトのライフタイムが終了したときにリソースがクリーンアップされることを保証するために、デストラクタ(drop)が使われます。future は単なるオブジェクトなので、そのデストラクタは子 future のキャンセルを保証するための自然な場所になります。しかし、async プログラムでは、クリーンアップ処理を非同期にしたいことが非常によくあります(そうしないと他のタスクをブロックする可能性があります)。残念ながら、Rust は現在、非同期デストラクタ(async drop)をサポートしていません。それをサポートするための作業は進行中ですが、いくつかの理由により困難です。その理由には、async デストラクタを持つオブジェクトが非 async コンテキストから drop される可能性があることや、drop の呼び出しは暗黙的であるため、明示的な await を書く場所がないことが含まれます。
スコープ付きスレッドが(一般的にも、構造化並行性にとっても)有用であることを踏まえると、もう 1 つのよい疑問は、async プログラミングに同様の構成要素(「スコープ付きタスク」)が存在しないのはなぜか、ということです。TODO これに答える
参考文献
興味があれば、さらに読むためのよいブログ記事をいくつか挙げます。
-
join ハンドルを使うことで、これらの欠点はいくらか軽減されますが、それは信頼できる保証のない場当たり的な仕組みです。構造化並行性の利点を完全に得るには、それらを常に使うことに加えて、キャンセルとエラーを適切に処理することについて細心の注意を払わなければなりません。これは、言語やライブラリのサポートなしには困難です。この点については、後でもう少し議論します。 ↩
-
これは実際には、構造化並行性における厳密な要件ではありません。タスクの時間的スコープをプログラム内で表現し、タスク間で渡せるなら、子タスクをあるタスクが開始し、別のタスクをその親にすることができます。 ↩
-
Tokio の
JoinHandleのセマンティクスは、ハンドルが drop された場合、その基盤となるタスクが「解放」される(比較: drop される)というものです。つまり、子タスクの結果は他のどのタスクによっても処理されません。 ↩