Dart 中的并发之Isolate

629 阅读9分钟

isolate

Dart 通过 async-await、isolate 以及一些异步类型概念(例如 Future 和 Stream)支持了并发代码编程。

在应用中,所有的 Dart 代码都在 isolate 中运行。每一个 Dart 的 isolate 都有独立的运行线程,它们无法与其他 isolate 共享可变对象。在需要进行通信的场景里,isolate 会使用消息机制。很多 Dart 应用都只使用一个 isolate,也就是 main isolate。你可以创建额外的 isolate 以便在多个处理器核心上执行并行代码。

主isolate

通常一个 Dart 应用会在主 isolate 下执行所有代码。

basics-main-isolate.png

就算是只有一个 isolate 的应用,只要通过使用 async-await 来处理异步操作,也完全可以流畅运行。一个拥有良好性能的应用,会在快速启动后尽快进入事件循环。这使得应用可以通过异步操作快速响应对应的事件。

Isolate.run()

如果应用受到耗时计算的影响而出现卡顿,例如 解析较大的 JSON 文件,可以考虑将耗时计算转移到单独工作的 isolate,通常我们称这样的 isolate 为 后台运行对象。下图展示了一种常用场景,可以生成一个 isolate,它将执行耗时计算的任务,并在结束后退出。这个 isolate 工作对象退出时会把结果返回。

isolate-bg-worker.png

每个 isolate 都可以通过消息通信传递一个对象,这个对象的所有内容都需要满足可传递的条件。并非所有的对象都满足传递条件,在无法满足条件时,消息发送会失败。举个例子,如果你想发送一个 List<Object>,你需要确保这个列表中所有元素都是可被传递的。假设这个列表中有一个 Socket,由于它无法被传递,所以你无法发送整个列表。

示例

单个函数调用中,isolate .run()isolate实现的所有部分跟我们自己的解析文本任务结合。

在一个新的isolate中读取和解析JSON数据, 然后存储返回的Dart表示。

const filename = 'json_01.json';

Future<void> main() async {
  final jsonData = await Isolate.run(() => _readAndParseJson(filename));
  print('Received JSON with ${jsonData.length} keys');
}

没有端口,没有单独的生成、退出或错误处理,也没有特殊的返回结构。 直接读取filename文件的内容, 解码JSON,并返回结果。

Future<Map<String, dynamic>> _readAndParseJson(String filename) async {
  final fileData = await File(filename).readAsString();
  final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
  return jsonData;
}

isolate以及在其之上编写的任何更高级别的 API 不再局限于仅运行静态或顶级函数。

耗时测试(展示isolate .run()速度)

/// Isolate.run基准测试
Future<void> iosfib() async {
  for (var i = 0; i < 2; i++) {
    const int n = 38;
    /// 计时器
    var sw = Stopwatch()..start();
    var fn = fib(n);
    sw.stop();
    print("fib($n) = $fn: ${sw.elapsed.toString()}");

    /// 重置
    sw.reset();
    int compFun() => fib(n);
    sw.start();
    var fs = await Future.wait([
      Isolate.run(() => compFun()),
      Isolate.run(() => compFun()),
      Isolate.run(() => compFun()),
    ]);
    sw.stop();
    /// 打印
    print("fib($n) = ${fs[0]} * 3 : ${sw.elapsed.toString()}");
  }
}

int fib(int n) => n <= 1 ? 1 : fib(n - 1) + fib(n - 2);

打印结果

flutter: fib(38) = 63245986: 0:00:00.309094
flutter: fib(38) = 63245986 * 3 : 0:00:00.631367
flutter: fib(38) = 63245986: 0:00:00.272038
flutter: fib(38) = 63245986 * 3 : 0:00:00.611722

从日志可以看出, 耗时计算的速度较正常计算提升不少。

Isolate.run()原理

@Since("2.19")
static Future<R> run<R>(FutureOr<R> computation(), {String? debugName}) {
  var result = Completer<R>();
  var resultPort = RawReceivePort();
  resultPort.handler = (response) {
    resultPort.close();
    if (response == null) {
      // onExit handler message, isolate terminated without sending result.
      result.completeError(
          RemoteError("Computation ended without result", ""),
          StackTrace.empty);
      return;
    }
    var list = response as List<Object?>;
    if (list.length == 2) {
      var remoteError = list[0];
      var remoteStack = list[1];
      if (remoteStack is StackTrace) {
        // Typed error.
        result.completeError(remoteError!, remoteStack);
      } else {
        // onError handler message, uncaught async error.
        // Both values are strings, so calling `toString` is efficient.
        var error =
            RemoteError(remoteError.toString(), remoteStack.toString());
        result.completeError(error, error.stackTrace);
      }
    } else {
      assert(list.length == 1);
      result.complete(list[0] as R);
    }
  };
  try {
    Isolate.spawn(_RemoteRunner._remoteExecute,
            _RemoteRunner<R>(computation, resultPort.sendPort),
            onError: resultPort.sendPort,
            onExit: resultPort.sendPort,
            errorsAreFatal: true,
            debugName: debugName)
        .then<void>((_) {}, onError: (error, stack) {
      // Sending the computation failed asynchronously.
      // Do not expect a response, report the error asynchronously.
      resultPort.close();
      result.completeError(error, stack);
    });
  } on Object {
    // Sending the computation failed synchronously.
    // This is not expected to happen, but if it does,
    // the synchronous error is respected and rethrown synchronously.
    resultPort.close();
    rethrow;
  }
  return result.future;
}

异步计算运行代码,通常用于在一个隔离体 (Isolate) 中执行某些计算任务,以避免阻塞主线程。

  1. static Future<R> run<R>(FutureOr<R> computation(), {String? debugName}):这是一个静态方法,用于执行一个异步计算任务,并返回一个 Future,该 Future 将在计算完成时得到结果。它接受两个参数:computation,一个无参函数或闭包,用于执行异步计算;debugName,一个可选的字符串参数,用于设置计算任务的调试名称。

  2. var result = Completer<R>();:创建一个 Completer 对象,用于控制异步计算的结果。

  3. var resultPort = RawReceivePort();:创建一个原始接收端口,用于接收隔离体中的计算结果。

  4. resultPort.handler:定义了接收端口的消息处理程序,当接收到消息时,将触发此处理程序。

  5. Isolate.spawn:这是关键部分。它用于在一个新的隔离体中执行计算任务。参数包括:

    • _RemoteRunner._remoteExecute:隔离体中要执行的函数。通常,这是 _RemoteRunner 类的 _remoteExecute 静态方法,它用于执行计算任务。
    • _RemoteRunner<R>(computation, resultPort.sendPort):这是 _RemoteRunner 的构造函数,它接受计算任务和发送端口作为参数,用于在隔离体中执行计算并将结果发送回主线程。
    • onErroronExit:分别指定了在隔离体中发生错误和隔离体退出时的处理程序。在这里,它们都使用了 resultPort.sendPort,以便将错误信息发送回主线程。
    • errorsAreFatal: true:指定任何错误都应视为致命错误,即使它们是未捕获的异步错误也应如此。
  6. resultPort.handler 的处理程序:在接收到隔离体中的消息时,这个处理程序会根据消息的内容来决定如何完成 Completer 对象。它可以处理以下情况:

    • 如果消息为 null,表示隔离体结束而没有发送结果,将会通过 result.completeError 报告错误。
    • 如果消息包含两个元素,分别是远程错误和堆栈信息,将会通过 result.completeError 报告错误。
    • 如果消息包含一个元素,将会通过 result.complete 报告结果。
  7. 异常处理:代码中也包含了异常处理部分,以处理可能发生的同步和异步异常。

总的来说,这段代码的目的是在隔离体中执行一个异步计算任务,并在计算完成后将结果或错误信息传递回主线程。这种方法可以保持主线程的响应性,即使在执行耗时计算时也不会阻塞用户界面。

compute

首先,我们通过 compute 函数认识一下计算密集型的耗时任务该如何处理。 compute 函数字如其名,用于处理计算。只要简单看一下,就知道它本身是 Isolate 的一个简单的封装使用方式。它作为全局函数被定义在 foundation/isolates.dart 中:

Future<R> compute<M, R>(ComputeCallback<M, R> callback, M message, {String? debugLabel}) {
return isolates.compute<M, R>(callback, message, debugLabel: debugLabel);
}

可以看出, compute函数调用的是Isolate.run, 是Isolate.run的简单使用。

Future<R> compute<M, R>(isolates.ComputeCallback<M, R> callback, M message, {String? debugLabel}) async {
  debugLabel ??= kReleaseMode ? 'compute' : callback.toString();

  return Isolate.run<R>(() {
    return callback(message);
  }, debugName: debugLabel);
}

下面的代码使用[compute]函数来检查给定的integer是一个素数。

Future<bool> isPrime(int value) {
  return compute(_calculate, value);
}

bool _calculate(int value) {
   if (value == 1) {
     return false;
   }
  for (int i = 2; i < value; ++i) {
     if (value % i == 0) {
        return false;
      }
   }
  return true;
}

在本地平台上await compute(fun, message)相当等待Isolate.run(() => fun(message))

spawn(Isolate 发送和接收消息的使用)

如果你想在 isolate 之间建立更多的通信,那么你需要使用 SendPort 的 send() 方法。下图展示了一种常见的场景,主 isolate 会发送请求消息至 isolate 工作对象,然后它们之间会继续进行多次通信,进行请求和回复。

isolate-custom-bg-worker.png

external static Future<Isolate> spawn<T>(
    void entryPoint(T message), T message,
    {bool paused = false,
    bool errorsAreFatal = true,
    SendPort? onExit,
    SendPort? onError,
    @Since("2.3") String? debugName});

spawn 函数用于在一个新的隔离体中执行指定的函数,并且可以用于传递初始消息以及配置隔离体的行为,例如是否处于暂停状态以及如何处理异常。

const filenames = [
  'json_01.json',
  'json_02.json',
  'json_03.json',
];

void main() async {
  await for (final jsonData in _sendAndReceive(filenames)) {
    print('Received JSON with ${jsonData.length} keys');
  }
}

接收返回的数据流。

生成一个隔离并异步发送一个文件名列表。读取和解码。在发送下一个之前, 等待包含解码JSON的响应。 返回一个流,该流发出每个文件的json解码内容。

Stream<Map<String, dynamic>> _sendAndReceive(List<String> filenames) async* {
  final p = ReceivePort();
  await Isolate.spawn(_readAndParseJsonService, p.sendPort);

  // Convert the ReceivePort into a StreamQueue to receive messages from the
  // spawned isolate using a pull-based interface. Events are stored in this
  // queue until they are accessed by `events.next`.
  final events = StreamQueue<dynamic>(p);

  // The first message from the spawned isolate is a SendPort. This port is
  // used to communicate with the spawned isolate.
  SendPort sendPort = await events.next;

  for (var filename in filenames) {
    // Send the next filename to be read and parsed
    sendPort.send(filename);

    // Receive the parsed JSON
    Map<String, dynamic> message = await events.next;

    // Add the result to the stream returned by this async* function.
    yield message;
  }

  // Send a signal to the spawned isolate indicating that it should exit.
  sendPort.send(null);

  // Dispose the StreamQueue.
  await events.cancel();
}

一个异步生成器函数,它用于与另一个隔离体(Isolate)进行通信,将一组文件名发送给隔离体执行,并逐个接收隔离体发送回的结果。

  1. Stream<Map<String, dynamic>> _sendAndReceive(List<String> filenames) async*:这是一个异步生成器函数,返回一个 Stream,该流会逐个生成包含解析后 JSON 数据的 Map 对象。

  2. final p = ReceivePort();:创建一个接收端口 p,用于与隔离体进行通信。

  3. await Isolate.spawn(_readAndParseJsonService, p.sendPort);:使用 Isolate.spawn 创建一个新的隔离体,将 _readAndParseJsonService 函数作为入口点,并通过 p.sendPort 向新隔离体发送一个通信端口,以便后续与该隔离体通信。

  4. 创建一个 StreamQueue 对象 events,它将用于从接收端口 p 中按需获取消息,直到消息被访问。

  5. 通过 await events.next 获取从隔离体返回的 SendPort,这个 SendPort 用于与隔离体进行后续的通信。

  6. 使用 for-in 循环,遍历传入的文件名列表 filenames

    • 使用 sendPort.send(filename) 向隔离体发送一个文件名,请求隔离体读取和解析该文件。
    • 使用 await events.next 接收从隔离体返回的解析后的 JSON 数据,将其存储在 Map 对象 message 中。
    • 使用 yield message 将解析后的数据作为流的下一个元素进行生成。
  7. 循环结束后,通过 sendPort.send(null) 向隔离体发送 null,以通知隔离体退出。

  8. 最后,使用 await events.cancel() 释放并销毁 StreamQueue,关闭与隔离体的通信。

这段代码实现了一个与隔离体之间的双向通信,并将从隔离体接收到的结果逐个生成为一个异步流。这允许主线程和隔离体之间异步地进行文件处理和数据解析,而不会阻塞主线程。

顶层函数


// The entrypoint that runs on the spawned isolate. Receives messages from
// the main isolate, reads the contents of the file, decodes the JSON, and
// sends the result back to the main isolate.
Future<void> _readAndParseJsonService(SendPort p) async {
  print('Spawned isolate started.');

  // Send a SendPort to the main isolate so that it can send JSON strings to
  // this isolate.
  final commandPort = ReceivePort();
  p.send(commandPort.sendPort);

  // Wait for messages from the main isolate.
  await for (final message in commandPort) {
    if (message is String) {
      // Read and decode the file.
      final contents = await File(message).readAsString();

      // Send the result to the main isolate.
      p.send(jsonDecode(contents));
    } else if (message == null) {
      // Exit if the main isolate sends a null message, indicating there are no
      // more files to read and parse.
      break;
    }
  }

  print('Spawned isolate finished.');
  Isolate.exit();
}

一个运行在派生(spawned)隔离体(Isolate)中的入口点函数 _readAndParseJsonService。它的主要功能是:

  1. 启动后的打印语句 print('Spawned isolate started.') 用于在派生隔离体启动时输出消息,以便进行调试和跟踪。

  2. 创建一个 commandPort,它是一个接收端口(ReceivePort),并通过主隔离体发送给主隔离体的通信端口 p

  3. 使用 await for 循环,等待来自主隔离体的消息。await for 语法用于等待异步流上的消息。

  4. 如果接收到的消息是一个 String,则表示主隔离体要求派生隔离体读取和解析文件。在这种情况下,它执行以下操作:

    • 使用文件路径 message 读取文件的内容,并将文件内容存储在变量 contents 中。
    • 使用 jsonDecode(contents) 将文件内容解析为 JSON 数据,并将解析结果发送回主隔离体,通过 p.send(jsonDecode(contents)) 完成。
  5. 如果接收到的消息为 null,则表示主隔离体通知派生隔离体没有更多文件需要读取和解析。在这种情况下,它会退出循环并结束派生隔离体的执行。

  6. 最后,当循环结束后,它会打印消息 print('Spawned isolate finished.'),表示派生隔离体已完成任务。然后,使用 Isolate.exit() 退出派生隔离体。

在隔离体中进行文件读取和 JSON 解析任务,并通过与主隔离体之间的通信来实现这些任务。派生隔离体通过接收消息并在必要时发送结果来处理主隔离体请求的文件操作。

开子isolate的条件

但是,当你需要进行一个非常复杂的计算时,例如解析一个巨大的 JSON 文档。如果这项工作耗时超过了 16 毫秒,那么你的用户就会感受到掉帧。

为了避免掉帧,像上面那样消耗性能的计算就应该放在后台处理。在 Android 平台上,这意味着你需要在不同的线程中进行调度工作。而在 Flutter 中,你可以使用一个单独的 Isolate

占用内存

One can expect the base memory overhead of an isolate to be in the order of 30 kb.

可以预期,隔离的基本内存开销约为30 kb。

总结

Isolate 工作对象可以进行 I/O 操作、设置定时器,以及其他各种行为。它会持有自己内存空间,与主 isolate 互相隔离。这个 isolate 在阻塞时也不会对其他 isolate 造成影响。

补充1、(双向通信-经典握手)

// main isolate
final mainPort = ReceivePort();
final iso = await Isolate.spawn(workerEntry, mainPort.sendPort);

// 第一次消息:子 Isolate 把自己的 SendPort 回给主 Isolate
late SendPort workerSendPort;
await for (final msg in mainPort) {
  if (msg is SendPort) {
    workerSendPort = msg;
    break;
  }
}

// 现在可以给子 Isolate 发指令了
final replyPort = ReceivePort();
workerSendPort.send(['doWork', {'n': 42}, replyPort.sendPort]);

replyPort.listen((result) {
  print('result from worker: $result');
});

// worker isolate
void workerEntry(SendPort mainSendPort) {
  final workerPort = ReceivePort();
  mainSendPort.send(workerPort.sendPort); // 握手:把子线程的 SendPort 发给主线程

  workerPort.listen((msg) {
    final cmd = msg[0] as String;
    final payload = msg[1];
    final SendPort reply = msg[2];

    if (cmd == 'doWork') {
      final n = (payload as Map)['n'] as int;
      reply.send({'sum': n * (n + 1) ~/ 2}); // 回应
    }
  });
}

这套握手流程就是官方推荐的“两向通道”建立方式。[dart.dev/language/is…]

补充2、请求-应答式「RPC」封装(生产可用)

为便于多请求并发,把每条请求包上一个 id,主线程用 Completer 表来匹配响应。

// main isolate:简易 RPC client
class IsolateRpc {
  final _incoming = ReceivePort();
  late final SendPort _worker;
  int _seq = 0;
  final _waiters = <int, Completer>{};

  Future<void> init() async {
    final iso = await Isolate.spawn(workerEntry, _incoming.sendPort);
    _worker = await _incoming.first as SendPort; // 第一个消息是 worker 的 SendPort
    _incoming.listen((msg) {
      final map = msg as Map;
      final id = map['id'] as int;
      _waiters.remove(id)?.complete(map['data']);
    });
  }

  Future<dynamic> call(String method, dynamic data) {
    final id = ++_seq;
    final c = Completer();
    _waiters[id] = c;
    _worker.send({'id': id, 'method': method, 'data': data, 'reply': _incoming.sendPort});
    return c.future;
  }
}

这个“带 id 的信封协议”对进度多次上报、并发请求特别好用。[api.dart.dev/dart-isolat…]

// worker isolate:简易 RPC server
void workerEntry(SendPort mainSendPort) {
  final port = ReceivePort();
  mainSendPort.send(port.sendPort);

  port.listen((msg) {
    final map = msg as Map;
    final int id = map['id'];
    final String method = map['method'];
    final data = map['data'];
    final SendPort reply = map['reply'];

    dynamic result;
    switch (method) {
      case 'prime':
        result = _isPrime(data as int);
        break;
      default:
        result = {'error': 'unknown method'};
    }
    reply.send({'id': id, 'data': result});
  });
}

bool _isPrime(int n) { /* 计算… */ return true; }

补充3、传大块二进制(零拷贝)

把字节打包为 TransferableTypedData,在 Isolate 间移动而非复制:

// worker: 生成大数据并回传
void workerEntry(SendPort main) {
  final bytes = Uint8List(10 * 1024 * 1024); // 10MB
  // ... 填充
  final t = TransferableTypedData.fromList([bytes]);
  main.send(t); // 移交所有权
  Isolate.exit();
}

// main: 接收并 materialize
final port = ReceivePort();
await Isolate.spawn(workerEntry, port.sendPort);
final t = await port.first as TransferableTypedData;
final data = t.materialize().asUint8List(); // O(1) 发送 + O(n) 构建时

TransferableTypedData 的发送是常数时间,真正耗时发生在创建时。

生命周期与异常处理

  • 退出Isolate.exit([message]) 发送最后一条消息后立即终止;或 isolate.kill(priority: Isolate.immediate)
  • 错误与退出监听addErrorListener / addOnExitListener 可将事件发到指定 SendPort(通常挂在主 Isolate 的 ReceivePort 上收集)。
  • 端口清理:用完 ReceivePort.close(),否则会泄露。

参考资料

Dart 中的并发

send_and_receive

long_running_isolate

计算耗时? Isolate 来帮忙