跳過到主要內容

Dart 中的併發

本頁面包含 Dart 中併發程式設計工作方式的概念性概述。它從高層次解釋了事件迴圈、非同步語言特性和隔離區。有關 Dart 中使用併發的更實際程式碼示例,請閱讀非同步程式設計頁面和隔離區頁面。

Dart 中的併發是指非同步 API(如 FutureStream)以及隔離區,後者允許您將程序移動到單獨的處理器核心。

所有 Dart 程式碼都在隔離區中執行,從預設的主隔離區開始,並可選地擴充套件到您顯式建立的任何後續隔離區。當您生成一個新的隔離區時,它有自己獨立的記憶體和自己的事件迴圈。事件迴圈是 Dart 中非同步和併發程式設計成為可能的原因。

事件迴圈

#

Dart 的執行時模型基於事件迴圈。事件迴圈負責執行程式的程式碼、收集和處理事件等等。

當您的應用程式執行時,所有事件都被新增到名為事件佇列的佇列中。事件可以是任何內容,從重新繪製 UI 的請求,到使用者點選和按鍵,再到磁碟的 I/O。由於您的應用程式無法預測事件發生的順序,事件迴圈會按照排隊的順序,一次一個地處理事件。

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

事件迴圈的功能類似於以下程式碼

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

這個示例事件迴圈是同步的,並且在單個執行緒上執行。然而,大多數 Dart 應用程式需要同時做多件事。例如,客戶端應用程式可能需要執行 HTTP 請求,同時也要監聽使用者點選按鈕。為了處理這種情況,Dart 提供了許多非同步 API,如 Future、Stream 和 async-await。這些 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 .

這個模型通常是事件迴圈處理 Dart 中所有其他非同步事件(例如 Stream 物件)的方式。

非同步程式設計

#

本節總結了 Dart 中非同步程式設計的不同型別和語法。如果您已經熟悉 FutureStream 和 async-await,那麼您可以跳到隔離區部分

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 語法

#

asyncawait 關鍵字提供了一種宣告式的方式來定義非同步函式並使用它們的結果。

這是一個同步程式碼的示例,它在等待檔案 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 關鍵字,以允許其他 Dart 程式碼(如事件處理程式)在使用原生程式碼(檔案 I/O)執行時使用 CPU。使用 await 還會將 _readFileAsync() 返回的 Future<String> 轉換為 String。因此,contents 變數具有隱式型別 String

如下圖所示,當 readAsString() 執行 Dart 執行時或作業系統中的非 Dart 程式碼時,Dart 程式碼會暫停。一旦 readAsString() 返回一個值,Dart 程式碼執行就會恢復。

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

Stream

#

Dart 還支援 Stream 形式的非同步程式碼。Stream 在未來和隨著時間的推移反覆提供值。承諾隨著時間提供一系列 int 值的型別是 Stream<int>

在以下示例中,使用 Stream.periodic 建立的 Stream 每秒重複發出一個新的 int 值。

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

await-for 和 yield

#

Await-for 是一種 for 迴圈,它在提供新值時執行迴圈的後續迭代。換句話說,它用於“迴圈遍歷”Stream。在此示例中,當作為引數提供的 Stream 發出新值時,新值將從 sumStream 函式中發出。在返回值 Stream 的函式中,使用 yield 關鍵字而不是 return

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

如果您想了解更多關於使用 asyncawaitStreamFuture 的資訊,請檢視非同步程式設計教程

隔離區 (Isolates)

#

除了非同步 API 之外,Dart 還透過隔離區支援併發。大多數現代裝置都具有多核 CPU。為了利用多個核心,開發人員有時會使用併發執行的共享記憶體執行緒。然而,共享狀態的併發容易出錯,並可能導致複雜的程式碼。

Dart 中的所有程式碼都執行在隔離區內,而不是執行緒中。使用隔離區,您的 Dart 程式碼可以同時執行多個獨立任務,如果可用,可以使用額外的處理器核心。隔離區類似於執行緒或程序,但每個隔離區都有自己的記憶體和一個執行事件迴圈的單執行緒。

每個隔離區都有自己的全域性欄位,確保隔離區中的任何狀態都不能從任何其他隔離區訪問。隔離區只能透過訊息傳遞相互通訊。隔離區之間沒有共享狀態意味著像互斥鎖或鎖資料競爭這樣的併發複雜性不會在 Dart 中發生。儘管如此,隔離區並不能完全阻止競爭條件。有關此併發模型的更多資訊,請閱讀Actor 模型

主隔離區

#

在大多數情況下,您根本不需要考慮隔離區。Dart 程式預設在主隔離區中執行。它是程式開始執行和執行的執行緒,如下圖所示

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

即使是單隔離區程式也能流暢執行。在繼續執行下一行程式碼之前,這些應用程式使用 async-await 等待非同步操作完成。一個行為良好的應用程式會快速啟動,儘快進入事件迴圈。然後,應用程式會及時響應每個排隊的事件,並在必要時使用非同步操作。

隔離區生命週期

#

如下圖所示,每個隔離區都從執行一些 Dart 程式碼(例如 main() 函式)開始。此 Dart 程式碼可能會註冊一些事件監聽器——例如,用於響應使用者輸入或檔案 I/O。當隔離區的初始函式返回時,如果需要處理事件,隔離區會繼續存在。處理完事件後,隔離區會退出。

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

事件處理

#

在客戶端應用中,主隔離區的事件佇列可能包含重繪請求以及點選和其他 UI 事件的通知。例如,下圖顯示了一個重繪事件,後跟一個點選事件,再後跟兩個重繪事件。事件迴圈按照先進先出的順序從佇列中獲取事件。

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(例如讀取和寫入檔案)、設定定時器等。它有自己的記憶體,不與主隔離區共享任何狀態。工作隔離區可以阻塞而不會影響其他隔離區。

使用隔離區

#

根據使用場景,在 Dart 中使用隔離區有兩種方式

  • 使用 Isolate.run() 在單獨的執行緒上執行單個計算。
  • 使用 Isolate.spawn() 建立一個隔離區,該隔離區將隨著時間處理多條訊息,或者作為後臺工作器。有關使用長期隔離區的更多資訊,請閱讀隔離區頁面。

在大多數情況下,Isolate.run 是在後臺執行程序的推薦 API。

Isolate.run()

#

靜態方法 Isolate.run() 需要一個引數:一個將在新生成的隔離區上執行的回撥。

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.spawn() 時,兩個隔離區擁有相同的可執行程式碼並處於相同的隔離區組中。隔離區組支援效能最佳化,例如共享程式碼;一個新的隔離區立即執行隔離區組擁有的程式碼。此外,Isolate.exit() 僅在隔離區處於同一隔離區組時才有效。

在某些特殊情況下,您可能需要使用 Isolate.spawnUri(),它會使用指定 URI 處的程式碼副本設定新的隔離區。然而,spawnUri()spawn() 慢得多,並且新的隔離區不在其生成器的隔離區組中。另一個性能影響是,當隔離區在不同的組中時,訊息傳遞會更慢。

隔離區的限制

#

隔離區不是執行緒

#

如果您是從支援多執行緒的語言轉到 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-awaitFutureStream 進行非阻塞、交錯的計算。然而,Dart Web 平臺不支援隔離區。Dart Web 應用可以使用 Web Worker 在類似於隔離區的後臺執行緒中執行指令碼。不過,Web Worker 的功能和能力與隔離區有所不同。

例如,當 Web Worker 線上程之間傳送資料時,它們會來回複製資料。然而,資料複製可能非常慢,特別是對於大型訊息。隔離區也會這樣做,但還提供了可以更有效地轉移儲存訊息記憶體的 API。

建立 Web Worker 和隔離區的方式也有所不同。您只能透過宣告單獨的程式入口點並單獨編譯來建立 Web Worker。啟動 Web Worker 類似於使用 Isolate.spawnUri 啟動隔離區。您還可以使用 Isolate.spawn 啟動隔離區,這需要更少的資源,因為它重用了與生成隔離區相同的某些程式碼和資料。Web Worker 沒有等效的 API。

其他資源

#