Completer: створюємо власні Future
Future є базовим поняттям для Dart. Детальний розбір асинхронного програмування ми вже робили у перекладі англомовної статті з dart.dev.
Та іноді, під час написання коду, інтерфейсу Future починає не вистачати. Таке трапляється частіше за все під час написання власних бібліотек та контролерів.
Також ми переклали цю статтю англійською
Як це, не вистачає?
Розгляньмо наявний інтерфейс класу Future:
Future(FutureOr<T> computation()) /* Виклик функції computation() асинхронно, використовуючи Timer.run */ Future.delayed(Duration duration, [FutureOr<T> computation()])/* Аналогічно Future(), але виконання відбувається після затримки */ Future.error(Object error, [StackTrace? stackTrace])// Створення Future, що завершується помилкою Future.microtask(FutureOr<T> computation()) // Аналогічно Future(), але використовується scheduleMicrotask Future.sync(FutureOr<T> computation()) // Аналогічно Future(), але функція викликається негайно (синхронно) Future.value([FutureOr<T>? value]) // Створення Future з уже вказаним результатом
Як бачимо, наданий інтерфейс дає лише можливість продукувати Future як результати інших обчислень, але не дає ніяких інструментів, щоб повернути індикацію обчислень, що ще тривають.
Наданий інтерфейс корисно використовувати хіба що у синхронному коді, якщо, наприклад, ми імплементуємо який-небудь інтерфейс, що вимагає повернення Future у методі, у той час як наша реалізація є синхронною:
import 'dart:io'; abstract class FileReader { Future<String> readFile(String name); } class SyncFileReader implements FileReader { @override Future<String> readFile(String name) { return Future(() { return File(name).readAsStringSync(); }); } }
Тепер уявімо ситуацію, що ми створюємо контролер для бази даних. Для того, щоб почати працювати з базою даних, до неї треба підключитися. До того ж, необхідно зберегти підключення до БД у контролері — адже створення багатьох паралельних підключень є неефективним.
Спробуймо написати такий код з тими знаннями, що у нас є:
import 'package:sqflite/sqflite.dart'; class DbConnection { Database? _database; Future<Database> use() async { _database ??= await openDatabase( 'my.db', onCreate: (db, version) async { ... }, version: 1, ); return _database!; } }
Даний клас має метод use()
, який:
- Перевіряє наявність вже активного підключення
- Якщо підключення немає, виконує його й повертає результат
- Якщо підключення вже є, повертає його
Здавалось би, все гаразд, але у даній реалізації можна знайти недолік — якщо кілька незалежних частин коду спробують отримати доступ до бази даних до того, як було ініційовано перше підключення, на кожний запит буде відкрите окреме підключення:
Ось ще один наглядний приклад цієї проблеми:
Розглянемо його детальніше:
- Клас
NumberHolder
має один раз отримати значення з асинхронної функціїasyncNumberGet()
, після чого зберегти його та щоразу повертати збережене значення asyncNumberGet()
після кожного виклику збільшує своє значення- У функції
main()
ми: - Паралельно запускаємо два запити на отримання значення числа
- Окремо запускаємо один запит на отримання числа
За логікою:
- Ми створюємо екземпляр
NumberHolder
- Виклик 1: Клас
NumberHolder
має отримати значення числа з функцїasyncNumberGet()
один раз та записати його у змінну_result
. Результат: 1 - Виклик 2: змінна
_result
вже наявна, тому використовуємо її. Результат: 1 - Виклик 3: змінна
_result
вже наявна, тому використовуємо її. Результат: 1
Тобто, очікуваним виводом даної програми мають бути такі рядки:
[1, 1] 1
Але якщо ми запустимо приклад, то побачимо інший результат:
[1, 2] 2
Даний результат виникає через проблему, що називається Race Condition, або конкуренція: коли від порядку виконання коду залежить результат.
У прикладі з базою даних стався б витік ресурсів через непотрібне та незакрите з'єднання з БД.
Давайте розглянемо, чому так стається. Переглянувши реалізацію Future.wait
, робимо висновок, що всередині Future.wait([task1(), task2()])
виклик відбувається так:
task1(); task2();
Тобто обидва асинхронні завдання виконуються конкурентно, не очікуючи один на одного.
Конкурентність у контексті Dart
JavaScript-розробники вже мають бути знайомі з поняттям черги подій (event loop).
Коротко: хоча й Dart підтримує справжню багатопоточність (isolates), увесь звичайний (синхронний та асинхронний) код виконується в одному потоці. Це означає, що усі команди можуть виконуватись виключно одна за одною.
Але одночасно у логіки роботи черги є одна особливість: як тільки інтерпретатор потрапляє на асинхронну команду, ця команда переміщується у кінець черги, очікуючи на виконання синхронного коду, що розміщується після неї.
У нашому прикладі замість task1
ми викликаємо number.use
. Перегляньмо цю функцію.
Future<int> use() async { _result ??= await asyncNumberGet(); return _result!; }
Перепишемо дану функцію простіше та без використання async
, щоб зрозуміти логіку роботи:
Future<int> use() { if (_result == null) { return asyncNumberGet() # Future .then((number) { _result = number; return number; }); } return Future.value(_result); }
Застосуємо наші знання про конкурентність та проілюструємо порядок виконання очима інтерпретатора Dart:
Тоді викличмо нашу функцію двічі підряд, як це робить Future.wait
й відсортуймо команди, як це робить Dart:
Так як асинхронний код було переміщено, виклик asyncNumberGet()
поки що не стався, а отже й значення _result не було встановлено. Через це друга перевірка _result == null
є істинною, через що дану ділянку коду не буде пропущено, а функцію asyncNumberGet()
буде викликано двічі:
Врешті-решт, асинхронна функція виконується синхронно в кінці черги.
У перенесеному коді записуємо усі конструкції Future().then()
аналогічною конструкцією з await
для спрощення сприйняття:
У результаті маємо два виклики asyncNumberGet()
через те, що перевірка умови під час другого виклику відбувається раніше, ніж завершення першого виклику.
Completer на допомогу
Але ж що робити, якщо хочеться створити безшовний доступ до кешованого результату?
Completer це вбудований у Dart контролер Future. Він здатний продукувати Future будь-якого виду та завершувати (resolve/complete) їх у будь-який час.
Completer дуже простий у використанні. Екземляр даного класу має такі методи:
complete([FutureOr<T>? value]) → void // Завершує Future наданим значенням completeError(Object error, [StackTrace? stackTrace]) → void // Завершує Future з помилкою
Та такі властивості:
future → Future<T> // Екземляр Future, який ми контролюємо isCompleted → bool // Повертає істину, якщо Future вже завершено
Отож, перепишемо наш приклад з використанням Completer:
Отримуємо результат, що підтверджує правильність.
CALLED [1, 1] 1
Висновки
Завдяки Completer ми змогли зробити контрольований екземпляр Future, який можна повертати, поки тривають конкурентні обчислення, у результаті чого ми змогли уникнути помилки Race Condition.
Запрошуємо усіх зацікавлених у Dart і Flutter приєднуватись до нашого чату, щоб отримувати більше цікавих матеріалів та новин про ці технології.