Асинхронность и многопоточность

Введение в асинхронность и Future

Последнее обновление: 11.04.2023

Язык Dart, как и многие соврменные языки программирования, имеет поддержку асинхронности. Зачем нужна и асинхронность? Возьмем, к примеру, графическое приложение на Android. При запуске приложения на Android оно запускается в основном потоке пользовательского интерфейса. Этот основной поток обрабатывает все события пользовательского интерфейса, в частности, нажатие кнопок, касания экрана и т.д. Мы можем нажимать на кнопки, вводить текст в текстовые поля, и в основном потоке все будет обрабатываться. Однако работа приложений, как правило, этим не огранчивается. Например, приложение для электронной почты может одновременно отправлять запросы по сети - отправлять ранее написанные письма или обращаться к серверу для проверки наличия новых писем. Запрос по сети может занимать большое количество времени, особенно в условиях нестабильной работы интернета. Однако подобные сетевые запросы обычно отправляются таким образом, чтобы не блокировать основной поток пользовательского интерфейса. Если сетевые запросы производились в основном потоке, то пользователю пришлось бы ждать, пока отправится сетевой запрос и будет получен его результат, а во время запроса приложение бы блокировалось и ждало бы окончания запроса. Однако во многих нормальных приложениях так не происходит - мы можем отправить письмо и и тут же начать писать новое письмо без сильных зависаний приложения - потому что сетевой запрос производится асинхронно. А когда он будет выполнен, мы можем просто увидеть некое уведомление. В этом и преимущество применения асинхронности. Причем это касается не только сетевых запросов, а вообще всех тяжеловесных задач в приложении, которые могут занять продолжительное время.

Что касается непосредственно языка Dart, то это однопоточный язык - он может выполнять только одну задачу в одно время. Тем не менее благодаря реализации цикла событий (event loop) и двух очередей событий (event queue и microTask queue) он позволяет асинхронно выполнять различные задачи.

Очередь MicroTask предназначена для хранения небольших внутренних задач - микрозадач и основном используется внутри Dart.

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

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

Единственный поток приложения инициализирует две очереди - MicroTask и Event, которые будут содержать задачи, которые необходимо будет выполнить. Далее поток запускает функцию main() и прежде всего выполняет в нем все синхронные задачи. Синхронные задачи в основном потоке всегда выполняются немедленно. Если Dart встречает долговыполняемую задачу, выполнение которой можно отложить, то она помещается в очередь событий Event Queue.

Когда Dart завершает выполнение синхронных задач, цикл событий (Event Loop) проверяет очередь микрозадач - Microtask Queue. Если эта очередь имеет какие-нибудь задачи, то цикл событий помещает их в основной поток для последующего выполнения.

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

Если в очередь Microtask Queue поступит новая микрозадача, то цикл событий выполняет ее до любой последующей задачи из очереди Event Queue.

Этот процесс продолжается до тех пор, пока очереди не станут пустыми.

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

Класс Future

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

Объект Future может находиться в двух состояниях: незавершенном (Uncompleted) и завершенном (Completed). В незавершенном состоянии операция, которую представляет объект Future, возможно, уже начала выполняться, но результат еще не получен. В завершенном состоянии операция уже завершила свое выполнение, ее результат - некое значение или ошибка - получен.

Рассмотрим простейший пример:

Future getMessage() {
  // для эмуляции длительной операции делаем задержку в 3 секунды
  return Future.delayed(Duration(seconds: 3), () => print("Пришло новое сообщение от Тома"));
}

void main() {
  getMessage();
  print("Проверка сообщений...");
}

Итак, здесь определена функция getMessage, который возвращает объект Future. В этой функции вызывается один из именнованных конструкторов класса Future - Future.delayed(). Данный конструктор принимает два параметра. Первый параметр - это объект Duration, который устанавливает время задержки (Duration(seconds: 3)) в данном случае три секунды. Второй параметр - это некоторая функция, которая выполняет какие-либо действия - в данном случае это просто вывод сообщения на консоль.

() => print("Пришло новое сообщение от Тома")

Таким образом, через три секунды на консоль будет выведена строка "Пришло новое сообщение от Тома". Это по сути и есть отложенная операция, которую представляет объект Future.

В функции main мы вызываем функцию getMessage и затем выводим некоторое сообщение. Обратите внимание,что функция getMessage вызывается первой. Однако консольный вывод программы будет следующим:

Проверка сообщений...
Пришло новое сообщение от Тома

Фактически мы видим, что функция getMessage завершается только после завершения всех остальных действий в функци main. То есть функция getMessage, которая выполняется 3 секунды, выполняется асинхронно. При вызове функция getMessage возвращает объект Future, который находится в незавершенном состоянии. И только через три секунды после выполнения функции из конструктора Future.delayed он перейдет в завершенное состояние.

Механика работы Future такова, что при определении нового объекта Future, операция, которую он представляет (например, в данном случае получение сообщения), попадает в event queue. Потом после выполнения функции main в цикле Event Loop данная операция извлекается из Event Queue и выполняется.

Конструкторы Future

Для создания объекта Future можно использовать один из его конструкторов:

  • Future(FutureOr<T> computation()): создает объект future, который с помощью метода Timer.run запускает функцию computation асинхронно и возвращает ее результат.

    Тип FutureOr<T> указывает, что функция computation должна возвращать либо объект Future<T> либо объект типа T. Например, чтобы получить объект Future<int>, функция computation должна возвращать либо объект Future<int>, либо объект int

  • Future.delayed(Duration duration, [FutureOr<T> computation()]): создает объект Future, который запускается после временной задержки, указанной через первый параметр Duration. Второй необязательный параметр указывает на функцию, которая запускается после этой задержки.

  • Future.error(Object error, [StackTrace stackTrace]): создает объект Future, который содержит информацию о возникшей ошибке.

  • Future.microtask(FutureOr<T> computation()): создает объект Future, который с помощью функции scheduleMicrotask запускает функцию computation асинхронно и возвращает ее результат.

  • Future.sync(FutureOr<T> computation()): создает объект Future, который содержит результат немедленно вызываемой функции computation.

  • Future.value([FutureOr<T> value]): создает объект Future, который содержит значение value.

Применение некоторых конструкторов. Первый конструктор:

void main() {
  Future myFuture = Future(() => print("Hello Future"));
  
  print("Main ends");
}

Здесь в конструктор Future передается анонимная функция, которая не принимает параметров и ничего не возвращает, а просто выводит некоторое сообщение. Хотя здесь применяется анонимная функция, но мы также можем сделать ее полноценной функцией. Консольный вывод:

Main ends
Hello Future

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

Применение именнованого конструктора Future.delayed() аналогично за тем исключением, что он ожидает определенное время (заданное первым параметром) перед тем, как перейти к выполнению функции из второго параметра:

void main() {
  Future future = Future.delayed(Duration(seconds: 3), () => print("Hello Future"));
  print("Main ends");
}

Применение конструктора Future.value(), который может использоваться, если уже известно значение, которое будет содержать объект Future. Этот конструктор может быть полезен, в частности, при создании веб-сервисов, которые используют кэширование.

Future future = Future.value(35);

В данном случае Future получает число 35 (хотя это может быть любой объект). И затем в программе мы сможем получить это значение из future.

Применение конструктора Future.error(), который принимает объект ошибки и необязательный параметр - стек трассировки:

Future future = Future.error(ArgumentError.notNull("input"));

В данном случае в качестве объекта ошибки используется выражение ArgumentError.notNull("input"), которое говорит, что аргумент input не должен быть равен null.

Получение значения

В реальности тип Future - это generic-тип или обобщенный тип, который типизируется определенным типом - Future<T>. T в угловых скобках как раз и представляет тип значения, которое несет Future.

Возьмем один из случаев выше:

void main() {
  Future myFuture = Future(() => print("Hello Future"));
  
  print("Main ends");
}

Здесь функция, которая передается в конструктор, ничего не возвращает, а просто выводит некоторое сообщение на консоль. Поэтому в реальности здесь мы получаем не просто объект Future, а Future<void>. То есть мы могли бы также написать:

Future<void> myFuture = Future(() => print("Hello Future"));

Изменим функцию - пусть теперь она возвращает строку:

Future<String> myFuture = Future(() {
	return "Hello Future";
});

Поскольку функция возвращает строку, то объект Future будет содержать объект String и будет представлять тип Future<String>.

То же самое касается и других конструкторов:

void main() {
	Future<double> future1 = Future(() {
		return 23.5;	// возвращает число double
	});
	Future<String> future2 = Future.delayed(Duration(seconds: 3), 
						() => "Hello Future");		// возвращает строку
	Future<int> future3 = Future.value(35);			// хранит число int
	Future<String> future4 = Future.value("Hello Dart");	// хранит строку
  
  print("Main ends");
}
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850