メインコンテンツにスキップ

ゾーン

非同期動的エクステント

#

この記事では、dart:async ライブラリのゾーン関連APIについて説明し、特にトップレベルのrunZoned()runZonedGuarded()関数に焦点を当てています。この記事を読む前に、Futures and Error Handlingで説明されているテクニックを確認してください。

ゾーンにより、以下のタスクが可能になります。

  • 未捕捉例外によるアプリの終了からの保護。たとえば、単純なHTTPサーバーは以下の非同期コードを使用できます。

    dart
    runZonedGuarded(() {
      HttpServer.bind('0.0.0.0', port).then((server) {
        server.listen(staticFiles.serveRequest);
      });
    },
    (error, stackTrace) => print('Oh noes! $error $stackTrace'));

    ゾーンでHTTPサーバーを実行すると、サーバーの非同期コードで未捕捉(しかし致命的ではない)エラーが発生しても、アプリは実行を続行できます。

  • 個々のゾーンにゾーンローカル値として知られるデータを関連付ける

  • コードの一部または全部で、print()scheduleMicrotask()などの限られたメソッドセットをオーバーライドする

  • コードがゾーンに入ったり出たりするたびに操作を実行する。このような操作には、タイマーの開始または停止、スタックトレースの保存などが含まれます。

他の言語でゾーンに似たものに遭遇したことがあるかもしれません。Node.jsのドメインはDartのゾーンのインスピレーションでした。Javaのスレッドローカルストレージにもいくつかの類似点があります。最も近いのは、Brian FordによるDartゾーンのJavaScriptポートであるzone.jsで、彼はこのビデオでそれを説明しています。

ゾーンの基本

#

ゾーンは、呼び出しの非同期動的エクステントを表します。これは、呼び出しの一部として実行される計算、および、推移的に、そのコードによって登録された非同期コールバックです。

たとえば、HTTPサーバーの例では、bind()then()、およびthen()のコールバックはすべて同じゾーン、つまりrunZoned()を使用して作成されたゾーンで実行されます。

次の例では、コードは3つの異なるゾーンで実行されます: ゾーン #1(ルートゾーン)、ゾーン #2、およびゾーン #3

import 'dart:async';

main() {
  foo();
  var future;
  runZoned(() {          // Starts a new child zone (zone #2).
    future = new Future(bar).then(baz);
  });
  future.then(qux);
}

foo() => ...foo-body...  // Executed twice (once each in two zones).
bar() => ...bar-body...
baz(x) => runZoned(() => foo()); // New child zone (zone #3).
qux(x) => ...qux-body...

次の図は、コードの実行順序と、コードがどのゾーンで実行されるかを示しています。

illustration of program execution

runZoned()が呼び出されるたびに新しいゾーンが作成され、そのゾーンでコードが実行されます。そのコードがタスク(baz()の呼び出しなど)をスケジュールすると、そのタスクはスケジュールされたゾーンで実行されます。たとえば、qux()main()の最後の行)の呼び出しは、ゾーン #2で実行されるFutureにアタッチされていますが、ゾーン #1(ルートゾーン)で実行されます。

子ゾーンは親ゾーンを完全に置き換えるわけではありません。代わりに、新しいゾーンは周囲のゾーンの中にネストされます。たとえば、ゾーン #2にはゾーン #3が含まれ、ゾーン #1(ルートゾーン)にはゾーン #2ゾーン #3の両方が含まれます。

すべてのDartコードはルートゾーンで実行されます。コードは他のネストされた子ゾーンでも実行される可能性がありますが、最低限、常にルートゾーンで実行されます。

未捕捉エラーの処理

#

ゾーンは未捕捉エラーをキャッチして処理できます。

未捕捉エラーは、しばしば、例外を処理するためのcatchステートメントなしで例外を発生させるためにthrowを使用するコードのために発生します。未捕捉エラーは、async関数でFutureがエラー結果で完了し、エラーを処理する対応するawaitがない場合にも発生する可能性があります。

未捕捉エラーは、それをキャッチできなかった現在のゾーンに報告されます。デフォルトでは、ゾーンは未捕捉エラーに応答してプログラムをクラッシュさせます。新しいゾーンに独自のカスタム未捕捉エラーハンドラーをインストールして、未捕捉エラーを任意の方法でインターセプトして処理できます。

未捕捉エラーハンドラーを持つ新しいゾーンを導入するには、runZoneGuardedメソッドを使用します。そのonErrorコールバックは、新しいゾーンの未捕捉エラーハンドラーになります。このコールバックは、その呼び出しが発生させる同期エラーを処理します。

dart
runZonedGuarded(() {
  Timer.run(() { throw 'Would normally kill the program'; });
}, (error, stackTrace) {
  print('Uncaught error: $error');
});

未捕捉エラー処理を容易にするその他のゾーンAPIには、Zone.forkZone.runGuarded、およびZoneSpecification.uncaughtErrorHandlerがあります。

前のコードには、例外を発生させる非同期コールバック(Timer.run()経由)があります。通常、この例外は未処理のエラーとなり、トップレベルに到達します(スタンドアロンDart実行可能ファイルでは、実行中のプロセスを終了します)。しかし、ゾーンエラーハンドラーを使用すると、エラーはエラーハンドラーに渡され、プログラムをシャットダウンしません。

try-catchとゾーンエラーハンドラーの顕著な違いの1つは、ゾーンは未捕捉エラーが発生した後も実行を続けることです。ゾーン内で他の非同期コールバックがスケジュールされている場合、それらは引き続き実行されます。その結果、ゾーンエラーハンドラーは複数回呼び出される可能性があります。

未捕捉エラーハンドラーを持つ任意のゾーンはエラーゾーンと呼ばれます。エラーゾーンは、そのゾーンの子孫で発生したエラーを処理できます。単純なルールが、一連のFuture変換(then()またはcatchError()を使用)でエラーがどこで処理されるかを決定します。Futureチェーンのエラーは、エラーゾーンの境界を越えることはありません。

エラーがエラーゾーンの境界に到達した場合、その時点で未処理のエラーとして扱われます。

例: エラーはエラーゾーンを越えられない

#

次の例では、最初の行で発生したエラーはエラーゾーンを越えることができません。

dart
var f = new Future.error(499);
f = f.whenComplete(() { print('Outside of zones'); });
runZoned(() {
  f = f.whenComplete(() { print('Inside non-error zone'); });
});
runZonedGuarded(() {
  f = f.whenComplete(() { print('Inside error zone (not called)'); });
}, (error) { print(error); });

例を実行すると表示される出力は次のとおりです。

Outside of zones
Inside non-error zone
Uncaught Error: 499
Unhandled exception:
499
...stack trace...

runZoned()またはrunZonedGuarded()の呼び出しを削除すると、この出力が表示されます。

Outside of zones
Inside non-error zone
Inside error zone (not called)
Uncaught Error: 499
Unhandled exception:
499
...stack trace...

ゾーンまたはエラーゾーンのいずれかを削除すると、エラーがさらに伝播することに注意してください。

エラーがエラーゾーン外で発生するため、スタックトレースが表示されます。コードスニペット全体をエラーゾーンで囲むと、スタックトレースを回避できます。

例: エラーはエラーゾーンから出られない

#

前のコードが示すように、エラーはエラーゾーンに越えることはできません。同様に、エラーはエラーゾーンから外に出ることはできません。この例を検討してください。

dart
var completer = new Completer();
var future = completer.future.then((x) => x + 1);
var zoneFuture;
runZonedGuarded(() {
  zoneFuture = future.then((y) => throw 'Inside zone');
}, (error) { print('Caught: $error'); });

zoneFuture.catchError((e) { print('Never reached'); });
completer.complete(499);

FutureチェーンはcatchError()で終了しますが、非同期エラーはエラーゾーンを離れることができません。runZonedGuarded()で見つかったゾーンエラーハンドラーがエラーを処理します。その結果、zoneFutureは決して完了しません — 値でもエラーでもありません。

ストリームでのゾーンの使用

#

ゾーンとストリームのルールは、Futureよりも簡単です。

このルールは、ストリームはリッスンされるまで副作用を持たないべきであるというガイドラインに従います。同期コードでの同様の状況は、Iterableの動作であり、値が要求されるまで評価されません。

例: runZonedGuarded() でストリームを使用する

#

次の例は、コールバックを持つストリームを設定し、そのストリームをrunZonedGuarded()を持つ新しいゾーンで実行します。

dart
var stream = new File('stream.dart').openRead()
    .map((x) => throw 'Callback throws');

runZonedGuarded(() { stream.listen(print); },
         (e) { print('Caught error: $e'); });

runZonedGuarded()のエラーハンドラーは、コールバックが発生させるエラーをキャッチします。出力は次のとおりです。

Caught error: Callback throws

出力が示すように、コールバックはmap()が呼び出されるゾーンではなく、リッスンしているゾーンに関連付けられています。

ゾーンローカル値の保存

#

静的変数を使用したいが、同時に実行される複数の計算が干渉するために使用できなかった場合、ゾーンローカル値の使用を検討してください。デバッグに役立つようにゾーンローカル値を追加できます。別のユースケースはHTTPリクエストの処理です。ユーザーIDとその認証トークンをゾーンローカル値に含めることができます。

runZoned()zoneValues引数を使用して、新しく作成されたゾーンに値を保存します。

dart
runZoned(() {
  print(Zone.current[#key]);
}, zoneValues: { #key: 499 });

ゾーンローカル値を読み取るには、ゾーンのインデックス演算子と値のキー: [key]を使用します。キーは、互換性のあるoperator ==hashCode実装を持っている限り、任意のオブジェクトを使用できます。通常、キーはシンボルリテラルです: #identifier

キーがマッピングするオブジェクトを変更することはできませんが、オブジェクトを操作することはできます。たとえば、次のコードはゾーンローカルリストに項目を追加します。

dart
runZoned(() {
  Zone.current[#key].add(499);
  print(Zone.current[#key]); // [499]
}, zoneValues: { #key: [] });

ゾーンは親ゾーンからゾーンローカル値を継承するため、ネストされたゾーンを追加しても既存の値が誤って失われることはありません。ただし、ネストされたゾーンは親の値をシャドウすることができます。

例: デバッグログにゾーンローカル値を使用する

#

foo.txtとbar.txtの2つのファイルがあり、それらのすべての行を印刷したいとします。プログラムは次のようになります。

dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';

Future splitLinesStream(stream) {
  return stream
      .transform(ASCII.decoder)
      .transform(const LineSplitter())
      .toList();
}

Future splitLines(filename) {
  return splitLinesStream(new File(filename).openRead());
}
main() {
  Future.forEach(['foo.txt', 'bar.txt'],
                 (file) => splitLines(file)
                     .then((lines) { lines.forEach(print); }));
}

このプログラムは機能しますが、各行がどのファイルから来たのかを知りたいと仮定し、splitLinesStream()にファイル名引数を追加するだけでは解決しないとします。ゾーンローカル値を使用すると、ファイル名を返される文字列に追加できます(改行はハイライト表示されます)。

dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';

Future splitLinesStream(stream) {
  return stream
      .transform(ASCII.decoder)
      .transform(const LineSplitter())
      .map((line) => '${Zone.current[#filename]}: $line')
      .toList();
}

Future splitLines(filename) {
  return runZoned(() {
    return splitLinesStream(new File(filename).openRead());
  }, zoneValues: { #filename: filename });
}

main() {
  Future.forEach(['foo.txt', 'bar.txt'],
                 (file) => splitLines(file)
                     .then((lines) { lines.forEach(print); }));
}

新しいコードは関数シグネチャを変更したり、ファイル名をsplitLines()からsplitLinesStream()に渡したりしないことに注意してください。代わりに、ゾーンローカル値を使用して、非同期コンテキストで機能する静的変数に似た機能を実装しています。

機能のオーバーライド

#

runZoned()zoneSpecification引数を使用して、ゾーンによって管理される機能をオーバーライドします。引数の値はZoneSpecificationオブジェクトであり、以下のいずれかの機能をオーバーライドできます。

  • 子ゾーンのフォーク
  • ゾーンでのコールバックの登録と実行
  • マイクロタスクとタイマーのスケジュール
  • 未捕捉非同期エラーの処理(runZonedGuarded()はこれのショートカットです)
  • 印刷

例: print をオーバーライドする

#

機能オーバーライドの簡単な例として、ゾーン内のすべてのprintをサイレンスする方法を以下に示します。

dart
import 'dart:async';

main() {
  runZoned(() {
    print('Will be ignored');
  }, zoneSpecification: new ZoneSpecification(
    print: (self, parent, zone, message) {
      // Ignore message.
    }));
}

フォークされたゾーン内では、print()関数は指定されたprintインターセプターによってオーバーライドされ、メッセージを単純に破棄します。print()scheduleMicrotask()やTimerコンストラクタと同様に)は、その作業を行うために現在のゾーン(Zone.current)を使用するため、printのオーバーライドが可能です。

インターセプターとデリゲートへの引数

#

printの例が示すように、インターセプターはZoneクラスの対応するメソッドに定義されている引数に3つの引数を追加します。たとえば、Zoneのprint()メソッドには1つの引数があります: print(String line)。ZoneSpecificationで定義されているprint()のインターセプターバージョンには、4つの引数があります: print(Zone self, ZoneDelegate parent, Zone zone, String line)

3つのインターセプター引数は常に同じ順序で、他の引数の前に表示されます。

self
コールバックを処理しているゾーン。
parent
親ゾーンを表すZoneDelegate。これを使用して、操作を親に転送します。
zone
操作が発生したゾーン。一部の操作は、操作がどのゾーンで呼び出されたかを知る必要があります。たとえば、zone.fork(specification)zoneの子として新しいゾーンを作成する必要があります。別の例として、scheduleMicrotask()を別のゾーンに委任する場合でも、元のzoneがマイクロタスクを実行するゾーンである必要があります。

インターセプターがメソッドを親に委任する場合、ZoneDelegateバージョンのメソッドには、元の呼び出しが発生したゾーンであるzoneという1つの追加引数があります。たとえば、ZoneDelegateのprint()メソッドのシグネチャはprint(Zone zone, String line)です。

ここでは、別のインターセプト可能なメソッドscheduleMicrotask()の引数の例を示します。

| 定義場所 | メソッドシグネチャ | | Zone | void scheduleMicrotask(void f()) | | ZoneSpecification  | void scheduleMicrotask(Zone self, ZoneDelegate parent, Zone zone, void f()) | | ZoneDelegate | void scheduleMicrotask(Zone zone, void f()) |

例: 親ゾーンへの委任

#

ここでは、親ゾーンへの委任方法を示す例を示します。

dart
import 'dart:async';

main() {
  runZoned(() {
    var currentZone = Zone.current;
    scheduleMicrotask(() {
      print(identical(currentZone, Zone.current));  // prints true.
    });
  }, zoneSpecification: new ZoneSpecification(
    scheduleMicrotask: (self, parent, zone, task) {
      print('scheduleMicrotask has been called inside the zone');
      // The origin `zone` needs to be passed to the parent so that
      // the task can be executed in it.
      parent.scheduleMicrotask(zone, task);
    }));
}

例: ゾーンへの入退出時にコードを実行する

#

非同期コードが実行に費やす時間を知りたいとします。コードをゾーンに入れ、ゾーンに入るたびにタイマーを開始し、ゾーンを離れるたびにタイマーを停止することで、これを行うことができます。

ZoneSpecificationにrun*パラメータを提供すると、ゾーンが実行するコードを指定できます。

run*パラメータ(runrunUnaryrunBinary)は、ゾーンがコードの実行を要求されるたびに実行されるコードを指定します。これらのパラメータは、それぞれ引数なし、1つ、2つのコールバックで機能します。runパラメータは、runZoned()を呼び出した直後に実行される最初の同期コードにも機能します。

ここでは、run*を使用したコードプロファイリングの例を示します。

dart
final total = new Stopwatch();
final user = new Stopwatch();

final specification = new ZoneSpecification(
  run: (self, parent, zone, f) {
    user.start();
    try { return parent.run(zone, f); } finally { user.stop(); }
  },
  runUnary: (self, parent, zone, f, arg) {
    user.start();
    try { return parent.runUnary(zone, f, arg); } finally { user.stop(); }
  },
  runBinary: (self, parent, zone, f, arg1, arg2) {
    user.start();
    try {
      return parent.runBinary(zone, f, arg1, arg2);
    } finally {
      user.stop();
    }
  });

runZoned(() {
  total.start();
  // ... Code that runs synchronously...
  // ... Then code that runs asynchronously ...
    .then((...) {
      print(total.elapsedMilliseconds);
      print(user.elapsedMilliseconds);
    });
}, zoneSpecification: specification);

このコードでは、各run*オーバーライドはユーザータイマーを開始し、指定された関数を実行し、その後ユーザータイマーを停止します。

例: コールバックの処理

#

ZoneSpecificationにregister*Callbackパラメータを提供すると、ゾーンで非同期に実行されるコールバックコードをラップまたは変更できます。run*パラメータと同様に、register*Callbackパラメータには3つの形式があります: registerCallback(引数なしのコールバック用)、registerUnaryCallback(1つの引数)、およびregisterBinaryCallback(2つの引数)。

ここでは、コードが非同期コンテキストに消える前にゾーンがスタックトレースを保存するようにする例を示します。

dart
import 'dart:async';

get currentStackTrace {
  try {
    throw 0;
  } catch(_, st) {
    return st;
  }
}

var lastStackTrace = null;

bar() => throw "in bar";
foo() => new Future(bar);

main() {
  final specification = new ZoneSpecification(
    registerCallback: (self, parent, zone, f) {
      var stackTrace = currentStackTrace;
      return parent.registerCallback(zone, () {
        lastStackTrace = stackTrace;
        return f();
      });
    },
    registerUnaryCallback: (self, parent, zone, f) {
      var stackTrace = currentStackTrace;
      return parent.registerUnaryCallback(zone, (arg) {
        lastStackTrace = stackTrace;
        return f(arg);
      });
    },
    registerBinaryCallback: (self, parent, zone, f) {
      var stackTrace = currentStackTrace;
      return parent.registerBinaryCallback(zone, (arg1, arg2) {
        lastStackTrace = stackTrace;
        return f(arg1, arg2);
      });
    },
    handleUncaughtError: (self, parent, zone, error, stackTrace) {
      if (lastStackTrace != null) print("last stack: $lastStackTrace");
      return parent.handleUncaughtError(zone, error, stackTrace);
    });

  runZoned(() {
    foo();
  }, zoneSpecification: specification);
}

例を実行してください。「last stack」トレース(lastStackTrace)が表示され、foo()が同期的に呼び出されたためfoo()が含まれます。次のスタックトレース(stackTrace)は非同期コンテキストからのもので、bar()は知っていますがfoo()は知りません。

非同期コールバックの実装

#

非同期APIを実装している場合でも、ゾーンをまったく扱う必要がない場合があります。たとえば、dart:ioライブラリが現在のゾーンを追跡していると予想されるかもしれませんが、実際にはFutureやStreamなどのdart:asyncクラスのゾーン処理に依存しています。

ゾーンを明示的に処理する場合、すべての非同期コールバックを登録し、各コールバックが登録されたゾーンで呼び出されるようにする必要があります。Zoneのbind*Callbackヘルパーメソッドは、このタスクを容易にします。これらはregister*Callbackrun*のショートカットであり、各コールバックが登録され、そのZoneで実行されることを保証します。

bind*Callbackよりも詳細な制御が必要な場合は、register*Callbackrun*を使用する必要があります。また、Zoneのrun*Guardedメソッドを使用することもできます。これらは呼び出しをtry-catchでラップし、エラーが発生した場合にuncaughtErrorHandlerを呼び出します。

まとめ

#

ゾーンは、非同期コードの未捕捉例外からコードを保護するのに役立ちますが、それ以上のことができます。ゾーンにデータを関連付けることができ、printやタスクスケジューリングなどのコア機能をオーバーライドできます。ゾーンは、デバッグを改善し、プロファイリングなどの機能に使用できるフックを提供します。

その他のリソース

#
ゾーン関連APIドキュメント
runZoned()runZonedGuarded()ZoneZoneDelegate、およびZoneSpecificationのドキュメントをお読みください。
stack_trace
stack_traceライブラリのChainクラスを使用すると、非同期に実行されるコードのスタックトレースを改善できます。詳細については、pub.devサイトのstack_traceパッケージを参照してください。

その他の例

#

ゾーンを使用した、より複雑な例をいくつか示します。

task_interceptor例
task_interceptor.dartのtoyゾーンは、scheduleMicrotaskcreateTimer、およびcreatePeriodicTimerをインターセプトして、イベントループに譲歩せずにDartプリミティブの動作をシミュレートします。
stack_traceパッケージのソースコード
stack_traceパッケージは、非同期コードのデバッグのためにスタックトレースのチェーンを形成するためにゾーンを使用します。使用されるゾーン機能には、エラー処理、ゾーンローカル値、およびコールバックが含まれます。stack_traceソースコードは、stack_trace GitHubプロジェクトで見つけることができます。
dart:asyncのソースコード
これら2つのSDKライブラリは、非同期コールバックを特徴とするAPIを実装しており、そのためゾーンを扱います。それらのソースコードは、Dart GitHubプロジェクトsdk/libディレクトリの下で閲覧またはダウンロードできます。

この記事のレビューに協力してくれたAnders JohnsenとLasse Reichstein Nielsenに感謝します。