[譯] Flutter 非同步程式設計:Future、Isolate 和事件迴圈

淚已無痕發表於2019-03-13

本文介紹了 Flutter 中不同的程式碼執行模式:單執行緒、多執行緒、同步和非同步。

難度:中級

概要

我最近收到了一些與 FutureasyncawaitIsolate 以及並行執行概念相關的一些問題。

由於這些問題,一些人在處理程式碼的執行順序方面遇到了麻煩。

我認為通過一篇文章來解釋非同步並行處理這些概念並消除其中任何歧義是非常有用的。


Dart 是一種單執行緒語言

首先,大家需要牢記,Dart單執行緒的並且 Flutter 依賴於 Dart

重點

Dart 同一時刻只執行一個操作,其他操作在該操作之後執行,這意味著只要一個操作正在執行,它就不會被其他 Dart 程式碼中斷。

也就是說,如果你考慮純粹的同步方法,那麼在它完成之前,後者將是唯一要執行的方法。

void myBigLoop(){
    for (int i = 0; i < 1000000; i++){
        _doSomethingSynchronously();
    }
}
複製程式碼

在上面的例子中,myBigLoop() 方法在執行完成前永遠不會被中斷。因此,如果該方法需要一些時間,那麼在整個方法執行期間應用將會被阻塞


Dart 執行模型

那麼在幕後,Dart 是如何管理操作序列的執行的呢?

為了回答這個問題,我們需要看一下 Dart 的程式碼序列器(事件迴圈)。

當你啟動一個 Flutter(或任何 Dart)應用時,將建立並啟動一個新的執行緒程式(在 Dart 中為 「Isolate」)。該執行緒將是你在整個應用中唯一需要關注的。

所以,此執行緒建立後,Dart 會自動:

  1. 初始化 2 個 FIFO(先進先出)佇列(「MicroTask」和 「Event」);
  2. 並且當該方法執行完成後,執行 main() 方法,
  3. 啟動事件迴圈

在該執行緒的整個生命週期中,一個被稱為事件迴圈單一且隱藏的程式將決定你程式碼的執行方式及順序(取決於 MicroTaskEvent 佇列)。

事件迴圈是一種無限迴圈(由一個內部時鐘控制),在每個時鐘週期內如果沒有其他 Dart 程式碼執行,則執行以下操作:

void eventLoop(){
    while (microTaskQueue.isNotEmpty){
        fetchFirstMicroTaskFromQueue();
        executeThisMicroTask();
        return;
    }

    if (eventQueue.isNotEmpty){
        fetchFirstEventFromQueue();
        executeThisEventRelatedCode();
    }
}
複製程式碼

正如我們看到的,MicroTask 佇列優先於 Event 佇列,那這 2 個佇列的作用是什麼呢?

MicroTask 佇列

MicroTask 佇列用於非常簡短且需要非同步執行的內部動作,這些動作需要在其他事情完成之後並在將執行權送還給 Event 佇列之前執行。

作為 MicroTask 的一個例子,你可以設想必須在資源關閉後立即釋放它。由於關閉過程可能需要一些時間才能完成,你可以按照以下方式編寫程式碼:

MyResource myResource;

...

void closeAndRelease() {
    scheduleMicroTask(_dispose);
    _close();
}

void _close(){
    // 程式碼以同步的方式執行
    // 以關閉資源
    ...
}

void _dispose(){
    // 程式碼在
    // _close() 方法
    // 完成後執行
}
複製程式碼

這是大多數時候你不必使用的東西。比如,在整個 Flutter 原始碼中 scheduleMicroTask() 方法僅被引用了 7 次。

最好優先考慮使用 Event 佇列。

Event 佇列

Event 佇列適用於以下參考模型

  • 外部事件如
    • I/O;
    • 手勢;
    • 繪圖;
    • 計時器;
    • 流;
    • ……
  • futures

事實上,每次外部事件被觸發時,要執行的程式碼都會被 Event 佇列所引用。

一旦沒有任何 micro task 執行,事件迴圈將考慮 Event 佇列中的第一項並執行它。

值得注意的是,Future 操作也通過 Event 佇列處理。


Future

Future 是一個非同步執行並且在未來的某一個時刻完成(或失敗)的任務

當你例項化一個 Future 時:

  • Future 的一個例項被建立並記錄在由 Dart 管理的內部陣列中;
  • 需要由此 Future 執行的程式碼直接推送到 Event 佇列中去;
  • future 例項 返回一個狀態(= incomplete);
  • 如果存在下一個同步程式碼,執行它(非 Future 的執行程式碼

只要事件迴圈Event 迴圈中獲取它,被 Future 引用的程式碼將像其他任何 Event 一樣執行。

當該程式碼將被執行並將完成(或失敗)時,then()catchError() 方法將直接被觸發。

為了說明這一點,我們來看下面的例子:

void main(){
    print('Before the Future');
    Future((){
        print('Running the Future');
    }).then((_){
        print('Future is complete');
    });
    print('After the Future');
}
複製程式碼

如果我們執行該程式碼,輸出將如下所示:

Before the Future
After the Future
Running the Future
Future is complete
複製程式碼

這是完全正確的,因為執行流程如下:

  1. print(‘Before the Future’)
  2. (){print(‘Running the Future’);} 新增到 Event 佇列;
  3. print(‘After the Future’)
  4. 事件迴圈獲取(在第二步引用的)程式碼並執行它
  5. 當程式碼執行時,它會查詢 then() 語句並執行它

需要記住一些非常重要的事情:

Future 並非並行執行,而是遵循事件迴圈處理事件的順序規則執行。


Async 方法

當你使用 async 關鍵字作為方法宣告的字尾時,Dart 會將其理解為:

  • 該方法的返回值是一個 Future
  • 同步執行該方法的程式碼直到第一個 await 關鍵字,然後它暫停該方法其他部分的執行;
  • 一旦由 await 關鍵字引用的 Future 執行完成,下一行程式碼將立即執行。

瞭解這一點是非常重要的,因為很多開發者認為 await 暫停了整個流程直到它執行完成,但事實並非如此。他們忘記了事件迴圈的運作模式……

為了更好地進行說明,讓我們通過以下示例並嘗試指出其執行的結果。

void main() async {
  methodA();
  await methodB();
  await methodC('main');
  methodD();
}

methodA(){
  print('A');
}

methodB() async {
  print('B start');
  await methodC('B');
  print('B end');
}

methodC(String from) async {
  print('C start from $from');

  Future((){                // <== 該程式碼將在未來的某個時間段執行
    print('C running Future from $from');
  }).then((_){
    print('C end of Future from $from');
  });

  print('C end from $from');
}

methodD(){
  print('D');
}
複製程式碼

正確的順序是:

  1. A
  2. B start
  3. C start from B
  4. C end from B
  5. B end
  6. C start from main
  7. C end from main
  8. D
  9. C running Future from B
  10. C end of Future from B
  11. C running Future from main
  12. C end of Future from main

現在,讓我們認為上述程式碼中的 methodC() 為對服務端的呼叫,這可能需要不均勻的時間來進行響應。我相信可以很明確地說,預測確切的執行流程可能變得非常困難。

如果你最初希望示例程式碼中僅在所有程式碼末尾執行 methodD() ,那麼你應該按照以下方式編寫程式碼:

void main() async {
  methodA();
  await methodB();
  await methodC('main');
  methodD();
}

methodA(){
  print('A');
}

methodB() async {
  print('B start');
  await methodC('B');
  print('B end');
}

methodC(String from) async {
  print('C start from $from');

  await Future((){                  // <== 在此處進行修改
    print('C running Future from $from');
  }).then((_){
    print('C end of Future from $from');
  });
  print('C end from $from');
}

methodD(){
  print('D');
}
複製程式碼

輸出序列為:

  1. A
  2. B start
  3. C start from B
  4. C running Future from B
  5. C end of Future from B
  6. C end from B
  7. B end
  8. C start from main
  9. C running Future from main
  10. C end of Future from main
  11. C end from main
  12. D

事實是通過在 methodC() 中定義 Future 的地方簡單地新增 await 會改變整個行為。

另外,需特別謹記:

async 並非並行執行,也是遵循事件迴圈處理事件的順序規則執行。

我想向你演示的最後一個例子如下。 執行 method1method2 的輸出是什麼?它們會是一樣的嗎?

void method1(){
  List<String> myArray = <String>['a','b','c'];
  print('before loop');
  myArray.forEach((String value) async {
    await delayedPrint(value);
  });
  print('end of loop');
}

void method2() async {
  List<String> myArray = <String>['a','b','c'];
  print('before loop');
  for(int i=0; i<myArray.length; i++) {
    await delayedPrint(myArray[i]);
  }
  print('end of loop');
}

Future<void> delayedPrint(String value) async {
  await Future.delayed(Duration(seconds: 1));
  print('delayedPrint: $value');
}
複製程式碼

答案:

method1() method2()
1. before loop 1. before loop
2. end of loop 2. delayedPrint: a (after 1 second)
3. delayedPrint: a (after 1 second) 3. delayedPrint: b (1 second later)
4. delayedPrint: b (directly after) 4. delayedPrint: c (1 second later)
5. delayedPrint: c (directly after) 5. end of loop (right after)

你是否清楚它們行為不一樣的區別以及原因呢?

答案基於這樣一個事實,method1 使用 forEach() 函式來遍歷陣列。每次迭代時,它都會呼叫一個被標記為 async(因此是一個 Future)的新回撥函式。執行該回撥直到遇到 await,而後將剩餘的程式碼推送到 Event 佇列。一旦迭代完成,它就會執行下一個語句:“print(‘end of loop’)”。執行完成後,事件迴圈 將處理已註冊的 3 個回撥。

對於 method2,所有的內容都執行在一個相同的程式碼「塊」中,因此能夠一行一行按照順序執行(在本例中)。

正如你所看到的,即使在看起來非常簡單的程式碼中,我們仍然需要牢記事件迴圈的工作方式……


多執行緒

因此,我們在 Flutter 中如何並行執行程式碼呢?這可能嗎?

是的,這多虧了 Isolates


Isolate 是什麼?

正如前面解釋過的, IsolateDart 中的 執行緒

然而,它與常規「執行緒」的實現存在較大差異,這也是將其命名為「Isolate」的原因。

「Isolate」在 Flutter 中並不共享記憶體。不同「Isolate」之間通過「訊息」進行通訊。


每個 Isolate 都有自己的事件迴圈

每個「Isolate」都擁有自己的「事件迴圈」及佇列(MicroTask 和 Event)。這意味著在一個 Isolate 中執行的程式碼與另外一個 Isolate 不存在任何關聯。

多虧了這一點,我們可以獲得並行處理的能力。


如何啟動 Isolate?

根據你執行 Isolate 的場景,你可能需要考慮不同的方法。

1. 底層解決方案

第一個解決方案不依賴任何軟體包,它完全依賴 Dart 提供的底層 API。

1.1. 第一步:建立並握手

如前所述,Isolate 不共享任何記憶體並通過訊息進行互動,因此,我們需要找到一種方法在「呼叫者」與新的 isolate 之間建立通訊。

每個 Isolate 都暴露了一個將訊息傳遞給 Isolate 的被稱為「SendPort」的。(個人覺得該名字有一些誤導,因為它是一個接收/監聽的埠,但這畢竟是官方名稱)。

這意味著「呼叫者」和「新的 isolate」需要互相知道彼此的埠才能進行通訊。這個握手的過程如下所示:

//
// 新的 isolate 埠
// 該埠將在未來使用
// 用來給 isolate 傳送訊息
//
SendPort newIsolateSendPort;

//
// 新 Isolate 例項
//
Isolate newIsolate;

//
// 啟動一個新的 isolate
// 然後開始第一次握手
//
//
void callerCreateIsolate() async {
    //
    // 本地臨時 ReceivePort
    // 用於檢索新的 isolate 的 SendPort
    //
    ReceivePort receivePort = ReceivePort();

    //
    // 初始化新的 isolate
    //
    newIsolate = await Isolate.spawn(
        callbackFunction,
        receivePort.sendPort,
    );

    //
    // 檢索要用於進一步通訊的埠
    //
    //
    newIsolateSendPort = await receivePort.first;
}

//
// 新 isolate 的入口
//
static void callbackFunction(SendPort callerSendPort){
    //
    // 一個 SendPort 例項,用來接收來自呼叫者的訊息
    //
    //
    ReceivePort newIsolateReceivePort = ReceivePort();

    //
    // 向呼叫者提供此 isolate 的 SendPort 引用
    //
    callerSendPort.send(newIsolateReceivePort.sendPort);

    //
    // 進一步流程
    //
}
複製程式碼

約束 isolate 的「入口必須是頂級函式或靜態方法。

1.2. 第二步:向 Isolate 提交訊息

現在我們有了向 Isolate 傳送訊息的埠,讓我們看看如何做到這一點:

//
// 向新 isolate 傳送訊息並接收回復的方法
//
//
// 在該例中,我將使用字串進行通訊操作
// (傳送和接收的資料)
//
Future<String> sendReceive(String messageToBeSent) async {
    //
    // 建立一個臨時埠來接收回復
    //
    ReceivePort port = ReceivePort();

    //
    // 傳送訊息到 Isolate,並且
    // 通知該 isolate 哪個埠是用來提供
    // 回覆的
    //
    newIsolateSendPort.send(
        CrossIsolatesMessage<String>(
            sender: port.sendPort,
            message: messageToBeSent,
        )
    );

    //
    // 等待回覆並返回
    //
    return port.first;
}

//
// 擴充套件回撥函式來處理接輸入報文
//
static void callbackFunction(SendPort callerSendPort){
    //
    // 初始化一個 SendPort 來接收來自呼叫者的訊息
    //
    //
    ReceivePort newIsolateReceivePort = ReceivePort();

    //
    // 向呼叫者提供該 isolate 的 SendPort 引用
    //
    callerSendPort.send(newIsolateReceivePort.sendPort);

    //
    // 監聽輸入報文、處理並提供回覆的
    // Isolate 主程式
    //
    newIsolateReceivePort.listen((dynamic message){
        CrossIsolatesMessage incomingMessage = message as CrossIsolatesMessage;

        //
        // 處理訊息
        //
        String newMessage = "complemented string " + incomingMessage.message;

        //
        // 傳送處理的結果
        //
        incomingMessage.sender.send(newMessage);
    });
}

//
// 幫助類
//
class CrossIsolatesMessage<T> {
    final SendPort sender;
    final T message;

    CrossIsolatesMessage({
        @required this.sender,
        this.message,
    });
}
複製程式碼
1.3. 第三步:銷燬這個新的 Isolate 例項

當你不再需要這個新的 Isolate 例項時,最好通過以下方法釋放它:

//
// 釋放一個 isolate 的例程
//
void dispose(){
    newIsolate?.kill(priority: Isolate.immediate);
    newIsolate = null;
}
複製程式碼
1.4. 特別說明 - 單監聽器流

你可能已經注意到我們正在使用在「呼叫者」和新 isolate 之間進行通訊。這些的型別為:「單監聽器」流。


2. 一次性計算

如果你只需要執行一些程式碼來完成一些特定的工作,並且在工作完成之後不需要與 Isolate 進行互動,那麼這裡有一個非常方便的稱為 computeHelper

主要包含以下功能:

  • 產生一個 Isolate
  • 在該 isolate 上執行一個回撥函式,並傳遞一些資料,
  • 返回回撥函式的處理結果,
  • 回撥執行後終止 Isolate

約束

「回撥」函式必須是頂級函式並且不能是閉包或類中的方法(靜態或非靜態)。


3. 重要限制

在撰寫本文時,發現這點十分重要

Platform-Channel 通訊僅僅主 isolate 支援。該主 isolate 對應於應用啟動時建立的 isolate

也就是說,通過程式設計建立的 isolate 例項,無法實現 Platform-Channel 通訊……

不過,還是有一個解決方法的……請參考此連線以獲得關於此主題的討論。


我應該什麼時候使用 Futures 和 Isolate?

使用者將根據不同的因素來評估應用的質量,比如:

  • 特性
  • 外觀
  • 使用者友好性
  • ……

你的應用可以滿足以上所有因素,但如果使用者在一些處理過程中遇到了卡頓,這極有可能對你不利。

因此,以下是你在開發過程中應該系統考慮的一些點:

  1. 如果程式碼片段不能被中斷,使用傳統的同步過程(一個或多個相互呼叫的方法);
  2. 如果程式碼片段可以獨立執行而不影響應用的效能,可以考慮通過 Future 使用事件迴圈
  3. 如果繁重的處理可能需要一些時間才能完成,並且可能影響應用的效能,考慮使用 Isolate

換句話說,建議儘可能地使用 Future(直接或間接地通過 async 方法),因為一旦事件迴圈擁有空閒時間,這些 Future 的程式碼就會被執行。這將使使用者感覺事情正在被並行處理(而我們現在知道事實並非如此)。

另外一個可以幫助你決定使用 FutureIsolate 的因素是執行某些程式碼所需要的平均時間。

  • 如果一個方法需要幾毫秒 => Future
  • 如果一個處理流程需要幾百毫秒 => Isolate

以下是一些很好的 Isolate 選項:

  • JSON 解碼:解碼 JSON(HttpRequest 的響應)可能需要一些時間 => 使用 compute
  • 加密:加密可能非常耗時 => Isolate
  • 影像處理:處理影像(比如:剪裁)確實需要一些時間來完成 => Isolate
  • 從 Web 載入影像:該場景下,為什麼不將它委託給一個完全載入後返回完整影像的 Isolate

結論

我認為了解事件迴圈的工作原理非常重要。

同樣重要的是要謹記 FlutterDart)是單執行緒的,因此,為了取悅使用者,開發者必須確保應用執行儘可能流暢。FutureIsolate 是非常強大的工具,它們可以幫助你實現這一目標。

請繼續關注新文章,同時……祝你程式設計愉快!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章