Dart 非同步程式設計

AgileStudio發表於2020-01-14

Dart 非同步程式設計

本文是【從零開始,一起學習開發個 Flutter App 吧】路上的第 2 篇文章。

本文將解決上一篇留下的問題: Dart 中是如何進行非同步處理的?我們首先簡單介紹了 Dart 中常用的非同步處理 Futuresyncawait ;第二部分試圖分析Dart作為單執行緒語言的非同步實現原理,進一步介紹IO模型和事件迴圈模型;最後介紹 如何在 Dart 實現多執行緒以執行緒的相互通訊。

如果你熟悉 JavaScript 的 Promise 模式的話,發起一個非同步http請求,你可以這樣寫:

new Promise((resolve, reject) =>{
    // 發起請求
    const xhr = new XMLHttpRequest();
    xhr.open("GET", 'https://www.nowait.xin/');
    xhr.onload = () => resolve(xhr.responseText); 
    xhr.onerror = () => reject(xhr.statusText);
    xhr.send();
}).then((response) => { //成功
   console.log(response);
}).catch((error) => { // 失敗
   console.log(error);
});
複製程式碼

Promise 定義了一種非同步處理模式:do... success... or fail...。

在 Dart 中,與之對應的是Future物件:

Future<Response> respFuture = http.get('https://example.com'); //發起請求
respFuture.then((response) { //成功,匿名函式
  if (response.statusCode == 200) {
    var data = reponse.data;
  }
}).catchError((error) { //失敗
   handle(error);
});
複製程式碼

這種模式簡化和統一了非同步的處理,即便沒有系統學習過併發程式設計的同學,也可以拋開復雜的多執行緒,開箱即用。

Future

Future 物件封裝了Dart 的非同步操作,它有未完成(uncompleted)和已完成(completed)兩種狀態。

在Dart中,所有涉及到IO的函式都封裝成Future物件返回,在你呼叫一個非同步函式的時候,在結果或者錯誤返回之前,你得到的是一個uncompleted狀態的Future

completed狀態也有兩種:一種是代表操作成功,返回結果;另一種代表操作失敗,返回錯誤。

我們來看一個例子:

Future<String> fetchUserOrder() {
  //想象這是個耗時的資料庫操作
  return Future(() => 'Large Latte');
}

void main() {
  fetchUserOrder().then((result){print(result)})
  print('Fetching user order...');
}
複製程式碼

通過then來回撥成功結果,main會先於Future裡面的操作,輸出結果:

Fetching user order...
Large Latte
複製程式碼

在上面的例子中,() => 'Large Latte')是一個匿名函式,=> 'Large Latte' 相當於 return 'Large Latte'

Future同名構造器是factory Future(FutureOr<T> computation()),它的函式引數返回值為FutureOr<T>型別,我們發現還有很多Future中的方法比如Future.thenFuture.microtask的引數型別也是FutureOr<T>,看來有必要了解一下這個物件。

FutureOr<T> 是個特殊的型別,它沒有類成員,不能例項化,也不可以繼承,看來它很可能只是一個語法糖。

abstract class FutureOr<T> {
  // Private generative constructor, so that it is not subclassable, mixable, or
  // instantiable.
  FutureOr._() {
    throw new UnsupportedError("FutureOr can't be instantiated");
  }
}
複製程式碼

你可以把它理解為受限制的dynamic型別,因為它只能接受Future<T>或者T型別的值:

FutureOr<int> hello(){}

void main(){
   FutureOr<int> a = 1; //OK
   FutureOr<int> b = Future.value(1); //OK
   FutureOr<int> aa = '1' //編譯錯誤

   int c = hello(); //ok
   Future<int> cc = hello(); //ok
   String s = hello(); //編譯錯誤
}
複製程式碼

在 Dart 的最佳實踐裡面明確指出:請避免宣告函式返回型別為FutureOr<T>

如果呼叫下面的函式,除非進入原始碼,否則無法知道返回值的型別究竟是int 還是Future<int>

FutureOr<int> triple(FutureOr<int> value) async => (await value) * 3;
複製程式碼

正確的寫法:

Future<int> triple(FutureOr<int> value) async => (await value) * 3;
複製程式碼

稍微交代了下FutureOr<T>,我們繼續研究Future

如果Future內的函式執行發生異常,可以通過Future.catchError來處理異常:

Future<void> fetchUserOrder() {
  return Future.delayed(Duration(seconds: 3), () => throw Exception('Logout failed: user ID is invalid'));
}

void main() {
  fetchUserOrder().catchError((err, s){print(err);});
  print('Fetching user order...');
}
複製程式碼

輸出結果:

Fetching user order...
Exception: Logout failed: user ID is invalid
複製程式碼

Future支援鏈式呼叫:

Future<String> fetchUserOrder() {
  return Future(() => 'AAA');
}

void main() {
   fetchUserOrder().then((result) => result + 'BBB')
     .then((result) => result + 'CCC')
     .then((result){print(result);});
}
複製程式碼

輸出結果:

AAABBBCCC
複製程式碼

async 和 await

想象一個這樣的場景:

  1. 先呼叫登入介面;
  2. 根據登入介面返回的token獲取使用者資訊;
  3. 最後把使用者資訊快取到本機。

介面定義:

Future<String> login(String name,String password){
  //登入
}
Future<User> fetchUserInfo(String token){
  //獲取使用者資訊
}
Future saveUserInfo(User user){
  // 快取使用者資訊
}
複製程式碼

Future大概可以這樣寫:

login('name','password').then((token) => fetchUserInfo(token))
  .then((user) => saveUserInfo(user));
複製程式碼

換成asyncawait 則可以這樣:

void doLogin() async {
  String token = await login('name','password'); //await 必須在 async 函式體內
  User user = await fetchUserInfo(token);
  await saveUserInfo(user);
}
複製程式碼

宣告瞭async 的函式,返回值是必須是Future物件。即便你在async函式裡面直接返回T型別資料,編譯器會自動幫你包裝成Future<T>型別的物件,如果是void函式,則返回Future<void>物件。在遇到await的時候,又會把Futrue型別拆包,又會原來的資料型別暴露出來,請注意,await 所在的函式必須新增async關鍵詞

await的程式碼發生異常,捕獲方式跟同步呼叫函式一樣:

void doLogin() async {
  try {
    var token = await login('name','password');
    var user = await fetchUserInfo(token);
    await saveUserInfo(user);
  } catch (err) {
    print('Caught error: $err');
  }
}
複製程式碼

得益於asyncawait 這對語法糖,你可以用同步程式設計的思維來處理非同步程式設計,大大簡化了非同步程式碼的處理。

注:Dart 中非常多的語法糖,它提高了我們的程式設計效率,但同時也會讓初學者容易感到迷惑。

送多一顆語法糖給你:

Future<String> getUserInfo() async {
  return 'aaa';
}

等價於:

Future<String> getUserInfo() async {
  return Future.value('aaa');
}
複製程式碼

Dart非同步原理

Dart 是一門單執行緒程式語言。對於平時用 Java 的同學,首先可能會反應:那如果一個操作耗時特別長,不會一直卡住主執行緒嗎?比如Android,為了不阻塞UI主執行緒,我們不得不通過另外的執行緒來發起耗時操作(網路請求/訪問本地檔案等),然後再通過Handler來和UI執行緒溝通。Dart 究竟是如何做到的呢?

先給答案:非同步 IO + 事件迴圈。下面具體分析。

I/O 模型

我們先來看看阻塞IO是什麼樣的:

int count = io.read(buffer); //阻塞等待
複製程式碼

注: IO 模型是作業系統層面的,這一小節的程式碼都是虛擬碼,只是為了方便理解。

當相應執行緒呼叫了read之後,它就會一直在那裡等著結果返回,什麼也不幹,這是阻塞式的IO。

但我們的應用程式經常是要同時處理好幾個IO的,即便一個簡單的手機App,同時發生的IO可能就有:使用者手勢(輸入),若干網路請求(輸入輸出),渲染結果到螢幕(輸出);更不用說是服務端程式,成百上千個併發請求都是家常便飯。

有人說,這種情況可以使用多執行緒啊。這確實是個思路,但受制於CPU的實際併發數,每個執行緒只能同時處理單個IO,效能限制還是很大,而且還要處理不同執行緒之間的同步問題,程式的複雜度大大增加。

如果進行IO的時候不用阻塞,那情況就不一樣了:

while(true){
  for(io in io_array){
      status = io.read(buffer);// 不管有沒有資料都立即返回
      if(status == OK){
       
      }
  }
}
複製程式碼

有了非阻塞IO,通過輪詢的方式,我們就可以對多個IO進行同時處理了,但這樣也有一個明顯的缺點:在大部分情況下,IO都是沒有內容的(CPU的速度遠高於IO速度),這樣就會導致CPU大部分時間在空轉,計算資源依然沒有很好得到利用。

為了進一步解決這個問題,人們設計了IO多路轉接(IO multiplexing),可以對多個IO監聽和設定等待時間:

while(true){
    //如果其中一路IO有資料返回,則立即返回;如果一直沒有,最多等待不超過timeout時間
    status = select(io_array, timeout); 
    if(status  == OK){
      for(io in io_array){
          io.read() //立即返回,資料都準備好了
      }
    }
}
複製程式碼

IO 多路轉接有多種實現,比如select、poll、epoll等,我們不具體展開。

有了IO多路轉接,CPU資源利用效率又有了一個提升。

眼尖的同學可能有發現,在上面的程式碼中,執行緒依然是可能會阻塞在 select 上或者產生一些空轉的,有沒有一個更加完美的方案呢?

答案就是非同步IO了:

io.async_read((data) => {
  // dosomething
});
複製程式碼

通過非同步IO,我們就不用不停問作業系統:你們準備好資料了沒?而是一有資料系統就會通過訊息或者回撥的方式傳遞給我們。這看起來很完美了,但不幸的是,不是所有的作業系統都很好地支援了這個特性,比如Linux的非同步IO就存在各種缺陷,所以在具體的非同步IO實現上,很多時候可能會折中考慮不同的IO模式,比如 Node.js 的背後的libeio庫,實質上採用執行緒池與阻塞 I/O 模擬出來的非同步 I/O [1]。

Dart 在文件中也提到是借鑑了 Node.js 、EventMachine, 和 Twisted 來實現的非同步IO,我們暫不深究它的內部實現(筆者在搜尋了一下Dart VM的原始碼,發現在android和linux上似乎是通過epoll實現的),在Dart層,我們只要把IO當做是非同步的就行了。

Dart 原始碼中的 epoll_wait

我們再回過頭來看看上面Future那段程式碼:

Future<Response> respFuture = http.get('https://example.com'); //發起請求
複製程式碼

現在你知道,這個網路請求不是在主執行緒完成的,它實際上把這個工作丟給了執行時或者作業系統。這也是 Dart 作為單程式語言,但進行IO操作卻不會阻塞主執行緒的原因。

終於解決了Dart單執行緒進行IO也不會卡的疑問,但主執行緒如何和大量非同步訊息打交道呢?接下來我們繼續討論Dart的事件迴圈機制(Event Loop)。

事件迴圈 (Event Loop)

在Dart中,每個執行緒都執行在一個叫做isolate的獨立環境中,它的記憶體不和其他執行緒共享,它在不停幹一件事情:從事件佇列中取出事件並處理它。

while(true){
   event = event_queue.first() //取出事件
   handleEvent(event) //處理事件
   drop(event) //從佇列中移除
}
複製程式碼

比如下面這段程式碼:

RaisedButton(
  child: Text('click me');
  onPressed: (){ // 點選事件 
     Future<Response> respFuture = http.get('https://example.com'); 
     respFuture.then((response){ // IO 返回事件
        if(response.statusCode == 200){
           print('success');
        }
     })
  }
)
複製程式碼

當你點選螢幕上按鈕時,會產生一個事件,這個事件會放入isolate的事件佇列中;接著你發起了一個網路請求,也會產生一個事件,依次進入事件迴圈。

線上程比較空閒的時候,isolate還可以去搞搞垃圾回收(GC),喝杯咖啡什麼的。

API層的FutureStreamasyncawait 實際都是對事件迴圈在程式碼層的抽象。結合事件迴圈,回到對Future物件的定義(An object representing a delayed computation.),就可以這樣理解了:isolate大哥,我快遞一個程式碼包裹給你,你拿到後開啟這個盒子,並順序執行裡面的程式碼。

事實上,isolate 裡面有兩個佇列,一個就是事件佇列(event queue),還有一個叫做微任務佇列(microtask queue)。

事件佇列:用來處理外部的事件,如果IO、點選、繪製、計時器(timer)和不同 isolate 之間的訊息事件等。

微任務佇列:處理來自於Dart內部的任務,適合用來不會特別耗時或緊急的任務,微任務佇列的處理優先順序比事件佇列的高,如果微任務處理比較耗時,會導致事件堆積,應用響應緩慢。

isolate event loop

你可以通過Future.microtask 來向isolate提交一個微任務:

import 'dart:async';

main() {
  new Future(() => print('beautiful'));
  Future.microtask(() => print('hi'));
}
複製程式碼

輸出:

hi
beautiful
複製程式碼

總結一下事件迴圈的執行機制:當應用啟動後,它會建立一個isolate,啟動事件迴圈,按照FIFO的順序,優先處理微任務佇列,然後再處理事件佇列,如此反覆。

多執行緒

注:以下當我們提到isolate的時候,你可以把它等同於執行緒,但我們知道它不僅僅是一個執行緒。

得益於非同步 IO + 事件迴圈,儘管Dart是單執行緒,一般的IO密集型App應用通常也能獲得出色的效能表現。但對於一些計算量巨大的場景,比如圖片處理、反序列化、檔案壓縮這些計算密集型的操作,只單靠一個執行緒就不夠用了。

在Dart中,你可以通過Isolate.spawn 來建立一個新的isolate

void newIsolate(String mainMessage){
  sleep(Duration(seconds: 3));
  print(mainMessage);
}

void main() {
  // 建立一個新的isolate,newIoslate
  Isolate.spawn(newIsolate, 'Hello, Im from new isolate!'); 
  sleep(Duration(seconds: 10)); //主執行緒阻塞等待
}
複製程式碼

輸出:

Hello, Im from new isolate!
複製程式碼

spawn 有兩個必傳引數,第一個是新isolate入口函式(entrypoint),第二個是這個入口函式的引數值(message)。

如果主isolate想接收子isolate的訊息,可以在主isolate建立一個ReceivePort物件,並把對應的receivePort.sendPort作為新isolate入口函式引數傳入,然後通過ReceivePort繫結SendPort物件給主isolate傳送訊息:

//新isolate入口函式
void newIsolate(SendPort sendPort){
  sendPort.send("hello, Im from new isolate!");
}

void main() async{
  ReceivePort receivePort= ReceivePort();
  Isolate isolate = await Isolate.spawn(newIsolate, receivePort.sendPort);
  receivePort.listen((message){ //監聽從新isolate傳送過來的訊息
   
    print(message);
     
    // 不再使用時,關閉管道
     receivePort.close();
     
    // 關閉isolate執行緒
     isolate?.kill(priority: Isolate.immediate);
  });
}
複製程式碼

輸出:

hello, Im from new isolate!
複製程式碼

上面我們瞭解了主isolate是如何監聽來自子isolate的訊息的,如果同時子isolate也想知道主isolate的一些狀態,那該如何處理呢?下面的程式碼將提供一種雙向通訊的方式:

Future<SendPort> initIsolate() async {
  Completer completer = new Completer<SendPort>();
  ReceivePort isolateToMainStream = ReceivePort();

  //監聽來自子執行緒的訊息
  isolateToMainStream.listen((data) {
    if (data is SendPort) {
      SendPort mainToIsolateStream = data;
      completer.complete(mainToIsolateStream);
    } else {
      print('[isolateToMainStream] $data');
    }
  });

  Isolate myIsolateInstance = await Isolate.spawn(newIsolate, isolateToMainStream.sendPort);
  //返回來自子isolate的sendPort
  return completer.future; 
}

void newIsolate(SendPort isolateToMainStream) {
  ReceivePort mainToIsolateStream = ReceivePort();
  //關鍵實現:把SendPort物件傳回給主isolate
  isolateToMainStream.send(mainToIsolateStream.sendPort);

  //監聽來自主isolate的訊息
  mainToIsolateStream.listen((data) {
    print('[mainToIsolateStream] $data');
  });

  isolateToMainStream.send('This is from new isolate');
}

void main() async{
  SendPort mainToIsolate = await initIsolate();
  mainToIsolate.send('This is from main isolate');
}
複製程式碼

輸出:

[mainToIsolateStream] This is from main isolatemain end
[isolateToMainStream] This is from new isolate
複製程式碼

在 Flutter 中,你還可以通過一個簡化版的compute函式啟動一個新的isolate

比如在反序列化的場景中,直接在主isolate進行序列化:

List<Photo> parsePhotos(String responseBody) {
  final parsed = json.decode(responseBody).cast<Map<String, dynamic>>();

  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response =
      await client.get('https://jsonplaceholder.typicode.com/photos');
  //直接在主isolate轉換
  return parsePhotos(response.body); 
}
複製程式碼

啟動一個新的isolate

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response =
      await client.get('https://jsonplaceholder.typicode.com/photos');
  // 使用compute函式,啟動一個新的isolate
  return compute(parsePhotos, response.body);
}
複製程式碼

本示例的完整版:Parse JSON in the background

isolate 訊息傳遞示意圖

總結一下,當遇到計算密集型的耗時操作,你可以開啟一個新的isolate來併發執行任務。不像我們常規認識的多執行緒,不同的isolate之間不能共享記憶體,但通過ReceivePortSendPort可以構建不同isolate之間的訊息通道,另外從別的isolate傳來的訊息也是要經過事件迴圈的。

參考資料

  1. Dart asynchronous programming: isolate and event loops
  2. The Event Loop and Dart
  3. Node.js 的非同步 I/O 實現
  4. Dart Isolate 2-Way Communication
  5. 徹底搞懂Dart非同步

關於AgileStudio

我們是一支由資深獨立開發者和設計師組成的團隊,成員均有紮實的技術實力和多年的產品設計開發經驗,提供可信賴的軟體定製服務。

相關文章