Асинхронность, промисы и циклы событий
Что это такое
Поток, который ждёт ответа от сети, всё это время ничего не делает — но продолжает занимать память, и асинхронное программирование заменяет его промисом: это заполнитель для значения, которого ещё нет, плюс обратный вызов на случай, когда оно придёт. Среда исполнения держит набор ожидающих операций и цикл, который исполняет ту из них, что готова, поэтому один поток оказывается способен отвечать за тысячи открытых соединений.
Почему это важно
Эта модель стоит почти за каждым современным сервером и интерфейсом, и отказывает она совсем не так, как гонка. Код, который блокирует цикл, останавливает все остальные задачи; поэтому один медленный вызов делает неотзывчивым весь сервис. Симптомом оказывается не медленный эндпоинт, а все эндпоинты сразу. Ошибки здесь тоже путешествуют иначе. Отклонённый промис, который никто не обработал, может быть проглочен целиком. Понимание цикла объясняет, почему исправление почти всегда звучит как «не блокируйте здесь», а не «добавьте поток».
Ключевые идеи
Один поток может держать много ожиданий
Ожидание ввода-вывода — не работа: цикл отдаёт ядру набор файловых дескрипторов и просит сообщить, когда любой из них станет готов, а затем засыпает. Когда данные приходят, среда исполнения отмечает соответствующую операцию завершённой и ставит в очередь её продолжение. Один поток поэтому управляет тысячами соединений: дорогая часть — ожидание — не стоит ничего.
loop:
ready = wait_for_any(open_ops) # спит, пока что-нибудь не произойдёт
for op in ready:
run its continuation
Цикл выполняет по одному обратному вызову за раз
Цикл однопоточный, поэтому, пока обратный вызов выполняется, его никто не прерывает. Преимущество настоящее: состояние, затронутое между двумя синхронными строками, не может измениться у вас под руками, так что асинхронный код свободен от гонок данных внутри задачи. А вот всё медленное внутри обратного вызова — синхронное чтение файла, долгое вычисление, захват блокирующей блокировки — цикл блокирует и замораживает на это время все остальные задачи.
обратный вызов A выполняется до конца -> обратный вызов B -> обратный вызов C
ничто не перемешивает один обратный вызов с другим
await — это точка приостановки, а не вызов функции
Когда функция ожидает промис, она останавливается на этой строке и возвращает управление циклу; остаток функции — продолжение, зарегистрированное на промисе. Строки после await выполняются позже, возможно, после того как отработали другие задачи и состояние, захваченное функцией, изменилось. Прочитать общее значение до await и воспользоваться им после — асинхронный эквивалент гонки. Поэтому значение, обязанное оставаться согласованным, нужно копировать или перечитывать вокруг приостановки.
before = state.value
await fetch() # здесь работают другие задачи; state может измениться
if (state.value !== before) ... # проверка бессмысленна
Асинхронность — это конкурентность, а не параллелизм
Конкурентность означает, что несколько задач находятся в работе. Параллелизм — что несколько выполняются в один и тот же миг. Цикл событий даёт первое на одном потоке. Для работы, упирающейся в процессор, он не даёт ничего, потому что вычисление не уступает управление. Обычный ответ — перенести тяжёлую работу в пул исполнителей, где она действительно может идти параллельно, а цикл оставить свободным для координации.
Отмена и тайм-ауты задаются явно
Промис не останавливается оттого, что вы перестали его ждать. Отмена — отдельный механизм, который нужно встроить в операцию, а тайм-аут обычно сводится к гонке между операцией и часами. Поскольку ошибки идут через отклонение, а не через выброс в месте вызова, каждой цепочке промисов нужен определённый путь отказа — иначе одна сбойная задача исчезает без следа.
Практика
Почувствуйте остановку цикла
Напишите программу, которая запускает десять задержек по одной секунде и печатает по мере их завершения. Затем добавьте в середину программы один синхронный занятый цикл на секунду и запустите снова.
Готово, когда: первая версия завершается примерно за секунду, вторая заметно дольше, и вы можете указать строку, остановившую цикл.
Обработайте один отказ
Возьмите цепочку из трёх ожидаемых операций и заставьте среднюю отклониться. Запустите её один раз без обработки отклонения и один раз с перехватом, который пишет в журнал и восстанавливается.
Готово, когда: необработанная версия не сообщает ничего полезного, обработанная называет сбойный шаг, а итоговое состояние совпадает с задуманным.
Частые ошибки
- Вызов блокирующего API внутри асинхронного кода — цикл замирает, и каждая конкурентная задача ждёт; исправить: используйте асинхронную версию API или перенесите вызов в исполнитель.
- Чтение состояния до await с доверием к нему после — между ними успели отработать другие задачи; исправить: скопируйте значение или перепроверьте его после возобновления.
- Отбрасывание отклонённых промисов — ошибка исчезает, а вызывающий ждёт вечно; исправить: прикрепляйте обработчик к каждой цепочке и к каждому вызову без ожидания.
- Отношение к асинхронному коду как к параллельному — тяжёлые вычисления всё равно занимают цикл; исправить: выносите расчёты в пул исполнителей.
- Уверенность, что задача прекращается, когда вы перестали её ждать — работа продолжается, а её результат отбрасывается; исправить: передавайте в операцию сигнал отмены.
Что читать дальше
- Потоки, блокировки и гонки (01-cs-fundamentals/05-concurrency-async-programming/01-threads-locks-and-races) — модель общего состояния, которую эта запись вытесняет; однако для работы с процессором она по-прежнему нужна.
- Компьютерные сети (01-cs-fundamentals/03-computer-networks) — то ожидание, ради избавления от которого и придуман этот стиль программирования.
- Отладка (01-cs-fundamentals/11-debugging) — как изолировать зависание, случающееся только когда одна конкретная задача работает долго.