土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

C#の非同期タスク処理でConfigureAwait(false)してもメインスレッドに戻る場合がある件について(前編)

前説(飛ばして可)

 ぐあー忙しい!(心の叫び)

 必要にかられてこの2週間くらいAddressables(AssetBundle)のドキュメントを読めるだけ読み漁ってます。

 商業ゲームでは万単位のリソースデータ(テクスチャファイルとかの事)を適切に管理する必要があって、Addressablesはその仕組みを提供する公式パッケージです。プロユースな物なので機能が膨大かつ使い方が難しい。これは同人誌一冊書けるレベルかもなーとぼんやり考え中。

 本の話は置いといて、Addressablesを調べているうちに関連する非同期処理についても学び直すことになり、その中で以前から気になっていた挙動について調べたので、その事を記事にしました。今回はC#の挙動の話で、Unityは直接関係無いですすみません。

今回のお題と記事構成

 今回は「非同期処理でConfigureAwait(false)してもメインスレッドに戻る場合があるのはどういうロジックによる物か」という疑問を深掘りしていきます。

 長くなってしまったので前後編に分けています。前編ではasnc/awaitにおける非同期タスク処理の挙動についておさらいし、またConfigureAwait(false)メソッドの機能についても再確認します。

 後編では、awaitの内部処理を読み解きながら、上記の疑問への回答を(土屋が出来る範囲において)試みます。ただ、後編のアップは少し遅れるかも(すみません)。

おさらい1:awaitでのタスクは別スレッドで実行された後メインスレッドに戻る

 ある非同期タスク処理をawaitで実行した時、そのタスクはメインスレッドとは別のスレッドで実行されます。また、そのタスクが終了するとawait後の処理が実行されるのですが、これはメインスレッドで実行されます。

 まず、この挙動をコードで確認してみます。

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private void Button1_Click(object sender, EventArgs e)
    {
        MethodAsync();
    }

    private static async Task MethodAsync()
    {
        Debug.Print($"1:タスク開始前 Id: {Environment.CurrentManagedThreadId}"); //A
        await Task.Run(() => 
            Debug.Print($"2:タスク実行中 Id: {Environment.CurrentManagedThreadId}")
        ); //B
        Debug.Print($"3:タスク実行後 Id: {Environment.CurrentManagedThreadId}"); //C
    }
}

 フォーム上のボタンがクリックされるとButton1_Click()経由でMethodAsync()が実行されます。

 MethodAsync()asyncが付与されたAsyncメソッドです。Asyncメソッドは、awaitを伴う非同期処理コードを実行出来ます。

Task.Run()は、新しいスレッド*1上で、引数で渡されたラムダ式(ここではDebug.Print())を非同期実行します。Environment.CurrentManagedThreadIdは、実行中のスレッドに割り当てられたインデックスを返します*2

 このコードの実行結果はこちら。

//実行結果
1:タスク開始前 Id: 1
2:タスク実行中 Id: 11
3:タスク実行後 Id: 1

 MethodAsync()メソッドの各行の説明を簡単にしておきます。

  • A:まずメインスレッドでDebug.Printを実行します。1はメインスレッドのインデックスです。
  • B:次にawaitを指定したTask.Run()を実行します。別スレッド(Id:11)が用意され、そこでDebug.Printを実行します。Idが11になっていて、メインスレッドとは異なるスレッドで実行されている事がわかります。
  • C:awaitの後で再度Debug.Printを実行します。これはメインスレッド(Id:1)で実行されます。この挙動は「処理がメインスレッドに戻る」と説明される事が一般的です。

おさらい2:awaitは非同期処理を待機する。

 非同期処理について更におさらいしておきます。Task.Run()などの非同期タスクにawaitをつけると、そのタスクの完了を待ってから処理が先に進みます。awaitをつけない場合、非同期タスク処理は別個に実行され、すぐに処理が先に進みます。

 コードで確認してみましょう(以下、MethodAsync()のみ更新します)。

private static async Task MethodAsync()
{
    Debug.Print($"1:タスク開始前 Id: {Environment.CurrentManagedThreadId}"); //A
    //awaitをつけずに非同期タスクを実行する
    Task.Run(() => 
        Debug.Print($"2:タスク実行中 Id: {Environment.CurrentManagedThreadId}")
    ); //B
    Debug.Print($"3:タスク実行後 Id: {Environment.CurrentManagedThreadId}"); //C
}

 先程とほぼ同じコードですが、Bにawaitをつけていません。

 実行結果はこちら。

//実行結果
1:タスク開始前 Id: 1
3:タスク実行後 Id: 1
2:タスク実行中 Id: 9

 非同期タスク処理を待たないので、A→C→Bの順に出力されています。このように、awaitは非同期処理の実行を「待つ」機能がある訳です。

 「せっかく非同期で動かしてるのにどうして待機しなきゃいけないんだ」という気もしまうが、非同期処理では「非同期処理が完了したらその結果を元に後処理をする」というシチュエーションが圧倒的に多いので、こういう風に記述出来るのはとても助かるのです。

おさらい3:await後にメインスレッドに戻したくない時はConfigureAwait(false)を使う

 先程、await後はメインスレッドに戻る事を確認しました。この「メインスレッドに戻る」という処理は、僅かながら処理コストが発生します*3。そのため、メインスレッドに戻る必要が無い時*4には処理を省略して効率を上げたいという需要があります。

 また、メインスレッドに戻る時に、メインスレッド側がTask.Waitなどで待機していてデッドロックが起きるという、有名な「非同期処理トラブルあるある」がありまして、これを回避するために、メインスレッドに戻さない(戻せない)場合があります*5

 このように、メインスレッドに処理を戻す必要が無い/戻したくない場合は、下記のようにConfigureAwait(false)メソッドを使います。

private static async Task MethodAsync()
{
    Debug.Print($"1:タスク開始前 Id: {Environment.CurrentManagedThreadId}"); //A
    await Task.Run(() => 
        Debug.Print($"2:タスク実行中 Id: {Environment.CurrentManagedThreadId}")
    ).ConfigureAwait(false); //B
    Debug.Print($"3:タスク実行後 Id: {Environment.CurrentManagedThreadId}"); //C
}

1:タスク開始前 Id: 1
2:タスク実行中 Id: 10
3:タスク実行後 Id: 10

 非同期タスク処理(B)とawait後(C)が同じインデックスになっています。これは、Cがメインスレッドではなく、Bのスレッド上で実行された事を示しています。

おさらい4:ConfigureAwait(false)でもメインスレッドに戻らないパターン

 おさらい1~3までは、await/asyncの解説記事で一通り出てくる話かと思います。後編に行く前に、ここでもう1パターン見ておきます。

 ConfigureAwait(false)メソッドを記述しても、await後がメインスレッドで実行される場合があります。例えば以下の様なコードの場合。

private static async Task MethodAsync()
{
    Debug.Print($"1:タスク開始前 Id: {Environment.CurrentManagedThreadId}"); //A
    await Task.Delay(0).ConfigureAwait(false); //B
    Debug.Print($"3:タスク実行後 Id: {Environment.CurrentManagedThreadId}"); //C
}

//実行結果
1:タスク開始前 Id: 1
3:タスク実行後 Id: 1

 BのTask.Delayは、別スレッドを用意して、引数で指定した時間(ミリセカンド)待機するメソッドです。ConfigureAwait(false)を実行しているのに、Cがメインスレッド(Id:1)になっています。

 ここでは引数にゼロを指定しているので待機しません。結果、待機用のスレッドは用意されないので、その意味ではCがメインスレッドで実行されるのは自然にも思えます。だってそもそも別スレッドが存在しないんですから。とはいえ、土屋は「これなんか不思議な挙動じゃない?」ともやもしたのです。

 おまけで、Task.Delay()について幾つか組み合わせを見ておくと、こんな感じになります。

await Task.Delay(0);                       //メインスレッドに戻る
await Task.Delay(0).ConfigureAwait(false); //メインスレッドに戻る(これだけおかしい?)
await Task.Delay(1);                       //メインスレッドに戻る
await Task.Delay(1).ConfigureAwait(false); //メインスレッドに戻らない

 うーん、やっぱり不思議な挙動な気がする……!

改めて今回のお題(土屋が知りたいこと)

 ここまでawaitConfigureAwait(false)について調べていて、土屋の中で「これは内部的にどういう挙動になっているんだ?」という疑問が生まれました。

 このConfigureAwait(false)は一体何をしているのでしょうか? 何故このメソッドを実行すると、await後にメインスレッドに処理が戻らなくなるのでしょうか? また、何故おさらい4のように、このメソッドを実行していてもメインスレッドに処理が戻る場合があるのでしょうか?

 一体、await、そしてConfigureAwait(false)は、どんなロジックでこのような挙動を実現しているのでしょうか?

 実はこれは、C#コンパイラによる巧妙なコード生成によって実現されているのです。

後編へ続く

 というわけで以下次回。後編ではawait処理が内部的にどのようなロジックで実現されているかについて見て行きます。

宣伝「Unityシェーダープログラミングの教科書シリーズ」

 土屋は同人誌「Unityシェーダープログラミングの教科書」シリーズを書いています。現在以下の5冊が出ています。

 これらの同人誌は、現在BOOTHの下記ストアにてPDF版を有料頒布しています。

https://s-games.booth.pm/s-games.booth.pm

 Unityのシェーダープログラミング関連について、世界で最も詳細な本だと自負しています。リンク先にサンプルページがあるので、是非御覧になってください。

*1:実際にはスレッドプールにストックされているスレッドを使う

*2:以前はThread.CurrentThread.ManagedThreadIdと書くのが一般的だったけど、.NET4.5以降非推奨らしい

*3:具体的には同期コンテキストのnullチェックとTaskSchedulerのdefaultチェックが毎回発生する

*4:基本的にはawait後にUIアクセスが無い時(UIアクセスはメインスレッドでしか実行できないから)に限られるのかと思うけど良く知らない。

*5:このデッドロックについては解説してる記事が沢山あるので参照してください