Dart
May 6, 2021

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() ми:
    • Паралельно запускаємо два запити на отримання значення числа
    • Окремо запускаємо один запит на отримання числа

За логікою:

  1. Ми створюємо екземпляр NumberHolder
  2. Виклик 1: Клас NumberHolder має отримати значення числа з функцї asyncNumberGet() один раз та записати його у змінну _result. Результат: 1
  3. Виклик 2: змінна _result вже наявна, тому використовуємо її. Результат: 1
  4. Виклик 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), увесь звичайний (синхронний та асинхронний) код виконується в одному потоці. Це означає, що усі команди можуть виконуватись виключно одна за одною.

Але одночасно у логіки роботи черги є одна особливість: як тільки інтерпретатор потрапляє на асинхронну команду, ця команда переміщується у кінець черги, очікуючи на виконання синхронного коду, що розміщується після неї.

Логіка роботи Future

У нашому прикладі замість 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 приєднуватись до нашого чату, щоб отримувати більше цікавих матеріалів та новин про ці технології.