.NETのプロパティから非同期メソッドを呼ぶと発生する問題(1)

.NETでasync/awaitが利用できるようになって久しいですが、プロパティの変更通知などの処理をセッターメソッド内で行うときに非同期メソッドを呼び出してしまうとレースコンディションにより意図しない問題が発生することがある…という例を簡単なサンプルで紹介します。


問題

今回の問題を再現するサンプルソースです。必要な部分のみ書いてかなり簡略化しています。
※ソース中のTask.Delay()/Thread.Sleep()は疑似的な時間の掛かる処理を表しています。

var asyncService = new AsyncService();
var someClass = new SomeClass(asyncService);

// メインループ
for (var i = 0; i < 100; i++)
{
    await Task.Delay(new Random().Next(100));
    someClass.Prop1 = $"Prop1={i}";

    await Task.Delay(new Random().Next(100));
    someClass.Prop2 = $"Prop2={i}";

    Console.WriteLine($"Count={someClass.Count}");
}

internal class SomeClass
{
    public SomeClass(AsyncService myAsyncClass)
    {
        _service = myAsyncClass;
    }

    public AsyncService _service;

    public string Prop1
    {
        // getは省略
        set
        {
            // 〜〜 ここでセッター処理を行う 〜〜
            // 変更通知処理の終了を待機しない(できない)
            _service.NotifyProp1ChangedAsync();
        }
    }

    public string Prop2
    {
        // getは省略
        set
        {
            // 〜〜 ここでセッター処理を行う 〜〜
            // 変更通知処理の終了を待機しない(できない)
            _service.NotifyProp2ChangedAsync();
        }
    }

    public int Count => _service.Count;
}

internal class AsyncService
{
    public int Count { get; set; } = 0;

    public async Task NotifyProp1ChangedAsync()
    {
        await Task.Delay(new Random().Next(100));
        Count++;
    }

    public async Task NotifyProp2ChangedAsync()
    {
        await Task.Delay(new Random().Next(100));
        Count--;
    }
}

このコードサンプルでは

  • メインループでSomeClassのプロパティProp1Prop2を設定。
  • 各プロパティのセッターでAsyncServiceクラスに状態変更を通知する非同期メソッドNotifyProp1ChangedAsyncNotifyProp2ChangedAsyncを待機せずファイアーアンドフォーゲットで呼び出し。
  • 非同期メソッドNotifyProp1ChangedAsyncNotifyProp2ChangedAsyncにてAsyncServiceクラスの共有リソースプロパティCountの値を更新。

のような処理を行っていますが、レースコンディションが発生して共有リソースCountプロパティの状態が不定になってしまうことが分かります。

ちなみに今回の問題は意図的に警告を抑えていない限り、IDEのエラー一覧に

CS4014: 現在のメソッドでは、Task または Task<TResult> を返す非同期メソッドを呼び出すため、await 演算子は結果に適用されません。非同期メソッド呼び出しにより、非同期タスクが開始されます。しかし、await 演算子が適用されないため、プログラムはタスクが完了するのを待たずに継続されます。ほとんどの場合、この動作は期待されているものではありません。通常、呼び出しているメソッドの他のアスペクトは呼び出し結果に依存します。または最低限でも、呼び出されたメソッドは、呼び出しを含んでいるメソッドから復帰する前に完了していることが必要とされます。

同様に重要な問題として、呼び出された非同期メソッドでどんな例外が発生するかということがあります。Task または Task<TResult> を返すメソッドで発生した例外は、返されたタスクに保管されます。タスクを待機しないか例外を明示的にチェックしない場合、例外は失われます。タスクを待機する場合、例外は再スローされます。

ベスト プラクティスとして、常に呼び出しを待機するようにしてください。

警告を表示しないことを考慮するのは、非同期の呼び出しの完了の待機を行う必要がなく、呼び出されたメソッドが例外を起こさないことが確実な場合だけにしてください。その場合、呼び出しのタスク結果を変数に割り当てて、警告を表示しないようにできます。

のような警告が出力されているため、問題がある部分はすぐに判断することができます。

実行結果:

Count=0
Count=1
Count=1
Count=1
Count=1
Count=0
Count=1
Count=0
Count=1
Count=0
Count=1
Count=0
Count=1
Count=0
Count=0
Count=1
Count=1
Count=1
Count=0
Count=-1
Count=1
Count=1
Count=0
・
・
・


対応

プロパティ処理内で時間のかかる処理や非同期処理を呼び出す必要がある場合、設計としてプロパティを採用することに問題があるといえることから、例えば

var asyncService = new AsyncService();
var someClass = new SomeClass(asyncService);

for (var i = 0; i < 100; i++)
{
    await Task.Delay(new Random().Next(100));
    // プロパティではなくプロパティに値をセットする非同期メソッドの実行を待機する
    await someClass.SetProp1($"Prop1={i}");

    await Task.Delay(new Random().Next(100));
    // プロパティではなくプロパティに値をセットする非同期メソッドの実行を待機する
    await someClass.SetProp2($"Prop2={i}");

    Console.WriteLine($"Count={someClass.Count}");
}

internal class SomeClass
{
    public SomeClass(AsyncService myAsyncClass)
    {
        _service = myAsyncClass;
    }

    public AsyncService _service;

    // Prop1/Prop2の取得処理は省略

    public async Task SetProp1(string value)
    {
        // 〜〜 ここでプロパティセット処理を行う 〜〜
        await _service.NotifyProp1ChangedAsync();
    }

    public async Task SetProp2(string value)
    {
        // 〜〜 ここでプロパティセット処理を行う 〜〜
        await _service.NotifyProp2ChangedAsync();
    }

    public int Count => _service.Count;
}

internal class AsyncService
{
    public int Count { get; set; } = 0;

    public async Task NotifyProp1ChangedAsync()
    {
        await Task.Delay(new Random().Next(100));
        Count++;
    }

    public async Task NotifyProp2ChangedAsync()
    {
        await Task.Delay(new Random().Next(100));
        Count--;
    }
}

のようにプロパティではなく非同期関数として待機することで問題を回避可能です。

実行結果:

Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
Count=0
・
・
・

ちなみに「わざわざ非同期メソッドにしなくてもプロパティセッター内で非同期関数をWait()やResultを使って同期にすれば良いのでは?」と考えるかもしれませんが、SynchronizationContextを使っているWinForms/WPF/ASP.NET(Core)のようなフレームワークの場合、非同期関数をConfigureAwait(false)で適切に実装していないとデッドロックを引き起こす危険性があるためオススメできません。


以上です。

シェアする

  • このエントリーをはてなブックマークに追加

フォローする