跳到主內容

區域

非同步動態範圍

#

本文討論了 dart:async 庫中與區域相關的 API,重點介紹了頂級的 runZoned()runZonedGuarded() 函式。在閱讀本文之前,請回顧 Future 和錯誤處理 中介紹的技術。

區域使得以下任務成為可能

  • 保護您的應用免受未捕獲異常導致退出的影響。例如,一個簡單的 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 中的 Domains 是 Dart 區域的靈感來源。Java 的執行緒本地儲存(thread-local storage)也有一些相似之處。最接近的是 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 語句來處理。未捕獲的錯誤也可能在 async 函式中出現,當 Future 以錯誤結果完成,但缺少相應的 await 來處理該錯誤時。

未捕獲的錯誤會報告給當前未能捕獲它的區域。預設情況下,區域在響應未捕獲錯誤時會使程式崩潰。您可以為新區域安裝自定義的未捕獲錯誤處理程式,以按照您喜歡的方式攔截和處理未捕獲的錯誤。

要引入一個帶有未捕獲錯誤處理程式的新區域,請使用 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 和區域錯誤處理程式之間的一個顯著區別是,區域在發生未捕獲錯誤後會繼續執行。如果區域內安排了其他非同步回撥,它們仍然會執行。因此,區域錯誤處理程式可能會被多次呼叫。

任何帶有未捕獲錯誤處理程式的區域都被稱為錯誤區域。錯誤區域可以處理源自該區域後代中的錯誤。一個簡單的規則決定了在 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 永遠不會完成——既不會帶有值,也不會帶有錯誤。

在 Stream 中使用區域

#

區域和 Stream 的規則比 Future 更簡單

此規則遵循 Stream 在被監聽之前不應產生副作用的指導原則。同步程式碼中的類似情況是 Iterable 的行為,它們在您請求值之前不會被評估。

示例:在 runZonedGuarded() 中使用 Stream

#

以下示例設定了一個帶有回撥的 Stream,然後在新的區域中使用 runZonedGuarded() 執行該 Stream

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,並且想列印它們的所有行。程式可能看起來像這樣

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

#

作為一個覆蓋功能的簡單示例,這裡有一個方法可以靜默區域內的所有列印輸出

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)來執行其工作。

攔截器和委託的引數

#

正如列印示例所示,攔截器會在 Zone 類對應方法定義的引數基礎上增加三個引數。例如,Zone 的 print() 方法有一個引數:print(String line)。由 ZoneSpecification 定義的 print() 攔截器版本有四個引數:print(Zone self, ZoneDelegate parent, Zone zone, String line)

這三個攔截器引數總是以相同的順序出現,在其他任何引數之前。

self
正在處理回撥的區域。
parent
表示父區域的 ZoneDelegate。使用它將操作轉發給父級。
zone
操作源自的區域。有些操作需要知道該操作是在哪個區域上呼叫的。例如,zone.fork(specification) 必須建立新區域作為 zone 的子區域。再舉一個例子,即使您將 scheduleMicrotask() 委託給另一個區域,執行微任務的必須是原始的 zone

當攔截器將方法委託給父級時,父級 (ZoneDelegate) 版本的方法只多一個引數:zone,即原始呼叫源自的區域。例如,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——指定了每次區域被要求執行程式碼時執行的程式碼。這些引數分別適用於零引數、一個引數和兩個引數的回撥。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:async 的原始碼
這兩個 SDK 庫實現了包含非同步回撥的 API,因此它們處理區域。您可以在Dart GitHub 專案sdk/lib 目錄下瀏覽或下載它們的原始碼。

感謝 Anders Johnsen 和 Lasse Reichstein Nielsen 對本文的審閱。