更新履歴: C#「Lazy<T>
はあるのに AsyncLazy<T>
が無いのはなぜ? とよく聞かれるが実装するつもりはない」とのことで、Lazy<T>
を使った非同期での遅延初期化を行う場合は注意が必要というお話。
最近は ○○.Shared
とか △△.Instance
とかよく見ますね。自分も静的クラスではなくこのパターンを良く使います。
アクセスの度にロックするのもなーと思ってシングルトンのマルチスレッド対応について色々と調べていたら、このパターン(遅延初期化)の実装には注意が必要みたいですね。
C# クラス実装例
上記の資料ほぼそのままですがピュア C# だとこんな感じですかね。ロックではなく Interlocked.CompareExchange
を使ってます。
lock
の方が早いという説がありますが、高速化のためのダブルチェックなので気にしなくて良いでしょう。
// volatile を付けると、変数へのアクセス順序が入れ替わり得る最適化(シングルスレッドなら問題ない)を抑制できる
volatile static MySingleton? _instance;
public static MySingleton Instance
{
get
{
if (_instance == null)
{
// 冒頭のヌルチェックが無いとアクセスの度にロック処理を行うことになってしまう
Interlocked.CompareExchange(ref _instance, FactoryMethod(), null);
}
return _instance;
}
}
最初のヌルチェックを複数のスレッドがパスした場合、Interlocked で new() した後に初期化するロジックだと別のスレッドは初期化が済んでいないインスタンスを返す可能性がある。Interlocked でファクトリーメソッドを使う理由
if (Interlocked.CompareExchange(ref _instance, new(), null) == null)
{
// ココで初期化を行う(テンプレとしてよく見るコード? 当初はなんとなく使ってしまっていた)
}
// new() の後の if 文に入るスレッドは最初の一つのみで、他のスレッドは初期化を待たず return してしまう
return _instance;
Unity の場合……
UnityEngine.Object
の場合はダブルチェックの2回目をメインスレッド上で実行する方法が必要です。
そして Unity 2023 で遂に! 待望の! Awaitable
が実装されました。
--
が!!!
public static MySingleton Instance
{
async get // 👈👈👈
{
if (_instance == null)
{
await Awaitable.MainThreadAsync();
if (_instance == null)
{
_instance = MySingleton.Create();
}
}
return _instance;
}
}
async get
なんていう非同期ゲッターは実装出来ません!!
で、👇 👇 👇
// volatile!!
volatile static MySingleton? _instance;
public static MySingleton Instance
{
get
{
if (_instance == null)
{
InitializeInstance();
}
return _instance;
}
}
// ゲッターはイベントと捉えて async void を良しとすべき?? つーかこれじゃ多分想定通りに動かないよね??
static async void InitializeInstance()
{
await Awaitable.MainThreadAsync();
if (_instance == null)
{
_instance = MySingleton.Create();
}
}
書いてみたけど多分ダメでしょうね。。。
非同期 get
set
問題
上記のコメントの通り多分まともに動かないですね。メインスレッドに処理が移るその合間にゲッターの return
に到達する可能性があります。
(しないケースの方が多いかもしれませんがそれは一番厄介なパターンのバグですね)
実行環境: https://dotnetfiddle.net/簡易的なテストコード
using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
Test();
Console.WriteLine("=== 完 ===");
}
static async void Test()
{
await Task.Delay(1000);
Console.WriteLine("見えてるーー?");
}
}
Awaitable の実装次第ですが、やってることが同期コンテキストと同様の処理なら性質上ミリ秒単位の遅延は絶対に入ります。
対処方法
対処するには UnitySynchronizationContext
を参考に同期的に処理されるようにする必要があります。
--
ナシ。参考資料を参照。Lazy<T>
もありやなしや。
https://learn.microsoft.com/ja-jp/dotnet/api/system.lazy-1
--
というか普通に Unity の同期コンテキストに Send
した方が良いでしょう。
(なんで素直に RunOnMainThread
を実装してくれなかったんだ。。。色んなライブラリがそれぞれ実装するぐらい Unity には必要なものだろ。。。)
- https://qiita.com/odayushin/items/fd729c2752121c84e0e6
- https://github.com/googlesamples/unity-jar-resolver/blob/master/source/VersionHandlerImpl/src/RunOnMainThread.cs
- https://github.com/firebase/firebase-unity-sdk/blob/main/app/platform/Dispatcher.cs#L23
TODO: これでも良さそう
Awaitable.MainThreadAsync().GetAwaiter().GetResult();
ダメでした。詳細は以下に。
--
なぜ await することで実行スレッドが変わるのかというと、continuation をメインスレッドの同期コンテキストに Post してるから。要するに上記のメインスレッドディスパッチャーと同じことをしている。
参考資料
備忘録。
ArrayPool.Shared
静的フィールドの初期化時にインスタンスを挿してますね。
.Value
が付いても良いなら Lazy<T>
Lazy<T>
という手も。ただし、、、
C# に AsyncLazy<T>
は存在しない
実装の予定もないようです。
前出のゲッターの例の通り、非同期処理というのはメソッドに async
を付ければ良いという訳ではないので Lazy<T>(async () => { ... })
としても何の意味もありません。普通にバグるので注意。
AsyncLazy<T>
の実装方法は以下の2011年(!)の記事を参照。
async set
/ async get
2020 年の投稿で Unanswered。実装の予定は無さそうな?
async void
で良いのか問題
(良くないです)
volatile
は Java 限定の話?
冒頭の JPCERT の記事は Java を例にしています。C# の volatile
については以下の通り。
注意
マルチプロセッサ システムでの揮発性読み取り操作では、任意のプロセッサがそのメモリ位置に書き込んだ最新の値を取得することが保証されません。 同様に、揮発性書き込み操作では、書き込まれた値が他のプロセッサにすぐに表示されることが保証されません。
(volatile はロックなしで何かを保証するわけじゃないよ、と読むべきか。難しい日本語ですね。。。)
ちなみに Java の volatile も条件を満たす場合に限るようです。
C#: Volatile
クラス
もある。
元のスレッドには戻れる/メインスレッドには戻れない
C# のタスク関連の処理は「同じスレッドで実行する/元のスレッドに戻る」系のパラメーターは多い。
ただタスク系の処理は何も指定しないとメインスレッド以外を使うのがデフォなので、処理が重なるとすぐに「元のスレッド=メインスレッド」が崩れてしまう。(実行スレッドの指定が不可能なタスク処理もある?)
外部のライブラリが内部でタスク関連の処理を行っていると、パラメーターをどう弄ってももうメインスレッドには戻れない状態になってることもある。別スレッドで回ってるタスクのコールバックとか。
なので Unity にとっては元のスレッドとかどうでも良くて特定のタイミングで「メインスレッドに戻る」方法が必要。で、Awaitable.MainThreadAsync()
が実装された訳だが。
おわりに
呼び出し側にメインスレッドを強制すれば良いっちゃ良いんだけど RunOnMainThread でも良いよね。呼び出し側も async/await コンテキスト内から呼べるとは限らないんだし。 「待機可能なメインスレッド/バックグラウンドスレッド」を待つと、なんとその後は実行スレッドが代わります! なんて変な構文を入れて RunOnMainThread は入れない理由なんてなくない? 素直に RunOnMainThread の公式対応マジで頼む。 長いけど API として色々おかしなことになるけど (長めの駄文
await Awaitable.MainThreadAsync();
が Unity 特有の事情からスゴイ便利に使えるのは分かるけど、setter
getter
での利用は想定してない?UnitySynchronizationContext.Default
でも良い。SynchronizationContext.Current
は「メインスレッドで実行するための同期コンテキストを得るためにメインスレッドで実行しなきゃならない」からダメなんだ。await Awaitable.MainThreadAsync(Action)
のオーバーロードの追加もあり。MoveToMainThreadAsync()
LeaveFromMainThreadAsync()
とかそういう名前じゃない感じ、もともとは RunOnMainThread 相当だった疑惑がある。メインスレッドでアクションを実行してその完了を待つなら自然だし await しなければ待たずに投げっぱなしにも出来るから便利。await MainThreadAsync() じゃ漠然とし過ぎててサーマルスロットリング対策で処理が落ち着くのを待つ? とか捉えようが色々とある。まさか実行スレッドを await で変えるとか思わんテクニックとしては面白いけど)
--
Awaitable ちょっと謎ですね。2023 LTS でしれっと変わるか?
以上です。お疲れ様でした。