🐕

【Flutter】Dartの非同期処理を本気で学ぶ

2022/11/06に公開

この記事について

本気で学ぶシリーズ第4弾です。この記事の目的は、公式ドキュメントなどの情報を参考に、初学者から中級者に向けてわかりやすく解説する事です。そのため、厳密に語ると難解になる部分は省略しております。

指摘事項や助言など歓迎しております。もしございましたら、TwitterのDMもしくは記事コメントまでお願い致します。
https://twitter.com/urasan_edu

非同期処理とは

非同期処理と言っても、ひとまとめには出来ません。Dart(Flutter)では大きく3つに分類できると言えるでしょう。それは、

FutureとStream、そしてIsolateです。

前者二つはよく聞くし、なんとなく使っている方も多いと思いますが、Isolateはあまり耳にしないかもしれませんね。この記事では、Isolateを含めて解説をしていきます。

Futureについて

Futureは、非同期処理を行うためにDart言語が提供している一機能です。他の言語ではPromiseと呼ばれる機能に近いと考えてください。

ではまず、非同期処理とは何なのでしょうか。コードを見る方がわかりやすいので、まずは以下のコードを解読してみてください。

void main() {
  task1();
  tast2();
}


Future<void> task1() {
  return Future.delayed(const Duration(seconds: 2), () => print('タスク1が完了しました。'));
}

void tast2() {
  return print("タスク2が完了しました。");
}

この処理の結果は以下の通りです。

タスク2が完了しました。
タスク1が完了しました。

タスク2の方が先に完了しています。
初学者の方の中には、プログラムは上から下に順番に処理されると考える方も多いのではないでしょうか。その直感に反していますね。

解説をすると、確かに、このコードにおいては上から下に順番に処理はされているのです。
ですが、task1の中には待ち時間が2秒発生しています。その待ち時間の間に、先にtask2が完了しているため上記のような結果になります。

簡単に述べると、Futureキーワードを用いる事で、
task1の処理が完了するのを待たずに、先にtask2の処理を始めてね。 と宣言しているのです。

これがDartにおける非同期処理の一種であるFutureです。

では次に、以下のプログラムの結果を予想してみてください。コンソールに出力される文字列とその順番は何だと思いますか?

void main() {
  print('ユーザ情報を取得しています...');
  print(createOrderMessage());
}

String createOrderMessage() {
  var order = fetchUserInfo();
  return '私の名前は $order です';
}

// 疑似的に時間がかかる処理を再現
Future<String> fetchUserInfo() {
  return Future.delayed(
    const Duration(seconds: 2),
    () => '田中太郎',
  );
}

結果は以下の通りです。

ユーザ情報を取得しています...
私の名前は Instance of '_Future<String>' です

私の名前は田中太郎です。 という出力にはなりません。
その理由は、fetchUserInfo()関数内では、Durationによって2秒間の遅延が発生しているからです。

つまり、田中太郎という名前を取得する前に、createOrderMessage()の処理が先に走り終えてしまう訳ですね。ちなみに、このプリント後に田中太郎という文字列を取得し終えます。

これではプログラム作成者の意図した処理にはなりません。

非同期処理を活用して、無駄な待ち時間を発生させず、かつ文字列も上手く取得し終えたい。
そんな場合の為に、Dartではいくつかの方法がありますが、この記事ではasync-awaitキーワードをご紹介したいと思います。

asyncとawait

以下のコードをご覧ください。

// mainの左記にFutureを明記していますが、必須ではありません。
// ですが、可読性を上げるためにも書くことをお勧めします。
Future<void> main() async {
  print('ユーザ情報を取得しています...');
  print(await createOrderMessage());
}

Future<String> createOrderMessage() async {
  var order = await fetchUserInfo();
  return '私の名前は $order です';
}

// 疑似的に時間がかかる処理を再現
Future<String> fetchUserInfo() {
  return Future.delayed(
    const Duration(seconds: 2),
    () => '田中太郎',
  );
}

このコードの結果は以下の通りです。作成者の意図した動作になっているかと思います。

ユーザ情報を取得しています...
私の名前は 田中太郎 です

前述したコードと比べてみると、様々な場所にasync-awaitというキーワードが追加されています。
awaitというのは、英単語のとおりで『待つ』という意味です。動作としては、
awaitキーワードを付与した非同期処理が完了するまで待つ
という処理を行います。

つまり、このプログラムにおいては、非同期処理であっても田中太郎が取得されるまで待ってくれるので、同期処理のように扱えるというわけです。

ですが、大事なのは、

  • 同期処理のように記述できるが、非同期処理には変わりない点
  • awaitキーワードを付与した処理を待つという点

これが結構、曲者で間違えやすい箇所です。

次のコードをご覧ください。

main関数のasyncとawaitキーワードだけを外しました。
ですが、createOrderMessage()関数のfetchUserInfo()には、awaitで処理を待つ記述をしたままです。この結果を予想してみてください。

void main()  {
  print('ユーザ情報を取得しています...');
  print(createOrderMessage());
}

Future<String> createOrderMessage() async {
  var order = await fetchUserInfo();
  return '私の名前は $order です';
}

// 疑似的に時間がかかる処理を再現
Future<String> fetchUserInfo() {
  return Future.delayed(
    const Duration(seconds: 2),
    () => '田中太郎',
  );
}

結果は以下の通りになります。

ユーザ情報を取得しています...
Instance of '_Future<String>'

想定した動作と異なりますね。それどころか、『私の名前は~~』というプリントすらされていません。これは、先ほど注意事項として挙げた理由が関係しています。

結論から述べると、main関数で呼び出しているcreateOrderMessage()を待っていないため、即座にPrintが実行されるからです。

createOrderMessage()関数内の、 fetchUserInfo()はawaitキーワードを付与して、処理を順に行おうと試みていますし、実際に、fetchUserInfo()内では意図通りに動作する予定でした。

ですが、そもそもの呼び出し元であるmain関数ではcreateOrderMessage()にawaitキーワードを付与していないため、即座にプリントが実行されます。

そのため、Future内で戻り値が保証(予約)されているInstance of ....という文字列が即座に返されるわけです。

Futureまとめ

Futureとasync-awaitを学んだ上で、注意点をまとめておきます。

  • async-awaitを利用すると同期処理のように記述できるが、非同期処理には変わりない
  • awaitは、awaitキーワードを付与した処理を待つ
  • awaitを使用する場合は、asyncキーワードを必ず明記する必要がある。

上記のとおりです。
Futureの非同期処理は主に、データベースからのデータの取得や、その他のネットワーク待ち時間が発生する場合にそれを上手く対処する為に使われる事が多いです。

最後に、Futureは未完了/完了/エラーの3つの状態を扱ったり、awaitキーワードではなく、thenキーワードという物を使用する事もあります。ここでは解説を省略しますので、詳しく知りたい方は参考文献を参照してください。

Streamについて

StreamもFutureと同じようにDartにおける非同期処理の一種です。

Futureは、1つの値を返し、1度きりの処理で完了する非同期処理を行う場合に用いる事が多いですが、Streamは、1つ以上の値を、継続的に非同期でデータを取得したい場合に用いる事が多いです。

このふるまいについて、詳しく説明をしていきます。

Streamは、その名前の通り、『流れ』を表しており、データの変化に対応してデータを取得するたびに更新するというような機能を実装する際に用いられる事が多いです。
具体的に言えばチャットアプリや、SNSのタイムラインの監視等などはStreamで実施されることが多いと私は感じます。

さっそくコードを見ながら学んでいきましょう。

void main() {
  final targetNum = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

  print("計算開始");

  doSquare(targetNum).listen((resultNum) {
    print(resultNum);
  });
}

Stream<int> doSquare(List<int> targetNum) async* {
  for (int n in targetNum) {
    yield n * n;
  }
}

実行すると以下の結果となります。

計算開始
1
4
9
16
25
36
49
64
81
100

main関数の中にあるdoSquare(targetNum).listenの部分に注目してください。
doSquare関数の処理がされた後に、listenメソッドに処理結果が渡ります。その後、処理をプリントしているという流れです。Streamを使用することで、非同期処理の観測を行う事が出来ます。

doSquare(targetNum)関数にはasync*というキーワードが書かれています。
Futureの場合はアスタリスクが付与されていませんでしたが、アスタリスクが付与されているのは、戻り値をStreamで返すよ。と明示しています。

また、yieldというキーワードも現れています。
これは、returnの代わりに用いられており、Streamで継続的に値を返すことを表すキーワードだと考えて構いません。

Streamは独自で作成するというよりも、状態管理パッケージ内で用意されている物を使用する事が多いです。その場合は、値の変化を観測してUIを常時更新するというようなアプリケーションのふるまいを簡単に実装する事が出来ます。

listenとawait forの違い

前述したコードはlistenによって観測をしていました。ただ、Dart言語にはawait forという似たような観測する手法があります。どちらもほとんど同じ機能ですが、少しだけふるまいが違いますので、ここで学んでおきましょう。

void main() {
  Stream<String> stream =
      Stream<String>.fromIterable(['1', '2', '3', '4']);
  
  print('BEFORE');
  
  stream.listen((resultValue) {
    print(resultValue);
  });
  
  print('AFTER');
}

このコードの結果は以下の通りです。

BEFORE
AFTER
1
2
3
4

少し、想定と異なるのではないでしょうか。解説を行う前に、もう一つの例であるawait forを見てみましょう。

void main() async {
  Stream<String> stream = Stream<String>.fromIterable(['1', '2', '3', '4']);

  print('BEFORE');

  await for (String resultValue in stream) {
    print(resultValue);
  }

  print('AFTER');
}

結果は以下の通りです。

BEFORE
1
2
3
4
AFTER

await forとlistenの違いは、Streamの完了を待つか待たないか。 です。
await forはStreamの完了を待ってから、その後に記述したコードが実行されるため、AFTERが後に表示されますが、listenはStreamの終了を待たずに後続する処理を実行します。
この部分は、ふるまいとして大きな違いであるため、よく理解しておきましょう。

並列処理と並行処理について

ここまで、FutureとStreamという非同期処理についてみてきましたが、ここで一つ誤解を解きたいと思います。

FutureやStreamは、並列処理ではありません。
誤解を承知の上で言い換えると、待ち時間に上手く対処するために並行処理を行っているが1つのCPUコア上と独自のメモリヒープ内で実行しているだけです。

つまり、CPU負荷の高い処理を非同期(FutureやStream)で行ってもほとんどの場合、処理は速くなりません。

これはどういうことでしょうか。
少し難解であるため、理解しやすいように、あまり深堀はせず表面だけを簡単に説明します。

DartにおけるConcurrency と Parallelism

まず前提として、並行処理(Concurrency)と並列処理(Parallelism)は異なります。

ほとんどのFlutterアプリケーションでは、Main Isolateと呼ばれる1つのスレッド上でほとんどの処理が実行されます。Main Isolateは独自のCPUとメモリヒープを確保しており、その中で各イベントを回すイベントサイクル(ループ)形式を取っています。


Concurrency in Dart

ここで、1つのアプリをAさんという一人の人間に置き換えて凄く簡単に例えます。

Aさんは仕事をしています。基本的にAさんは一つの事しか出来ません。しかし、キーボードを打ちながらPCの時計をちらっと見たり、後輩にある仕事の依頼をしていて待っている間に別の作業は出来ます。ですが、キーボードを打ちながら昼食を買いに行くことは出来ませんし、着替えながら何か考え事をするというのもの出来ません。

PCの時計をチラッと見たり、後輩へ依頼した仕事の待ち時間を他の作業で消費するというのは、FutureやStreamにあたる並行処理(Concurrency)です。そして、キーボードを打ちながら昼食を買いに行く、着替えながら何か考え事をするというのは並列処理(Parallelism)にあたります。

Isolateについて

前述した処理を実行したい場合の対策も勿論ありますし、簡単です。
DartはMain Isolateと呼ばれるスレッドでFutureやStreamを含めるほとんどの処理が実行されるわけですから、Main Isolate以外のスレッドを作ってあげれば良いのです。それが、Isolateです。

Isolateを使用すると、利用可能な場合は追加のプロセッサコアを使用して、Dartコードで複数の独立したタスクを一度に実行できます。
Isolateはスレッドまたはプロセスに似ていますが、各Isolateには独自のメモリと、イベントループを実行する1つのスレッドを持ちます。つまりこれで並列処理が実行可能というわけです。

Isolateの使い方と場面

サイズの大きいJSONのパース作業のようなCPUを大きく使用する作業の場合に、Isolateは適しています。

Isolateを使う方法はいくつかありますが、この記事ではcompute関数をご紹介します。

Flutter公式サイトに掲載されているコードを一部抜粋し引用します。

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));

  return compute(parsePhotos, response.body);
}

List<Photo> parsePhotos(String responseBody) {
  final parsed = jsonDecode(responseBody).cast<Map<String, dynamic>>();

  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

class Photo {
  final int albumId;
  final int id;
  final String title;
  final String url;
  final String thumbnailUrl;

  const Photo({
    required this.albumId,
    required this.id,
    required this.title,
    required this.url,
    required this.thumbnailUrl,
  });

  factory Photo.fromJson(Map<String, dynamic> json) {
    return Photo(
      albumId: json['albumId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
      url: json['url'] as String,
      thumbnailUrl: json['thumbnailUrl'] as String,
    );
  }
}

fetchPhotosの中に、computeという関数があることに注目してください、これが他のIsolateを作るキーワードです。
computeの第一引数には、別Isolateで実行したい関数を、そして第二引数にはその指定した関数に与える引数を入れます。

Isolateについては、登場場面がそこまで多くない事と、初学者~中級者向けではなく、もう少し難易度が高いと個人的に思うので詳しい説明は省略します。
詳しく知りたい方は、monoさんという方が書かれたこの記事がおススメです。もしくは、参考サイトからぜひ他のサイトの記事をご覧ください、またTwitterDMに質問を送って頂ければ回答できる内容であればお答えさせて頂きます。

Isolateについてちょっと詳しく

FutureやStreamが並行処理で、Isolateが並列処理ならば、Isolateを常に使えばいいのでは。と思う方もいらっしゃると思います。詳しい方は既に知っているかもしれませんが、Dart 2.15でIsolateの立ち上げる際のコストが軽減され、処理も高速化されました。

そのため躊躇いなく使えるかもしれませんが、留意して欲しい点があります。それは、

  • 各Isolateで共有メモリを持たない為、やり取りが複雑になりがちである。
  • Isolate間でmutableな値のやり取りは出来ず、渡すデータは複製されたものとなる(私は完全に検討しきれていませんが、参考文献等によればそのようです)
  • Isolateの立ち上げや記述方法によってはWEBとモバイルで振る舞いが異なる場合がある。

この3点に関連して、コードが複雑化する可能性があります。調べて出てくる情報量の関係や、プログラムは受け継がれて他者に保守されるという考えに基づき、FutureやStreamで済む場合はそれで済ませる事をお勧めします。

最後に

誤字脱字や内容の誤り等があれば、ぜひ記事コメントもしくはTwitterまでご連絡ください。

https://twitter.com/urasan_edu

参考サイト

非同期とアイソレートの違い | Decoding Flutter
Isolates and Event Loops - Flutter in Focus
mono:FlutterでIsolateを用いた並列処理をするべきシーンとそのやり方
Anvith Bhat:Multithreading in Flutter using Dart isolates
Asynchronous programming: futures, async, await
Concurrency in Dart
Difference between await for and listen in Dart
shindex:dart の stream を理解して async* と yield を正しく使う
Parse JSON in the background

Discussion