目次

Dartにおける並行性

このページでは、Dartにおける並行プログラミングの概念的な概要について説明します。イベントループ、非同期言語機能、Isolateについて、高レベルな視点から解説します。Dartで並行性を使用する具体的なコード例については、非同期サポートページとIsolateページをご覧ください。

Dartの並行プログラミングとは、`Future`や`Stream`などの非同期APIと、プロセスを別のコアに移動できる*Isolate*の両方を指します。

すべてのDartコードはIsolate内で実行され、デフォルトのメインIsolateで開始し、必要に応じて明示的に作成した後続のIsolateに拡張できます。新しいIsolateを生成すると、Isolate独自の分離されたメモリと独自のイベントループが作成されます。イベントループは、Dartで非同期および並行プログラミングを可能にするものです。

イベントループ

#

Dartのランタイムモデルは、イベントループに基づいています。イベントループは、プログラムのコードの実行、イベントの収集と処理などを行います。

アプリケーションの実行中に、すべてのイベントは*イベントキュー*と呼ばれるキューに追加されます。イベントは、UIの再描画要求、ユーザーのタップやキーストローク、ディスクからのI/Oなど、あらゆるものが考えられます。アプリはイベントが発生する順序を予測できないため、イベントループはキューに入れられた順序でイベントを1つずつ処理します。

A figure showing events being fed, one by one, into the
event loop

イベントループの動作は、次のコードに似ています。

dart
while (eventQueue.waitForEvent()) {
  eventQueue.processNextEvent();
}

このイベントループの例は同期型で、シングルスレッドで実行されます。ただし、ほとんどのDartアプリケーションは、一度に複数のことを行う必要があります。たとえば、クライアントアプリケーションは、HTTPリクエストを実行すると同時に、ユーザーがボタンをタップするのをリッスンする必要がある場合があります。これを処理するために、Dartは`Future`、`Stream`、`async-await`など、多くの非同期APIを提供しています。これらのAPIは、このイベントループを中心に構築されています。

たとえば、ネットワークリクエストを行うことを考えてみましょう。

dart
http.get('https://example.com').then((response) {
  if (response.statusCode == 200) {
    print('Success!');
  }  
}

このコードがイベントループに到達すると、すぐに最初の句`http.get`を呼び出し、`Future`を返します。また、HTTPリクエストが解決されるまで、`then()`句のコールバックを保持するようにイベントループに指示します。解決されると、リクエストの結果を引数として渡して、そのコールバックを実行する必要があります。

Figure showing async events being added to an event loop and
holding onto a callback to execute later
.

このモデルは、一般的に、イベントループが`Stream`オブジェクトなど、Dartの他のすべての非同期イベントを処理する方法と同じです。

非同期プログラミング

#

このセクションでは、Dartにおける非同期プログラミングのさまざまな種類と構文をまとめています。`Future`、`Stream`、`async-await`にすでに精通している場合は、Isolateセクションに進んでください。

Future

#

`Future`は、最終的に値またはエラーで完了する非同期操作の結果を表します。

このサンプルコードでは、`Future<String>`の戻り値の型は、最終的に`String`値(またはエラー)を提供するという約束を表しています。

dart
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を待機している間にブロックする同期コードの例を次に示します。

dart
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();
}

同様のコードですが、非同期にするための変更(強調表示)が加えられています。

dart
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`が設定されます。

次の図に示すように、Dartコードは、`readAsString()`がDartランタイムまたはオペレーティングシステムのいずれかでDart以外のコードを実行している間、一時停止します。`readAsString()`が値を返すと、Dartコードの実行が再開されます。

Flowchart-like figure showing app code executing from start to exit, waiting
for native I/O in between

ストリーム

#

Dartは、ストリームの形式で非同期コードもサポートしています。ストリームは、将来にわたって繰り返し値を提供します。一連の`int`値を時間の経過とともに提供するという約束は、`Stream<int>`型を持ちます。

次の例では、`Stream.periodic`で作成されたストリームは、1秒ごとに新しい`int`値を繰り返し出力します。

dart
Stream<int> stream = Stream.periodic(const Duration(seconds: 1), (i) => i * i);

await-forとyield

#

Await-forは、新しい値が提供されるたびにループの次の反復を実行するforループの一種です。言い換えれば、ストリームを「ループオーバー」するために使用されます。この例では、引数として提供されたストリームから新しい値が出力されるたびに、関数`sumStream`から新しい値が出力されます。値のストリームを返す関数では、`return`ではなく`yield`キーワードが使用されます。

dart
Stream<int> sumStream(Stream<int> stream) async* {
  var sum = 0;
  await for (final value in stream) {
    yield sum += value;
  }
}

`async`、`await`、`Stream`、`Future`の使用について詳しくは、非同期プログラミングチュートリアルをご覧ください。

Isolate

#

Dartは、非同期APIに加えて、Isolateを介した並行性をサポートしています。最新のデバイスのほとんどは、マルチコアCPUを搭載しています。複数のコアを活用するために、開発者は並行して実行される共有メモリのスレッドを使用することがあります。ただし、共有状態の並行性はエラーが発生しやすいため、コードが複雑になる可能性があります。

スレッドの代わりに、すべてのDartコードはIsolate内で実行されます。Isolateを使用すると、Dartコードは、使用可能な場合は追加のプロセッサコアを使用して、複数の独立したタスクを同時に実行できます。Isolateはスレッドやプロセスに似ていますが、各Isolateには独自のメモリとイベントループを実行する単一のスレッドがあります。

各Isolateには独自のグローバルフィールドがあり、Isolateの状態のいずれにも他のIsolateからアクセスできないことが保証されます。Isolateは、メッセージパッシングを介してのみ相互に通信できます。Isolate間で状態を共有しないということは、ミューテックスやロックデータ競合などの並行性の複雑さがDartでは発生しないことを意味します。とはいえ、Isolateは競合状態を完全に防ぐわけではありません。この並行性モデルについて詳しくは、アクターモデルをご覧ください。

メインIsolate

#

ほとんどの場合、Isolateについてまったく考える必要はありません。Dartプログラムは、デフォルトでメインIsolateで実行されます。これは、次の図に示すように、プログラムが実行を開始するスレッドです。

A figure showing a main isolate, which runs , responds to events,
and then exits

シングルIsolateプログラムでもスムーズに実行できます。コードの次の行に進む前に、これらのアプリはasync-awaitを使用して、非同期操作が完了するまで待機します。正常に動作するアプリはすぐに起動し、できるだけ早くイベントループに到達します。その後、アプリはキューに入れられた各イベントに迅速に応答し、必要に応じて非同期操作を使用します。

Isolateのライフサイクル

#

次の図に示すように、すべてのIsolateは、`main()`関数など、いくつかのDartコードを実行することから始まります。このDartコードは、たとえばユーザー入力またはファイルI/Oに応答するために、いくつかのイベントリスナーを登録する場合があります。Isolateの初期関数が戻ると、イベントを処理する必要がある場合、Isolateはそのまま残ります。イベントを処理した後、Isolateは終了します。

A more general figure showing that any isolate runs some code, optionally responds to events, and then exits

イベント処理

#

クライアントアプリでは、メインIsolateのイベントキューに、再描画要求、タップの通知、その他のUIイベントが含まれている場合があります。たとえば、次の図は、再描画イベント、タップイベント、2つの再描画イベントの順序を示しています。イベントループは、先入れ先出しの順序でキューからイベントを取得します。

A figure showing events being fed, one by one, into the event loop

イベント処理は、main() が終了した後にメインアイソレートで行われます。次の図では、main() が終了した後、メインアイソレートは最初の再描画イベントを処理します。その後、メインアイソレートはタップイベントを処理し、続いて再描画イベントを処理します。

同期操作に処理時間がかかりすぎると、アプリが応答しなくなる可能性があります。次の図では、タップ処理コードに時間がかかりすぎるため、後続のイベントの処理が遅れています。アプリがフリーズしたように見えたり、アニメーションがぎこちなくなったりする可能性があります。

A figure showing a tap handler with a too-long execution time

クライアントアプリでは、長すぎる同期操作の結果、多くの場合、ぎこちない(スムーズでない)UIアニメーションが発生します。さらに悪いことに、UIが完全に応答しなくなる可能性があります。

バックグラウンドワーカー

#

アプリのUIが時間のかかる計算(たとえば、大きなJSONファイルの解析)によって応答しなくなる場合は、その計算をワーカーアイソレート(多くの場合、*バックグラウンドワーカー*と呼ばれます)にオフロードすることを検討してください。次の図に示す一般的なケースは、計算を実行して終了する単純なワーカーアイソレートを生成することです。ワーカーアイソレートは、終了時にメッセージで結果を返します。

A figure showing a main isolate and a simple worker isolate

ワーカーアイソレートは、I/O(ファイルの読み取りと書き込みなど)の実行、タイマーの設定などを行うことができます。独自のメモリを持ち、メインアイソレートと状態を共有しません。ワーカーアイソレートは、他のアイソレートに影響を与えることなくブロックできます。

Isolateの使用

#

Dartでは、ユースケースに応じて、アイソレートを扱う方法は2つあります。

  • Isolate.run()を使用して、別のスレッドで単一の計算を実行します。
  • Isolate.spawn()を使用して、時間の経過とともに複数のメッセージを処理するアイソレート、またはバックグラウンドワーカーを作成します。長時間実行されるアイソレートの操作の詳細については、アイソレートのページをご覧ください。

ほとんどの場合、バックグラウンドでプロセスを実行するには、Isolate.run が推奨されるAPIです。

Isolate.run()

#

静的メソッドIsolate.run() は、新しく生成されたアイソレートで実行されるコールバックという1つの引数を必要とします。

dart
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グループ

#

アイソレートがIsolate.spawn()を呼び出すと、2つのアイソレートは同じ実行可能コードを持ち、同じ*アイソレートグループ*に属します。アイソレートグループは、コードの共有などのパフォーマンスの最適化を可能にします。新しいアイソレートは、アイソレートグループが所有するコードをすぐに実行します。また、Isolate.exit() は、アイソレートが同じアイソレートグループに属している場合にのみ機能します。

特別な場合によっては、Isolate.spawnUri()を使用する必要がある場合があります。これは、指定されたURIにあるコードのコピーを使用して新しいアイソレートを設定します。ただし、spawnUri()spawn() よりはるかに遅く、新しいアイソレートは生成元のアイソレートグループに属しません。もう1つのパフォーマンスへの影響は、アイソレートが異なるグループに属している場合、メッセージの受け渡しが遅くなることです。

Isolateの制限

#

アイソレートはスレッドではありません

#

マルチスレッドを備えた言語からDartに移行する場合、アイソレートがスレッドのように動作することを期待するのは当然ですが、そうではありません。各アイソレートは独自の状態を持ち、あるアイソレートの状態には他のどのアイソレートからもアクセスできないことが保証されます。したがって、アイソレートは自身のメモリへのアクセスによって制限されます。

たとえば、グローバルな可変変数を持つアプリケーションがある場合、その変数は生成されたアイソレートでは別の変数になります。生成されたアイソレートでその変数を変更しても、メインアイソレートでは変更されません。これがアイソレートの機能であり、アイソレートの使用を検討する際には注意することが重要です。

メッセージタイプ

#

SendPortを介して送信されるメッセージは、ほとんどすべてのタイプのDartオブジェクトにすることができますが、いくつかの例外があります。

これらの例外を除いて、どのオブジェクトでも送信できます。詳細については、SendPort.sendのドキュメントをご覧ください。

Isolate.spawn()Isolate.exit()SendPort オブジェクトを抽象化しているため、同じ制限の対象となります。

アイソレート間の同期ブロッキング通信

#

並列に実行できるアイソレートの数には制限があります。この制限は、Dartにおけるメッセージを介したアイソレート間の標準的な*非同期*通信には影響しません。数百のアイソレートを同時に実行して進行させることができます。アイソレートはCPU上でラウンドロビン方式でスケジュールされ、頻繁に互いに譲り合います。

アイソレートは、FFIを介してCコードを使用して、純粋なDartの外部で*同期的に*通信することのみ可能です。FFI呼び出しで同期ブロッキングによってアイソレート間で同期通信を試みると、特別な注意を払わない限り、アイソレートの数が制限を超えた場合にデッドロックが発生する可能性があります。制限は特定の数値にハードコードされておらず、Dartアプリケーションで使用可能なDart VMヒープサイズに基づいて計算されます。

この状況を回避するには、同期ブロッキングを実行するCコードは、ブロッキング操作を実行する前に現在のアイソレートを離れ、FFI呼び出しからDartに戻る前に再び入る必要があります。Dart_EnterIsolateDart_ExitIsolateについて読んで、詳細を学んでください。

Webにおける並行性

#

すべてのDartアプリは、非ブロッキングのインターリーブされた計算にasync-awaitFuture、およびStreamを使用できます。ただし、Dart Webプラットフォームはアイソレートをサポートしていません。Dart Webアプリは、Webワーカーを使用して、アイソレートと同様にバックグラウンドスレッドでスクリプトを実行できます。ただし、Webワーカーの機能と możliwości はアイソレートとは多少異なります。

たとえば、Webワーカーがスレッド間でデータを送信する場合、データをコピーしてやり取りします。ただし、データのコピーは、特に大きなメッセージの場合、非常に時間がかかる場合があります。アイソレートも同じことをしますが、メッセージを保持するメモリをより効率的に*転送*できるAPIも提供します。

Webワーカーとアイソレートの作成も異なります。Webワーカーは、別のプログラムのエントリポイントを宣言し、個別にコンパイルすることによってのみ作成できます。Webワーカーの開始は、Isolate.spawnUri を使用してアイソレートを開始するのと似ています。Isolate.spawn を使用してアイソレートを開始することもできます。これは、生成元のアイソレートと同じコードとデータの一部を再利用するため、必要なリソースが少なくなります。Webワーカーには、同等のAPIはありません。

追加リソース

#