Запуск параллельного кода во Flutter с изолятами

Запуск параллельного кода во Flutter с изолятами

18 февраля 2023 г.

Мне всегда было интересно, какой набор кода запустить в фоновом режиме, чтобы сделать мое приложение мощным и отзывчивым, но я не знаю, как это сделать. Некоторое время назад я узнал об изолятах и ​​попытался их реализовать. И я должен сказать вам, это было болезненно. Но недавно я обнаружил, насколько легко это стало. Так вот.

Возможно, вы слышали об изолятах, но никогда не понимали. Или, может быть, вы внедрили изоляты, но код всегда был грязным и утомительным для написания. В любом случае, этот блог проведет вас через взлеты и падения истории Isolate, а также текущую и лучшую реализацию. Возможно, вы захотите использовать новейший метод или, в конце концов, захотите использовать старый метод, все зависит от вашего варианта использования.

Основы

Вот как документация Flutter определяет изоляты< /p>

<цитата>

Изолированный контекст выполнения Dart.

Вы что-нибудь поняли? По крайней мере, я этого не сделал. Итак, давайте начнем понимать изоляты, а затем напишем собственное определение. Итак, что нам нужно для рецепта изолятов?

  1. Что такое изоляты?
  2. Зачем они нам нужны?
  3. Что такое обработка событий?
  4. Как внедрить изоляты?
  5. И, наконец, что такое изолированные группы?

Мы начнем с общей картины изолятов и посмотрим, что они на самом деле означают, а затем углубимся и соберем все части воедино, чтобы увидеть, как каждая часть работает вместе, чтобы мы могли понять, что на самом деле делают изоляты и почему. они нам нужны?

Что такое изоляты?

Чтобы действительно понять изоляты, сначала нам нужно вернуться еще дальше и убедиться, что мы знаем ответы на эти 2 вопроса:

  1. **В чем разница между процессорными ядрами и потоками? n **Ядро — это физический аппаратный компонент, тогда как поток — это виртуальный компонент, который управляет задачами ядра. Ядра позволяют выполнять больше работы за раз, а потоки повышают скорость вычислений и пропускную способность. Ядра используют переключение контента, а потоки используют несколько процессоров для выполнения разных процессов.
  2. **В чем разница между параллельной и параллельной обработкой? n ** Параллелизм — это когда две или более задач могут запускаться, выполняться и завершаться в перекрывающиеся периоды времени. Это не обязательно означает, что они когда-либо будут работать одновременно. Например, многозадачность на одноядерной машине. Параллелизм — это когда задачи выполняются буквально одновременно, например, на многоядерном процессоре.

Вернемся к изолятам.

Dart использует изолированную модель для параллелизма. Изолировать — это не что иное, как обертка вокруг потока. Но потоки по определению могут совместно использовать память, что может быть легко для разработчика, но делает код подверженным условиям гонки и блокировкам. Изоляты, с другой стороны, не могут совместно использовать память и вместо этого полагаются на механизм передачи сообщений для общения друг с другом. Если что-то сложно понять, продолжайте читать. Я уверен, вы получите это.

Используя изоляты, код Dart может выполнять несколько независимых задач одновременно, используя дополнительные ядра, если они доступны. Каждый Isolate имеет свою собственную память и один поток, выполняющий цикл обработки событий. Через минуту мы перейдем к циклу событий.

Зачем нужны изоляты?

Прежде чем мы углубимся в детали, нам нужно понять, как на самом деле работает async-await.

void main() async {
  // Read some data.
  final fileData = await _readFileAsync();
  final jsonData = jsonDecode(fileData);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

Future<String> _readFileAsync() async {
  final file = File(filename);
  final contents = await file.readAsString();
  return contents.trim();
}

Мы хотим прочитать некоторые данные из файла, а затем декодировать этот JSON и распечатать длину ключей JSON. Нам не нужно вдаваться в детали реализации, но мы можем воспользоваться изображением ниже, чтобы понять, как это работает.

Когда мы нажимаем на эту кнопку Place Bid, она отправляет запрос в _readFileAsync, который представляет собой написанный нами код дротика. Но эта функция _readFileAsync выполняет код с использованием виртуальной машины/ОС Dart для выполнения операции ввода-вывода, которая сама по себе является другим потоком, потоком ввода-вывода. Это означает, что код основной функции выполняется внутри основного изолята. Когда код достигает _readFileAsync, он передает выполнение кода в поток ввода-вывода, а основной изолятор ожидает, пока код не будет полностью выполнен или не произойдет ошибка. Это то, что делает ключевое слово await.

Basic await function

Теперь, когда содержимое файлов прочитано, элемент управления возвращается к основному изоляту, и мы начинаем анализировать данные String как JSON и печатать количество ключей. Это довольно прямолинейно. Но давайте предположим, что синтаксический анализ JSON был очень большой операцией, учитывая очень большой JSON, и мы начинаем манипулировать данными, чтобы они соответствовали нашим потребностям. Затем эта работа происходит на Главном Изоляте. В этот момент пользовательский интерфейс мог зависнуть, что раздражало наших пользователей.

Что такое обработка событий?

Как мы уже говорили, Isolate является оболочкой потока, и каждый Isolate имеет цикл обработки событий, выполняющий события. Эти события не что иное, как то, что происходит, когда мы используем приложение. Эти события добавляются в очередь, которую затем принимает и обрабатывает цикл событий. Эти события обрабатываются в порядке поступления. Это изображение ниже является лишь примером.

Basic event loop

Давайте снова воспользуемся этим кодом для понимания обработчиков событий. Мы уже знаем, что происходит в этом блоке.

void main() async {
  // Read some data.
  final fileData = await _readFileAsync();
  final jsonData = jsonDecode(fileData);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

Future<String> _readFileAsync() async {
  final file = File(filename);
  final contents = await file.readAsString();
  return contents.trim();
}

Наши приложения запускаются, и он отрисовывает пользовательский интерфейс (событие Paint), помещая его в очередь. Мы нажимаем кнопку Place Bid, и запускается код обработки файлов. Таким образом, событие Tap помещается в очередь. После завершения, давайте предположим, что пользовательский интерфейс обновлен, поэтому событие рисования снова помещается в очередь.

Теперь, поскольку наша логика для обработки файла и JSON была очень маленькой, пользовательский интерфейс не заикался и не дергался. Но давайте на время снова представим, что наш код для работы с файлом огромен и занимает много времени. Теперь очередь событий и цикл обработки событий выглядят примерно так, как показано на изображении ниже.

Теперь, когда основному изоляту требуется много времени для фактической обработки этого события, наша анимация или пользовательский интерфейс могут зависать и раздражать ваших пользователей, вызывая огромные потери. Именно здесь появляется новый изолят или рабочий изолят.

Как внедрить изоляты?

Весь наш код дротика в приложении флаттера работает изолированно. Будет ли это основной изолят или рабочий изолят, решать вам. Основной изолят создан для вас, и вам больше ничего здесь делать не нужно. Основная функция начинается с основного изолятора. Как только наша основная функция будет запущена, мы можем начать создавать новые изоляты.

Таким образом, есть 2 способа реализации изолятов: короткий и новый метод или длинный и старый метод. Мы можем использовать любой из них в зависимости от варианта использования.

Начнем с уже существующего метода.

Как мы уже говорили, изоляты, в отличие от потоков, не разделяют память. Это сделано для предотвращения состояния гонки и блокировок. Но связь между изолятами осуществляется с помощью передачи сообщений. Эти сообщения являются примитивными, и вы можете просмотреть весь список объектов, которые могут передаваться между изолятами здесь.

Для передачи сообщений Dart предоставляет нам порты. SendPort и ReceivePort.

Поскольку мы обсуждаем старый метод создания изолятов, нам нужно знать, что методы изоляции должны быть высокоуровневыми или статическими функциями.

Вот ссылка на код, если вы хотите продолжить.

Future<String> startDownloadUsingOldIsolateMethod() async {
  const String imageDownloadLink = 'this is a link';
  // create the port to receive data from
  final resultPort = ReceivePort();
  // spawn a new isolate and pass down a function that will be used in a new isolate
  // and pass down the result port that will send back the result.
  // you can send any number of arguments.
  await Isolate.spawn(
    _readAndParseJson,
    [resultPort.sendPort, imageDownloadLink],
  );
  return await (resultPort.first) as String;
}

Что делает этот код:

  1. Здесь мы создаем экземпляр RecievePort для получения данных. Помните, что это старый метод создания изолятов. Это может быть немного длинно, но необходимо знать детали.
  2. Мы создаем изоляцию рабочего процесса на основной изоляции с помощью Isolate.spawn и передаем функцию верхнего уровня, которая запускает блокирующий код. Мы также передаем список аргументов, первый — SendPort, который будет использоваться для отправки данных из рабочего Isolate, а второй — ссылка для скачивания. Мы ждем, пока не появится новый изолят.
  3. Затем мы ждем результата, который представляет собой строку, и используем ее по своему усмотрению. Эти данные могут быть любыми из этих список объектов.
  4. ResultPort.first использует подписку на поток за экраном и ожидает, пока данные из рабочего изолята будут переданы на него. Как только приходит первый элемент, мы возвращаем результат.

n Это функция _readAndParseJson, которая получает аргумент и запускает код изоляции рабочего процесса. Это фиктивная функция, которая ничего не делает, кроме как задерживает управление на 2 секунды, а затем завершает работу. Функция выхода синхронно завершает текущий изолят. Перед отправкой данных обратно в вызывающий изолят выполняются определенные проверки, и данные отправляются обратно с использованием SendPort.

// we create a top-level function that specifically uses the args
// which contain the send port. This send port will actually be used to
// communicate the result back to the main isolate

// This function should have been isolate-agnostic
Future<void> _readAndParseJson(List<dynamic> args) async {
  SendPort resultPort = args[0];
  String fileLink = args[1];

  String newImageData = fileLink;

  await Future.delayed(const Duration(seconds: 2));

  Isolate.exit(resultPort, newImageData);
}

Хотя это работает правильно, мы не обработали какие-либо ошибки, которые могут быть вызваны рабочим изолятом, или любые ошибки, которые могут возникнуть при создании нового изолята.

// Error Handling
Future<String> startDownloadUsingOldIsolateMethodWithErrorHandling() async {
  const String imageDownloadLink = 'this is a link';
  // create the port to receive data from
  final resultPort = ReceivePort();
  // Adding errorsAreFatal makes sure that the main isolates receives a message
  // that something has gone wrong
  try {
    await Isolate.spawn(
      _readAndParseJson,
      [resultPort.sendPort, imageDownloadLink],
      errorsAreFatal: true,
      onExit: resultPort.sendPort,
      onError: resultPort.sendPort,
    );
  } on Object {
    // check if sending the entrypoint to the new isolate failed.
    // If it did, the result port won’t get any message, and needs to be closed
    resultPort.close();
  }

  final response = await resultPort.first;

  if (response == null) {
    // this means the isolate exited without sending any results
    // TODO throw error
    return 'No message';
  } else if (response is List) {
    // if the response is a list, this means an uncaught error occurred
    final errorAsString = response[0];
    final stackTraceAsString = response[1];
    // TODO throw error
    return 'Uncaught Error';
  } else {
    return response as String;
  }
}

Здесь все почти так же, мы просто добавили сюда обработку ошибок.

Что делает этот код:

  1. Мы добавляем для errorAreFatal значение true при создании нового изолята, чтобы убедиться, что основной изолят знает о любых ошибках. Мы назначаем SendPort для обработчиков onExit и onError, чтобы исключить любые ошибки, возникающие при выходе или порождении.
  2. Мы также добавляем блок try-catch при создании нового изолята, чтобы гарантировать, что если во время создания возникнет какая-либо ошибка, мы перехватим ее и полностью остановим эту операцию.
  3. Если порождение прошло успешно и какие-то данные получены от рабочего изолята, нам нужно проверить, является ли это ошибкой или нет.
  4. Если сообщение, отправленное обратно, пустое, это означает, что изоляция завершилась без какого-либо сообщения и произошла ошибка. Если ответ представляет собой список, это означает, что рабочий процесс Isolate отправил обратно ошибку и трассировку стека. В противном случае это успешная транзакция.

Это кажется излишним, если мы хотим сделать только одноразовую передачу сообщений. Одно сообщение и закрыть Изолировать. Каждый раз, когда вы хотите создать новый изолят, вам придется снова писать один и тот же код. Так как логика изоляции довольно стандартна. Каждый раз вы можете захотеть передать несколько разных аргументов, и это будет очень утомительно. Вот почему был разработан новый метод для разовых транзакций.

Новый метод: Isolate.run

// Isolates with run function
Future<String> startDownloadUsingRunMethod() async {
  final imageData = await Isolate.run(_readAndParseJsonWithoutIsolateLogic);
  return imageData;
}

Future<String> _readAndParseJsonWithoutIsolateLogic() async {
  await Future.delayed(const Duration(seconds: 2));
  return 'this is downloaded data';
}

Это все, что нужно для нового метода.

Мы создаем новый Isolate, используя метод run, который абстрагирует все детализированные детали и обработку ошибок и экономит вам много времени. Это помогает в порождении, обработке ошибок, передаче сообщений и завершении изоляции с помощью этих нескольких небольших строк кода.

== Здесь следует отметить, что функция _readAndParseJsonWithoutIsolateLogic не содержит какой-либо пользовательской логики для изоляции. Нет портов, нет аргументов.==

Когда использовать новый метод Run, а когда старый метод spawn?

В приведенных выше примерах показана передача сообщений только один раз. Поэтому следует использовать метод запуска. Это значительно сокращает строки кода и тестовые примеры.

Но если вы хотите создать что-то, что требует передачи нескольких сообщений между изолятами, нам нужно использовать старый метод Isolate.spawn(). Примером этого может быть, когда вы начинаете загрузку файла на рабочем изоляте и хотите показать ход загрузки в пользовательском интерфейсе. Это означает, что счетчик выполнения необходимо передавать снова и снова.

При этом нам нужно реализовать весь SendPort и ReceivePort для передачи сообщений и пользовательскую логику для получения аргументов и отправки хода выполнения обратно в основной изолятор.

Что такое изолированные группы?

Итак, мы уже знаем, как Isolates передают сообщения друг другу. Но давайте предположим, что сообщение, которое мы передаем, представляет собой огромный JSON. До Dart 2.15 прохождение этого огромного объекта могло вызывать заикание в пользовательском интерфейсе. Это потому, что мы уже знаем, что у Isolate есть некоторая память, и когда один Isolate передает объект другому, этот объект должен быть глубоко скопирован. Это означало, что на копирование объекта в основной изолятор уходит много времени, что может привести к зависанию.

Чтобы избежать этого обстоятельства, Изоляты были переработаны и придуманы Группы Изолятов. Группы изолятов, то есть группы изолятов, которые имеют некоторые общие внутренние структуры данных, представляющие работающее приложение. Это означает, что каждый раз, когда создается новый Isolate, новые внутренние структуры данных не нужно создавать снова. Потому что они делятся ими вместе.

Не путайте эти внутренние структуры данных с изменяемыми объектами. Изоляты до сих пор не могут поделиться этой памятью друг с другом. Передача сообщений по-прежнему необходима. Но поскольку изоляты в одной и той же группе изолятов используют одну и ту же кучу, это означает, что создание нового изолята происходит в 100 раз быстрее и потребляет в 10–100 раз меньше памяти.

Примером может служить изолят рабочего процесса, который выполняет сетевой вызов для получения данных, анализирует эти данные в большой граф объектов JSON, а затем возвращает этот граф JSON в основной изолят. До Dart 2.15 этот результат нужно было глубоко копировать, что само по себе могло вызвать зависание пользовательского интерфейса, если копирование занимало больше времени, чем бюджет кадра. Это означает, что основной Isolate может получить этот JSON практически за постоянное время. А отправка сообщений теперь примерно в 8 раз быстрее.

Хорошая новость заключается в том, что если вы используете версию Flutter выше 2.8, вам не нужно ничего делать, чтобы использовать эти улучшения.


<цитата>

Надеюсь, вам понравилось понимание изолятов. Если у вас есть какие-либо сомнения, пожалуйста, прокомментируйте.

Ссылка на этот код:

https://github.com/DhruvamSharma/NFT-Material3?embedable=true

:::информация Также опубликовано здесь.

:::


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE