186. Асинхронность: поток кадров, таймауты, переподключение, изолят
Прибор шлёт байты когда захочет, отвечает не всегда и отключается в самый неподходящий момент. Приложение при этом обязано не зависать и не врать. Раздел — про четыре вещи, которые это обеспечивают: поток, таймаут, повтор и изолят.
Всё проверено прогоном: пакет _verify/dart186 и семь проверок в
dart test, плюс замер разбора на 200 000 кадрах.
Как это называется в документации
Оригинал Перевод Future, async/await будущее значение, ожидание без блокировки Stream, async* , yield поток значений, порождающая функция broadcast stream поток с несколькими подписчиками backpressure сдерживание: потребитель медленнее источника timeout срок ожидания exponential backoff повтор с растущей паузой reconnect переподключение isolate изолят: отдельная нить со своей памятью event loop цикл событий
186.1 Поток байт превращается в поток кадров
Порт, сокет и BLE отдают байты кусками произвольной длины: кадр может прийти по частям, а может прийти два сразу. Разборщик из раздела 185 уже умеет это переживать — осталось обернуть его в поток:
Stream<FrameEvent> frames(Stream<List<int>> bytes) async* {
final parser = FrameParser();
await for (final chunk in bytes) {
for (final b in chunk) {
final e = parser.feed(b);
if (e is! FrameNone) yield e;
}
}
}
Три строчки дают ровно то, что нужно: состояние живёт между посылками, а наружу идут только события. Проверено обоими крайними случаями: кадр, разрезанный на три куска, собирается в один; два кадра в одной посылке дают два события.
Источник байт не назван: это может быть последовательный порт, сокет или BLE-характеристика — о них дальше в этой части. Разбор от этого не зависит — и потому проверяется без прибора.
186.2 Ошибка — это событие, а не исключение
Битая CRC и мусор в проводе — не исключительная ситуация, а обычный
режим работы: помеха, отключение, перезапуск прибора. Если бросить
исключение, поток закроется, и связь придётся поднимать заново.
Поэтому разборщик отдаёт FrameError событием, и поток живёт
дальше:
| Что подали | Что получили |
|---|---|
| мусор, кадр с битой CRC и правильный кадр | одно событие ошибки и один кадр |
Исключения остаются для того, что действительно исключение: порт исчез, разрешение не выдано, устройство отключили.
186.3 Запрос-ответ: таймаут и повтор с растущей паузой
Команда без срока ожидания — это зависшее приложение. Срок ставится всегда:
final answer = _events.firstWhere((e) => e is FrameReady).timeout(timeout);
sent++;
_out(encodeFrame(payload));
Подписка оформляется до отправки: иначе быстрый ответ успевает прийти раньше, чем его начали ждать, — классическая гонка, которая воспроизводится раз в сто запусков и отлаживается неделю.
Второе условие — ответ узнают по номеру. firstWhere((e) => e is FrameReady) берёт
первый попавшийся кадр: в опыте прибор отвечал на каждый запрос, но позже срока, и
вторая попытка засчитала себе ответ на первую — отправлено 2, ответ от попытки 1. Любой
кадр телеметрии тоже «ответит» на любую команду. Поэтому в каждый запрос кладут
счётчик, прибор возвращает его в ответе, а ждут
firstWhere((e) => e is FrameReady && seqOf(e) == seq), где seqOf достаёт номер из
кадра. И поток событий должен быть широковещательным — в Link его делает таким
events.asBroadcastStream(): у обычного потока второй firstWhere при повторе
бросает «Bad state: Stream has already been listened to».
Повтор — с растущей паузой: 1, 2, 4 срока. В проверке прибор отвечает только на третью попытку:
| Что считаем | Значение |
|---|---|
| отправлено запросов | 3 |
| повторов | 2 |
| ответ получен | да |
Растущая пауза нужна не из вежливости: если прибор перезагружается, частые повторы просто съедают его входной буфер и мешают ему встать. За десять секунд молчания приложение с постоянной паузой 20 мс отправит 500 запросов, а с удвоением до потолка 2 с — 10.
186.4 Переподключение: состояние связи как часть протокола
Обрыв — не ошибка программы, а событие. Поток кадров, который его
переживает, выглядит так: открыть источник, отдавать кадры, при
закрытии или отказе сообщить offline, подождать и открыть снова —
пауза удваивается до предела.
yield LinkState.connecting;
try {
final source = await open();
yield LinkState.online;
wait = pause; // связь была — пауза снова короткая
yield* frames(source);
yield LinkState.offline;
} catch (_) {
yield LinkState.offline;
}
В проверке первая попытка открытия падает, вторая удаётся; наружу идёт
ровно та последовательность состояний, которую ждёт экран:
connecting → offline → connecting → online → offline, а между ними —
разобранный кадр.
Сбрасывать паузу после успешного подключения обязательно, иначе после одного плохого дня приложение будет ждать по две секунды даже при живом приборе.
186.5 Изолят: разбор не должен трогать экран
Dart однопоточный: пока считает ваш код, не рисуется ничего. Насколько это важно, видно на замере — 200 000 кадров, 2 200 000 байт:
| Что считаем | Значение |
|---|---|
| разобрано кадров | 200 000 |
| скорость разбора | больше 1 000 000 кадров в секунду |
| тиков таймера 1 мс во время разбора в главном потоке | не больше 2 |
| тиков во время разбора в изоляте | больше 10 |
Сам разбор быстрый — узкое место не он. Но даже эти десятки миллисекунд видны глазом: пока идёт разбор в главном потоке, таймер на 1 мс не тикает почти ни разу, то есть кадр анимации пропущен. Тот же разбор в изоляте главный поток не трогает:
final int parsed = await Isolate.run(() => parseAll(bytes));
На вебе изолятов нет: Isolate.run там не собирается, а compute выполняет
работу в том же потоке. Там пачку режут на куски и между ними отдают
управление: await Future<void>.delayed(Duration.zero).
Правило простое: в изолят уходит то, что считает пачкой — разбор журнала, прореживание графика, проверка обновления прошивки. Поток живых кадров с прибора обычно оставляют в главном: пересылка данных между изолятами тоже не бесплатна.
186.6 Грабли
Нет срока ожидания. Прибор молчит — приложение ждёт вечно, кнопка не отвечает, пользователь перезапускает.
Подписка после отправки. Быстрый ответ теряется, и запрос уходит в таймаут ровно тогда, когда всё работает.
Повтор засчитал чужой ответ. Ответ ждут по типу кадра, а не по номеру запроса: поздний ответ на первую попытку достаётся второй.
Повтор без растущей паузы. Перезагружающийся прибор получает шквал запросов и не успевает подняться.
Пауза не сбрасывается после успеха. Связь давно есть, а приложение всё ещё думает по две секунды.
Ошибка разбора рвёт поток. Одна помеха — и связь «потеряна» до перезапуска приложения.
Тяжёлый разбор в главном потоке. Экран замирает на десятки миллисекунд; на слабом телефоне — на сотни.
186.7 Итоги
- Поток байт превращается в поток событий тремя строками, и состояние разборщика живёт между посылками: кадр из трёх кусков собирается, два кадра в одной посылке дают два события.
- Ошибка разбора — событие
FrameError, а не исключение: поток переживает помеху и продолжает работать. - У каждой команды есть срок ожидания, подписка оформляется до отправки, повтор идёт с растущей паузой: ответ на третьей попытке — 3 запроса и 2 повтора.
- Переподключение сообщает состояние наружу и сбрасывает паузу после успеха.
- Разбор 200 000 кадров идёт быстрее миллиона кадров в секунду, но в главном потоке крадёт кадры анимации: таймер 1 мс не тикает; в изоляте — тикает.
Дальше: раздел 187 «Экран прибора на Flutter: виджеты, состояние, команды, тесты».