乾貨 | Dart 併發機制詳解

Flutter發表於2022-01-20

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

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

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

多平臺使用時注意

所有的 Dart 應用都可以使用 async-await、FutureStream
而 isolate 僅針對 原生平臺的使用 進行實現。
使用 Dart 構建的網頁應用可以 使用 Web Workers 實現相似的功能。

非同步的型別和語法

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

Future 和 Stream 型別

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

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

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

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 型別。

await 關鍵字僅在函式體前定義了 async 的函式中有效。

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

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

Isolate 的工作原理

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

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

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

主 isolate

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

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

Isolate 的生命週期

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

事件處理

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

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

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

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

後臺執行物件

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

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

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

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

程式碼示例

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

Flutter 開發提示

如果你在非 Web 平臺上使用 Flutter 進行開發,那麼與其直接使用 Isolate API,可以考慮使用 Flutter 提供的 compute() 方法 方法將工作移交到單獨 isolate 中"),compute() 方法能以簡單的方式將一個函式的呼叫封裝至 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;
}

_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 _readAndParseJson(SendPort p) async {
  final fileData = await File(filename).readAsString();
  final jsonData = jsonDecode(fileData);
  Isolate.exit(p, jsonData);
}

在最後一句程式碼後,isolate 會退出,將 jsonData 通過傳入的 SendPort 傳送。
在 isolate 之間傳遞訊息時,通常會發生資料拷貝,所耗費的時間隨著資料的大小而發生改變,複雜度為 O(n)。然而,當你使用 Isolate.exit() 傳送資料時,isolate 中持有的訊息並沒有發生拷貝,而是直接轉移到了接收的 isolate 中。這樣的轉移速度很快,耗費的時間複雜度僅為 O(1)

Isolate.exit() 在 Dart 2.15 中被引入

在先前的 Dart 版本中,僅支援通過 Isolate.send() 進行顯式的訊息傳遞,
下一個小節的示例中將進行說明。

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

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

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

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

  • send_and_receive.dart 展示瞭如何從主 isolate 傳送訊息至生成的 isolate。與前面的示例較為接近。
  • long_running_isolate.dart 展示瞭如何生成一個長期執行、且多次傳送和接收訊息的 isolate。

效能和 isolate 組

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

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

在 Flutter 開發中請注意

Flutter 不支援 Isolate.spawnUri()

文章資訊

相關文章