LoginSignup
2
2

マルチスレッド対応のシングルトン実装(遅延初期化/JPCERT適合/非同期)

Last updated at Posted at 2024-03-28

更新履歴: 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 でファクトリーメソッドを使う理由

最初のヌルチェックを複数のスレッドがパスした場合、Interlocked で new() した後に初期化するロジックだと別のスレッドは初期化が済んでいないインスタンスを返す可能性がある。

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 には必要なものだろ。。。)

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() が実装された訳だが。

おわりに

長めの駄文

await Awaitable.MainThreadAsync(); が Unity 特有の事情からスゴイ便利に使えるのは分かるけど、setter getter での利用は想定してない?

呼び出し側にメインスレッドを強制すれば良いっちゃ良いんだけど RunOnMainThread でも良いよね。呼び出し側も async/await コンテキスト内から呼べるとは限らないんだし。

「待機可能なメインスレッド/バックグラウンドスレッド」を待つと、なんとその後は実行スレッドが代わります! なんて変な構文を入れて RunOnMainThread は入れない理由なんてなくない? 素直に RunOnMainThread の公式対応マジで頼む。

長いけど UnitySynchronizationContext.Default でも良い。SynchronizationContext.Current は「メインスレッドで実行するための同期コンテキストを得るためにメインスレッドで実行しなきゃならない」からダメなんだ。

API として色々おかしなことになるけど await Awaitable.MainThreadAsync(Action) のオーバーロードの追加もあり。

MoveToMainThreadAsync() LeaveFromMainThreadAsync() とかそういう名前じゃない感じ、もともとは RunOnMainThread 相当だった疑惑がある。メインスレッドでアクションを実行してその完了を待つなら自然だし await しなければ待たずに投げっぱなしにも出来るから便利。await MainThreadAsync() じゃ漠然とし過ぎててサーマルスロットリング対策で処理が落ち着くのを待つ? とか捉えようが色々とある。まさか実行スレッドを await で変えるとか思わんテクニックとしては面白いけど)

--

Awaitable ちょっと謎ですね。2023 LTS でしれっと変わるか?

以上です。お疲れ様でした。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2