目錄

Dart 中的併發

Dart 透過 async-await、isolate 以及一些非同步型別概念(例如 FutureStream)支援了併發程式碼程式設計。本篇文章會對 async-await、FutureStream 進行簡略的介紹,而側重點放在 isolate 的講解上。

在應用中,所有的 Dart 程式碼都在 isolate 中執行。每一個 Dart 的 isolate 都有獨立的執行執行緒,它們無法與其他 isolate 共享可變物件。在需要進行通訊的場景裡,isolate 會使用訊息機制。儘管 Dart 的 isolate 模型設計是基於作業系統提供的處理序和執行緒等更為底層的原語進行設計的,但在本篇文章中,我們不對其具體實現展開討論。

大部分 Dart 應用只會使用一個 isolate(即 主 isolate),同時你也可以建立更多的 isolate,從而在多個處理器核心上達成並行執行程式碼的目的。

非同步的型別和語法

如果你已經對 FutureStream 和 async-await 比較熟悉了,可以直接跳到 isolate 部分 進行閱讀。

Future 和 Stream 型別

Dart 語言和庫透過 FutureStream 物件,來提供會在當前呼叫的未來返回某些值的功能。以 JavaScript 中的 Promise 為例,在 Dart 中一個最終會返回 int 型別值的 promise,應當宣告為 Future<int>;一個會持續返回一系列 int 型別值的 promise,應當宣告為 Stream<int>

讓我們用 dart:io 來舉另外一個例子。File 的同步方法 readAsStringSync() 會以同步呼叫的方式讀取檔案,在讀取完成或者丟擲錯誤前保持阻塞。這個會返回 String 型別的物件,或者丟擲例外。而與它等效的非同步方法 readAsString(),會在呼叫時立刻返回 Future<String> 型別的物件。在未來的某一刻,Future<String> 會結束,並返回一個字串或錯誤。

為什麼非同步的程式碼如此重要?

It matters whether a method is synchronous or asynchronous because most apps need to do more than one thing at a time.

大部分應用需要在同一時刻做很多件事。例如,應用可能會發起一個 HTTP 請求,同時在請求返回前對使用者的操作做出不同的介面更新。非同步的程式碼會有助於應用保持更高的可互動狀態。

非同步場景包括呼叫系統 API,例如非阻塞的 I/O 操作、HTTP 請求或與瀏覽器互動。還有一些場景是利用 Dart 的 isolate 進行計算,或等待一個計時器的觸發。這些場景要麼是在不同的執行緒執行,要麼是被系統或 Dart 執行時處理,讓 Dart 程式碼可以在計算時同步執行。

async-await 語法

asyncawait 關鍵字是用宣告來定義非同步函式和獲取它們的結果的方式。

下面是一段同步程式碼呼叫檔案 I/O 時阻塞的例子:

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

下面是類似的程式碼,但是變成了 非同步呼叫

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 程式碼(例如事件處理器)能繼續執行。使用 await 後,_readFileAsync() 呼叫返回的 Future<String> 型別也轉換為了 String。從而在將結果 content 賦予變數時,隱含轉換為 String 型別。

如下圖所示,無論是在 Dart VM 還是在系統中, Dart 程式碼都會在 readAsString() 執行非 Dart 程式碼時暫停。在 readAsString() 返回值後,Dart 程式碼將繼續執行。

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

如果你想了解更多關於 asyncawaitFuture 的內容,可以存取 非同步程式設計 codelab 進行學習。

Isolate 的工作原理

現代的裝置通常會使用多核 CPU。開發者為了讓程式在裝置上有更好的表現,有時會使用共享內容的執行緒來併發執行程式碼。然而,狀態的共享可能會 產生競態條件,從而造成錯誤,也可能會增加程式碼的複雜度。

Dart 程式碼並不在多個執行緒上執行,取而代之的是它們會在 isolate 內執行。每一個 isolate 會有自己的堆記憶體,從而確保 isolate 之間互相隔離,無法互相存取狀態。由於這樣的實現並不會共享記憶體,所以你也不需要擔心 互斥鎖和其他鎖

在使用 isolate 時,你的 Dart 程式碼可以在同一時刻進行多個獨立的任務,並且使用可用的處理器核心。 Isolate 與執行緒和處理序近似,但是每個 isolate 都擁有獨立的記憶體,以及執行事件迴圈的獨立執行緒。

主 isolate

在一般場景下,你完全無需關心 isolate。通常一個 Dart 應用會在主 isolate 下執行所有程式碼,如下圖所示:

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

就算是隻有一個 isolate 的應用,只要透過使用 async-await 來處理非同步操作,也完全可以流暢執行。一個擁有良好效能的應用,會在快速啟動後儘快進入事件迴圈。這使得應用可以透過非同步操作快速響應對應的事件。

Isolate 的生命週期

如下圖所示,每個 isolate 都是從執行 Dart 程式碼開始的,比如 main() 函式。執行的 Dart 程式碼可能會註冊一些事件監聽,例如處理使用者操作或檔案讀寫。當 isolate 執行的 Dart 程式碼結束後,如果它還需要處理已監聽的事件,那麼它依舊會繼續被保持。處理完所有事件後,isolate 會退出。

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

事件處理

在客戶端應用中,主 isolate 的事件佇列內,可能會包含重繪的請求、點選的通知或者其他介面事件。例如,下圖展示了包含四個事件的事件佇列,佇列會按照先進先出的模式處理事件。

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

如下圖所示,在 main() 方法執行完畢後,事件佇列中的處理才開始,此時處理的是第一個重繪的事件。而後主 isolate 會處理點選事件,接著再處理另一個重繪事件。

A figure showing the main isolate executing event handlers, one by one

如果某個同步執行的操作花費了很長的處理時間,應用看起來就像是失去了響應。在下圖中,處理點選事件的程式碼比較耗時,導致緊隨其後的事件並沒有及時處理。這時應用可能會產生卡頓,所有的動畫都無法流暢播放。

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

在一個客戶端應用中,耗時過長的同步操作,通常會導致 卡頓的動畫。而最糟糕的是,應用介面可能完全失去響應。

後臺執行物件

如果你的應用受到耗時計算的影響而出現卡頓,例如 解析較大的 JSON 檔案,你可以考慮將耗時計算轉移到單獨工作的 isolate,通常我們稱這樣的 isolate 為 後臺執行物件。下圖展示了一種常用場景,你可以產生一個 isolate,它將執行耗時計算的任務,並在結束後退出。這個 isolate 工作物件退出時會把結果返回。

A figure showing a main isolate and a simple worker isolate

每個 isolate 都可以透過訊息通訊傳遞一個物件,這個物件的所有內容都需要滿足可傳遞的條件。並非所有的物件都滿足傳遞條件,在無法滿足條件時,訊息傳送會失敗。舉個例子,如果你想傳送一個 List<Object>,你需要確保這個列表中所有元素都是可被傳遞的。假設這個列表中有一個 Socket,由於它無法被傳遞,所以你無法傳送整個列表。

你可以查閱 send() 方法 的文件來確定哪些型別可以進行傳遞。

Isolate 工作物件可以進行 I/O 操作、設定定時器,以及其他各種行為。它會持有自己記憶體空間,與主 isolate 互相隔離。這個 isolate 在阻塞時也不會對其他 isolate 造成影響。

程式碼範例

本節將重點討論使用 Isolate API 實現 isolate 的一些範例。

實現一個簡單的 isolate 工作物件

本節將展示一個主 isolate 與它產生的 isolate 工作物件的實現。 Isolate 工作物件會執行一個函式,完成後結束物件,並將函式結果傳送至主 isolate。(Flutter 提供的 compute() 方法 也是以類似的方式工作的。)

下面的範例將使用到這些與 isolate 相關的 API:

主 isolate 的程式碼如下:

void main() async {
  // Read some data.
  final jsonData = await _parseInBackground();

  // Use that data
  print('Number of JSON keys: ${jsonData.length}');
}

// Spawns an isolate and waits for the first message
Future<Map<String, dynamic>> _parseInBackground() async {
  final p = ReceivePort();
  await Isolate.spawn(_readAndParseJson, p.sendPort);
  return await p.first as Map<String, dynamic>;
}

_parseInBackground() 方法包含了 產生 後臺 isolate 工作物件的程式碼,並返回結果:

  1. 在產生 isolate 之前,程式碼建立了一個 ReceivePort,讓 isolate 工作物件可以傳遞資訊至主 isolate。

  2. 接下來是呼叫 Isolate.spawn(),產生並啟動一個在後台執行的 isolate 工作物件。該方法的第一個引數是 isolate 工作物件執行的函式參考:_readAndParseJson。第二個引數則是 isolate 用來與主 isolate 傳遞訊息的 SendPort。此處的程式碼並沒有 建立 新的 SendPort,而是直接使用了 ReceivePortsendPort 屬性。

  3. Isolate 初始化完成後,主 isolate 即開始等待它的結果。由於 ReceivePort 實現了 Stream,你可以很方便地使用 first 屬性獲得 isolate 工作物件返回的單個訊息。

初始化後的 isolate 會執行以下程式碼:

Future<void> _readAndParseJson(SendPort p) async {
  final fileData = await File(filename).readAsString();
  final jsonData = jsonDecode(fileData);
  Isolate.exit(p, jsonData);
}

在最後一句程式碼後,isolate 會退出,將 jsonData 透過傳入的 SendPort 傳送。在 isolate 之間傳遞訊息時,通常會發生資料複製,然而,當你使用 Isolate.exit() 傳送資料時, isolate 中持有的訊息並沒有發生複製,而是直接轉移到了接收的 isolate 中。

下圖說明了主 isolate 和 isolate 工作物件之間的通訊流程:

A figure showing the previous snippets of code running in the main isolate and in the worker isolate

在 isolate 之間傳送多次訊息內容

如果你想在 isolate 之間建立更多的通訊,那麼你需要使用 SendPortsend() 方法。下圖展示了一種常見的場景,主 isolate 會發送請求訊息至 isolate 工作物件,然後它們之間會繼續進行多次通訊,進行請求和回覆。

A figure showing the main isolate spawning the isolate and then sending a request message, which the worker isolate responds to with a reply message; two request-reply cycles are shown

下方列舉的 isolate 範例 包含了傳送多次訊息的使用方法:

  • send_and_receive.dart 展示瞭如何從主 isolate 傳送訊息至產生的 isolate。與前面的範例較為接近。

  • long_running_isolate.dart 展示瞭如何產生一個長期執行、且多次傳送和接收訊息的 isolate。

效能和 isolate 組

當一個 isolate 呼叫了 Isolate.spawn(),兩個 isolate 將擁有同樣的執行程式碼,並歸入同一個 isolate 組 中。 Isolate 組會帶來效能最佳化,例如新的 isolate 會執行由 isolate 組持有的程式碼,即共享程式碼呼叫。同時,Isolate.exit() 僅在對應的 isolate 屬於同一組時有效。

某些場景下,你可能需要使用 Isolate.spawnUri(),使用執行的 URI 產生新的 isolate,並且包含程式碼的副本。然而,spawnUri() 會比 spawn() 慢很多,並且新產生的 isolate 會位於新的 isolate 組。另外,當 isolate 在不同的組中,它們之間的訊息傳遞會變得更慢。