目录

异步动态范围

#

本文讨论了 dart:async 库中与区域相关的 API,重点介绍了顶级 runZoned()runZonedGuarded() 函数。在阅读本文之前,请先回顾 Futures 和错误处理 中介绍的技术。

区域使以下任务成为可能

  • 保护您的应用程序免受未捕获异常导致的退出。例如,一个简单的 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() 的最后一行)在 区域 #1(根区域)中运行,即使它附加到本身在 区域 #2 中运行的 Future。

子区域不会完全取代其父区域。相反,新区域会嵌套在其周围的区域内。例如,区域 #2 包含 区域 #3,而 区域 #1(根区域)包含 区域 #2区域 #3

所有 Dart 代码都在根区域执行。代码也可能在其他嵌套的子区域中执行,但至少它始终在根区域中运行。

处理未捕获的错误

#

区域能够捕获和处理未捕获的错误。

未捕获的错误通常发生在代码使用 throw 抛出异常但没有伴随的 catch 语句来处理它时。当 Future 完成时带有错误结果,但缺少相应的 await 来处理错误时,未捕获的错误也可能出现在 async 函数中。

未捕获的错误会报告给当前未能捕获它的区域。默认情况下,区域会响应未捕获的错误而崩溃程序。您可以将自己的自定义未捕获错误处理程序安装到新区域,以拦截和处理您喜欢的未捕获错误。

要引入一个带有未捕获错误处理程序的新区域,请使用 runZoneGuarded 方法。它的 onError 回调成为新区域的未捕获错误处理程序。此回调处理调用抛出的任何同步错误。

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

其他促进未捕获错误处理的区域 API 包括 Zone.forkZone.runGuardedZoneSpecification.uncaughtErrorHandler

前面的代码有一个异步回调(通过 Timer.run())抛出异常。通常,此异常将是一个未处理的错误,并到达顶层(在独立的 Dart 可执行文件中,这将终止正在运行的进程)。但是,使用区域化错误处理程序,错误将传递给错误处理程序,并且不会关闭程序。

try-catch 和区域化错误处理程序之间的一个显著区别是,区域化错误处理程序在发生未捕获错误后会继续执行。如果在区域内调度了其他异步回调,它们仍然会执行。因此,区域化错误处理程序可能会被多次调用。

任何具有未捕获错误处理程序的区域都称为错误区域。错误区域可以处理源自该区域后代的错误。一个简单的规则决定了在未来的一系列转换(使用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 更简单

此规则遵循流在监听之前不应产生副作用的准则。同步代码中的类似情况是 Iterables 的行为,它们在您请求值之前不会被评估。

示例:使用流与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 及其授权令牌。

使用 zoneValues 参数传递给 runZoned() 函数,以便在新建的区域中存储值。

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,并且想要打印所有行。该程序可能如下所示

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()。相反,它使用区域局部值来实现类似于在异步上下文中工作的静态变量的功能。

覆盖功能

#

使用 zoneSpecification 参数传递给 runZoned() 函数,以覆盖由区域管理的功能。该参数的值是一个 ZoneSpecification 对象,您可以使用它来覆盖以下任何功能

  • 分叉子区域
  • 在区域中注册和运行回调
  • 调度微任务和计时器
  • 处理未捕获的异步错误(runZonedGuarded() 是此功能的快捷方式)
  • 打印

示例:覆盖 print

#

作为覆盖功能的简单示例,以下是如何在区域内静默所有打印的方法

dart
import 'dart:async';

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

在分叉区域内,print() 函数被指定的打印拦截器覆盖,该拦截器只简单地丢弃消息。覆盖打印是可能的,因为 print()(如 scheduleMicrotask() 和 Timer 构造函数)使用当前区域(Zone.current)来完成其工作。

拦截器和委托的参数

#

如打印示例所示,拦截器在 Zone 类中相应方法定义的三个参数中添加了三个参数。例如,Zone 的 print() 方法有一个参数:print(String line)。ZoneSpecification 定义的 print() 的拦截器版本有四个参数:print(Zone self, ZoneDelegate parent, Zone zone, String line)

三个拦截器参数始终以相同的顺序出现,在任何其他参数之前。

self
正在处理回调的区域。
parent
一个代表父区域的 ZoneDelegate。使用它将操作转发给父区域。
区域
操作起源的区域。某些操作需要知道在哪个区域调用了操作。例如,zone.fork(specification) 必须创建一个新的区域作为 zone 的子区域。另一个例子是,即使你将 scheduleMicrotask() 委托给另一个区域,原始 zone 必须是执行微任务的区域。

当拦截器将方法委托给父区域时,父区域 (ZoneDelegate) 版本的方法只有一个额外的参数:zone,即原始调用起源的区域。例如,ZoneDelegate 上的 print() 方法的签名是 print(Zone zone, String line)

以下是一个关于另一个可拦截方法 scheduleMicrotask() 的参数示例。

| **定义位置** | **方法签名** | | 区域 | void scheduleMicrotask(void f()) | | 区域规范 | void scheduleMicrotask(Zone self, ZoneDelegate parent, Zone zone, void f()) | | 区域委托 | 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)指定每次区域被要求执行代码时要执行的代码。这些参数分别适用于零参数、一个参数和两个参数的回调。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 参数有三种形式:registerCallback(用于没有参数的回调)、registerUnaryCallback(一个参数)和 registerBinaryCallback(两个参数)。

以下是一个让区域在代码消失到异步上下文之前保存堆栈跟踪的示例。

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

运行示例。你将看到一个“最后一个堆栈”跟踪 (lastStackTrace),其中包含 foo(),因为 foo() 是同步调用的。下一个堆栈跟踪 (stackTrace) 来自异步上下文,它知道 bar(),但不知道 foo()

实现异步回调

#

即使你正在实现一个异步 API,你可能根本不需要处理区域。例如,虽然你可能期望 dart:io 库跟踪当前区域,但它实际上依赖于 dart:async 类(如 Future 和 Stream)的区域处理。

如果您显式地处理区域,则需要注册所有异步回调,并确保每个回调都在其注册的区域中被调用。Zone 的 bind*Callback 辅助方法使此任务变得更容易。它们是 register*Callbackrun* 的快捷方式,确保每个回调都在该区域中注册和运行。

如果您需要比 bind*Callback 提供的更多控制,则需要使用 register*Callbackrun*。您可能还想使用 Zone 的 run*Guarded 方法,这些方法将调用包装在 try-catch 中,并在发生错误时调用 uncaughtErrorHandler

总结

#

区域非常适合保护您的代码免受异步代码中的未捕获异常,但它们可以做更多的事情。您可以将数据与区域关联,并且可以覆盖核心功能,例如打印和任务调度。区域可以实现更好的调试,并提供可用于功能(例如分析)的挂钩。

更多资源

#
与区域相关的 API 文档
阅读有关 runZoned()runZonedGuarded()ZoneZoneDelegateZoneSpecification 的文档。
stack_trace
使用 stack_trace 库的 Chain 类,您可以为异步执行的代码获得更好的堆栈跟踪。有关更多信息,请参阅 pub.dev 网站上的 stack_trace 包

更多示例

#

以下是一些使用区域的更复杂示例。

task_interceptor 示例
task_interceptor.dart 中的玩具区域拦截 scheduleMicrotaskcreateTimercreatePeriodicTimer,以模拟 Dart 原语的行为,而不会让步给事件循环。
stack_trace 包的源代码
stack_trace 包 使用区域来形成用于调试异步代码的堆栈跟踪链。使用的区域功能包括错误处理、区域局部值和回调。您可以在 stack_trace GitHub 项目 中找到 stack_trace 源代码。
dart:html 和 dart:async 的源代码
这两个 SDK 库实现了具有异步回调的 API,因此它们处理区域。您可以在 Dart GitHub 项目的 sdk/lib 目录 下浏览或下载它们的源代码。

感谢 Anders Johnsen 和 Lasse Reichstein Nielsen 对本文的审阅。