🤔

async/await を完全に理解する

2020/10/03に公開

この記事ではasync/awaitについて完全に理解する[1]ことを目標に説明します。

完全に理解するために一部厳密には正確ではない表現をしますがご了承ください。
(明らかに事実と異なる内容が含まれている場合はご指摘いただけると助かります)

ちなみにC#の文法説明になりますが、他の言語でも基本的な考え方は同じはずです。例えばJavaScriptの場合はTask型をPromise型に置き換えていい感じに読んでください。

非同期処理とTask

async/awaitを完全に理解するためには、非同期処理の扱い方について完全に理解する必要があります。

そもそも非同期処理って何?という方はググってください。同期処理と非同期処理の違いについては完全に理解した前提で説明します。

非同期処理は複数のタスクを同時並行に処理するためのものですが、実際にコードを書いていると非同期で処理しているタスクが終わるまで待ちたいケースが圧倒的に多い気がします。

public int RunTaskA()
{
    RunTaskBAsync(); // 非同期に処理される
    // taskBが終わってから続きがしたい・・・
}

タスクB(RunTaskBAsync)が非同期処理の場合、このままではタスクBが終わる前に続きが処理されてしまいます。

タスクAがタスクBの完了を待ちたい場合、これらの状態を知りたいはずです。

  1. 正常終了したかどうか
  2. 例外が発生して異常終了したかどうか
  3. 処理が途中でキャンセルされたかどうか

また、タスクBに戻り値がある場合、タスクBが完了したらその値ももらいたいはずです。

このときに登場するのがTask型です。
Taskオブジェクトはこれらの状態と戻り値を教えてくれます。

public int RunTaskA()
{
    Task taskB = RunTaskBAsync(); // Task型のオブジェクトを返す
    
    bool status1 = taskB.IsCompletedSuccessfully; // 正常終了したかどうか
    bool status2 = taskB.IsFaulted; // 例外が発生して異常終了したかどうか
    bool status3 = taskB.IsCanceled; // 処理が途中でキャンセルされたかどうか
    var returnValue = taskB.Result; // 戻り値(ある場合)
}

実際はこのように状態を取得してハンドリングするようなコードをゴリゴリ書くことはしません。

では、タスクBが完了した後にタスクAの続きを実行するためにはどのように記述すればよいでしょうか?そこで登場するのがasync/awaitです。

await

非同期処理しているタスクの完了を待つ場合はawaitを使います。

await RunTaskBAsync(); // タスクBに戻り値がない(void)場合
var returnValue = await RunTaskBAsync(); // タスクBに戻り値がある場合

タスクBが正常終了した後、その戻り値を使って続きの処理をする場合は下記のようになります。

var result = await RunTaskBAsync();
Console.WriteLine(result); // 続きの処理

タスクBが異常終了、またはキャンセルされた場合のエラーハンドリングはtry/catchを使って下記のようになります。

try
{
  await RunTaskBAsync();
}
catch (TaskCanceledException ex)
{
  // キャンセルされた場合の例外処理
}
catch (Exception ex)
{
  // 異常終了した場合の例外処理
}

なお、awaitTask型に対して使用します。そのため、必ずしもメソッドの実行時にawaitを使う必要はなく、待機のタイミングを遅らせることもできます。

Task<int> taskB = RunTaskBAsync(); // awaitをつけていないのでTask<int>型を受け取る

// 何か別の処理 (この間もタスクBは非同期で実行されている)

int result = await taskB; // ここでタスクBの完了を待つ

非同期処理っぽさが出てきましたね。

async

awaitを使う場合、下記ルールに則って実装します。

  1. メソッドにasyncキーワードを付与 (文法)
  2. 戻り型としてTask型を利用[2][3] (文法)
    • returnする場合: Task<戻り値の型>
    • returnしない場合: Task
  3. メソッド名の語尾にAsyncをつける (推奨)
    • 呼び出す側が非同期処理であることを把握しやすくするため
public async Task<int> RunTaskAAsync()
{
  int result = await RunTaskBAsync();
  return result + 1; // taskBの戻り値を使った計算例
}

このサンプルコードでのポイントは「戻り型はTask<int>型なのに、実際にreturnしているのはint型である」という点です。実は asyncキーワードをつけると、戻り値をTask型で自動的にラップして返してくれるようになります。これで呼び出し元はTask型のオブジェクトを使って非同期処理のハンドリングができますし、自分でTask型を生成しなくていいので楽ですね。

以上のポイントを踏まえ、 タスクA -> タスクB -> タスクC の非同期呼び出しを実装すると下記になります。

public async Task<int> RunTaskAAsync()
{
  var result = await RunTaskBAsync(); // taskBの完了を待つ
  return result + 1;
}

private async Task<int> RunTaskBAsync()
{
  await RunTaskCAsync(); // taskCの完了を待つ
  return 1 + 2 + 3;
}

private async Task RunTaskCAsync()
{
  await Task.Delay(500); // 0.5秒待機
}

同時に複数の非同期処理を扱う

例えば、タスクBとタスクCを同時に処理して、両方が完了した後にタスクAの続きを処理したい場合があります。

そういった場合はTask.WhenAllを使用します。Task.WhenAllは、「複数のタスクが全て完了したかどうかを確認できるTask」を返します。Task型なのでawaitできます。

public async Task<int> RunTaskAAsync()
{
  Task<int> taskB = RunTaskBAsync();
  Task<int> taskC = RunTaskCAsync();
  
  int[] results = await Task.WhenAll(taskB, taskC); // 両方完了後、戻り値が配列として返る
  
  return results[0] + results[1]; // タスクBとタスクCの結果を加算
}

private async Task<int> RunTaskBAsync()
{
  await Task.Delay(100); // 0.1秒待機
  return 1 + 2 + 3;
}

private async Task<int> RunTaskCAsync()
{
  await Task.Delay(200); // 0.2秒待機
  return 4 + 5 + 6;
}

まとめ

完全に理解した。

続きも書きました 👉 async/await を完全に理解してからもう少し理解する

脚注
  1. cf. パソコンの大先生によるエンジニア用語解説 ↩︎

  2. cf. neue cc - asyncの落とし穴Part3, async voidを避けるべき100億の理由 ↩︎

  3. UI操作に関するasync voidパターンもありますがここでは敢えて触れません。初心者は気にしないように。 ↩︎

Discussion