Основы Future и EventLoop

1. Процессы и Потоки

Асинхронное программирование стало неотъемлемой частью современной разработки, особенно важной для создания отзывчивых пользовательских интерфейсов и эффективной обработки данных.

Dart, будучи однопоточным языком, эффективно справляется с долгими операциями, не блокируя пользовательский интерфейс. Мы изучим ключевые концепции, такие как Future, async/await, Stream и Isolate.

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

Ключевой момент заключается в том, что Dart в своей основе является однопоточным языком. Это означает, что весь ваш Dart-код по умолчанию выполняется в одном-единственном потоке. Но как же тогда выполнять "тяжелые" операции, не замораживая приложение? Ответ кроется в асинхронности и изолятах.

2. Что такое асинхронность в Dart

Асинхронность работает в рамках ОДНОГО потока и не подразумевает одновременное выполнение кода. Это возможность не ждать выполнения трудоемкой операции сейчас, а получить результат в будущем, не блокируя другой код.

Аналогия с загрузкой картинки: Представьте, что вы начинаете загружать большую картинку из интернета. В синхронном случае ваше приложение “замирает” и ждет завершения загрузки, прежде чем выполнить следующую строку кода. В асинхронном случае загрузка картинки передается операционной системе и помещается в специальную очередь Event Loop, а ваш код продолжает выполняться. Когда ОС завершит загрузку и подойдет очередь в Event Loop, картинка станет доступной для использования.

Dart - Асинхронная загрузка картинки
Светлая тема Темная тема
Future getDataAsync() async {
  // Трудоемкая операция, нужно много времени
  var data = await HackingServer(); 
}

getData() {
  // Трудоемкая операция, нужно много времени
  var data = HackingServer(); 
}


print('hello');
getData(); // * Синхронная операция
print('Ok');

// В синхронном случае, на экран попадёт 'hello' и начнётся обработка получения с сервера. 
// Всё приложение замирает и ждёт конца выполнения. И только потом на экран попадёт 'Ok'

print('hello');
getDataAsync() // * Асинхронная операция
print('Ok');

// В Асинхронном случае, на экран сразу попадёт 'hello' 'Ok'
// А выполнение тяжелой операции выполняется в фоне и приложение не зависает

Рассмотрим пример работы синхронной и асинхронной операции в приложении.

1. При нажатии на кнопку Синхронная загрузка начинается выполнение тяжёлой операции, весь UI зависает, ничего не возможно сделать, пока условно не загрузится картинка. Тыкаем на чекбокс, увеличивает счётчик и ничего не происходит.

sync

2. При нажатии на кнопку Асинхронная загрузка, UI не замораживается и можно продолжать с ним взаимодействовать без проблем.

async

3. Future: Обещание результата

Future — это центральный объект в асинхронном Dart. Он представляет собой "обещание" на получение результата какой-либо операции в будущем.

У Future есть три состояния:

  1. Незавершенное (Uncompleted): Когда вы вызываете асинхронную функцию, она сразу возвращает Future в этом состоянии. Операция еще выполняется.
  2. Завершенное со значением (Completed with a value): Операция успешно выполнена, и Future содержит результат (например, данные из файла).
  3. Завершенное с ошибкой (Completed with an error): Во время операции произошла ошибка, и Future содержит информацию об этой ошибке.
Dart
Светлая тема Темная тема
void main() {
  
  // "Тяжелая" асинхронная функция
  // Эта функция сразу вернет объект Future в состоянии "незавершенное". 
  Future fetchUserData() {
    // Через 5 секунд Future завершится со значением объекта Map.
    return Future.delayed(
      Duration(seconds: 5),
      () => {'name': 'Роман', 'fav': 'Окрошка'},
    );
  }
          
  // "Тяжелая" синхронная функция
  syncFunc() {
    for (var i = 0; i < 10000000000; i++) { var res = i * i * i * i; }
  }
          
          
  print('ДО вызова fetchUserData'); 
  
  // Чтобы получить значение, нужно дождаться завершения Future
  fetchUserData().then((userData) {
    print('Имя: ${userData['name']}');
    print('Любимое блюдо: ${userData['fav']}');
  });
          
  print('ПОСЛЕ вызова fetchUserData'); 
  print("Делаем что-то ещё... пока ждём данные...");
}
then

4. Использование async await

Работать с Future через метод .then() может быть громоздко. Для этого в Dart есть ключевые слова async и await — синтаксический сахар, который позволяет писать асинхронный код так, будто он синхронный.

await — это как сказать: "Я подожду здесь, пока не придет посылка (Future), а потом продолжу".

Ещё одна прекрасная аналогия для асинхронности — это процесс готовки. Представим, что мы делаем окрошку.

  1. Синхронный подход: Вы поставили вариться картошку и просто стоите у плиты все 15 минут, неотрывно наблюдая за кастрюлей. Вы больше ничего не делаете. В это время задачи по нарезке огурцов, лука и варке яиц простаивают. Весь процесс готовки заблокирован ожиданием одного — пока сварится картофель.
  2. Асинхронный подход: Вы ставите вариться картошку и заводите таймер на 15 минут. Сразу после этого ставите вариться яйца в другой кастрюле и заводите второй таймер на 10 минут. Пока оба таймера тикают, вы не ждете, а беретесь за другую работу — нарезаете огурцы и лук. Когда сработает таймер для яиц, вы прерываетесь, чтобы заняться ими. Затем, когда сварится картофель, вы переключаетесь на него. Таким образом, вы эффективно использовали время ожидания.»

Пример с приготовлением окрошки

Dart
Светлая тема Темная тема
// Это быстрая синхронная работа  
void chopIngredients() {  
  print('🥒 Начинаю нарезать огурцы, лук и колбасу...');  
  print('✅ Все ингредиенты нарезаны!');  
}  
  
// Future.delayed() НЕ БЛОКИРУЕТ поток, а создает "обещание" (Future)  
Future boilPotatoesAsync() async {  
  print('🥔 Пока картошка вариться (таймер 15 мин)... делаем другие дела.');  
  await Future.delayed(Duration(milliseconds: 4500));  
  print('🔔 Сработал таймер! Картошка сварилась!');  
}  
  
Future boilEggsAsync() async {  
  print('🥚 Пока яйца вариться (таймер 10 мин)... делаем другие дела.');  
  await Future.delayed(Duration(milliseconds: 4000));  
  print('🔔 Сработал таймер! Яйца сварились!');  
}  
  
void mixIngredients() {  
  print('🥣 Все ингредиенты готовы. Начинаю смешивать окрошку!');  
  print('🎉 Окрошка готова!');  
}  
  
void main() {  
  // 1. ЗАПУСКАЕМ обе долгие операции ОДНОВРЕМЕННО.  
  // Не используем 'await' здесь, чтобы не ждать их завершения.  
  // Мы получаем "обещания" (Futures), что они когда-нибудь завершатся.  
  final potatoFuture = boilPotatoesAsync();  
  final eggFuture = boilEggsAsync();  
  
  // 2. ПОКА картошка и яйца варятся, мы НЕ ЖДЕМ, а выполняем другую работу.  
  // Наш основной поток не заблокирован!  
  chopIngredients();  
  
  // 3. ТЕПЕРЬ, когда вся быстрая работа сделана, нам нужно дождаться  
  // завершения всех долгих операций, чтобы смешать ингредиенты.  
  // Используем Future.wait для ожидания завершения всех Future.
  Future.wait([potatoFuture, eggFuture]).then((_) {  
    // Выполнится ТОЛЬКО после того, как potatoFuture и eggFuture завершатся.  
    mixIngredients();  
  });  
  
  // Этот принт может появиться до mixIngredients  
  print('Ожидаем завершения приготовления...');  
  
}
Вывод
Светлая тема Темная тема
🥔 Пока картошка вариться (таймер 15 мин)... делаем другие дела.
🥚 Пока яйца вариться (таймер 10 мин)... делаем другие дела.
🥒 Начинаю нарезать огурцы, лук и колбасу...
Ожидаем завершения приготовления...

🔔 Сработал таймер! Яйца сварились!
🔔 Сработал таймер! Картошка сварилась!
🥣 Все ингредиенты готовы. Начинаю смешивать окрошку!
🎉 Окрошка готова!
await

5. Модель выполнения в Dart: Event Loop

Вспомним подходы к приготовлению окрошки в наших прошлых примерах

  1. Синхронный подход: Вы поставили вариться картошку и просто стоите у плиты все 15 минут, неотрывно наблюдая за кастрюлей. Вы больше ничего не делаете. В это время задачи по нарезке огурцов, лука и варке яиц простаивают. Весь процесс готовки заблокирован ожиданием одного — пока сварится картофель.
  2. Асинхронный подход: Вы ставите вариться картошку и заводите таймер на 15 минут. Сразу после этого ставите вариться яйца в другой кастрюле и заводите второй таймер на 10 минут. Пока оба таймера тикают, вы не ждете, а беретесь за другую работу — нарезаете огурцы и лук. Когда сработает таймер для яиц, вы прерываетесь, чтобы заняться ими. Затем, когда сварится картофель, вы переключаетесь на него. Таким образом, вы эффективно использовали время ожидания.»

Как Dart управляет этими "отложенными" задачами? С помощью механизма под названием Event Loop (Цикл Событий).

Когда запускается Dart-приложение, создается главный Изолят (о нем позже), который инициализирует две очереди:

  1. Event Queue (Очередь событий) — для внешних событий: операции ввода-вывода, жесты, таймеры, сетевые запросы. Сюда попадают все Future.
  2. Microtask Queue (Очередь микрозадач) — для очень коротких внутренних действий, которые нужно выполнить немедленно после текущей операции, но до того, как обработать следующее событие.

Очередь Microtask всегда имеет приоритет перед очередью Event.

Цикл Event Loop работает непрерывно по простому алгоритму:

Dart
Светлая тема Темная тема
// Псевдокод, объясняющий логику Event Loop
void eventLoop() {
  // Пока приложение работает, цикл повторяется
  while (true) {
    // 1. Сначала проверяем очередь микрозадач.
    // Если не пуста, выполняем самую старую микрозадачу и начинаем цикл заново.
    if (microtaskQueue.isNotEmpty) {
      executeFirstMicrotask();
      continue; // Возвращаемся к началу, чтобы снова проверить Microtask Queue
    }

    // 2. Только если очередь микрозадач ПУСТА, смотрим в очередь событий.
    if (eventQueue.isNotEmpty) {
      executeFirstEvent();
    }
  }
}

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

Теперь давайте на основе аналогии про готовку окрошки разложим по полочкам, как работает Event Loop в Dart.

Как это работает вместе

event-loop-1
event-loop-2
event-loop-3
event-loop-4
event-loop-5
event-loop-6
event-loop-7
event-loop-8
event-loop-9
event-loop-10
event-loop-10
  1. Начало: Повар (основной поток) начинает выполнять рецепт (функцию main()).
    event-loop-1
  2. Запуск асинхронной задачи: Пункт "сварить картошку". Он дает команду плите, ставит таймер на 15 минут (Future) и сразу же идет дальше по рецепту. Он не ждет перед плитой пока сварится картошка
  3. Тоже самое для пункта "Сварить яйца"
    event-loop-2 event-loop-3
  4. Выполнение синхронного кода: Теперь поток свободен, и повар начинает резать огурцы и лук (выполняет синхронный код, который идет после вызова асинхронной функции).
    event-loop-4 event-loop-5
  5. Событие: Через 10 минут звенит таймер для яиц. Это событие (Event) попадает в Event Queue. (Будет 1й в очереди)
  6. Событие: Через 15 минут звенит таймер для картошки. Это событие (Event) попадает в Event Queue. (Будет 2й в очереди)
    event-loop-6 event-loop-7
  7. Обработка события: Как только повар закончит нарезать текущий огурец (завершит текущую синхронную операцию), нужно проверить очередь микротасков и очередь событий. В очереди событий сейчас ждут два действия, но в это момент выполняется микротаска "Добавить соль", а очередь микротасков имеет больший приоритет, поэтому выполняется сначала действие от туда
    event-loop-8
  8. Теперь Event Loop проверит очередь событий. Он увидит событие "яйца сварились", возьмет его из очереди и начнет выполнять соответствующий код, тоже самое для события "картошка сварилась."
    event-loop-9 event-loop-10
  9. Теперь когда оба асинхронных события выполнились, можно запустить синхронное событие "Дорезать и перемешать"

    В коде это место

    Dart
    Светлая тема Темная тема
    Future.wait([potatoFuture, eggFuture]).then((_) {  
      // Выполнится ТОЛЬКО после того, как potatoFuture и eggFuture завершатся.  
      mixIngredients();  
    });
    
    event-loop-11

Эта аналогия идеально ложится на модель Dart: один "повар" (поток) эффективно управляет множеством задач, делегируя долгие операции "плите" (ОС) и реагируя на "звонки таймеров" (Future'ы, попадающие в Event Queue), в то время как в свободные промежутки выполняет быструю работу (синхронный код).

6. Обработка ошибок

В асинхронном коде ошибки — обычное дело (например, нет интернет-соединения). Их нужно правильно обрабатывать.

1. С async / await — блок try-catch

Cамый естественный и читаемый способ.

Dart
Светлая тема Темная тема
Future boilPotatoesAsync() async {
  try {
    print('🥔 Пока картошка варится (таймер 15 мин)... делаем другие дела.');
    await Future.delayed(Duration(milliseconds: 20000));
    print('🔔 Сработал таймер! Картошка сварилась!');
  } catch (e) {
    print('Ошибка: $e');
  } finally {
    print('➡️ Не забудьте выключить плиту!');
  }
}

Future boilPotatoesAsyncError() async {
  try {
    print('🥔 Пока картошка варится (таймер 15 мин)... делаем другие дела.');

    // Имитируем операцию, которая завершится ошибкой
    await Future.delayed(
      Duration(milliseconds: 20000), 
      () => throw 'Света нет! Картошка не сварилась!'
    );
    print('🔔 Сработал таймер! Картошка сварилась!');

  } catch (e) {
    // Ловим ошибку, выброшенную из Future
    print('💥 Ошибка: $e');
  } finally {
    // Этот блок выполнится всегда, независимо от ошибки
    print('➡️ Не забудьте выключить плиту!');
  }
}
Вывод
Светлая тема Темная тема
🥔 Пока картошка вариться (таймер 15 мин)... делаем другие дела.
💥 Ошибка: Света нет! Картошка не сварилась!
➡️ Не забудьте выключить плиту!

2. С Future API — метод .catchError()

Aльтернатива для кода, написанного в стиле цепочек вызовов.

Dart
Светлая тема Темная тема
void performTasksWithCatchError() {
  print('Начинаем...');
  Future.delayed(const Duration(seconds: 2), () {
    throw Exception('Не удалось загрузить данные!');
  })
  .then((value) => print('Успех!')) // Этот блок будет пропущен
  .catchError((e) => print('Произошла ошибка: $e'))
  .whenComplete(() => print('Операция завершена.'));
}
Вывод
Светлая тема Темная тема
Начинаем...
Произошла ошибка: Exception: Не удалось загрузить данные!
Операция завершена.