FlutterでIsolateを用いた並列処理をするべきシーンとそのやり方

mono 
Flutter 🇯🇵
Published in
19 min readFeb 2, 2019

操作感の良いアプリとするためには、ユーザー操作に即座に反応し、滑らかな画面更新がなされることが重要です。現在普及しているモバイル端末の多くは60fps(1秒間に60回の頻度)で画面更新をするようになっていて(iPad Proなど120fpsの端末も一部存在します)、つまりアプリケーションコードはそれに間に合うように約16ms以内に処理を実行する必要があります。これを超えると、操作に対する反応がワンテンポ遅れてもっさり感がでたり、画面がカクついたぎこちないものとなって、使用感が下がってしまいます。

本記事では、Flutterでアプリを作る際にこのあたりについてはどうケアしていけば良いのかを解説します。記事は少し長くなりますが、要点は以下です。

TL;DR

  • 大前提として、Dartの実行モデルはシングルスレッド・イベントループである
  • Main Isolateだけでは FuturesStreams APIなどを用いて並行処理はできるが並列処理はできない
  • ネットワーク通信のレスポンス待ちなどのCPU負荷が低く単に長い待ち時間が発生するだけの処理は、単にMain Isolate上での並行処理で済ませるだけで良い
  • 16ms(60fps端末の場合)を超える処理時間を要するようなCPU負荷の特に高い処理をUIのフレームレートを落とさずに行うには、別のIsolateを起動して並列処理する必要がある
  • Isolate を直接扱うとコードが煩雑になるので、それをラップした compute 関数で済む場合はそれを使うのが良い
  • ただし、別Isolateの利用にはスイッチングコスト(10–20ms以上程度)がかかるので、それは意識した方が良さそう(巷の軽量なスレッドよりはコストが高い)

それでは以下、詳しく述べていきます。FlutterというよりDartの話が中心になります。

Dartの実行モデルはシングルスレッド・イベントループである

Dartの実行モデルはシングルスレッド・イベントループで、次のWeb向けの少し古い公式ドキュメントで詳しく述べられています。

普通に調べてもなかなか見つけにくいドキュメントですが、以下で紹介されているのを見て知りました。

日本語訳もあります:

シングルスレッド・イベントループという実行モデルは、Node.jsと同様なので、豊富にあるこういうNode.jsについての説明を併読すると理解しやすいです。

要点としては、次の図のイメージが掴めれば十分だと思います。

The Event Loop and Dart より
  • Main isolate: 通常の処理はすべてこのシングルスレッド上で実行される
  • キューに溜まったイベントが順に実行される

ゆえに、もしあるイベントの処理時間が長いと、その間は後続の溜まっているイベントが一切処理されないことになります。シングルスレッドなので、UI更新など含めてあらゆる処理が詰まってしまいます。

というわけで、それらをどう対処すれば良いのかを説明していきます。

CPU負荷が低く単に長い待ち時間が発生するだけの処理はFutureで捌くだけで良い

Flutterアプリにおける大半の処理はこちらに分類されるはずです。典型的な例としては、ネットワーク通信のレスポンス待ちなどが該当します。

例えば、 http パッケージの get関数 は次のようにFutureに包んだResponseを返すようになっています。

Future<Response> get(url, {Map<String, String> headers}) =>
_withClient((client) => client.get(url, headers: headers));

FutureはJavaScriptのPromiseとほぼ同様のものと捉えてOKです。

利用する際は、次のようにコールバック形式でも良いですが、

http.get("http://example.com/xxx").then((response) {
// レスポンスが返ってきた後に時間差で呼ばれる
});
// 同期的にすぐ実行される

async/await を活用して次のようにあたかも同期処理かのように書けるため、基本的にはこう書いた方がコードがすっきりして良いです。

var response = await http.get("http://example.com/xxx");
// レスポンスが返ってきた後に時間差で呼ばれる

Futureの処理の流れ

ただ、処理の流れをイメージする場合は、前者のthenを用いたコールバック形式の方が分かりやすいはずです。ざっくり次のような流れで、Main Isolateのシングルスレッドを詰まらせることなく時間のかかる処理が捌かれます。

  1. http.get の戻り値の Future<Response> に対して、 .then でレスポンスが返ってきた後の処理を登録する
  2. レスポンスはイベントループ中に定期的にポーリングされ、実際に返ってきたタイミングで .then に登録した処理が継続される

Dartの実行モデルはシングルスレッド・イベントループのため、即座に結果を得られない可能性がある関数の戻り値は大抵 Future 型となっています。つまり、普通に素直なコードを書いていれば、スレッドをブロックしてしまうようなコードを書く方が難しいくらいの作りになっています。

あるいは、ioパッケージの次のメソッドのように非同期版と同期版が用意されている場合もありますが、名前的に「デフォルトは非同期で同期版も用意」という形になっていることからもなるべく非同期版を使ってスレッドをブロックさせないような設計になっていることが伺えます。

同期版はCLIツールなど後続の処理がブロックされても問題ない場合に使うのはありですが、async/awaitがサポートされて非同期処理も同期的な書き方ができるようになった今となっては同期版を別途用意する意義はほとんど無い気もします🤔

CPU負荷が高く時間のかかる連続的な処理はスレッドがブロックされる問題

上の例では、HTTPリクエストのレスポンス待ちという、CPUはほとんど仕事をせずに結果が来るまでほぼ待っているだけで良い類の処理でしたが、一方CPUが占有されるような処理の場合はその間どうしてもスレッドがブロックされることになってしまいます。

Flutterアプリでよくありそうな例としては、APIレスポンスのパースでしょうか。例として、次のようなユーザー情報100要素から成るJSONレスポンス(サイズは約15KB)のパースで考えてみます。

{"id":"acc4ab51-bf29-90bc-315e-c7ee959e61fe","name":"Edythe Schultz IV","email":"windler_torrey@wuckert.info","createdAt":"2019-02-02T14:01:31.860318"}

実際のJSONレスポンス: http://www.mocky.io/v2/5c55243c2f00005000bf758a

次のようなUserクラスを用意して、

次のようにレスポンスをパースしてみます。

上記のように便利なStopwatchクラスを用いてiPhone 7 Plus実機でリリースモードで計測したところ、15ms前後でした。60fpsを満たすための16msギリギリでけっこう危ういですね。ただ、実機での操作を試したところ、このように「1フレーム欠落するかも」程度の処理では体感として全く違和感ないレベルでした。

ただ、性能の低い端末では気になることもあり得そうですし、また実際のアプリではもっとレスポンスサイズが増えることもあって、レスポンスパース処理でのメインスレッドブロックはケアするべきポイントの1つというは確かです。

ここではレスポンスのパースを一例としてあげましたが、他にもヘビーな計算などアプリ要件によって色々あり得ます。

この公式のツイートではfibonacci数列の計算をコストの高い処理の例として使ってますね。

というわけで、シングルスレッドのイベントループモデルはこういったケースに対して相性が良くありません。ただ、成す術がないわけではなくDartの場合はMain 以外のIsolateで処理する方法があります。

非Main Isolateを用いた並列処理

Main Isolate上でのFutureを用いた処理はシングルスレッド上での並行処理でしたが、Main以外のIsolateを起動してそこで処理を行うことでDartでもマルチスレッドを用いた並列処理ができます。

並行処理・並列処理の違いについては、 並行処理、並列処理のあれこれ など参照してください。次の図を見るだけでも分かりやすいかと思います。

並行処理、並列処理のあれこれ より

Isolateは次のような特徴を持っていて、いわゆるスレッドなどとは異なります。

  • それぞれがイベントループを持つ
  • Isolate同士でメモリーを共有することはできず、データはメッセージによって交換する

Isolateの実体はプラットフォームごとに異なります。iOSでは新しいIsolateを起動する度にスレッドが起動したり、たまに既存のスレッドが再利用されることが観測されました。

実際に非MainのIsolate上でパース処理をしてみる

非MainのIsolate上でのパース処理は、次のように書けます。

次のような処理の流れになっています。

  1. Isolate.spawnにて新しいIsolateを起動
  2. 起動されたIsolateではデータ(この場合JSONレスポンス)が送られてきたらパース処理をするように登録
  3. Main Isolateはポートを通じて、JSONレスポンスを送って、パース結果を非同期で受け取る

プリミティブな処理で煩雑で、型の取り違えによる実行時エラーなども容易に発生しそうなコードです🤔

compute関数を活用

FlutterではIsolateを手軽に扱えるようにcompute関数が用意されているので、通常はIsolateを直接使うのではなくそれを活用します。

すると、Isolateを扱う箇所はたった1行で書けるようになりました🎉

compute 関数は次のように定義されていて、タイプセーフに扱えるのも良いです。

Future<R> compute <Q, R>(
ComputeCallback<Q, R> callback,
Q message, {
String debugLabel
})

コードの見た目としては、巷のスレッドを扱うメソッドくらいの感覚で使えるようになった感がありますが、多少癖や制約があります。簡単に扱えるといってもIsolateのラッパーなので、上でIsolate APIを直接使って書いた例と照らし合わせると納得できるはずです。

  • compute 関数の第一引数に処理したい関数、第二引数に渡したい引数、と分けて指定する
  • 第一引数に指定する関数は、クラスなどに属さない普通の関数、あるいはstaticメソッドでなくてはならない(→ だったはずが、Dart 2.15でのIsolate/compute改善でこの制約がなくなったように見える。要確認。)
  • 第二引数に指定するQ型のmessageおよびR型の戻り値は、プリミティブ型かその組み合わせの単純なクラスである必要がある(上では List<User> を結果として受け取っていてそれはOK)

普通に使う上では「ちょっと制約のあるスレッド」くらいの感覚でも良いと思います。

というわけで、これで包めばどんなにヘビーな処理でもメインスレッドをフリーズさせずに済むようになりました。

例えば、次のような上2つのコードなどを普通に書くとメインスレッドをブロックすることになって、3秒間操作不能になりますが、

// sleepベタ書き
sleep(Duration(seconds: 3));
// sleepは、Futureを使ってもスレッドを占有する
await Future(() => sleep(Duration(seconds: 3)));
// (ちなみに、ブロックせずに単に3秒後に処理を継続したい場合はこう書くのが正解)
await Future.delayed(Duration(seconds: 3));

compute を使った場合は別のIsolateのループがブロックされるだけでメインスレッドのブロック要因にはならず、スムーズな動作が継続されます。

await compute(sleep, Duration(seconds: 3));

compute は積極的に利用するべき?

数百msなどかかる処理は迷うことなく使うべきだと思っていますが、上で例に出した15ms程度で完了するようなそこまでヘビーでは無い処理では微妙だと思っています。

compute を使っているFlutterコードをあまり見かけないなと思って、Dart製アプリのOSSとして有名なroughike/inKinoを調べてみたら、次のようなコメントを見つけました。

https://github.com/roughike/inKino/issues/52#issuecomment-380351681

The last time I tried it, which was in January, it seemed that at least parsing JSON was fast enough. IIRC, the overhead of switching from main to background Isolate took longer than parsing the JSON, and there wasn’t any UI thread stutter to begin with.

2018年1月に調査したところ、JSONのパース処理は充分高速で、Isolateのスイッチングコストの方が大きかった、とのことです。

なるほど、そこまで巨大ではないJSONレスポンスの場合、上で15msでは体感では全く違和感無かったと書いた通り、同意です。

Isolateのスイッチングコストがいかほどかは明記されておらず気になったので、調査しました。

Isolateのスイッチングコスト

上述のMain Isolate上でパースに15msほどかかったJSONレスポンスを用いてiPhone 7 Plusにて確かめたところ、次のようになりました。

このようにリリースモードとデバッグモードではパフォーマンスに大きな差が出ることがあるので、その調査をするときは必ずリリースモードでの数値を確認することが大事です。

引き算するとスイッチングコストは30msくらいということになりますね。空のIsolateで確かめたら15msくらいだったりもしましたが、多分パースに必要なメッセージのやり取り分処理時間が長くなったりするのだろうと思います。また、パース時間が15msから9msくらいに短縮されているのは、別IsolateだとそれにCPUコアをフルに充てられるからかなと思いました。

というわけで、この例の場合、どちらがベターか悩ましい気がしました🤔

  • Main Isolate上で15msで処理(メインスレッドを多少ブロックさせる可能性あり)
  • 別Isolate使って40msで処理(メインスレッドをブロックさせるリスクはほぼゼロ)

ごく小さなレスポンスのパースならMain Isolate上で良さそうですし、もっとレスポンスサイズが増えると別Isolate上でやるのが良いでしょうが、このくらいの中間的なサイズだと迷います。

ただ、個人的には許容できないほどスイッチングコストが高くないと感じたので、常に別Isolateでパースというのもありなやり方だとは思いました。
(とはいえ、iOSネイティブで計測したらスレッドのスイッチコストは1ms未満〜3ms程度だったので、やはりそれよりはコストがかかるなとは思いましたが。)

「とりあえずMain Isolateで処理して問題が生じたら気づけるようにしたい」場合はFirebase Performance Monitoringが便利

roughike/inKino の判断と同様、現状一通り問題無さそうだからとりあえずすべてMain Isolateでの実行にする( compute 関数を一切使わない)、というのもありだと思っています。

ただ、以下の可能性もあるので、できればこれを検知したいところです。

  • 実はスペックの低い端末でけっこう問題になっていた
  • いつの間にか処理コストが増えてきて問題になってきた

Firebase Performance Monitoring はその監視に有効です。

Flutter用にも firebase_performance パッケージがあって、次のように簡単に使えます。

操作後、12時間以内に反映されて、次のように詳しい分析結果が表示されます。

さらに、次のように処理時間の閾値を設定することもできます。

閾値を超えると、次のようにDashboardのトップで知らせてくれます(Slack通知機能があればなお良いのですが、現状なさそうです)。

これによって、手元で様々な実機を用意してテストすることなく実際の利用動向をみて問題があれば次のアップデートで対処する、という対応をスムーズにできます。

その他、詳しい活用の仕方は、公式ドキュメントや以下の公式記事など参照してみてください。

というわけで、必要に応じて compute 関数で並列処理をすると、基本シングルスレッドのFlutterでもユーザー体験を損ねることなくヘビーな処理をさばける、という話でした。

また、本記事のサンプルコードはこちらです:

参考記事

本記事で触れたのと同様にJSONレスポンスのパースをcompute 関数で処理する例は公式ドキュメントにも含まれています。

また、次の記事でも詳しく説明されていて、参考になりました。

--

--