Dartにおける並行処理
このページでは、Dartにおける並行プログラミングの概念について説明します。イベントループ、非同期言語機能、およびアイソレートについて高レベルから説明します。Dartで並行処理を使用する、より実践的なコード例については、非同期サポートページとアイソレートページをお読みください。
Dartにおける並行プログラミングは、FutureやStreamのような非同期APIと、処理を別々のコアに移動できるアイソレートの両方を指します。
Dartのコードはすべてアイソレート内で実行され、デフォルトのメインアイソレートから始まり、オプションで明示的に作成した後続のアイソレートに拡張されます。新しいアイソレートを生成すると、そのアイソレートは独自の分離メモリと独自のイベントループを持つ。イベントループはDartで非同期プログラミングと並行プログラミングを可能にするものです。
イベントループ
Dartのランタイムモデルは、イベントループに基づいている。イベントループは、プログラムのコードの実行、イベントの収集と処理などを行います。
アプリケーションが実行されると、すべてのイベントがイベントキューと呼ばれるキューに追加されます。イベントは、UIの再描画要求から、ユーザーのタップやキー入力、ディスクからのI/Oまで、何でもあり得ます。アプリケーションがどのような順番でイベントが発生するかを予測することはできないため、イベント・ループはキューに入れられた順番にイベントを処理します。
(図)イベントが1つずつ -> イベント・ループ
イベントループが機能する方法は、次のコードに似ている:
while (eventQueue.waitForEvent()) {
eventQueue.processNextEvent();
}
この例のイベントループは同期的で、シングルスレッドで実行される。しかし、ほとんどのDartアプリケーションは、一度に複数のことを行う必要があります。例えば、クライアントアプリケーションは、HTTPリクエストを実行すると同時に、ユーザーがボタンをタップするのを待つ必要があるかもしれません。これを処理するために、DartはFutures、Streams、async-awaitといった多くの非同期APIを提供しています。これらのAPIは、このイベントループを中心に構築されている。
例えば、ネットワークリクエストを行うことを考えてみよう:
http.get('https://example.com').then((response) {
if (response.statusCode == 200) {
print('Success!')'
}
}
このコードがイベントループに到達すると、直ちに最初の節であるhttp.getを呼び出し、Futureを返す。また、HTTPリクエストが解決するまでthen()節のコールバックを保持するようにイベントループに指示します。解決したら、そのコールバックを実行し、リクエストの結果を引数として渡します。
(図)非同期イベントがイベントループに追加される。
(図)コールバックを保持し、後で実行する
これと同じモデルで、イベントループは、Streamオブジェクトなど、Dartの他のすべての非同期イベントを処理する。
非同期プログラミング
このセクションでは、Dartにおける非同期プログラミングのさまざまな種類と構文をまとめます。すでにFuture、Stream、async-awaitに慣れている場合は、isolatesのセクションまで読み飛ばしてかまいません。
Future
Futureは、最終的に値またはエラーで完了する非同期操作の結果を表します。
このサンプルコードでは、戻り値の Future<String>
型は、最終的に String 値(またはエラー)を返すという約束を表しています。
Future<String> _readFileAsync(String filename) {
final file = File(filename);
// .readAsString() returns a Future.
// .then() registers a callback to be executed when `readAsString` resolves.
return file.readAsString().then((contents) {
return contents.trim();
});
}
async-await構文
asyncとawaitキーワードは、非同期関数を宣言的に定義し、その結果を使用する方法を提供します。
以下は、ファイルI/Oを待つ間にブロックする同期コードの例です:
const String filename = 'with_keys.json';
void main() {
// Read some data.
final fileData = _readFileSync();
final jsonData = jsonDecode(fileData);
// Use that data.
print('Number of JSON keys: ${jsonData.length}');
}
String _readFileSync() {
final file = File(filename);
final contents = file.readAsStringSync();
return contents.trim();
}
似たようなコードだが、非同期にするために変更(ハイライト)してある:
const String filename = 'with_keys.json';
void main() async {
// Read some data.
final fileData = await _readFileAsync();
final jsonData = jsonDecode(fileData);
// Use that data.
print('Number of JSON keys: ${jsonData.length}');
}
Future<String> _readFileAsync() async {
final file = File(filename);
final contents = await file.readAsString();
return contents.trim();
}
main()関数は_readFileAsync()の前にawaitキーワードを使用し、ネイティブコード(ファイルI/O)が実行されている間、他のDartコード(イベントハンドラなど)にCPUを使用させます。awaitを使用すると、_readFileAsync()が返すFuture<String>
をStringに変換する効果もあります。その結果、contents変数は暗黙のString型になります。
Note
awaitキーワードは、関数本体の前にasyncを持つ関数でのみ機能する。
次の図に示すように、readAsString() が Dart ランタイムまたはオペレーティング・システムで非 Dart コードを実行している間、Dart コードは一時停止します。readAsString()が値を返すと、Dartコードの実行が再開されます。
(図)フローチャートのような図は、アプリのコードが開始から終了まで実行され、待機していることを示している。 (図)ネイティブI/O用
Streams
Dartは、ストリームという形で非同期コードもサポートしている。ストリームは未来の値を時間経過とともに繰り返し提供します。一連の int 値を提供する約束は Stream<int>
型を持ちます。
次の例では、Stream.periodicで作成したストリームが、1秒ごとに新しいint値を繰り返し出力している。
Stream<int> stream = Stream.periodic(const Duration(seconds: 1), (i) => i * i);
await-for と yield
Await-forはforループの一種であり、新しい値が提供されると、それに続くループの各反復を実行する。言い換えれば、ストリームを「ループ・オーバー」するために使用されます。この例では、引数として指定されたストリームから新しい値が出力されると、関数sumStreamから新しい値が出力されます。値のストリームを返す関数では、returnではなくyieldキーワードが使用されます。
Stream<int> sumStream(Stream<int> stream) async* {
var sum = 0;
await for (final value in stream) {
yield sum += value;
}
}
async、await、Streams、Futuresの使い方についてもっと知りたい方は、非同期プログラミングのコードラボをご覧ください。
アイソレート
Dartは、非同期APIに加えて、アイソレートによる並行処理をサポートしている。最近のデバイスの多くはマルチコアCPUを搭載している。マルチコアを活用するために、開発者は共有メモリ・スレッドを同時に実行することがある。しかし、共有状態の同時実行はエラーが発生しやすく、コードが複雑になる可能性がある。
スレッドの代わりに、すべてのDartコードはアイソレート内で実行される。アイソレートを使用することで、Dartコードは複数の独立したタスクを同時に実行できます。アイソレートはスレッドやプロセスのようなものですが、各アイソレートは独自のメモリとイベントループを実行する単一のスレッドを持っています。
各アイソレートは独自のグローバルフィールドを持ち、あるアイソレート内の状態に他のアイソレートからアクセスできないようにします。アイソレート同士はメッセージパッシングによってのみ通信できる。アイソレート間でステートが共有されないということは、Dartではミューテックスやロック、データ競合のような複雑な並行処理が発生しないことを意味する。とはいえ、アイソレートが競合状態を完全に防ぐわけではありません。この同時実行モデルの詳細については、Actorモデルを参照してください。
アイソレートを使用することで、Dartコードは複数の独立したタスクを同時に実行することができます。アイソレートはスレッドやプロセスのようなものですが、各アイソレートは独自のメモリとイベントループを実行する単一のスレッドを持ちます。
Platform note
Dart Nativeプラットフォームのみがアイソレートを実装しています。Dart Web プラットフォームの詳細については、「Web での同時実行」セクションを参照してください。
主なアイソレート
ほとんどの場合、アイソレートについて考える必要は全くありません。Dartのプログラムはデフォルトでメインアイソレートで実行されます。下図に示すように、プログラムの実行を開始するスレッドです:
(図)実行され、イベントに応答し、終了するメインのアイソレートを示す。
単一分離プログラムでもスムーズに実行できる。次の行に進む前に、これらのアプリはasync-awaitを使って非同期処理の完了を待つ。お行儀の良いアプリは素早く開始し、できるだけ早くイベントループに入る。そして、必要に応じて非同期処理を使用しながら、キューに入れられた各イベントに迅速に応答します。
アイソレートのライフサイクル
以下の図が示すように、すべてのアイソレートはmain()関数などのDartコードを実行することから始まります。このDartコードは、例えばユーザー入力やファイルI/Oに応答するためのイベントリスナーを登録するかもしれません。アイソレートの初期関数が戻ると、アイソレートはイベントを処理する必要がある場合に残ります。イベントを処理した後、アイソレートは終了します。
より一般的な図は、アイソレートがいくつかのコードを実行し、オプションでイベントに応答し、終了することを示しています。
イベントハンドリング
クライアントアプリでは、メインアイソレートのイベントキューに再描画要求やタップなどの UI イベントの通知が含まれることがあります。例えば、下図は再描画イベントの後にタップイベントが続き、その後に2つの再描画イベントが続いています。イベント・ループは、先入れ先出しの順番でキューからイベントを受け取ります。
(図)イベントが1つずつイベント・ループに送り込まれる
イベント処理は、main() が終了した後、メイン・アイソレートで行われます。下図では、main() が終了した後、main isolate は最初の repaint イベントを処理します。その後、main isolate は tap イベントを処理し、続いて repaint イベントを処理します。
同期処理に時間がかかりすぎると、アプリが応答しなくなることがあります。次の図では、タップ処理のコードに時間がかかりすぎるため、後続のイベントの処理が遅すぎます。アプリがフリーズしたように見えたり、アニメーションがぎこちなくなったりします。
(図)実行時間が長すぎるタップ・ハンドラを示す
クライアント・アプリでは、長すぎる同期操作の結果、UIアニメーションがぎこちなくなる(スムーズでなくなる)ことがよくある。さらに悪いことに、UIがまったく反応しなくなることもあります。
バックグラウンドワーカー
例えば大きな JSON ファイルの解析など、時間のかかる計算によってアプリの UI が応答しなくなった場合、しばしばバックグラウンドワーカーと呼ばれるワーカーアイソレートに計算をオフロードすることを検討してください。次の図に示す一般的なケースは、計算を実行して終了する単純なワーカーアイソレートを生成することです。ワーカーアイソレートは、終了時にその結果をメッセージで返します。
(図)メインアイソレートと単純なワーカーアイソレートを示す
ワーカーアイソレートはI/O(ファイルの読み書きなど)やタイマーの設定などを行うことができます。独自のメモリを持ち、メインアイソレートと状態を共有することはありません。ワーカーアイソレートは他のアイソレートに影響を与えることなくブロックすることができます。
アイソレートの使用
Dartでアイソレートを扱うには、使用ケースに応じて2つの方法がある:
- Isolate.run()を使用して、別のスレッドで単一の計算を実行します。
- Isolate.spawn()を使用して、時間をかけて複数のメッセージを処理するアイソレート、またはバックグラウンドワーカーを作成します。長期間のアイソレートを扱うための詳細については、アイソレートのページを参照してください。
ほとんどの場合、Isolate.runはバックグラウンドでプロセスを実行するために推奨されるAPIです。
Isolate.run()
静的なIsolate.run()メソッドは1つの引数を必要とします:新しく生成されたアイソレートで実行されるコールバックです。
int slowFib(int n) => n <= 1 ? 1 : slowFib(n - 1) + slowFib(n - 2);
// Compute without blocking current isolate.
void fib40() async {
var result = await Isolate.run(() => slowFib(40));
print('Fib(40) = $result');
}
パフォーマンスと複数のアイソレート
アイソレートがIsolate.spawn()を呼び出すと、2つのアイソレートは同じ実行可能コードを持ち、同じアイソレートグループになります。新しいアイソレートは、アイソレートグループが所有するコードを即座に実行します。また、Isolate.exit() はアイソレートが同じアイソレート・グループ内にある場合にのみ動作します。
特殊なケースでは、Isolate.spawnUri()を使用する必要があるかもしれません。これは、指定されたURIにあるコードのコピーで新しいアイソレートを設定します。しかし、spawnUri()はspawn()よりもはるかに遅く、新しいアイソレートはそのスポナーのアイソレートグループに含まれません。もう1つのパフォーマンス上の影響は、アイソレートが異なるグループにある場合にメッセージの受け渡しが遅くなることです。
複数のアイソレートのリミット
アイソレートはスレッドではない
マルチスレッド機能を持つ言語からDartに来たのであれば、アイソレートがスレッドのように振る舞うことを期待するのが妥当だろうが、そうではない。各アイソレートは独自のステートを持ち、アイソレート内のどのステートも他のアイソレートからアクセスできないようになっています。そのため、アイソレートは自身のメモリへのアクセスによって制限される。
例えば、グローバルなミュータブル変数を持つアプリケーションがある場合、その変数はスポーンしたアイソレート内の別の変数になります。スポーンしたアイソレートでその変数を変更しても、メインのアイソレートでは変更されずに残ります。これはアイソレートがどのように機能するかということであり、アイソレートの使用を検討する際には覚えておくことが重要です。
メッセージの種類
SendPort 経由で送信されるメッセージは、ほぼすべてのタイプの Dart オブジェクトを使用できますが、いくつかの例外があります:
- Socketのようなネイティブリソースを持つオブジェクト。
- ReceivePort
- DynamicLibrary
- Finalizable
- Finalizer
- NativeFinalizer
- Pointer
- UserTag
- @pragma('vm:isolate-unsendable')でマークされたクラスのインスタンス。
これらの例外を除けば、どんなオブジェクトでも送信できます。詳細はSendPort.sendのドキュメントを参照してください。
Isolate.spawn()とIsolate.exit()はSendPortオブジェクトを抽象化しているため、同じ制限を受けることに注意してください。
ウェブ上での同時実行
すべての Dart アプリは、async-await、Future、および Stream を使用して、ノンブロッキングでインターリーブされた計算を行うことができます。ただし、Dart Web プラットフォームはアイソレートをサポートしていません。Dart Web アプリでは、Web ワーカーを使用して、アイソレートと同様にバックグラウンドのスレッドでスクリプトを実行できます。しかし、ウェブワーカーの機能と性能はアイソレートとは多少異なります。
例えば、ウェブワーカーがスレッド間でデータを送信する場合、データを前後にコピーします。しかしデータのコピーは、特に大きなメッセージの場合、非常に時間がかかることがある。アイソレートも同じですが、代わりにメッセージを保持するメモリをより効率的に転送できるAPIを提供します。
ウェブワーカーとアイソレートの作成方法も異なります。ウェブ ワーカーを作成するには、別のプログラムのエントリーポイントを宣言し、別にコンパイルする必要があります。ウェブワーカーの起動は、Isolate.spawnUri を使ってアイソレートを起動するのと似ています。Isolate.spawnを使用してアイソレートを開始することもできますが、これはスポーンするアイソレートと同じコードとデータの一部を再利用するため、必要なリソースが少なくなります。Webワーカーには同等のAPIがありません。
その他のリソース
- 多くのアイソレートを使用する場合は、FlutterのIsolateNameServerや、Flutter以外のDartアプリケーションに同様の機能を提供するpackage:isolate_name_serverを検討してください。
- DartのアイソレートがベースとしているActorモデルについてはこちらを参照。
- Isolate APIに関する追加ドキュメント:
- Isolate.exit()
- Isolate.spawn()
- ReceivePort
- SendPort