20190209の新人プログラマ応援に関する記事は1件です。

C# async/await でつまずきそうなところからの逆引き解説

async/await による非同期処理に関する記事はたくさん存在していますが、うまくいかない結果からの逆引きスタイルの記事が見当たらなかったのでまとめてみました。

次のようなことで困っていませんか?

この呼び出しを待たないため…というコンパイラ警告が検出される

Visual Studio から次のような警告が検出されます。

この呼び出しを待たないため、現在のメソッドの実行は、呼び出しが完了するまで続行します。呼び出しの結果に 'await' 演算子を適用することを検討してください。

警告が検出されるコード
private void BtnHeavyAction_Click(object sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    // 次のコンパイル警告が検出されます。
    // この呼び出しを待たないため、現在のメソッドの実行は、呼び出しが完了するまで続行します。
    // 呼び出しの結果に 'await' 演算子を適用することを検討してください。
    HeavyActionAsync();
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    await Task.Delay(3000);
    Debug.WriteLine("HeavyActionAsync exit");
}

実行してみると、HeavyActionAsync メソッドの終了を待たずに BtnHeavyAction_Click メソッドが終了していることが分かります。

出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter
BtnHeavyAction_Click exit
HeavyActionAsync exit

原因

コンパイラがコーディングミスの可能性があると解釈するため。

対処方法

  1. HeavyActionAsync メソッドが終わるまで待ちたい場合は await をつけて呼び出します。
  2. HeavyActionAsync メソッドは別スレッドで実行させておき、次へ進めたい場合は戻り値を受けます。

1. await 呼び出しで待機する

HeavyActionAsync メソッドの終了を待ちたいのであれば、警告の内容のとおり、await をつけます。BtnHeavyAction_Click メソッドには async をつけます。await 呼び出しを含むメソッドには async をつける必要があります。async はコンパイラに対して await 呼び出しを行うことを明示的に示すためのキーワードです。面倒だと感じることもありますが、コーディングミスを減らすためのものだと思います。

HeavyActionAsyncの終了を待つコード
// async をつけます。
private async void BtnHeavyAction_Click(object sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    // await をつけます。
    // BtnHeavyAction_Click に async をつけないと次のコンパイルエラーが検出されます。
    // 'await' 演算子は、非同期メソッド内でのみ使用できます。
    // このメソッドに 'async' 修飾子を指定し、戻り値の型を 'Task' に変更することを検討してください。
    await HeavyActionAsync();
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    await Task.Delay(3000);
    Debug.WriteLine("HeavyActionAsync exit");
}

HeavyActionAsync メソッドが終了してから BtnHeavyAction_Click メソッドが終了していることが分かります。

出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter
HeavyActionAsync exit
BtnHeavyAction_Click exit

2. 戻り値を受ける(待機しない)

HeavyActionAsync メソッドの戻り値である Task を変数で受け取るようにすると、コンパイラは呼び出しを待つ必要がないことが明示的に示されたと解釈し、警告として検出しなくなります。但し、この方法には HeavyActionAsync メソッドで例外が発生したときに呼び出し元でキャッチできないという問題があります。これについては後述します。

警告が検出されなくなるコード
private void BtnHeavyAction_Click(object sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    // 戻り値を受けます。
    // このような場合、私は nowait のような変数名にすることが多いです。
    // 人間に対しても「待つ必要がない」ということが伝わるからです。
    Task nowait = HeavyActionAsync();
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    await Task.Delay(3000);
    Debug.WriteLine("HeavyActionAsync exit");
}

実行結果は警告が表示されていたときと同じです。HeavyActionAsync メソッドの終了を待たずに BtnHeavyAction_Click メソッドが終了します。

出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter
BtnHeavyAction_Click exit
HeavyActionAsync exit

呼び出したメソッドが終了しない

Task を返すメソッドが終了するまで待機しようとして Wait() を呼び出したとき、いつまでたってもそのメソッドが終了せずにアプリケーションがフリーズします。

フリーズするコード1
private void BtnHeavyAction_Click(object sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    // ここでフリーズします。
    HeavyActionAsync().Wait();
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    await Task.Delay(3000);
    Debug.WriteLine("HeavyActionAsync exit");
}

HeavyActionAsync メソッドが終了せず、アプリケーションがフリーズします。

出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter

Task<T> を返すメソッドの場合、戻り値を Result で取得しようとしたところでフリーズします。

フリーズするコード2
private void BtnHeavyFunc_Click(object sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyFunc_Click enter");
    // ここでフリーズします。
    int result = HeavyFuncAsync(2, 3).Result;
    Debug.WriteLine($"result={result}");
    Debug.WriteLine("BtnHeavyFunc_Click exit");
}
private async Task<int> HeavyFuncAsync(int x, int y)
{
    Debug.WriteLine("HeavyFuncAsync enter");
    await Task.Delay(3000);
    Debug.WriteLine("HeavyFuncAsync exit");
    return x * y;
}

HeavyFuncAsync メソッドが終了せず、アプリケーションがフリーズします。

出力結果
BtnHeavyFunc_Click enter
HeavyFuncAsync enter

原因

デッドロックが発生するため。Task.Wait() や Task.Result は Task が終わるまでスレッドをブロックします。その Task の内部で await 呼び出しを行っている場合、その呼び出しが終わるときに呼び出し元のスレッドに戻ろうとします。しかし、ブロックされているため戻ることができません。Task が終わらなくなり、フリーズします。

対処方法

前述のように await 呼び出しで待機しましょう。

await呼び出しでブロックすることなく待機
// async をつけます。
private async void BtnHeavyAction_Clickobject sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    // await をつけます。
    await HeavyActionAsync();
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    await Task.Delay(3000);
    Debug.WriteLine("HeavyActionAsync exit");
}
出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter
HeavyActionAsync exit
BtnHeavyAction_Click exit

Task.Wait() や Task.Result は扱いが難しいです。この例のようなシンプルな非同期処理であればどのような結果になるかがわかりやすいですが、同時に複数の非同期処理が実行されるようなケースで安全にコントロールすることは難しいです。そもそも UI をブロックしないようにするために非同期処理を利用しているのに、その非同期処理が終わるまでブロックしてしまっては本末転倒です。

補足

HeavyActionAsync メソッドの内部の await 呼び出しに対して ConfigureAwait(false) をつけると、とりあえずフリーズすることは回避できます。ConfigureAwait(false) を簡単に説明すると、メソッドの終了後に呼び出し元に戻ってこなくてもよいということを指定します。ブロックされたスレッドに戻ろうとしなくなるため、HeavyActionAsync メソッドは終了し、BtnHeavyAction_Click メソッドに戻ってくるようになります。

HeavyActionAsyncは終了するようになるコード
private void BtnHeavyAction_Click(object sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    // HeavyActionAsync の実行中 UI はブロックされますが、フリーズすることはなくなります。
    HeavyActionAsync().Wait();
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    // ConfigureAwait(false) をつけます。
    await Task.Delay(3000).ConfigureAwait(false);
    Debug.WriteLine("HeavyActionAsync exit");
}

例外をキャッチできない

Task を返すメソッドを await をつけずに呼び出した場合、そのメソッド内で例外が発生しても呼び出し元でキャッチすることはできません。

例外をキャッチできないコード
private void BtnHeavyAction_Clickobject sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    try
    {
        Task nowait = HeavyActionAsync();
    }
    catch (Exception ex)
    {
        // ここではキャッチできない。
        Debug.WriteLine($"{ex.GetType().Name}:{ex.Message}");
    }
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    await Task.Delay(3000);
    throw new Exception("HeavyActionAsyncで例外が発生しました。");
    Debug.WriteLine("HeavyActionAsync exit");
}

Visual Studio の出力コンソールには「例外がスローされました」と出力されますが、これはフレームワーク内部から出力されたものです。

出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter
BtnHeavyAction_Click exit
例外がスローされました: 'System.Exception' (Sample.exe の中)

原因

Task を用いた非同期処理では、発生した例外は一旦 Task で管理され、非同期処理が完了したときに呼び出し元へ排出される形でスローされます。await をつけない呼び出しでは Task から排出される機会がなくなり、結果的に例外は飲み込まれます。

対処方法

やはりこの場合も await 呼び出しを行いましょう。

補足

Task を操作したときに例外がスローされる

「この呼び出しを待たないため…」の警告を消す方法として Task を変数で受ける方法を紹介しましたが、Task には Wait, Result や IsComleted, IsFault など、タスクの完了を参照するメソッドやプロパティがあります。タスク内で例外が発生した場合、これらにアクセスしたタイミングで呼び出し元に例外が排出されます。

Taskから例外が排出されるコード
private void BtnHeavyAction_Clickobject sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    try
    {
        Task task = HeavyActionAsync();
        // タスクが完了するまでポーリングしたりすると例外が排出されます。
        // ※ただ待ちたいだけなら await を使いましょう。
        while (task.IsCompleted) {
        }
        // await task;
    }
    catch (Exception ex)
    {
        // ここで例外がキャッチされます。
        Debug.WriteLine($"{ex.GetType().Name}:{ex.Message}");
    }
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    await Task.Delay(3000);
    throw new Exception("HeavyActionAsyncで例外が発生しました。");
    Debug.WriteLine("HeavyActionAsync exit");
}

catch 句の中で出力したデバッグメッセージが出力され、キャッチできていることが分かります。

出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter
BtnHeavyAction_Click exit
例外がスローされました: 'System.Exception' (Sample.exe の中)
例外がスローされました: 'System.Exception' (mscorlib.dll の中)
Exception:HeavyActionAsyncで例外が発生しました。
BtnHeavyAction_Click exit

Wait や Result で完了を待っていた場合は、発生した例外が AggregateException にラップされてスローされます。これは Task クラスの仕様です。

Taskから例外が排出されるコード
private void BtnHeavyAction_Clickobject sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    try
    {
        // Wait で完了を待つ。
        HeavyActionAsync().Wait();
    }
    catch (Exception ex)
    {
        // ここで例外がキャッチされます。
        Debug.WriteLine($"{ex.GetType().Name}:{ex.Message}");
    }
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    await Task.Delay(3000);
    throw new Exception("HeavyActionAsyncで例外が発生しました。");
    Debug.WriteLine("HeavyActionAsync exit");
}

catch 句の中で出力したデバッグメッセージから、例外の型が AggregateException であることが分かります。発生した例外を取得するには、AggregateException の InnerExceptions を参照してください。

出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter
BtnHeavyAction_Click exit
例外がスローされました: 'System.Exception' (Sample.exe の中)
例外がスローされました: 'System.AggregateException' (mscorlib.dll の中)
AggregateException:1 つ以上のエラーが発生しました。
BtnHeavyAction_Click exit

コントロールにアクセスすると例外がスローされる

非同期処理の中でコントロールにアクセスしたときに次のような例外がスローされます。

有効ではないスレッド間の操作: コントロールが作成されたスレッド以外のスレッドからコントロール '' がアクセスされました。

例外がスローされるコード
private async void BtnHeavyAction_Clickobject sender, EventArgs e)
{
    Debug.WriteLine("BtnHeavyAction_Click enter");
    try
    {
        await HeavyActionAsync();
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"{ex.GetType().Name}:{ex.Message}");
    }
    Debug.WriteLine("BtnHeavyAction_Click exit");
}
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    // コントロールにアクセス
    btnHeavyAction.Enabled = false;
    await Task.Delay(3000).ConfigureAwait(false);
    // コントロールにアクセス
    btnHeavyAction.Enabled = true;
    Debug.WriteLine("HeavyActionAsync exit");
}

"HeavyActionAsync enter" は出力され、"HeavyActionAsync exit" は出力されていません。Task.Delay の後の btnHeavyAction.Enabled = true で例外が発生していることが分かります。

出力結果
BtnHeavyAction_Click enter
HeavyActionAsync enter
例外がスローされました: 'System.InvalidOperationException' (System.Windows.Forms.dll の中)
例外がスローされました: 'System.InvalidOperationException' (mscorlib.dll の中)
InvalidOperationException:有効ではないスレッド間の操作: コントロールが作成されたスレッド以外のスレッドからコントロール '' がアクセスされました。
BtnHeavyAction_Click exit

原因

例外メッセージの通り、フォームやコントロールは UI スレッド(≒アプリケーションのメインスレッド)以外からアクセスすることができません。この例では Task.Delay の呼び出しに対して ConfigureAwait(false) を指定しています。これがポイントです。Task.Delay の後は呼び出し元のスレッドには戻らずに別のスレッドで実行されますので、コントロールにアクセスすると例外が発生します。

// ここまでは呼び出し元スレッド
await Task.Delay(3000).ConfigureAwait(false);
// ここから後は別のスレッド

対処方法

この例では ConfigureAwait(false) をつけなければ呼び出し元スレッドに戻りますので例外は発生しなくなります。ただ、複雑な非同期処理ではどのスレッドで実行されるかが分かりにくくなる場合があります。Control の InvokeRequired プロパティと Invoke メソッドを使用し、確実に UI スレッドで実行されるようにします。

  • Control.Invoke メソッド
    • 指定された処理をそのコントロールが生成されたスレッドで実行します。
    • 多少オーバーヘッドが発生します。
  • Control.InvokeRequired プロパティ
    • そのコントロールにアクセスするときに Invoke を使用する必要があるかどうかを取得します。
必要に応じてInvokeするコード
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    InvokeIfRequired(btnHeavyAction, () => btnHeavyAction.Enabled = false);
    await Task.Delay(3000).ConfigureAwait(false);
    InvokeIfRequired(btnHeavyAction, () => btnHeavyAction.Enabled = true);
    Debug.WriteLine("HeavyActionAsync exit");
}
private void InvokeIfRequired(Control control, Action action)
{
    if (control.InvokeRequired)
    {
        control.Invoke(action, new object[] { });
    }
    else
    {
        action();
    }
}

InvokeIfRequired メソッドを Control に対する拡張メソッドとして定義しておくと、簡潔に記述できるようになります。

Controlに対する拡張メソッド
/// <summary>
/// 
/// </summary>
public static class ControlExtensions
{
    public static void InvokeIfRequired(this Control control, Action action)
    {
        if (control.InvokeRequired)
        {
            control.Invoke(action, new object[] { });
        }
        else
        {
            action();
        }
    }

    public static T InvokeIfRequired<T>(this Control control, Func<T> func)
    {
        if (control.InvokeRequired)
        {
            return (T)control.Invoke(func, new object[] { });
        }
        else
        {
            return func();
        }
    }
}
拡張メソッドで書き換え
private async Task HeavyActionAsync()
{
    Debug.WriteLine("HeavyActionAsync enter");
    btnHeavyAction.InvokeIfRequired(() => btnHeavyAction.Enabled = false);
    await Task.Delay(3000).ConfigureAwait(false);
    btnHeavyAction.InvokeIfRequired(() => btnHeavyAction.Enabled = true);
    Debug.WriteLine("HeavyActionAsync exit");
}

これから非同期処理を学びたいと考えている人へ

これから学ぶなら async/await

.NET Framework ではいくつかの非同期処理の仕組みが提供されています。.NET Framework 4.5 以降であれば、機能面や使い勝手から考えると async/await 一択になると思います。ただ、異なる仕組みを混在させると混乱のもとになります。もしあなたが関わっているプロジェクトで async/await 以外の仕組みで非同期処理を制御しており、それがプロジェクト標準ルールである場合、無理に async/await を使うことはお勧めしません。プロジェクトメンバーと十分に検討してください。

インターネットなどの情報を参考にするとき、その説明に次のような型やメソッドが現れた場合は古い時代の仕組みである可能性が高いです。

  • Thread.Start メソッド
  • ThreadStart クラス
  • AsyncCallback クラス
  • IAsyncResult インターフェース
  • BeginInvoke メソッド
  • EndInvoke メソッド
  • BackgroundWorker クラス

もっと詳しい情報が知りたくなった人へ

【Qiita】Taskを極めろ!async/await完全攻略
【Qiita】C# 今更ですが、await / async
【Qiita】C# Taskの待ちかた集
【SE(たぶん)の雑感記】async、awaitそしてTaskについて(非同期とは何なのか)
【kekyoの丼】できる!C#で非同期処理(Taskとasync-await)
【kekyoの丼】技術解説 – 非同期

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む