Dart 非同步程式設計
程式設計中的程式碼執行,通常分為同步
與非同步
兩種。簡單說,同步就是按照程式碼的編寫順序,從上到下依次執行,這也是最簡單的我們最常接觸的一種形式。但是同步程式碼的缺點也顯而易見,如果其中某一行或幾行程式碼非常耗時,那麼就會阻塞,使得後面的程式碼不能被立刻執行。
非同步的出現正是為了解決這種問題,它可以使某部分耗時程式碼不在當前這條執行線路上立刻執行,那究竟怎麼執行呢?最常見的一種方案是使用多執行緒,也就相當於開闢另一條執行線,然後讓耗時程式碼在另一條執行線上執行,這樣兩條執行線並列,耗時程式碼自然也就不能阻塞主執行線上的程式碼了。
多執行緒雖然好用,但是在大量併發時,仍然存在兩個較大的缺陷,一個是開闢執行緒比較耗費資源,執行緒開多了機器吃不消,另一個則是執行緒的鎖問題,多個執行緒操作共享記憶體時需要加鎖,複雜情況下的鎖競爭不僅會降低效能,還可能造成死鎖。因此又出現了基於事件的非同步模型。簡單說就是在某個單執行緒中存在一個事件迴圈和一個事件佇列,事件迴圈不斷的從事件佇列中取出事件來執行,這裡的事件就好比是一段程式碼,每當遇到耗時的事件時,事件迴圈不會停下來等待結果,它會跳過耗時事件,繼續執行其後的事件。當不耗時的事件都完成了,再來檢視耗時事件的結果。因此,耗時事件不會阻塞整個事件迴圈,這讓它後面的事件也會有機會得到執行。
我們很容易發現,這種基於事件的非同步模型,只適合I/O
密集型的耗時操作,因為I/O
耗時操作,往往是把時間浪費在等待對方傳送資料或者返回結果,因此這種非同步模型往往用於網路伺服器併發。如果是計算密集型的操作,則應當儘可能利用處理器的多核,實現平行計算。
Dart 的事件迴圈
Dart 是事件驅動的體系結構,該結構基於具有單個事件迴圈和兩個佇列的單執行緒執行模型。 Dart雖然提供呼叫堆疊。 但是它使用事件在生產者和消費者之間傳輸上下文。 事件迴圈由單個執行緒支援,因此根本不需要同步和鎖定。
Dart 的兩個佇列分別是
-
MicroTask queue
微任務佇列 -
Event queue
事件佇列
Dart事件迴圈執行如上圖所示
- 先檢視
MicroTask
佇列是否為空,不是則先執行MicroTask
佇列 - 一個
MicroTask
執行完後,檢查有沒有下一個MicroTask
,直到MicroTask
佇列為空,才去執行Event
佇列 - 在
Evnet
佇列取出一個事件處理完後,再次返回第一步,去檢查MicroTask
佇列是否為空
我們可以看出,將任務加入到MicroTask
中可以被儘快執行,但也需要注意,當事件迴圈在處理MicroTask
佇列時,Event
佇列會被卡住,應用程式無法處理滑鼠單擊、I/O訊息等等事件。
排程任務
注意,以下呼叫的方法,都定義在dart:async
庫中。
將任務新增到MicroTask
佇列有兩種方法
import 'dart:async';
void myTask(){
print("this is my task");
}
void main() {
# 1. 使用 scheduleMicrotask 方法新增
scheduleMicrotask(myTask);
# 2. 使用Future物件新增
new Future.microtask(myTask);
}
複製程式碼
將任務新增到Event
佇列
import 'dart:async';
void myTask(){
print("this is my task");
}
void main() {
new Future(myTask);
}
複製程式碼
現在學會了排程任務,趕緊編寫程式碼驗證以上的結論
import 'dart:async';
void main() {
print("main start");
new Future((){
print("this is my task");
});
new Future.microtask((){
print("this is microtask");
});
print("main stop");
}
複製程式碼
執行結果:
main start
main stop
this is microtask
this is my task
複製程式碼
可以看到,程式碼的執行順序並不是按照我們的編寫順序來的,將任務新增到佇列並不等於立刻執行,它們是非同步執行的,當前main
方法中的程式碼執行完之後,才會去執行佇列中的任務,且MicroTask
佇列執行在Event
佇列之前。
延時任務
如需要將任務延伸執行,則可使用Future.delayed
方法
new Future.delayed(new Duration(seconds:1),(){
print('task delayed');
});
複製程式碼
表示在延遲時間到了之後將任務加入到Event
佇列。需要注意的是,這並不是準確的,萬一前面有很耗時的任務,那麼你的延遲任務不一定能準時執行。
import 'dart:async';
import 'dart:io';
void main() {
print("main start");
new Future.delayed(new Duration(seconds:1),(){
print('task delayed');
});
new Future((){
// 模擬耗時5秒
sleep(Duration(seconds:5));
print("5s task");
});
print("main stop");
}
複製程式碼
執行結果:
main start
main stop
5s task
task delayed
複製程式碼
從結果可以看出,delayed
方法呼叫在前面,但是它顯然並未直接將任務加入Event
佇列,而是需要等待1秒之後才會去將任務加入,但在這1秒之間,後面的new Future
程式碼直接將一個耗時任務加入到了Event
佇列,這就直接導致寫在前面的delayed
任務在1秒後只能被加入到耗時任務之後,只有當前面耗時任務完成後,它才有機會得到執行。這種機制使得延遲任務變得不太可靠,你無法確定延遲任務到底在延遲多久之後被執行。
Future 詳解
Future類是對未來結果的一個代理,它返回的並不是被呼叫的任務的返回值。
void myTask(){
print("this is my task");
}
void main() {
Future fut = new Future(myTask);
}
複製程式碼
如上程式碼,Future
類例項fut
並不是函式myTask
的返回值,它只是代理了myTask
函式,封裝了該任務的執行狀態。
建立 Future
Future
的幾種建立方法
Future()
Future.microtask()
Future.sync()
Future.value()
Future.delayed()
Future.error()
其中sync
是同步方法,任務會被立即執行
import 'dart:async';
void main() {
print("main start");
new Future.sync((){
print("sync task");
});
new Future((){
print("async task");
});
print("main stop");
}
複製程式碼
執行結果:
main start
sync task
main stop
async task
複製程式碼
註冊回撥
當Future
中的任務完成後,我們往往需要一個回撥,這個回撥立即執行,不會被新增到事件佇列。
import 'dart:async';
void main() {
print("main start");
Future fut =new Future.value(18);
// 使用then註冊回撥
fut.then((res){
print(res);
});
// 鏈式呼叫,可以跟多個then,註冊多個回撥
new Future((){
print("async task");
}).then((res){
print("async task complete");
}).then((res){
print("async task after");
});
print("main stop");
}
複製程式碼
執行結果:
main start
main stop
18
async task
async task complete
async task after
複製程式碼
除了then
方法,還可以使用catchError
來處理異常,如下
new Future((){
print("async task");
}).then((res){
print("async task complete");
}).catchError((e){
print(e);
});
複製程式碼
還可以使用靜態方法wait
等待多個任務全部完成後回撥。
import 'dart:async';
void main() {
print("main start");
Future task1 = new Future((){
print("task 1");
return 1;
});
Future task2 = new Future((){
print("task 2");
return 2;
});
Future task3 = new Future((){
print("task 3");
return 3;
});
Future fut = Future.wait([task1, task2, task3]);
fut.then((responses){
print(responses);
});
print("main stop");
}
複製程式碼
執行結果:
main start
main stop
task 1
task 2
task 3
[1, 2, 3]
複製程式碼
如上,wait
返回一個新的Future
,當新增的所有Future
完成時,在新的Future
註冊的回撥將被執行。
async 和 await
在Dart1.9中加入了async
和await
關鍵字,有了這兩個關鍵字,我們可以更簡潔的編寫非同步程式碼,而不需要呼叫Future
相關的API
將 async
關鍵字作為方法宣告的字尾時,具有如下意義
- 被修飾的方法會將一個
Future
物件作為返回值 - 該方法會同步執行其中的方法的程式碼直到第一個 await 關鍵字,然後它暫停該方法其他部分的執行;
- 一旦由 await 關鍵字引用的 Future 任務執行完成,await的下一行程式碼將立即執行。
// 匯入io庫,呼叫sleep函式
import 'dart:io';
// 模擬耗時操作,呼叫sleep函式睡眠2秒
doTask() async{
await sleep(const Duration(seconds:2));
return "Ok";
}
// 定義一個函式用於包裝
test() async {
var r = await doTask();
print(r);
}
void main(){
print("main start");
test();
print("main end");
}
複製程式碼
執行結果:
main start
main end
Ok
複製程式碼
需要注意,async 不是並行執行,它是遵循Dart 事件迴圈規則來執行的,它僅僅是一個語法糖,簡化Future API
的使用。
Isolate
前面已經說過,將非常耗時的任務新增到事件佇列後,仍然會拖慢整個事件迴圈的處理,甚至是阻塞。可見基於事件迴圈的非同步模型仍然是有很大缺點的,這時候我們就需要Isolate
,這個單詞的中文意思是隔離。
簡單說,可以把它理解為Dart中的執行緒。但它又不同於執行緒,更恰當的說應該是微執行緒,或者說是協程。它與執行緒最大的區別就是不能共享記憶體,因此也不存在鎖競爭問題,兩個Isolate
完全是兩條獨立的執行線,且每個Isolate
都有自己的事件迴圈,它們之間只能通過傳送訊息通訊,所以它的資源開銷低於執行緒。
從主Isolate
建立一個新的Isolate
有兩種方法
spawnUri
static Future<Isolate> spawnUri()
spawnUri
方法有三個必須的引數,第一個是Uri,指定一個新Isolate
程式碼檔案的路徑,第二個是引數列表,型別是List<String>
,第三個是動態訊息。需要注意,用於執行新Isolate
的程式碼檔案中,必須包含一個main函式,它是新Isolate
的入口方法,該main函式中的args引數列表,正對應spawnUri
中的第二個引數。如不需要向新Isolate
中傳引數,該引數可傳空List
主Isolate
中的程式碼:
import 'dart:isolate';
void main() {
print("main isolate start");
create_isolate();
print("main isolate stop");
}
// 建立一個新的 isolate
create_isolate() async{
ReceivePort rp = new ReceivePort();
SendPort port1 = rp.sendPort;
Isolate newIsolate = await Isolate.spawnUri(new Uri(path: "./other_task.dart"), ["hello, isolate", "this is args"], port1);
SendPort port2;
rp.listen((message){
print("main isolate message: $message");
if (message[0] == 0){
port2 = message[1];
}else{
port2?.send([1,"這條資訊是 main isolate 傳送的"]);
}
});
// 可以在適當的時候,呼叫以下方法殺死建立的 isolate
// newIsolate.kill(priority: Isolate.immediate);
}
複製程式碼
建立other_task.dart
檔案,編寫新Isolate
的程式碼
import 'dart:isolate';
import 'dart:io';
void main(args, SendPort port1) {
print("isolate_1 start");
print("isolate_1 args: $args");
ReceivePort receivePort = new ReceivePort();
SendPort port2 = receivePort.sendPort;
receivePort.listen((message){
print("isolate_1 message: $message");
});
// 將當前 isolate 中建立的SendPort傳送到主 isolate中用於通訊
port1.send([0, port2]);
// 模擬耗時5秒
sleep(Duration(seconds:5));
port1.send([1, "isolate_1 任務完成"]);
print("isolate_1 stop");
}
複製程式碼
執行主Isolate
的結果:
main isolate start
main isolate stop
isolate_1 start
isolate_1 args: [hello, isolate, this is args]
main isolate message: [0, SendPort]
isolate_1 stop
main isolate message: [1, isolate_1 任務完成]
isolate_1 message: [1, 這條資訊是 main isolate 傳送的]
複製程式碼
整個訊息通訊過程如上圖所示,兩個Isolate是通過兩對Port物件通訊,一對Port分別由用於接收訊息的ReceivePort
物件,和用於傳送訊息的SendPort
物件構成。其中SendPort
物件不用單獨建立,它已經包含在ReceivePort
物件之中。需要注意,一對Port物件只能單向發訊息,這就如同一根自來水管,ReceivePort
和SendPort
分別位於水管的兩頭,水流只能從SendPort
這頭流向ReceivePort
這頭。因此,兩個Isolate
之間的訊息通訊肯定是需要兩根這樣的水管的,這就需要兩對Port物件。
理解了Isolate
訊息通訊的原理,那麼在Dart程式碼中,具體是如何操作的呢?
ReceivePort
物件通過呼叫listen
方法,傳入一個函式可用來監聽並處理髮送來的訊息。SendPort
物件則呼叫send()
方法來傳送訊息。send
方法傳入的引數可以是null
,num
, bool
, double
,String
, List
,Map
或者是自定義的類。 在上例中,我們傳送的是包含兩個元素的List
物件,第一個元素是整型,表示訊息型別,第二個元素則表示訊息內容。
spawn
static Future<Isolate> spawn()
除了使用spawnUri
,更常用的是使用spawn
方法來建立新的Isolate
,我們通常希望將新建立的Isolate
程式碼和main Isolate
程式碼寫在同一個檔案,且不希望出現兩個main函式,而是將指定的耗時函式執行在新的Isolate
,這樣做有利於程式碼的組織和程式碼的複用。spawn
方法有兩個必須的引數,第一個是需要執行在新Isolate
的耗時函式,第二個是動態訊息,該引數通常用於傳送主Isolate
的SendPort
物件。
spawn
的用法與spawnUri
相似,且更為簡潔,將上面例子稍作修改如下
import 'dart:isolate';
import 'dart:io';
void main() {
print("main isolate start");
create_isolate();
print("main isolate end");
}
// 建立一個新的 isolate
create_isolate() async{
ReceivePort rp = new ReceivePort();
SendPort port1 = rp.sendPort;
Isolate newIsolate = await Isolate.spawn(doWork, port1);
SendPort port2;
rp.listen((message){
print("main isolate message: $message");
if (message[0] == 0){
port2 = message[1];
}else{
port2?.send([1,"這條資訊是 main isolate 傳送的"]);
}
});
}
// 處理耗時任務
void doWork(SendPort port1){
print("new isolate start");
ReceivePort rp2 = new ReceivePort();
SendPort port2 = rp2.sendPort;
rp2.listen((message){
print("doWork message: $message");
});
// 將新isolate中建立的SendPort傳送到主isolate中用於通訊
port1.send([0, port2]);
// 模擬耗時5秒
sleep(Duration(seconds:5));
port1.send([1, "doWork 任務完成"]);
print("new isolate end");
}
複製程式碼
執行結果:
main isolate start
main isolate end
new isolate start
main isolate message: [0, SendPort]
new isolate end
main isolate message: [1, doWork 任務完成]
doWork message: [1, 這條資訊是 main isolate 傳送的]
複製程式碼
無論是上面的spawn
還是spawnUri
,執行後都會建立兩個程式,一個是主Isolate
的程式,一個是新Isolate
的程式,兩個程式都雙向繫結了訊息通訊的通道,即使新的Isolate
中的任務完成了,它的程式也不會立刻退出,因此,當使用完自己建立的Isolate
後,最好呼叫newIsolate.kill(priority: Isolate.immediate);
將Isolate
立即殺死。
Flutter 中建立Isolate
無論如何,在Dart中建立一個Isolate
都顯得有些繁瑣,可惜的是Dart官方並未提供更高階封裝。但是,如果想在Flutter中建立Isolate
,則有更簡便的API,這是由Flutter
官方進一步封裝ReceivePort
而提供的更簡潔API。詳細API文件
使用compute
函式來建立新的Isolate
並執行耗時任務
import 'package:flutter/foundation.dart';
import 'dart:io';
// 建立一個新的Isolate,在其中執行任務doWork
create_new_task() async{
var str = "New Task";
var result = await compute(doWork, str);
print(result);
}
void doWork(String value){
print("new isolate doWork start");
// 模擬耗時5秒
sleep(Duration(seconds:5));
print("new isolate doWork end");
return "complete:$value";
}
複製程式碼
compute
函式有兩個必須的引數,第一個是待執行的函式,這個函式必須是一個頂級函式,不能是類的例項方法,可以是類的靜態方法,第二個引數為動態的訊息型別,可以是被執行函式的引數。需要注意,使用compute
應匯入'package:flutter/foundation.dart'
包。
使用場景
Isolate
雖好,但也有合適的使用場景,不建議濫用Isolate
,應儘可能多的使用Dart中的事件迴圈機制去處理非同步任務,這樣才能更好的發揮Dart語言的優勢。
那麼應該在什麼時候使用Future,什麼時候使用Isolate呢? 一個最簡單的判斷方法是根據某些任務的平均時間來選擇:
- 方法執行在幾毫秒或十幾毫秒左右的,應使用
Future
- 如果一個任務需要幾百毫秒或之上的,則建議建立單獨的
Isolate
除此之外,還有一些可以參考的場景,如JSON 解碼、加密、影像處理:比如剪裁、長時間的網路請求來載入資源
參考資料: Dart 文件 Isolate 文件
歡迎關注我的公眾號:程式設計之路從0到1