Edited at

非同期理解のためにasync/awaitとTaskの基礎を学んだ話


最初に

どうも、ろっさむです。

引き続きUnity非同期を完全に理解するために今回はasync/awaitについて見ていこうと思います。

ちなみにこれが2018年最後の記事となります。お疲れ様でした。

Unity非同期完全に理解するための歩み:

- 【Part0】タスクとスレッドとプロセスの違いを知って非同期完全理解に近付く

- 【Part1】Unity非同期完全に理解するための第一歩~非同期処理とは何か~

- 【Part2】古来よりUnity非同期を実現していたコルーチンとは何者か?

- 【Part3】非同期理解のためにasync/awaitとTaskの基礎を学んだ話 ←今ココ!


async/await?

C#5.0以降から追加された機能です。Unity2018以降ではC#6.0が使用できるため、このasync/awaitも使用することが可能です。

async/awaitTaskクラスと紐付いた言語構文の一つです。

多くの場面でシングルスレッド的な動作になり、lock不要となったりします。

awaitとは「待つ」と言う意味で、awaitをつけた処理は一旦別スレッドに制御を移した上で、処理完了後に続きの処理を再開します。「式」が書ける場所であればどこにでも書くことができます。C#6.0以降はcatch/finallyの中でもawait演算子を使用することができます。

asyncawaitをメソッド内で使用する際に、メソッドにつける必要があるキーワードです(async:Asynchronous:非同期)。ただしメソッドにasyncをつけただけでは通常のメソッドと挙動は変わりません。実際に意味を持つのはawaitの方となります。

async/await「処理を非同期的に行う仕組み」ではなく「非同期の処理を待つための仕組み」となります。

構文としては以下のようになります。

async 戻り値の型 関数名( 引数 )

{
同期的な処理 または 非同期メソッドをawaitで待機するコード
}

asyncをつけたメソッドの戻り値は

非同期の完了を待つ必要がない場合にvoid

完了を待つ必要がある場合には非同期メソッドビルダー(Task、Task<T>、ValueTask<T>など)を実装した型である必要がある(ValueTask<T>はC#7.0から)

となります。ただvoidはメリットが薄いためそこまで使われていない印象です。


非同期メソッドビルダーの実装型

Taskクラスは便利な非同期メソッドが数多く用意されています。


Task

非同期メソッドの完了を待つ場合、返り値がない場合Taskを用います。

async Task WaitMethod()

{
await Task.Delay(3000);
// returnステートメント不要
}


Task.Run()

引数として渡したデリゲートを別スレッドで実行するタスクを作成することができます。重たい処理をUIスレッドではなく別スレッドで行いたい場合に使用します。

Task.Run(デリゲート(ラムダ式など))

var x = 500;

// 重い処理なのでUIスレッドから逃す
await Task.Run(() =>
{
HeavyWork(x)
});

// ラムダ式を与えるコード
await Task.Run(() => {
Console.WriteLine("処理1");
Thread.Sleep(3000);
Console.WriteLine("処理2");
Thread.Sleep(3000);
});


Task.WhenAll()

全てのタスクが完了したら完了とするタスクを作成することができます。

var x = 500;

await Task.WhenAll(
HeavyWork1(x),
HeavyWork2(x),
HeavyWork3(x));


Task<T>

戻り値が欲しい場合Task<T>を使います。

private async Task<string> Hoge()

{
await Task.Delay( 3000 );

return "ほにゃー";
}

string result = await Hoge();

Console.WriteLine($"{result}");


ValueTask

C#7.0以降から使用できます。どんな場合に使用するかと言うと、まず以下のコードを確認してください。

async Task<string> Hoge(int num)

{
if(num == 100)
{
await Task.Delay(3000);
return "OK";
}

return "NG";
}

上記コードですと、numの値が100の場合にのみ非同期処理が行われます。この場合に、ほんの一部分しか非同期処理が行われないのにTask(参照型)のインスタンスを生成するとコストが大きくなってしまいます。ここでValueTask構造体(値型)を使うことで非同期が必要な場合にのみ内部でTaskのインスタンスが作られるようになり、同期的処理がメインのメソッドのパフォーマンスが大幅に向上します。

なぜ、TaskとValueTaskで使い分けを行うかと言うと、WhenAllがTaskしか受け取らないことが起因しています。Task.WhenAllはneue.ccさんのブログに寄ると、


Taskのinternalなメソッドに依存して最適化が施されているので、外部からはどうしても非効率的なWhenAllしか作れない仕様になっています(クソですね!)。


とのことです。

悲しいですね。


非同期ラムダ式

asyncを付けたラムダ式を非同期ラムダ式と呼びます。

myButton.Click += async ( sender, e ) => 

{
// なんか処理
await Task.Delay( 3000 );
myTextBox.Text = "OK";
};


注意点

async/awaitは実は必ずしも非同期実行にはなりません。Taskクラスの値をawaitする際に、タスクが既に完了している可能性があるからです。

またTask自体もスレッドの存在を意識しない、「同期的な処理のまとまり」とだけみなされているため、どのスレッドが対象のタスクを実行するかは定められていません。TaskクラスでのRun()等は可能な限り同じスレッドを使いまわそうとします(スレッドプール上で動作)。


その他


Awaitableパターン

Awaitableパターンを満たしていれば、Task型以外の型をawaitできるようになります。awaitしたいクラスにGetAwaiter拡張メソッドを実装すれば良いようです(詳細は省略)。


例外

async/awaitがキャンセルされた場合には、awaitされた元にOperationCanceledExceptionと言う特別な例外が投げられます。


最後に

本記事を書くことでasync/awaitを少し理解できたような気がします。ただ、実際の使い心地的なところやパフォーマンス面ではUniRxを使うのが主流となっているようです。今回はUniRxについては触れませんでしたが、また色々調べて使ってみた記事などあげ、非同期完全理解に近づけたらなと思います。それでは有難うございました。


参考