本記事は、2021年8月5日に公開されたMultithreading in Photon を翻訳したものとなります。
この記事では、バックエンドでのマルチスレッディングについてご紹介していきます。
- 実装方法について
- 使用方法について
- 機能について
- 当社での開発について
これらの質問はすべて、サーバーSDKコードの修正、独自のプラグイン記述、サーバーアプリケーションのスクラッチからの開始、などサーバー側での開発を行っている場合にのみでてくるものです。
Photonでは、どのようにマルチスレッディングの問題を解決しますか?
Photonのサーバーアプリケーションは、複数のクライアント接続から同時にリクエストを受け入れます。 このような接続をピアとよびます。 これらのリクエストはキューを要求します。 各ピアに1つのキューです。 複数のピアが同じルームに接続されている場合、それらのピアのキューは1つにまとめられ、ルームキューとなります。
このようなルームは数千まで存在し、数あるルームのリクエストキューは並行して処理されます。
Photonのタスクキューの実装の基礎として、Jetlangライブラリを基に開発された、Retlangライブラリが使用されていました。
タスクやasync/awaitを使用しないのはなぜでしょう
これは、次の条件によるものです。
- Photon Serverの開発はこれらの機能が生まれる前に始まりました。
- ファイバーにより実行されるタスク数が膨大で、 毎秒何万タスクにも及びます。 このことから、GC(ガベージコレクター)も引き起こす可能性のある他の抽象化を追加する意味がありませんでした。 ファイバーの抽象化はとても繊細であるといわれています。
- たしかに、ファイバーと同じ機能を持つTaskSchedulerというものもあり、コメントでTaskSchesulerについて教えていただいたこともありますが、ホイール全体をもう一度考えることはしたくありませんでした。
ファイバーとは一体何なのでしょうか?
コマンドキューを実装するクラスのことをファイバーといいます。 コマンドは、先入先出形式で次々と並び、実行されます。 ここでは、複数のライターと1つのリーダーというテンプレートが実装されていると言えます。 繰り返しになりますが、コマンドは受信された順番通りに次々と実行される点にご注意ください。 これがマルチスレッドの環境下でのデータアクセスの安全性の基礎をとなります。
Photonで使用しているファイバーは、PoolFiberという1つのタイプのみですが、ライブラリは5つのタイプを提供しています。 5つすべて、IFiber インターフェイスを実装しています。 各タイプの簡単な説明をご紹介します。
- ThreadFiber - 専用スレッドによって裏付けされたIFiber。 頻繁であったり、パフォーマンスの影響を受けやすいオペレーションに使用する。
- PoolFiber - .NETスレッドプールによって裏付けされたIFiber。 これも順番で実行され、一度に1つのプールスレッドのみが実行される。 頻繁でなく、パフォーマンスの影響を受けにくい実行や、スレッドカウントを行いたくない場合に使用する。
- FormFiber / DispatchFiber - WinFormsやWPFのメッセージポンプに裏付けされたIFiber。 FormFiber / DispatchFiber - Invoke または BeginInvokeを呼び出して、異なるスレッドからウィンドウで通信する必要を全面的に排除する。
- StubFiber - 決定性のテストに便利。 実行中に緻密なコントロールが提供されるので、テストレースがシンプルに。 呼び出し側のスレッドのアクションすべてを実行する。
PoolFiberについて
PoolFiberでのタスク実行についてご説明します。 スレッドプールを使用していても、中のタスクは順番に実行され、一度に使用されるスレッドは1つです。 以下の様な動作となります。
- ファイバーにタスクをエンキューし、実行し始めます。QueueUserWorkItemを呼び出してこれを実行します。 ある時点で、プールから1つのスレッドが選ばれ、そのスレッドがタスクを実行します。
- 最初のタスクの実行中、さらにいくつかのタスクをキューに並べます。最初のタスクが終わりに近づいたら、すべての新しいタスクがキューから出され、再度QueueUserWorkItemが呼び出されてこれらのタスクがすべて実行に送られます。 プールの新しいスレッドが選ばれます。 完了し、キューの中にタスクがあれば最初から繰り返します。
毎回、プールの新しいスレッドによってタスクのバッチが新しく実行されます。ただし、実行されるのは一度に1つのみとなります。 そのため、ゲームルームで動くためのタスクがすべてファイバーの中にあれば、そこ(タスク)からルームのデータに安全にアクセスすることができます。 異なるファイバーで実行中のタスクからオブジェクトにアクセスする場合は、同期が必要です。
次の絵がわかりやすいでしょう
タスクA、B、Cをあまり使わないファイバーにエンキューします。
以下の様に実行されます。
タスクAは1つのスレッド(真ん中の線)で実行され、タスクBはまた別のスレッドで実行されます。 タスクCには、システムが3番目のスレッドを選択します。 タスクがより頻繁にエンキューされるようなアクティブな動作の場合は、次のようになります。
タスクAのグループが1つのスレッドを使用し、タスクBのグループが2番目のスレッドを使用し、タスクCのグループが3番目のスレッドを使用します。 ただし、これらのグループ・タスクがタイムラインで交差することはないということを理解しておく必要があります。 全てのタスクは必ず順番に実行されます。
PoolFiberを使用している理由
PhotonではそこかしこにPoolFiberを使用しています。 最初の理由として、PoolFiberは追加スレッドを作成せず、必要であれば自分のファイバーに追加することができる点が挙げられます。 これを少し調整して、停止しないようにしました。 つまり、PoolFiber.Stop は、現在のタスクを停止させないということです。 これは重要なことでした。
どんなスレッドからでもファイバーにタスクを設定できます。 全てスレッドセーフです。 現在実行されているタスクは、実行中しているファイバーに新しいタスクをエンキューすることもできます。
ファイバーにタスクを設定する方法は3通りあります。
- キューにタスクを入れる
- 一定のインターバルを経て実行されるキューにタスクを入れる
- 定期的に実行されるキューにタスクを入れる
次をご覧ください。
// タスクのエンキュー
fiber.Enqueue(()=>{some action code;});
// 10秒でタスクが実行されるようにスケジュールする
var scheduledAction = fiber.Schedule(()=>{some action code;}, 10_000);
...
// タイマーを止める
scheduledAction.Dispose()
// 10秒でタスクが実行され、5秒ごとに繰り返されるようにスケジュールする
var scheduledAction = fiber.Schedule(()=>{some action code;}, 10_000, 5_000);
...
// タイマーを止める
scheduledAction.Dispose()
一定のインターバルで実行するタスクは、fiber.Scheduleで返されるオブジェクトへの参照を保つことが大切です。 このようなタスクの実行を停止するには、これが唯一の方法となります。
Executor
では、Executorの説明に移ります。 実際にタスクを実行するクラスというものも存在します。 そのようなクラスは、Execute(Action a)メソッド やExecute(List<Action> a)メソッド を実装します。 PoolFiberは2番目のメソッドを使用します。 つまり、タスクがバッチでexecutorに投げ込まれるということです。 次に何が起こるかは、executorによって異なります。 初めは、DefaultExecutor クラスを使用していました。このクラスでは、以下が行われていました。
public void Execute(List<Action> toExecute)
{
foreach (var action in toExecute)
{
Execute(action);
}
}
public void Execute(Action toExecute)
{
if (_running)
{
toExecute();
}
}
実際には、これでは十分ではありませんでした。
「アクション」の中に例外があると、toExecute リスト内の他のすべてのものがスキップされてしまっていました。
そのため、デフォルトでFailSafeBatchExecutor を使用し、 try/catch をループに追加するようにしました。 特別何か必要としない場合は、このexecutorの使用をお勧めします。
このexecutorが当社で追加したため、githubなどにあるバージョンではまだ対応していません。
その他当社で行ったこと
BeforeAfterExecutor
その後、ログの問題を解消するため、executorをもう1つ追加しました。 それがBeforeAfterExecutorです。 ここにパスされたexecutorを「ラップ」します。 何もパスされない場合は、FailSafeBatchExecutor が作成されます。 BeforeAfterExecutor の特徴的な機能が、タスクリストの実行前にアクションを実行し、タスクリストの実行後に他のアクションを実行する機能です。 コンストラクタは次のようになります。
public BeforeAfterExecutor(Action beforeExecute, Action afterExecute, IExecutor executor = null)
使用目的 ファイバーとexecutorのオーナーは同一です。 executorの作成時、2つのアクションがオーナーにパスされます。 前者は、スレッドコンテキストにキー・値ペアを追加し、後者がそれらを取り除くことで、クリーナーとしての機能を果たします。 スレッドコンテキストに追加されたペアは、ロギングシステムによってメッセージに追加され、メッセージを残したオブジェクトのメタデータをいくつか見ることができるようになります。
例:
var beforeAction = ()=>
{
log4net.ThreadContext.Properties["Meta1"] = "value";
};
var afterAction = () => ThreadContext.Properties.Clear();
//executorを作成する
var e = new BeforeAfterExecutor(beforeAction, afterAction);
//PoolFiberを作成する
var fiber = new PoolFiber(e);
ファイバー内で実行するタスクから何かがログされると、log4netによって、value値を持つMeta1タグが追加されます。
ExtendedPoolFiber and ExtendedFailSafeExecutor
retlangのオリジナルバージョンには存在せず、後から弊社で開発したものがもう一つあります。 これに先立ち、次のストーリー:There is PoolFiber がありました(.NETスレッドプールに加えて実行しているものです)。 このファイバーが実行するタスクでは、HTTPリクエストを同期的に実行する必要がありました。
以下の様なシンプルな方法で行いました。
- リクエストの実行前に、同期イベントを作成;
- リクエストを実行するタスクは他のファイバーに送信され、完了時にシグナル化したステージに同期イベントを入れます。
- その後、同期イベントを待ちます。.
これはスケーラビリティの観点からはベストなソリューションとは言えず、予期せぬ障害に繋がり始めました。 ステップ2で他のファイバーに入れたタスクが、まさに同期イベントを待ち始めたそのスレッドのキューに入ってしまうのです。 デッドロックです。 いつもこうなるわけではありませんが、 懸念事項となるには十分でした。
解決策は、ExtendedPoolFiberとExtendedFailSafeExecutorに実装されました。 ファイバー全体を保留にするというアイディアを閃いたのです。 この状態では、新しいタスクをキューに累積しながら実行しない、ということが可能になります。 ファイバーを保留させるため、Pauseメソッドを呼び出します。 Pauseメソッドが呼び出されるとすぐに、現在のタスクが完了するまでファイバー(ファイバーexecutor)が待機状態に入り、フリーズします。 その他のすべてのタスクは、2つのイベントのうちの1つ目の待機状態に入ります。
- Resumeメソッドを呼び出します。
- タイムアウト(Pauseメソッドを呼び出したときに特定される)します。 Resumeメソッドで、すべてのキュー済みタスクの前に実行されるタスクを設定することができます。
この小技は、プラグインがHTTPリクエストを使用してルームステートを読み込む必要がある際に使用します。 ルームの最新のステートをすぐにプレイヤーに反映させるため、ルームのファイバーを保留します。 Resumeメソッドを呼び出す場合、ここに読み込んだステートを適用するタスクをパスします。他のタスクは既に最新のステートで動作しています…
ちなみに、ファイバーを完全に機能停止させて保留にする必要があるのは、ゲームルームのタスクキューに_ThreadFiberを使用するためです。
IFiberAction
IFiberAction はGCのロードを軽減させるための実験です。 .NETのアクションを作成するプロセスの制御は不可能です。 そのため、IFiberActionインターフェースを実装するクラスのインスタンスを持つ標準のアクションに置き換えることを決定しました。 このようなクラスのインスタンスはオブジェクトプールから取り出され、完了時にすぐにそこに戻ると想定されました。 こうするとGCが減ります。
IFiberActionインターフェースは以下の様になります。
public interface IFiberAction
{
void Execute()
void Return()
}
Executeメソッドには、実行がまさに必要なものが含まれています。
Returnメソッドは、Executeの後、プールにオブジェクトを戻すタイミングで呼び出されます。
例:
public class PeerHandleRequestAction : IFiberAction
{
public static readonly ObjectPool<PeerHandleRequestAction> Pool = initialization;
public OperationRequest Request {get; set;}
public PhotonPeer Peer {get; set;}
public void Execute()
{
this.Peer.HandleRequest(this.Request);
}
public void Return()
{
this.Peer = null;
this.Request = null;
Pool.Return(this);
}
}
//以下のように使用を変更しました
var action = PeerHandleRequestAction.Pool.Get();
action.Peer = peer;
action.Request = request;
peer.Fiber.Enqueue(action);
まとめ
まとめとして簡単に要約します。 Photonでのスレッドセーフティを確実にするため、タスクキュー(このケースではファイバーに代表されるもの)を使用しています。 使用するファイバーの主なタイプはPoolFiberと、これを拡張するクラスです。 PoolFiber は、標準の.NETスレッドプールに加えてタスクキューを実装します。 PoolFiberの小規模のパフォーマンスフットプリントにより、これが必要なところには独自のファイバーを入れられます。 タスクキューを保留する必要がある場合は、ExtendedPoolFiberを使用します。
IExecutorインターフェースを実装するexecutorは、ファイバー内で直接タスクを実行します。 DefaultExecutorは誰にとっても良いものですが、例外の場合は、実行のためにパスされていたタスクの残りすべてを失ってしまいます。 この点では、FailSafeExecutorが妥当な選択だといえそうです。 executorがタスクのバッチを実行する前後に何らかのアクションを行う必要がある場合は、BeforeAfterExecutorが便利です。
コメント
0件のコメント
サインインしてコメントを残してください。