单线程,多线程,同步和异步。本文解释了 Flutter 中不同的代码执行模式。
难度:中级
介绍
我最近收到了几个与Future、async、await、Isolate和并行处理的概念相关的问题。
伴随着这些问题,有些人在处理代码的顺序方面遇到了麻烦。
我认为利用一篇文章的机会来涵盖这些主题并消除任何歧义,主要是围绕异步和并行处理的概念,这将是非常有用的。
Dart 是一种单线程语言
首先,每个人都需要记住Dart是单线程的,而Flutter依赖于Dart。
重要的
Dart一次执行一个操作,一个接一个地执行,这意味着只要一个操作正在执行,它就不会被任何其他 Dart 代码打断。
换句话说,如果你考虑一个纯粹的同步方法,后者将是唯一一个在它完成之前被执行的方法。
for (int i = 0; i < 1000000; i++){
_doSomethingSynchronously();
}
}
在上面的示例中,myBigLoop()方法的执行在完成之前永远不会被中断。因此,如果这个方法需要一些时间,应用程序将在整个方法执行过程中被“阻塞”。
Dart执行模型
在幕后,Dart实际上是如何管理要执行的操作的顺序的?
为了回答这个问题,我们需要看一下Dart代码序列器,称为事件循环。
当您启动Flutter(或任何Dart )应用程序时,将创建并启动一个新的Thread进程(在Dart语言中 =“ Isolate ”)。该线程将是您唯一需要关心整个应用程序的线程。
所以,当这个线程被创建时,Dart 会自动
初始化2个队列,即“ MicroTask ”和“ Event ”FIFO队列;
执行main()方法,一旦代码执行完成,
启动事件循环
在线程的整个生命周期中,一个称为“事件循环”的内部和不可见进程将驱动代码的执行方式和顺序,具体取决于MicroTask和事件队列的内容。
Event Loop对应于某种无限循环,由一个内部时钟决定节奏,如果没有其他 Dart 代码正在执行,在每个tick时,它会执行如下操作:
void eventLoop(){
while (microTaskQueue.isNotEmpty){
fetchFirstMicroTaskFromQueue();
executeThisMicroTask();
return;
}
if (eventQueue.isNotEmpty){
fetchFirstEventFromQueue();
executeThisEventRelatedCode();
}
}
正如我们所见,MicroTask Queue 优先于Event Queue,但这 2 个队列的用途是什么?
Microtask Queue微任务队列
MicroTask Queue 用于需要异步运行的非常短的内部操作,在其他事情完成之后并且在将手交还给Event Queue 之前。
作为MicroTask的示例,您可以想象必须在资源关闭后立即对其进行处置。由于关闭过程可能需要一些时间才能完成,您可以这样写:
MyResource myResource;
...
void closeAndRelease() {
scheduleMicroTask(_dispose);
_close();
}
void _close(){
// The code to be run synchronously
// to close the resource
...
}
void _dispose(){
// The code which has to be run
// right after the _close()
// has completed
}
在大多数情况下,这是您不必处理的事情。例如,整个Flutter源代码只引用了scheduleMicroTask()方法 7 次。
最好考虑使用事件队列。
Event Queue 事件队列
事件队列用于引用由以下结果产生的操作
外部事件,例如
输入/输出;
手势;
绘画;
计时器;
溪流;
……
期货
实际上,每次触发外部事件时,都会将相应要执行的代码引用到Event Queue中。
一旦不再有任何微任务要运行,事件循环就会考虑事件队列中的第一项并执行它。
值得注意的是,Futures也是通过事件队列处理的。
Futures
Future对应于异步运行并在未来某个时间点完成(或失败)的任务。
当您实例化一个新的Future时:
该Future的一个实例被创建并记录在一个内部数组中,由Dart管理;
将这个Future需要执行的代码直接push到Event Queue中;
未来的实例返回状态(=不完整);
如果有的话,执行下一个同步代码(不是 Future 的代码)
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
这是完全正常的,因为执行流程如下:
print(‘在未来之前’)
添加“ (){print(‘Running the Future’);} ”到事件队列;
print(‘未来之后’)
事件循环获取代码(在项目符号 2 中引用)并运行它
执行代码时,它会查找then()语句并运行它
要记住一些非常重要的事情:
Future不是并行执行的,而是遵循由事件循环处理的常规事件序列
异步方法
当您使用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((){ // <== This code will be executed some time in the future
print('C running Future from $from');
}).then((_){
print('C end of Future from $from');
});
print('C end from $from');
}
methodD(){
print('D');
}
正确的顺序如下:
A
B开始
C 从 B 开始
C 从 B 结束
B端
C 从 main 开始
C端从主
丁
C 从 B 运行 Future
B 未来的 C 结束
C 从 main 运行 Future
来自 main 的 Future 的 C 端
现在,让我们考虑上面代码中的“ 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((){ // <== modification is here
print('C running Future from $from');
}).then((_){
print('C end of Future from $from');
});
print('C end from $from');
}
methodD(){
print('D');
}
这给出了以下序列:
A
B开始
C 从 B 开始
C 从 B 运行 Future
B 未来的 C 结束
C 从 B 结束
B端
C 从 main 开始
C 从 main 运行 Future
来自 main 的 Future 的 C 端
C端从主
丁
在methodC()中的Future级别添加一个简单的await这一事实改变了整个行为。
同样重要的是要记住:
异步方法不是并行执行的,而是遵循由事件循环处理的常规事件序列。
我想向您展示的最后一个示例如下。运行method1和method2之一
的输出是什么?他们会一样吗?
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');
}
回答:
方法1() 方法2()
循环前
循环结束
delayedPrint: a (1秒后)
delayedPrint: b (紧接着)
delayedPrint: c (紧接着)
循环前
delayedPrint: a (1秒后)
delayedPrint: b (1秒后)
delayedPrint: c (1秒后)
循环结束(紧随其后)
您是否看到了差异以及他们的行为不一样的原因?
解决方案在于method1使用函数forEach()来迭代数组。每次迭代时,它都会调用一个新的回调,该回调被标记为异步(因此是Future)。它执行它直到到达await,然后将剩余的代码推送到事件队列中。一旦迭代完成,它就会执行下一条语句“print(‘end of loop’)”。完成后,事件循环将处理 3 个回调。
至于method2,所有内容都在同一代码“块”中运行,因此(在本例中)一行接一行地运行。
如您所见,即使在看起来非常简单的代码中,我们仍然需要牢记事件循环的工作原理……
多线程
那么,我们如何在 Flutter 中运行并行代码呢?这可能吗?
是的,多亏了Isolates的概念。
什么是隔离?
如前所述,Isolate对应于Thread概念的Dart版本。
然而,与通常的“线程”实现有很大的不同,这就是它们被命名为“隔离”的原因。
Flutter 中的“Isolates”不共享内存。不同“Isolates”之间的交互在通信方面是通过“消息”进行的。
每个 Isolate 都有自己的“事件循环”
每个“隔离”都有自己的“事件循环”和队列(微任务和事件)。这意味着代码在Isolate内部运行,独立于另一个Isolate。
多亏了这一点,我们可以获得并行处理。
如何启动隔离?
根据运行Isolate的需要,您可能需要考虑不同的方法。
- 底层解决方案
第一个解决方案不使用任何包,完全依赖于Dart提供的低级 API 。
1.1. 第一步:创作与握手
正如我之前所说,Isolates不共享任何内存并通过消息进行通信,因此,我们需要找到一种方法在“调用者”和新的isolate之间建立这种通信。
每个Isolate公开一个端口,用于向该Isolate传送消息。这个端口叫做“ SendPort ”(我个人觉得这个名字有点误导,因为它是一个旨在接收/监听的端口,但这是官方名称)。
这意味着“ caller ”和“ new isolate ”都需要知道彼此的端口才能进行通信。此握手过程如下所示:
//
// The port of the new isolate
// this port will be used to further
// send messages to that isolate
//
SendPort newIsolateSendPort;
//
// Instance of the new Isolate
//
Isolate newIsolate;
//
// Method that launches a new isolate
// and proceeds with the initial
// hand-shaking
//
void callerCreateIsolate() async {
//
// Local and temporary ReceivePort to retrieve
// the new isolate's SendPort
//
ReceivePort receivePort = ReceivePort();
//
// Instantiate the new isolate
//
newIsolate = await Isolate.spawn(
callbackFunction,
receivePort.sendPort,
);
//
// Retrieve the port to be used for further
// communication
//
newIsolateSendPort = await receivePort.first;
}
//
// The entry point of the new isolate
//
static void callbackFunction(SendPort callerSendPort){
//
// Instantiate a SendPort to receive message
// from the caller
//
ReceivePort newIsolateReceivePort = ReceivePort();
//
// Provide the caller with the reference of THIS isolate's SendPort
//
callerSendPort.send(newIsolateReceivePort.sendPort);
//
// Further processing
//
}
约束条件
隔离的“入口点”必须是顶层函数或静态方法。
1.2. 第 2 步:向 Isolate 提交消息
现在我们有了用于向 Isolate 发送消息的端口,让我们看看如何做:
//
// Method that sends a message to the new isolate
// and receives an answer
//
// In this example, I consider that the communication
// operates with Strings (sent and received data)
//
Future<String> sendReceive(String messageToBeSent) async {
//
// We create a temporary port to receive the answer
//
ReceivePort port = ReceivePort();
//
// We send the message to the Isolate, and also
// tell the isolate which port to use to provide
// any answer
//
newIsolateSendPort.send(
CrossIsolatesMessage<String>(
sender: port.sendPort,
message: messageToBeSent,
)
);
//
// Wait for the answer and return it
//
return port.first;
}
//
// Extension of the callback function to process incoming messages
//
static void callbackFunction(SendPort callerSendPort){
//
// Instantiate a SendPort to receive message
// from the caller
//
ReceivePort newIsolateReceivePort = ReceivePort();
//
// Provide the caller with the reference of THIS isolate's SendPort
//
callerSendPort.send(newIsolateReceivePort.sendPort);
//
// Isolate main routine that listens to incoming messages,
// processes it and provides an answer
//
newIsolateReceivePort.listen((dynamic message){
CrossIsolatesMessage incomingMessage = message as CrossIsolatesMessage;
//
// Process the message
//
String newMessage = "complemented string " + incomingMessage.message;
//
// Sends the outcome of the processing
//
incomingMessage.sender.send(newMessage);
});
}
//
// Helper class
//
class CrossIsolatesMessage<T> {
final SendPort sender;
final T message;
CrossIsolatesMessage({
@required this.sender,
this.message,
});
}
1.3. 第三步:销毁新的Isolate
当您不再需要新的 Isolate 实例时,最好按以下方式释放它:
//
// Routine to dispose an isolate
//
void dispose(){
newIsolate?.kill(priority: Isolate.immediate);
newIsolate = null;
}
1.4. 特别说明 - 单听流
您可能肯定已经注意到我们正在使用Streams在“调用者”和新的isolate之间进行通信。这些Streams 的类型为:“ Single-Listener ”Streams。
2.一次性计算
如果你只需要运行一些代码来完成一些特定的工作,并且在工作完成后不需要与 Isolate 交互,那么有一个非常方便的Helper,称为compute。
这个功能:
产生一个Isolate,
在那个 isolate 上运行一个回调函数,向它传递一些数据,
返回值,结果回调,
并在回调执行结束时终止Isolate 。
约束条件
“回调”函数必须是顶级函数,不能是闭包或类的方法(静态或非静态)。
3.重要限制
在撰写本文时,请务必注意
平台通道通信仅由主隔离支持。这个主隔离对应于启动应用程序时创建的隔离。
换句话说,平台通道通信不可能通过您以编程方式创建的隔离实例…
然而,有一个解决方法……请参考此链接以了解有关此主题的讨论。
我什么时候应该使用 Futures 和 Isolates?
用户会根据不同的因素来评估应用程序的质量,例如:
特征
看
用户友好性
……
您的应用程序可以满足所有这些因素,但如果用户在某些处理过程中体验滞后,这很可能会对您不利。
因此,这里有一些提示,您应该在开发中系统地考虑:
如果一段代码MAY NOT被中断,使用一个正常的同步过程(一个方法或多个方法相互调用);
如果代码片段可以独立运行而不影响应用程序的流动性,请考虑通过使用Futures来使用事件循环;
如果繁重的处理可能需要一些时间才能完成并且可能会影响应用程序的流动性,请考虑使用Isolates。
换句话说,建议尽可能多地使用Futures的概念(直接或间接通过异步方法),因为这些Futures的代码将在Event Loop有时间时立即运行。这会给用户一种事情正在并行处理的感觉(虽然我们现在知道情况并非如此)。
另一个可以帮助您决定是使用Future还是Isolate 的因素是运行某些代码所需的平均时间。
如果一个方法需要几毫秒 => Future
如果处理可能需要几百毫秒 =>隔离
以下是Isolates的一些不错的候选者:
JSON解码
解码 JSON,HttpRequest 的结果,可能需要一些时间 => 使用计算
加密
加密可能非常耗时 =>隔离
图像处理
处理图像(例如裁剪)确实需要一些时间才能完成 =>隔离
从 Web 加载图像
在这种情况下,为什么不将其委托给一个Isolate,它会在完全加载后返回完整图像?
结论
我认为了解事件循环的工作原理至关重要。
同样重要的是要记住Flutter ( Dart ) 是单线程的,因此为了取悦用户,开发人员必须确保应用程序尽可能流畅地运行。 Futures和Isolates是非常强大的工具,可以帮助您实现这一目标。
请继续关注新文章,同时……祝您编码愉快!