本記事は、Crystal の RFC(Request for Comments)を日本語に翻訳したものです。本文は Apache License 2.0 の下で提供されているため、ライセンス条件に従う限り翻訳・転載(Qiitaを含む)は自由に行えます。
本文:https://github.com/crystal-lang/rfcs/blob/main/text/0002-execution-contexts.md
ライセンス:https://github.com/crystal-lang/rfcs/blob/main/LICENSE
Feature Name: execution_contexts
Start Date: 2024-02-05
RFC PR: "https://github.com/crystal-lang/rfcs/pull/2"
Issue: "https://github.com/crystal-lang/crystal/issues/15342"
概要
Crystalの並行実行モデルをマルチスレッド対応に再構築します。これにより、スリープ中のスレッドがある間にファイバーがブロックされることを回避するなど、より効率的な実行が可能になります。また、開発者には_実行コンテキスト_と呼ばれる、ファイバーをスケジューリングするための異なる戦略を提供します。
注意: この提案はGoとKotlinからインスピレーションを得ています。
動機(現在の状況の分析)
用語
- スレッド: オペレーティングシステムによって実行が制御されるプログラム(wikipedia)。
- ファイバー: 作業単位または「実行を一時停止できる関数」(wikipedia)。1つのスレッドで複数のファイバーを実行できます。
- スケジューラー: プログラム内でファイバーの実行を管理します(Crystalによって制御)。これは、オペレーティングシステムによってスケジューリングされるスレッド(プログラムの制御範囲外)とは異なります。
- イベントループ: タイマー(例:一定時間が経過するまで待機)やIO(例:ソケットが読み取り可能または書き込み可能になるまで待機)などの特定のイベントを待機する抽象化。
- マルチスレッド(MT): 環境が複数のスレッドを使用し、ファイバーは異なるCPUコア上で並行かつ並列に実行できます。
- シングルスレッド(ST): 環境が1つのスレッドのみを使用し、ファイバーは並列に実行されることはありませんが、依然として並行に実行されます。
並行性
[!NOTE]
並行性モデルについて簡単にまとめるだけです。並行性について考慮すべき非常に興味深いトピックがあります(例:構造化並行性、ファイバーのキャンセル)が、このRFCは並列性に焦点を当てています。
ファイバーは、一般的にコルーチン、軽量スレッド、またはユーザースレッドとも呼ばれ、Crystalの並行性システムの基盤です。
ファイバーはブロックを中心に設計されています。ファイバーのランタイムは、そのブロック内で実行されるコードです。ファイバーは、例えばKotlinの他のコルーチンシステムとは異なり、値を返しません。
操作がブロックされる場合、現在実行中のファイバーのみがブロックされます。操作がブロックなしで実行できる(またはエラーになる)ときに後で再開されます。ファイバーが中断されている間、別のファイバーが実行されます。他に実行中のファイバーがない場合、ランタイムはイベントループがこのファイバーまたは他のファイバーを再開できることを報告するまで待機します。
例えば、ファイルを開いたりソケットから読み取ったりするIO操作は、オペレーティングシステムとCrystalの標準ライブラリレベルでは実際には非ブロッキングですが、開発者にとってはブロッキングとして見えます。
ファイバーは協調的であり、外部手段によってプリエンプトされることはありません。とはいえ、ファイバーを実行しているスレッド自体がオペレーティングシステムによってプリエンプトされる可能性があり、そのスレッド上の保留中のすべてのファイバースケジューラーもブロックされます。
ファイバーがプリエンプトされないという事実は副作用であり、並行性モデルの一部ではありません。将来の進化では、長時間実行されているファイバーを積極的にプリエンプトしたり、プリエンプト可能なポイントで譲歩を求めたりする可能性があります。
並列性
現在のMTモデルは一文で要約できます:
[!IMPORTANT]
ファイバーは常に同じスレッドによって再開されます。
ファイバーをスポーンすると、新しいファイバーを異なるスレッドに分散させるために、実行中の任意のスレッド(ラウンドロビン的な方法で)に送信されます。スポーンされたファイバーは、オプションで現在のファイバーと同じスレッドに関連付けることができ、いくつかのファイバーをグループ化して、最終的に並行して実行できるが互いに並列に実行されることのないファイバーのリストを形成します。
技術的な詳細では、現在のMTソリューションは以下も意味します:
- スレッドとスケジューラーは結合されています。各スレッドにはスケジューラーがあり、スケジューラーは専用のスレッドに属します。ファイバーはスレッドに結び付けられています(おそらくスケジューラーに結び付けられるべきです。ファイバーはエンキューを除いてスレッドをあまり気にしません)。
- 各スレッド/スケジューラーには、イベントを待機するための専用のイベントループがあります。
- スケジューラーはそのキューを処理し、キューが空になるとすぐにスリープ状態になります(イベントループでイベントを待機)。
- ファイバーをスポーンするときに特定のスレッドやスケジューラーをターゲットにすることはできません:現在のスレッドを含むスレッドに送信されるか、現在のスレッドに送信されるかのいずれかです。
基本的に、現在のMTモデルでは、スケジューラーとスレッドという言葉はほぼ互換性があります。
利点
1つのスレッド上のすべてのファイバーが同じデータを操作する場合、そのデータはすでにCPUキャッシュで利用可能である必要があります。
データの局所性
少数のファイバーが実行されている場合、再開によりキャッシュの再利用が改善され、パフォーマンスが向上する可能性があります。スレッドが多すぎる(利用可能なCPUより多い)場合や、多くのアプリを持つ忙しいオペレーティングシステムで、多くのスレッドコンテキストスイッチがある場合、それは当てはまらない可能性があります。
ファイバーのセットをグループ化
このようなファイバーのグループは並列に実行されることはありません。これにより、並列性ではなく並行性のみを扱えばよいため、同期ロジックを大幅に簡素化できます。これははるかに簡単で高速に処理できます。例えば、コストのかかるアトミック操作は必要なく、値に直接アクセスできます。並列性の問題とアプリケーションパフォーマンスへの影響は、グローバル通信に限定されます。
問題
ラウンドロビンディスパッチャーは忙しいスレッドを考慮しません
Crystalは新しいファイバーを任意のスレッドにプッシュします。つまり、利用可能なスレッドや、より小さい/より高速なキューがある可能性があるにもかかわらず、大きなキューを持つ忙しいスレッドにファイバーをプッシュする可能性があります。
運が悪いと、忙しいファイバーが同じスレッドでスケジューリングされ、別のスレッドがスリープしている間に、並列性の恩恵を受けるのではなく、パフォーマンスに影響を与える可能性があります。
飢餓状態のスレッドはスリープします
実行可能なファイバーのスケジューラーキューが空になったスレッドは、他のスレッドがどれほど忙しくても、すぐにスリープ状態になります。新しいファイバーがキューに入れられたときにのみ再開される1つ以上のCPUを失う可能性があるため、アプリケーションのパフォーマンスが影響を受ける可能性があります。一方で、ファイバーは忙しいスレッドでキューに入れられ続けます。
CPU集約的なファイバーが同じスレッド/スケジューラーでキューに入れられた他のファイバーをブロック
この問題の例については、issue #12392を参照してください:プリエンプト可能なポイントに遭遇しないCPU集約的なファイバーは、スレッドで立ち往生している他のファイバーを別のスレッドに移動する可能性なしにブロックします。
最悪の場合、1つのファイバーを待機してアプリケーション全体を停止させ、忙しいファイバーがすべてのスレッドに分散されるのではなく、一部のスレッドで立ち往生し、並列性を制限または防止します。
例
次の図では、スレッド#1が忙しい(ファイバー1を実行中、ファイバー2と3がブロックされている)一方で、スレッド#2がスリープしていることがわかります。
制限事項
[!IMPORTANT]
これらの制限事項の一部は現在のモデルで修正可能かもしれませんが、いくつかの制限が残る可能性があります。
実行時にスレッド/スケジューラーの数を制御できません
スレッド/スケジューラーの初期数を決定したり、リストのサイズを変更したりするAPIがありません。
[!NOTE]
これは現在のモデルで修正できます。サイズアップは問題ありませんが、サイズダウンは可能です:ファイバーを移動できないため、スレッドが停止する前にキューが空になるまで待つ必要があります。長時間実行されるファイバーが異なるスレッドに送信される場合(例:シグナルハンドラーループ、ロガー)、サイズダウンできない可能性があります!
スケジューラーとイベントループなしでスレッドを開始できません
技術的には可能です(Thread.newは非公開APIですが呼び出すことができます)が、ファイバーやイベントループに関連するものを呼び出すことは危険です。スレッドのスケジューラーやイベントループをすぐに作成し、スレッドがイベントを待機してスリープ状態になったり、その他の問題が発生したりする可能性があります。
[!NOTE]
これは現在のモデルで修正できます。例えば、「ベア」スレッドの作成をサポートすることで。ファイバーをスポーンすると常に他のスケジューラーに送信される(または例外を発生させる)ようになります。イベントループとタイマーについても何かを考え出すことができるでしょう(例:Thread.sleep)。
ファイバーをスレッドに分離できません
ファイバーは、一定期間または永続的に、そのスレッドで実行される唯一のファイバーになります。そのスレッドでは他のファイバーがスケジューリングされることはありません。以下にいくつかのユースケースを示します:
- 専用スレッドで実行する必要があるGtkやQTメインループを実行します。UIからのコールバックはそのスレッドで実行され、残りのCrystalアプリケーションと通信する可能性があります。たとえ他のすべてのファイバーを実行するための1つの他のスレッドしかない場合でも、通信はスレッドセーフである必要があります。
- 遅いCPU集約的な操作の実行には時間がかかる場合があり(例:bcryptパスワードハッシュの計算)、同じスレッドで実行される他のファイバーの進行をすべてブロックします。
- 再び、issue #12392を参照してください。
[!NOTE]
これは現在のモデルで回避できます。例えば、スケジューラーなしでスレッドを作成し、そこでアクションを実行することをサポートすることで。非常に長い操作には適していますが(数百ミリ秒続く可能性のある操作にはそれほど適していません)。ただし、これはissue #12392を修正しません:現在のスレッドが現在のファイバーの実行を継続し、他のファイバーをブロックしないようにスケジューラーを別のスレッドに積極的に移動することはできません。そうしないと、ファイバーが分離されたファイバーと並列に実行され、契約を破ることになります!
ガイドレベルの説明(提案)
利点(局所性)と欠点(ブロックされたファイバー)を考慮して、「ファイバーは常に同じスレッドによって再開される」という概念を破り、代わりに「ファイバーは任意のスレッドによって再開される可能性がある」ことで、例えばワークスティーリングを使用したMT環境など、より多くのシナリオを可能にすることを提案します。
これらのシナリオは、Goが提案するようなワークスティーリングを使用した単一のMT環境である必要はなく、代わりに実行時に特定のファイバーをスポーンするための環境を作成する能力を持つことです。
実行コンテキスト
実行コンテキストは、ファイバーを実行できる1つ以上のスレッドの専用プールを作成および管理します。各コンテキストは、内部でファイバーを実行、中断、スワップするルールを管理します。
アプリケーションは、並列に任意の数の実行コンテキストを作成できます。これらのコンテキストは分離されていますが、通常のスレッドセーフ同期プリミティブ(例:Channel、Mutex)で一緒に通信できる必要があります。
言い換えると:実行コンテキストはファイバーをグループ化します。ファイバーを特定のスレッドに関連付ける代わりに、ファイバーを実行コンテキストに関連付け、実際に実行されるスレッドを抽象化します。
ファイバーをスポーンするとき、ファイバーはデフォルトで現在の実行コンテキスト、つまり現在のファイバーを実行しているコンテキストにエンキューされます。子ファイバーは、(別途指示されない限り)親と同じ実行コンテキストで実行されます。
一度スポーンされたファイバーは、別の実行コンテキストに_移動_すべきではありません。例えば、再エンキュー時にファイバーはその実行コンテキストで再開される必要があります:コンテキストBで実行されているファイバーがコンテキストAからの待機中の送信者をエンキューする場合、それをコンテキストAにエンキューする必要があります。とはいえ、ファイバーを別のコンテキストに_送信_することは許可できます。
実行コンテキストの種類
以下は、Crystalが標準ライブラリで実装できる潜在的なコンテキストです。
並行コンテキスト: ファイバーは並列に実行されることはなく、内部でより簡単で高速な同期プリミティブを使用できます(アトミックなし、スレッドセーフティなし)。デフォルトのスレッドセーフプリミティブ(例:Channel)で他のコンテキストと通信できます。欠点は、ブロッキングファイバーが他のファイバーの進行をブロックすることです。ただし、この並行性の制限は、ファイバーが同じシステムスレッドに留まることを保証するものではありません。
並列コンテキスト: ファイバーは並列に実行され、任意のスレッドによって再開される可能性があります。スケジューラーとスレッドの数は増減でき、スケジューラーは別のスレッドに移動し(M:Nスケジューラー:スレッド)、互いからファイバーを盗むことができます。利点は、実行できるファイバーは、スレッドが利用可能である限り実行できることです(つまり、飢餓状態のスレッドがなくなります)。スケジューラーの数を縮小できます。
分離コンテキスト: 専用スレッドで1つのファイバーのみが実行を許可されます(例:Gtk.main、ゲームループ、CPU集約的な計算)。これにより、そのスレッドでの並行性が無効になります。イベントループは正常に動作します(現在のファイバー、つまりスレッドをブロック)。明示的なコンテキストなしでファイバーをスポーンしようとすると、分離コンテキストを作成するときに指定された別のコンテキストにスポーンされ、デフォルトでFiber::ExecutionContext.defaultになる可能性があります。
詳細:
- 上記のリストは排他的ではありません:異なるルールを持つ他のコンテキストがある可能性があります(例えば、ワークスティーリングなしのMT)。
- 各コンテキストは排他的ではありません:アプリケーションは必要に応じて並列に多くのコンテキストを開始できます。
- 1つのコンテキストで実行されているファイバーは、他のコンテキストのファイバーと並列に実行されます。
- 実行コンテキストはラップ可能である必要があります。例えば、既存のコンテキストの上にナーサリーのような機能を追加したい場合があります。コンテキストがすべてのファイバーを監視し、すべてのファイバーが完了したときに自動的にシャットダウンします。
デフォルトの実行コンテキスト
Crystalは、ファイバーがデフォルトでスポーンされるワークスティーリングを使用したMT実行コンテキストを開始します。このコンテキストの目標は、開発者がリソースへの並行アクセスを保護したり、できればチャネルを使用して通信したりすること以外、あまり考える必要なく、複数のCPUコアを自由に活用できる環境を提供することです。
必要に応じて1つのスレッドで実行するように設定でき、デフォルトコンテキストの並列性を無効にできます。ただし、他のコンテキストと並列に実行される可能性があります!
注意: Crystal 2.xまで、デフォルトの実行コンテキストは破壊的変更を避けるためにデフォルトでSTになる可能性があり、MTをデフォルトで選択するためにコンパイルフラグが必要になる場合があります(例:-Dmt)。
追加の実行コンテキスト
アプリケーションは、デフォルトのものに加えて他の実行コンテキストを作成できます。これらのコンテキストは異なる動作を持つことができます。例えば、コンテキストは一部のファイバーが並列に実行されないことを確認したり、実行するための専用リソースを持ったりできます(特定のファイバーをブロックしない)。スレッドの優先度とCPUアフィニティを調整して、CPUコアでのより良い割り当てを可能にすることもできます。
理想的には、誰でもアプリケーションに適した実行コンテキストを実装したり、既存の実行コンテキストをラップしたりできます。
例
-
現在のMT実装を模倣する、完全に分離されたシングルスレッド実行コンテキストを持つことができます。
-
アプリケーションのUIやゲームループを処理する専用の実行コンテキストを作成し、デフォルトコンテキストのスレッドを計算やリクエストの処理に使用し、UIの応答性に影響を与えないようにできます。
-
現在のスレッドをブロックするCPU集約的なアルゴリズム(例:高コストでBCryptを使用したパスワードハッシュ)用のMT実行コンテキストを作成し、オペレーティングシステムにスレッドをプリエンプトさせることで、何千ものユーザーが同時にログインしようとしてもWebアプリバックエンドを実行するデフォルトコンテキストがブロックされないようにできます。
-
Crystalコンパイラーは、解析と意味解析パス中にMTを必要としません(現在のところ)。デフォルトの実行コンテキストを1スレッドのみに設定し、CPUと同じ数のスレッドでコード生成用の別の実行コンテキストを開始し、このコンテキストに多くのファイバーをスポーンできます。
-
異なるコンテキストは異なる優先度とアフィニティを持つことができ、オペレーティングシステムが異種コンピューティングアーキテクチャ(例:ARM big.LITTLE)でスレッドをより効率的に割り当てることを可能にします。
リファレンスレベルの説明
実行コンテキストは以下を提供する必要があります:
- 設定(例:最大並列性、...)
- その範囲内でファイバーをスポーン、エンキュー、譲歩、再スケジューリングするメソッド
- ファイバーを実行するスケジューラー(またはMTコンテキスト用の多くのスケジューラー)
- イベントループ(IOとタイマー)
理想的には、開発者がカスタム実行コンテキストを作成できるようになります。つまり、少なくともEventLoopと、おそらくScheduler(少なくともそのresumeメソッド)のパブリックAPIが必要です。これは良いアイデアのようです。
さらに、Channel(T)やMutexなどの同期プリミティブは、実行コンテキスト間での通信と同期を可能にし、したがってスレッドセーフである必要があります。
変更
class Thread
# 現在の実行コンテキストへの参照。
property! execution_context : Fiber::ExecutionContext
# 現在のスケジューラーへの参照(すべてのコンテキストには少なくとも1つのスケジューラーがあります)。
property! scheduler : Fiber::ExecutionContext::Scheduler
# 現在実行中のファイバーへの参照(より簡単なアクセス + ファイバーが長時間ブロックされたときに
# スケジューラー全体が別のスレッドに移動されるシナリオをサポート:
# ファイバーは依然として`Fiber.current`にアクセスする必要があります)。
property! current_fiber : Fiber
end
class Fiber
def self.current : Fiber
Thread.current.current_fiber
end
def self.suspend : Nil
ExecutionContext.reschedule
end
property execution_context : ExecutionContext
def initialize(@name : String?, @execution_context : ExecutionContext, &proc : ->)
end
def enqueue : Nil
@execution_context.enqueue(self)
end
@[Deprecated("Use Fiber#enqueue instead")]
def resume : Nil
ExecutionContext.resume(self)
end
end
def spawn(*, name : String?, &block) : Fiber
Fiber::ExecutionContext.current.spawn(name: name, &block)
end
そして提案されたAPI。特定の部分をそれぞれ処理する2つの異なるモジュールがあります:
-
Fiber::ExecutionContextは、コンテキスト作成とクロスコンテキスト通信のためのパブリック向けAPIの実装を目的とするモジュールです。実行コンテキストのインスタンスオブジェクトは一度に1つしか存在できません。 -
Fiber::ExecutionContext::Schedulerは、各スケジューラーの内部APIの実装を目的とするモジュールです。スレッドごとに1つのスケジューラーがあり、単一の実行コンテキストに対して一度に1つ以上のスケジューラーがある場合があります(例:MT)。
各モジュール間には、特にファイバーのスポーンとエンキューに関していくつかの重複がありますが、実行されることが期待されるコンテキストは異なります:前者はスレッドセーフなメソッド(つまり、クロスコンテキストエンキュー)を必要としますが、後者はスレッドローカルセーフティを仮定できます。
module Fiber::ExecutionContext
# デフォルトの実行コンテキスト(常に開始される)
class_getter default = Concurrent.default
def self.current : ExecutionContext
Thread.current.execution_context
end
def self.current? : ExecutionContext?
Thread.current.execution_context?
end
# 以下のメソッドは現在の実行コンテキストに委譲します。これらは、
# 現在の実行コンテキストでのみ呼び出すことが安全な、
# 保護されたインスタンスメソッドを公開します:
# 現在のファイバーを中断し、次の実行可能なファイバーを再開します。
def self.reschedule : Nil
Scheduler.current.reschedule
end
# 実行コンテキストで`fiber`を再開します。ファイバーが
# コンテキストに属していない場合は例外を発生させます。
def self.resume(fiber : Fiber) : Nil
if fiber.execution_context == current
Scheduler.current.resume(fiber)
else
raise RuntimeError.new
end
end
# 以下のメソッドは任意のコンテキストから呼び出すことができ、
# スレッドセーフである必要があります(STでも):
def spawn(name : String?, &block) : Fiber
fiber = Fiber.new(name, self, &block)
enqueue(fiber)
fiber
end
abstract def spawn(name : String?, same_thread : Bool, &block) : Fiber
abstract def enqueue(fiber : Fiber) : Nil
# 以下のアクセサーは保護される必要はありませんが、その実装は
# スレッドセーフである必要があります(STでも):
abstract def stack_pool : Fiber::StackPool
abstract def stack_pool? : Fiber::StackPool?
abstract def event_loop : Crystal::EventLoop
end
module Fiber::ExecutionContext::Scheduler
def self.current : ExecutionContext
Thread.current.scheduler
end
protected abstract def thread : Thread
protected abstract def execution_context : ExecutionContext
# 以下のメソッドは現在の実行コンテキストスケジューラー(つまり現在のスレッド)
# からのみ呼び出されることが期待されます:
def spawn(name : String?, &block) : Fiber
fiber = Fiber.new(name, execution_context, &block)
enqueue(fiber)
fiber
end
abstract def spawn(name : String?, same_thread : Bool, &block) : Fiber
# 以下のメソッドは現在の実行コンテキストスケジューラーでのみ呼び出される必要があります。
# そうでなければ、任意のコンテキストでファイバーを再開または中断する可能性があります:
protected abstract def enqueue(fiber : Fiber) : Nil
protected abstract def reschedule : Nil
protected abstract def resume(fiber : Fiber) : Nil
# ファイバーコンテキストスイッチの一般的なラッパーで、GCのrwlock、
# 死んだファイバーのスタックの安全な解放などを処理します...
protected def swapcontext(fiber : Fiber) : Nil
end
end
その後、多くのデフォルト実行コンテキストを実装できます。
並行コンテキストは、単一のスレッドのみを扱うことを活用して、両方のモジュールを単一の型として実装できます。例えば:
class Fiber::ExecutionContext::Concurrent
include Fiber::ExecutionContext
include Fiber::ExecutionContext::Scheduler
def initialize(name : String)
# todo: 1つのスレッドを開始
end
# todo: 抽象メソッドを実装
end
実際には、並行コンテキストは単一スレッドの並列コンテキストよりもパフォーマンスの向上をもたらさない可能性があり、両方とも同じベースを共有する可能性があります。
並列コンテキストは、1つの実行コンテキストしかないが、それぞれがスケジューラーを必要とする多くのスレッドがあるため、両方のモジュールを異なる型として実装する必要があります。例えば:
class Fiber::ExecutionContext::Parallel
include Fiber::ExecutionContext
class Scheduler
include Fiber::ExecutionContext::Scheduler
# todo: 抽象メソッドを実装
end
getter name : String
def initialize(name : String, @size : Int32)
# todo: @sizeのスレッドを開始
end
def spawn(name : String?, same_thread : Bool, &block) : Fiber
raise ArgumentError.new if same_thread
self.spawn(name, &block)
end
# todo: 抽象メソッドを実装
end
最後に、分離コンテキストは、実行する分離されたファイバーとスレッドのメインファイバーのみを扱えばよいため、並行コンテキストを拡張できます。実際には、分離されたファイバーとイベントループを実行するかスレッドをパークするスレッドのメインファイバーのみを扱えばよいため、異なる実装を持ちたいと思います。
class Fiber::ExecutionContext::Isolated < Fiber::ExecutionContext::Concurrent
def initialize(name : String, @spawn_context = ExecutionContext.default, &@func : ->)
super name
@fiber = Fiber.new(name: name, &@func)
enqueue @fiber
end
def spawn(name : String?, &block) : Fiber
@spawn_context.spawn(name, &block)
end
def spawn(name : String?, same_thread : Bool, &block) : Fiber
raise RuntimeError.new if same_thread
@spawn_context.spawn(name, &block)
end
# todo: @fiber以外のenqueue/resumeを防ぐ
end
例
# (メインファイバーはデフォルトコンテキストで実行される)
# メインコンテキストを単一スレッドに縮小:
Fiber::ExecutionContext.default.resize(maximum: 1)
# Nスレッドで専用コンテキストを作成:
ncpu = System.cpu_count.to_i32
codegen = ExecutionContext::Parallel.new(name: "CODEGEN", maximum: ncpu)
channel = Channel(CompilationUnit).new(ncpu * 4)
group = WaitGroup.new(ncpu)
spawn do
# (デフォルトコンテキストで実行される)
units.each do |unit|
channel.send(unit)
end
end
ncpu.times do
codegen.spawn do
# (codegenコンテキストで実行される)
while unit = channel.receive?
unit.compile
end
ensure
group.done
end
end
group.wait
# 今、実行可能ファイルをリンクできます
破壊的変更
デフォルトの実行コンテキストが「ファイバーは常に同じスレッドで再開される」からより寛容な「ファイバーは任意のスレッドによって再開される可能性がある」に移行することで、preview_mtに対していくつかの破壊的変更が導入されます。
-
「1つのファイバーは常に同じスレッドで再開される」という仮定を削除し、代わりに:
- デフォルトコンテキストを単一スレッドに制限する(デフォルトコンテキストでの並列性を無効にする)
- または追加の単一スレッド実行コンテキストを開始する
-
spawnのsame_threadオプションを削除し、代わりに:- デフォルトコンテキストを単一スレッドに制限する(デフォルトコンテキストでの並列性を無効にする)
- または同じスレッドに存在する必要があるファイバー用の単一スレッド実行コンテキストを作成する
-
preview_mtフラグを削除し、実行コンテキストを唯一のコンパイルモードにする(最悪の場合without_mtを導入):- デフォルトコンテキストを単一スレッドに制限できる(実行時に並列性を無効にする)
- 同期プリミティブはスレッドセーフである必要がある。異なるスレッドまたは異なる実行コンテキストで実行されるファイバーが安全に通信する必要があるため
- 同期プリミティブは並列性がない場合に可能な限り最高のパフォーマンスに最適化される必要がある
- コミュニティが維持するシャードは、単一スレッドコンテキスト内でいくつかの追加パフォーマンスを絞り出すためにスレッドセーフティを望まない場合に、代替同期プリミティブを提案する可能性がある
-
Fiber#resumeパブリックメソッドは、ファイバーを任意の実行コンテキストに再開できないため非推奨:- そのコンテキストが現在のものでない場合に例外を発生させるべきか?
- 他のコンテキストにエンキューして再スケジューリングするべきか?それは動作を変更します:ファイバーは_今_再開されるはずですが、後ではありません。現在のファイバーがその前に再開される可能性があります(おっと)
- この機能は標準ライブラリ全体でも使用されておらず、単一のspecにのみ現れます
- 例外を発生させることは受け入れられる可能性があり、誰かがCrystalで明示的な継続が興味深いことを証明できれば非推奨を削除できます
[!NOTE]
破壊的変更は、デフォルトの実行コンテキストをSTにし、STではsame_thread: trueをサポートし続け、MTでは例外を発生させ、same_thread: falseをNOOPにすることで、Crystal 2まで延期できます。Crystal 1でデフォルトコンテキストをMTに変更するためにコンパイルフラグを導入できます(例:-Dpreview_mtを維持しますが、-Dmtだけを考慮)。Crystal 2ではsame_thread引数を削除し、デフォルトコンテキストをMT:Nにし、移行を容易にするために-Dwithout_mtコンパイルフラグを導入します。あるいは、
-Dpreview_mtコンパイルフラグが実験的機能を示すため、Crystal 1.xリリースでsame_threadを非推奨にし、その後のCrystal 1.yリリースでNOOPにしてデフォルトコンテキストをMT:1に設定できます。Crystal 2ではsame_thread引数を削除し、デフォルトコンテキストをMT:Nに変更できます。
欠点
Crystal::ThreadLocalValueヘルパークラスの使用は、ファイバーがスレッド間を移動することで破損する可能性があります。GCがスレッドのThread Local Storage領域にアクセスできないため、これを使用しています。一部の使用法はThread.currentの直接アクセサーで置き換えられる可能性がありますが、一部の使用法は特定のオブジェクトインスタンスを特定のスレッドにリンクします。これらのケースはリファクタリングが必要になります。
-
Reference(#exec_recursiveと#exec_recursive_clone):再帰を検出している間にファイバーがプリエンプトされて別のスレッドに移動される可能性があります(ファイバープロパティであるべきか?)...
[!CAUTION]
これは実際にはSTでもバグかもしれません:スレッドは再帰をチェックしている間に別のファイバーに切り替わる可能性があります。現在の使用法はCPU集約的でプリエンプト可能なポイントに到達しないと仮定していますが、それでも保証はありません。
-
Regex(PCRE2):影響なし(JITスタックとマッチデータオブジェクトの使用内でファイバープリエンプションなし) -
IO::Evented:イベントループの詳細に応じてオーバーホールが必要(スレッドごとに1つ?またはコンテキストごとに1つ?)
[!WARNING]
イベントループは、非ブロッキング呼び出し周りのOS固有の詳細を完全に抽象化するインターフェースが非常に必要になる可能性があります(epoll、kqueue、IOCP、io_uring、...)。#10766を参照してください。
根拠と代替案
根拠は、CPUコアを最大限に活用するための効率的な並列性を持ち、通常は機能する環境を提供しながら、開発者が並列性の最後の可能性を絞り出すための選択肢を制限しないことです。
最も明白な代替案は、現在のMTモデルを維持することです。おそらくいくつかの欠点を修正できます。例えば、縮小/リサイズ、スケジューラーなしでスレッドを開始、same threadとして明示的にマークされていないファイバーをスレッド間で移動することを許可する(ファイバーを効率的に盗むのは難しいかもしれませんが)...
別の代替案は、Goのモデルを実装し、それを唯一可能な環境にすることです。Crystal(Goとは異なり)はファイバーをプリエンプトできないため、これは理想的なシナリオではない可能性があります。専用スレッドを持つことは、何もブロックせずにCPU集約的な計算を実行するためのより良いソリューションかもしれません(可能であれば)。
最後の、あまり明白でないソリューションは、MTを完全に削除し、Crystalアプリケーションが単一スレッドでのみ存在できると仮定することです。これにより、同期プリミティブが大幅に簡素化されます。並列性は複数のプロセスとIPC(Inter Process Communication)で実現でき、アプリケーションをクラスター内に分散できます。
先行技術
導入で述べたように、この提案はGoとKotlin(コルーチンガイド)からインスピレーションを得ています。
両方の言語にはコルーチンと並列性が言語に直接組み込まれています。両方とも、コルーチンが任意のスレッドによって再開される可能性があることを宣言し、両方のランタイムが複数のスレッドがコルーチンを再開できる環境を公開しています。
Goは1つの実行コンテキストを提供します:MT最適化ソリューション。すべてのコルーチンはそのグローバルコンテキスト内で実行されます。
一方、Kotlinは、デフォルトでGoに似た環境を提供しますが、1つ以上のスレッドで追加の実行コンテキストを作成することを許可します。コルーチンはその後、指定されたコンテキスト内に存在し、それぞれがSTまたはMTである可能性があります。
RustのTokioクレートは、シングルスレッドスケジューラーまたはマルチスレッドスケジューラー(ワークスティーリング付き)を提供しますが、コンパイル時に選択する必要があります。どうやらMTスケジューラーがデフォルトで選択されるようです(要確認)。
未解決の質問
機能の導入
この機能には、機能を実装してロールアウトする間、preview_mtとexecution_contextsの両方のフラグが必要です。最初のフラグは標準ライブラリでスレッドセーフティを有効にするためのもので、2番目のフラグはレガシースケジューラーを実行コンテキストスケジューラーに置き換えるためのものです。
これはpreview_mtとは無関係に安定したCrystalからの破壊的変更であるため、MTと実行コンテキストはCrystalメジャーリリースでのみデフォルトになる可能性がありますか?
おそらくMTはデフォルトで1スレッドに制限でき、spawn(same_thread)は非推奨にできますが、spawn(same_thread: true)をどうするか?
デフォルトコンテキスト設定
この提案は、アプリケーションのメインが開始する前にコンテキストを作成するため、実行時にデフォルトコンテキストを設定する(例:MTスケジューラーの数)という固有の問題を解決しません。
合理的なデフォルト + 遅延開始されるスケジューラーとスレッドを持つことができます。つまり、デフォルトコンテキストは1つのスケジューラーを実行する1つのスレッドのみを開始し、その後需要に応じてSystem.cpu_countまで開始します(何らかのヒューリスティックを使用)。
その後、アプリケーションはデフォルトコンテキストをプログラム的にスケールできます。例えば、最低4つのスケジューラーにすることで、追加のスケジューラーとスレッドがすぐに開始されます。
将来の可能性
並列実行コンテキストは最終的に動的な数のスレッドを持つことができ、十分な作業があるときにコンテキストに新しいスレッドを追加し、スレッドが飢餓状態のときにスレッドを削除します(GoのGOMAXPROCSのような最大制限まで)。理想的には、スレッドはスレッドプールに戻り、他のコンテキストで再利用できます。