Асинхронность обещает, что программа станет быстрее. Обещание сбывается ровно в одном случае из трёх, а в остальных приносит сложность без выигрыша — и разница определяется не качеством кода, а тем, чем занята программа.
Что она делает на самом деле
Асинхронность не ускоряет вычисления. Она позволяет не простаивать во время ожидания.
Когда программа отправила запрос к базе и ждёт ответа, процессор свободен. В обычном, последовательном коде он всё равно занят: поток стоит на этой строке и ничего не делает. Асинхронность даёт возможность в этот момент заняться другим запросом, а к первому вернуться, когда ответ придёт.
Отсюда правило, из которого следует всё: асинхронность помогает там, где программа ждёт, и не помогает там, где считает. Если процессор загружен работой, отдавать ему нечего — переключаться некуда.
Тупик: сделать асинхронным всё подряд
Первое, что пробуют, — пометить все функции как асинхронные и расставить ожидания. Логика такая: раз это ускоряет, пусть будет везде.
Это не помогает, а обычно замедляет. Каждый асинхронный вызов стоит дороже обычного: нужно сохранить состояние, поставить задачу в очередь, вернуться. Для функции, которая просто складывает два числа, эта плата не окупается ничем.
Хуже другое: асинхронность заразна. Функция, вызывающая асинхронную, сама становится асинхронной, и так вверх по цепочке до самого верха. В итоге всё приложение написано в асинхронном стиле ради нескольких мест, где это действительно нужно, а читать и отлаживать его стало сложнее везде.
Второй частый ход — заменить последовательные ожидания на асинхронные, ничего не меняя в порядке. Программа была последовательной и осталась последовательной, только теперь с дополнительными расходами: ожидания по-прежнему идут одно за другим, потому что никто не запустил их одновременно.
Где выигрыш настоящий
Он появляется в одном месте: когда несколько независимых ожиданий запускаются вместе и завершаются параллельно.
Три запроса к трём разным сервисам, выполненные последовательно, занимают сумму их времён. Запущенные одновременно — время самого медленного. Вот это и есть выигрыш, и он не зависит от того, сколько у вас ядер: процессор всё это время почти не работает.
Второе место — обслуживание многих соединений одним потоком. Сервер, у которого тысяча клиентов ждут ответа базы, не нуждается в тысяче потоков: он нуждается в одном, который умеет переключаться. Это экономия памяти, а не времени, и для сервисов с большим числом одновременных подключений она решающая.
Разница между этими двумя случаями важнее, чем кажется. В первом вы ускоряете один запрос: пользователь ждёт меньше. Во втором время одного запроса не меняется вовсе — меняется то, сколько запросов сервер выдерживает одновременно. Это разные величины, и путаница между ними приводит к разочарованию: переписали ради скорости, получили пропускную способность, а отклик остался прежним.
Признак, по которому это узнаётся: в коде есть несколько ожиданий, между которыми нет зависимости по данным. Если второй запрос использует результат первого, запускать их вместе нельзя, и асинхронность здесь ничего не даст.
Что она приносит вместе с выигрышем
Каждый плюс оплачивается, и цену стоит знать заранее.
Ошибки сложнее. Раньше падало по очереди и было понятно, где. Теперь падает несколько задач одновременно, и надо решить, что делать с остальными, когда одна из них не удалась: ждать, отменять, продолжать без неё.
Порядок перестаёт быть очевидным. Код читается сверху вниз, а выполняется иначе. Это отдельная сложность при чтении чужого кода и при разборе происшествий.
Появляются состязания. Две задачи, работающие с одними данными, могут перемешаться в местах, где раньше это было невозможно. Ошибки такого рода воспроизводятся плохо и живут долго.
Один заблокированный вызов ломает всё. Если внутри асинхронного кода оказался обычный, блокирующий вызов, он останавливает не только себя, но и все остальные задачи, которые обслуживал этот поток. Такое место найти трудно, а последствия непропорциональны причине.
Чаще всего такой вызов приходит из библиотеки, которая выглядит безобидно: чтение файла, разрешение имени, обращение к системному вызову без асинхронного варианта. Проверяется это не чтением кода, а наблюдением: если под нагрузкой задержки растут скачками при незагруженном процессоре, где-то в цепочке стоит блокирующий вызов.
Отмена оказывается сложнее запуска. Запустить десять задач просто; корректно остановить их, когда пользователь ушёл со страницы или истёк срок ожидания, — отдельная работа, о которой вспоминают, когда в системе накапливаются задачи, которые никому уже не нужны, но которые продолжают занимать ресурсы.
Как решить, нужна ли она
Порядок простой и требует одного измерения.
Посмотрите, чем занята программа под нагрузкой: ждёт или считает. Если процессор загружен, асинхронность не поможет — там нужна другая работа, вплоть до смены языка или алгоритма.
Если ждёт — посмотрите, есть ли независимые ожидания рядом. Нет — асинхронность даст только расходы. Есть — считайте выигрыш: разница между суммой времён и максимумом из них.
Считать стоит на реальных временах, а не на равных. Если один из трёх запросов занимает столько же, сколько два других вместе, выигрыш от параллельного запуска будет вдвое меньше ожидаемого: вы всё равно ждёте самый медленный. Отсюда частое разочарование — параллельность включили, а стало быстрее едва заметно, потому что в наборе есть один тяжёлый вызов, определяющий всё.
И оцените, сколько кода придётся перевести в асинхронный стиль ради этого выигрыша. Если ради двух параллельных запросов асинхронным становится всё приложение, дешевле бывает решить задачу иначе: отдельным потоком, очередью, фоновой задачей.
Полезно посчитать и то, сколько ожиданий вообще можно убрать. Три независимых запроса, запущенных вместе, — выигрыш. Три запроса, которых могло быть два, потому что данные дублируются, — это лишняя работа, которую асинхронность просто сделает быстрее. Убрать лишнее ожидание всегда выгоднее, чем ускорить его.
Позиция редакции
Мы считаем, что асинхронность стоит вводить точечно — там, где рядом стоят независимые ожидания, — и не стоит делать стилем всего приложения по умолчанию. Практический вывод: прежде чем переписывать, посмотрите, загружен ли процессор. Если да, вы лечите не ту болезнь.
Мы неправы, если пишете сервис, обслуживающий много одновременных соединений: там асинхронность не оптимизация, а способ вообще уместиться в память, и вводить её надо с самого начала, а не точечно.