🌚

dart非同期処理 await vs isolate vs compute vs unawaited vs wait

2024/12/06に公開

はじめに

Flutter/dartでの非同期処理について明確に説明できますか?
コードリーディングをしたりしていると、async/awaitだけでなく、thenやcomputeなどが出てきて、あれこれ何だったっけ?となった経験がある方もいるのではないでしょうか?
今回は改めて非同期処理周りを整理して、全体像を確認したいと思います。

同期処理、非同期処理とは

プログラムというのはパソコンやスマホのようなコンピュータに搭載されているCPUに計算を指示するものです。
CPUは簡単な計算を爆速で大量に行うため、プログラムを書くことで手計算では大変な計算も一瞬で計算できたりします。
しかし、CPUの計算スピードにも限界があるため、一部の処理は一瞬で終わらずに時間がかかる場合があります。
また、インターネット等を介して通信を行う場合、通信を始めてから終えるまでに時間がかかってしまいます。

このように時間がかかってしまう処理を行う際に律儀に順番を待って一つずつ処理を計算していくのが同期処理、時間のかかる処理を待っている間に他の処理を先に進めてしまうのが非同期処理です。

ここでは時間のかかる処理と時間のかからない処理と呼ぶことにしましょう。

重たい非同期メソッドと軽い非同期メソッド

また、ここで明確に言及したいのが処理の重さです。

よく重い処理とか軽い処理とか言いますが、時間のかかる処理を重い処理、時間のかからない処理を軽い処理と呼ぶことがあるかと思いますが、本記事では時間のかかる処理のうちスレッドを占有し続ける処理を重い処理、時間はかかるもののスレッドは占有し続けない処理を軽い処理と呼びます。

シングルスレッドとは

スレッドの占有ってなんですか?Slackの話ですか?というあなたのためにシングルスレッドとマルチスレッドについて簡単に説明させてください。
Dartはシングルスレッドです。という話を聞いたことがありますか?スレッドとはプログラムにおける処理の実行単位のことです。

そして、シングルスレッドではスレッドがシングルなので処理が1つずつ実行されます。逆にマルチスレッドでは複数の処理を並列に実行することが可能です。
dartでスレッドの話になると、dartはシングルスレッドでメインスレッドがUIスレッドなので、重たい処理がスレッドを占有するとUIが止まってしまったり、カクついてしまったりするというのを聞いたことがないでしょうか?

つまりUIの描画処理と裏側での重たい処理を同じように順番に処理しているので、重たい処理がある場合はUIの描画を一旦ストップさせてでも重たい処理を行なってしまったりするということですね。

then と await

ここまで同期処理、非同期処理と、重たい処理、軽い処理の話をしてきましたが、一旦重たい処理のことを忘れて、軽いけど時間がかかる処理の話です。
このような処理の場合処理が始まってから終わるまでスレッドが忙しいわけではないですが、時間がかかります。暇だけど時間がかかるみたいな感じです。

仕事でもそういうタスクありませんか?やること自体は少ないけど、関係各所の連絡待ちが発生して時間がかかるタスク。そういうタスクって無駄に疲れますよね。

余談はさておき、同期処理のみのプログラムを書いた場合(少なくともDartでは)、プログラムの上から順に処理が行われていきます。

dart
main(){
    methodA();
    methodB();
    methodC();
    methodD();
}

methodAからmethodDまでがそれぞれ同期的なメソッドであれば処理は順番に行われます。とてもわかりやすいですね。
しかし、処理に時間がかかる場合どうでしょうか?

dart
main() {
    method2sec(); // 2秒かかる処理
    method1sec(); // 1秒かかる処理
    method3sec(); // 3秒かかる処理
    method4sec(); // 2秒かかる処理
}

この場合どの処理の順番はどうなるでしょうか?パッとわかりにくいですね、、
そこで、thenやawaitというキーワードが登場します。

then

thenは日本語で言うと、「そして」「それから」「その後」のような意味になります。
時間のかかる非同期処理の次に行う処理をthenで記述するわけです。

dart
main() {
    methodA().then(
      (value) => methodB(value),
    );
}

このように書いた場合はmethodAが完了後methodBが実行されると言うことになります。また、valueにはmethodAの返り値が入るため、methodBではmethodAの返り値を利用することができます。
また、下記のようにエラーハンドリングを行うこともできます。

dart
main() {
    methodA().then(
      (value) => methodB(value),
    ).catchError(
      (error) => 例外をハンドリングする処理();
    );
}

この場合methodAで例外がスローされるとcatchErrorで定義したコールバックが実行されます。
なるほどthenわかりやすいかもと思ったところで次の例を見てみましょう。

main() {
    methodA().then(
      (_) => methodB().then(
        (__) => methodC().then(
          (___) => methodD(),
        ),
      ),
    );
}

順番に一つずつ処理をしていくとこうなりますよね。つまりthenの数だけネストしてしまうのです。
簡単な例でもこんな感じで複雑になってしまうので、扱いづらいのがわかると思います。

await

そこでdartではasync/awaitという書き方があります。

main() async {
    await method2Sec();
    await method1Sec();
    await method3Sec();
    await method4Sec();
}

このようにメソッドのブロックの前にasyncキーワードをつけ、呼び出しの際にawaitキーワードをつけることで時間のかかる非同期処理についても、同期処理のように逐次的に処理を行うようになります。
呼び出しの数だけネストをしていたthenと比べてスッキリ見えますよね?

またエラーハンドリングについても同様にスッキリします。

main() async {
    try {
      await method2Sec();
      await method1Sec();
      await method3Sec();
      await method4Sec();
    } on Exception catch (e) {
      例外をハンドリングする処理();
    }
}

ここまでの説明でasync/awaitを使った方が楽じゃんと思った方も、業務コードなどでthenに出会ったことがあるのではないでしょうか?以前はasync/awaitがサポートされてなかったためコードにthenが残っていることもあるかもしれませんが、今後非同期な処理を書くときはthenasync/awaitのどちらを使うかを迷う必要はなく、async/awaitを使うようにすれば良いと思います。
コードリーディング中にthenキーワードが出てきて、もっと詳しく知りたいとなった方は下記のドキュメントを参考にしてみてください。

https://dart.dev/libraries/async/futures-error-handling

await と unawaited

次にunawaitedというキーワードを見たことがありますでしょうか?
unは否定の接頭語なので、awaitしないということになります。

ただ、そもそもawaitキーワードをつけなければawaitされることもないです。下記の例を見てみましょう。

main() async {
    method2Sec();
    method1Sec();
    method3Sec();
    method4Sec();
}

このように、awaitをつけない場合、4つのメソッドは上から順に実行されるものの、前のメソッドの完了を待たずに実行されるため、ほぼ同時に4つのメソッドが実行されます。
つまり、先ほどのawaitをつけた場合とつけない場合で挙動が変わるわけです。
ただし、多くの場合時間のかかる処理はawaitキーワードが付けられて実行されることが期待されています。つまりメソッドを呼び出す際にawaitを付け忘れてしまうと期待通りに動かないことがあるのです。

そこで、dartにはunawaited_futuresというlintルールがあります。

https://dart.dev/tools/linter-rules/unawaited_futures

これは Future 型を返すメソッドについて await キーワードがついてないと指摘してくれるlintです。
これによって awaitキーワードがついていることを前提にしたメソッドについて await のつけ忘れで不具合が発生することは無くなります。

ただし、あえて待たずに実行したい場合はどうすればいいのでしょうか?
その行だけlintルールを無視する方法もありますがこういう場合に unawaited が利用できます。

void main() async {
  await doSomething();

  unawaited(doSomething());
}

このようにすることで、コードを読む人に意図的に awaitをつけずに呼び出していると明示できます。
このようにFuture型を返すメソッドについて敢えて待たずに呼び出しをすることもできます。

analyticsのログ送信のようなユーザ体験と関係のない非同期処理の呼び出しの際には使うのが有用かなと思います。

wait

次にwaitを紹介します。

void main() async {
    await Future.wait([
      method2Sec(),
      method1Sec(),
      method3Sec(),
    ]);
    await method4Sec();
}

複数の非同期処理を同時に行いたいけれど、それらのすべての完了を待ってから次の処理を行いたい場合は waitを使うことができます。
上記の例では3つのメソッドの処理は同時に行われるものの、三つともすべて完了してから4つ目のメソッドの処理を行うようになります。

すべてawaitで繋いだ場合上記の処理は10秒かかってしまいますが、上記のようにすると3秒と4秒で合わせて7秒になり3秒短くなります。

複数のAPIを呼び出しているけれど、それぞれに依存関係がない場合などに使うのが有用かなと思います。

await と isolate

ここまではdartで非同期処理を扱ういくつかの方法を見てきましたが、次は最初に説明した重い処理についてみていきます。

それでは重い処理を用意してみます。

 void heavyCalc(String test) {
    for (int i = 0; i < 1000; i++) {
      for (int j = 0; j < 1000; j++) {
        setState(() {
          _counter++;
        });
      }
    }
  }

今回はシンプルに2重ループの中でそれぞれ1000回ずつループさせていて、stateをインクリメントさせています。
この処理を普通に呼び出してしまうとこの処理がメインスレッドを占有してしまい、処理が終わるまで画面描画が更新されなくなります。

main() async {
    await heavyCalc();
}

このように呼び出した場合には場合にはheavyCalcの実行完了まで際描画されず、ローディングアニメーション等のアニメーションが固まってしまいます。
また、試しにawaitの代わりにunawaitedを使ってこの処理の完了を待たずに次の処理を行うようにもしてみましたがその場合でもローディングは止まってしまいます。

こんな感じです。
そこで利用するのがisolateです。
呼び出しはこんな感じ

main() {
    await Isolate.run(heavyCalc);
}

このようにするとメインスレッドを占有せずにheavyCalcを呼び出すことができます。そうすることでローディングなどのアニメーションもカクつくことはありません。

特定のメソッドの処理が重たくてUIのアニメーションがカクついたときはisolateを思い出してみてください。

isolateとcompute

最後に computeメソッドを紹介します。
computeはisolateをラップしたメソッドで簡単に記述できるようになっています。
あれ、でもさっきのisolateも簡単でしたよね?何が簡単に記述できるのでしょうか?

それを理解するためには、isolateに引数を渡す場合を考えてあげる必要があります。
isolateはmainスレッドではないスレッドで処理を実行する都合上mainスレッドのメモリから値を直接渡してあげることができないのです。
そのためisolateの中で処理した値はmainスレッドから参照できず、isolate内で数字をincrementしたとしてもUIには反映されません。

Future<void> _incrementCounter() async {
  setState(() {
    _counter++;
  });
}

Future<void> isolateIncrement() async {
  await Isolate.run(_incrementCounter);
}

UIに反映させたい場合、下記のようにPortを使って値をやり取りしてあげる必要があります。

Future<void> _getIncrements(ReceivePort receivePort) async {
  final num = await receivePort.first;
  receivePort.sendPort.send(num + 1);
}

Future<void> isolateIncrement2() async {
  final receivePort = ReceivePort();
  receivePort.listen((message) {
    setState(() {
      _counter = message;
    });
    receivePort.close();
  });
  await Isolate.spawn<ReceivePort>(_getIncrements, receivePort);
}

すると先ほどと比べて少しややこしくなりましたね。ここで登場するのがcomputeです。
computeを使うことで下記のように書き換えることができます。

int _counterForCompute(int counter) {
  return counter + 1;
}

Future<void> computeIncrement() async {
  final num = await compute(_counterForCompute, _counter);
  setState(() {
    _counter = num;
  });
}

このようにcomputeを使うことで他の非同期処理と同じような形でシンプルに値を受渡できるようになります。

結論

ここまでdartで非同期処理について、それぞれのパターンを見てきました。

  • 時間がかかるだけではなく、処理自体が重い場合はcomputeを使って並列処理をすると良いでしょう。
  • 単なる非同期処理の場合はasync/awaitを使うことで簡単に同期処理のように扱えます。この際thenも同じように使うことができますが基本的にはasync/awaitで書くのが良いでしょう。
  • 非同期のメソッドについて完了を待つ必要がない場合はunawaitedを使って明示的に完了を待たないように記述するのが良いでしょう。
  • 完了は待ちたいが複数の非同期処理を同時に処理したい場合はFuture.waitを使うと複数の非同期処理を同時に待つことができます。

おわりに

基本的には何も考えずに全部async/awaitを噛ませておけば非同期処理自体は問題なく完了しますが、画面のカクツキが気になったり、ローディングが無駄に長いことが気になった場合は非同期処理の呼び方を見直してみると良いかもしれません。

2024年は技術発信も頑張ろうと思っているので、記事が参考になった方は記事とGitHubのいいね(スター)とフォローをしていただけると励みになります!
最後まで読んでいただきありがとうございました✨

https://github.com/miyasic

Discussion