この記事はC# Advent Calender 2015の2日目です。
自分はいままで、ゲーム制作の折にイテレーターブロックによるコルーチンを使っていたのですが、戻り値が得られないとか、呼び出し方が面倒だったりの問題があったので、async/awaitを使ったコルーチンについて考えていました。ただ、async/awaitを使って実現しようとすると、プログラムはマルチスレッドな動作になってしまいます。コルーチンの用途にはマルチスレッド機能はオーバースペックであり、それなのにマルチスレッドの管理に責任を持たないといけなくなるため、できればシングルスレッドで動作してほしいところです。
#マルチスレッド利用者の責任
というわけでまずは、どんなときにマルチスレッドの管理が必要になるか、例を出して解説します。今回はゲームの例なのでゲームエンジンAltseedを使いますが、そんなに特別な機能は使っていないのでご安心ください。以下の様なプログラムを書いたとします。
class Program
{
// 現在のスレッドをラベル付きで出力するメソッド
static void ShowSyncInfo(string title)
{
Console.WriteLine($"{title}: {Thread.CurrentThread.ManagedThreadId}");
}
// ビューの更新をする
static async Task ControlViewAsync()
{
ShowSyncInfo("ControlView");
// 描画オブジェクトを作って
var obj = new TextureObject2D()
{
Texture = Engine.Graphics.CreateTexture2D("Image.png"),
};
// Altseedのシステムに追加
Engine.AddObject2D(obj);
}
// モデル側。本当はビューに依存しないように書くべきだけど、今回は簡単のため直接ビューを呼び出してる。
static async Task RunModelAsync()
{
ShowSyncInfo("RunAsync");
await Task.Delay(1000);
ShowSyncInfo("Delayed");
await ControlViewAsync();
ShowSyncInfo("Controled");
await Task.Delay(3000);
ShowSyncInfo("Delayed");
}
public static void Main(string[] args)
{
ShowSyncInfo("Main");
// Altseedの初期化
Engine.Initialize("TaskChannel", 640, 480, new EngineOption() { GraphicsDevice = GraphicsDeviceType.OpenGL });
Engine.File.AddRootDirectory("Resources");
// モデル側の処理を実行
var modelTask = RunModelAsync();
// DoEventsはAltseedの機能。毎フレーム呼ぶ必要があり、アプリケーションの終了時にfalseを返す
while(Engine.DoEvents() && !modelTask.IsCompleted)
{
// Altseedの更新処理をする。毎フレーム呼ぶ必要がある
Engine.Update();
}
if(modelTask.Exception != null)
{
Console.WriteLine(modelTask.Exception);
}
// Altseedの終了処理
Engine.Terminate();
Console.ReadLine();
}
}
ゲームプログラムでは一般的な、メインループのあるプログラムなのですが、このプログラムはモデル側とビュー側に分かれていて、今回の主役はRunModelAsync
、ControlViewAsync
メソッドです。Altseedは(というかOpenGLに基づくライブラリはそうだと思いますが)基本的にマルチスレッドでは動作しなくて、ビュー側の処理はAltseedの初期化と同じスレッド(= メインスレッド)で行わないといけません。そう考えると上のプログラムは全然ダメですね。ControlViewAsync
メソッドがメインスレッドで動作する保証が何もありません。
実際に実行してみると以下のようになります。
Main: 9
RunAsync: 9
Delayed: 6
ControlView: 6
ControlViewの直後に例外(AccessViolationException)で落ちてしまいました。ControlViewAsyncメソッドがメインスレッド(ID=9)とは別のスレッド(ID=6)で実行されているのがわかると思います。ちょうどawait Task.Delay(1000)
の前後でスレッドが異なっているので、時間のかかる処理をawaitすると、その後の処理(= 継続処理)が別のスレッドに投げられてしまうようです。
さて、描画関連の処理がメインスレッドで動作するよう保証する処理を書いてもいいのですが、別にモデル側を別スレッドで動かす利点があるわけでもないので、シングルスレッドで動作させたいところです。それでいろいろと調べたのですが、同期コンテキストを自作するのがいい方法みたいです。
#同期コンテキストの自作
同期コンテキスト(SynchronizationContext
)の詳細は割愛しますが、普段はこいつが継続処理を適切なスレッドで実行していると考えてもらえばいいと思います。
SynchronizationContext.Current
プロパティで得られるものが現在のスレッド上での継続処理をハンドルする同期コンテキストです。SynchronizationContext.SetSynchronizationContext
メソッドを呼び出して現在の同期コンテキストを設定することができます。上のプログラム上、たとえばMainメソッドの先頭で試してみると、SynchronizationContext.Current
はnull
です。初期値はnull
のようですね。こいつがnull
のときの動作についての資料はあまり見つけられませんでしたが、とりあえずスレッドプールに継続処理が投げられる感じの動作をしているようです。
継続処理を任意のスレッドで実行する同期コンテキストが作れれば、シングルスレッド動作を保証できそうです。SynchronizationContext
を継承したクラスを作ってみましょう。ひとまず次のように書きます。
class MySynchronizationContext : SynchronizationContext
{
public override void Post(SendOrPostCallback d, object state)
{
// 継続処理を1つの特定のスレッドで実行できるようにする
}
}
Postメソッドは、この同期コンテキストが設定されているスレッドで継続処理が発生したときに呼ばれます。SendOrPostCallback
型の引数に継続処理の入ったデリゲートが渡されます。それで、こうして渡されるデリゲートがすべて単一スレッド上で実行されればいいわけなのですが、どうやって実現しましょうか?Thread
クラス使う?せっかく普段から.NETが生Thread触らなくてもよいように計らってくれてるので、そういうのはあんまり……(自分がThreadの扱い知らないですし)
それで考えたのですが、今回はゲームプログラムでメインループがあるので、やってきた継続処理をメインスレッドから定期的に実行できるようにしてみましょう。以下の様な感じの実装ができます。
using TaskItem = System.Tuple<SendOrPostCallback, object>;
class MySynchronizationContext : SynchronizationContext
{
// スレッドセーフなキュー。Postメソッドはどのスレッドから呼ばれるかわからないため。
ConcurrentQueue<TaskItem> continuations = new ConcurrentQueue<TaskItem>();
public override void Post(SendOrPostCallback d, object state)
{
continuations.Enqueue(new TaskItem(d, state));
}
public void Update()
{
TaskItem cont;
while(continuations.TryDequeue(out cont))
{
cont.Item1(cont.Item2);
}
}
}
これをSetSynchronizationContext
メソッドで設定して、定期的にUpdate
メソッドを呼び出せば、Post
メソッドに渡されてきた継続処理はUpdate
メソッド内で処理されます。つまり、継続処理はUpdate
を呼び出したスレッドで実行されるわけです。これによってUpdate
メソッドさえメインスレッドで呼ばれていれば継続処理はすべてメインスレッドで動作するので、メインループ内でUpdateする限りは問題ないはずです。
というわけで、これを使うようにしたMainメソッドは次のようになります。
public static void Main(string[] args)
{
// 自作の同期コンテキストを生成して、Currentに設定
var sync = new MySynchronizationContext();
SynchronizationContext.SetSynchronizationContext(sync);
ShowSyncInfo("Main");
Engine.Initialize("TaskChannel", 640, 480, new EngineOption() { GraphicsDevice = GraphicsDeviceType.OpenGL });
Engine.File.AddRootDirectory("Resources");
var modelTask = RunModelAsync();
while(Engine.DoEvents() && !modelTask.IsCompleted)
{
Engine.Update();
sync.Update(); // 自作の同期コンテキストをUpdate
}
if(modelTask.Exception != null)
{
Console.WriteLine(modelTask.Exception);
}
Engine.Terminate();
Console.ReadLine();
}
実行結果は以下のようになります。
Main: 8
RunAsync: 8
Delayed: 8
ControlView: 8
Controled: 8
Delayed: 8
すべての処理がメインスレッドで行われているのがわかると思います。やったぜ。今回はゲームループありきの解決策でしたが、他のプラットフォームでも定期的にUpdateする手段があれば、似たような方針がとれるかと思います。
他にもawaitでコルーチンをする上での障害はあるかもしれませんが、逞しくやっていこうと思います。
C# Advent Calender 2015、明日はtanaka_733さんの担当です。お楽しみに!