Webrium
Подписаться

Асинхронность: что она ускоряет, а что нет

Асинхронность не ускоряет вычисления — она позволяет не простаивать в ожидании. Как понять, поможет ли она, до того как переписывать код.

Независимые ожидания, запущенные вместе, занимают время самого медленногоожидания вместе

Асинхронность обещает, что программа станет быстрее. Обещание сбывается ровно в одном случае из трёх, а в остальных приносит сложность без выигрыша — и разница определяется не качеством кода, а тем, чем занята программа.

Что она делает на самом деле

Асинхронность не ускоряет вычисления. Она позволяет не простаивать во время ожидания.

Когда программа отправила запрос к базе и ждёт ответа, процессор свободен. В обычном, последовательном коде он всё равно занят: поток стоит на этой строке и ничего не делает. Асинхронность даёт возможность в этот момент заняться другим запросом, а к первому вернуться, когда ответ придёт.

Отсюда правило, из которого следует всё: асинхронность помогает там, где программа ждёт, и не помогает там, где считает. Если процессор загружен работой, отдавать ему нечего — переключаться некуда.

Тупик: сделать асинхронным всё подряд

Первое, что пробуют, — пометить все функции как асинхронные и расставить ожидания. Логика такая: раз это ускоряет, пусть будет везде.

Это не помогает, а обычно замедляет. Каждый асинхронный вызов стоит дороже обычного: нужно сохранить состояние, поставить задачу в очередь, вернуться. Для функции, которая просто складывает два числа, эта плата не окупается ничем.

Хуже другое: асинхронность заразна. Функция, вызывающая асинхронную, сама становится асинхронной, и так вверх по цепочке до самого верха. В итоге всё приложение написано в асинхронном стиле ради нескольких мест, где это действительно нужно, а читать и отлаживать его стало сложнее везде.

Второй частый ход — заменить последовательные ожидания на асинхронные, ничего не меняя в порядке. Программа была последовательной и осталась последовательной, только теперь с дополнительными расходами: ожидания по-прежнему идут одно за другим, потому что никто не запустил их одновременно.

Где выигрыш настоящий

Он появляется в одном месте: когда несколько независимых ожиданий запускаются вместе и завершаются параллельно.

Три запроса к трём разным сервисам, выполненные последовательно, занимают сумму их времён. Запущенные одновременно — время самого медленного. Вот это и есть выигрыш, и он не зависит от того, сколько у вас ядер: процессор всё это время почти не работает.

Второе место — обслуживание многих соединений одним потоком. Сервер, у которого тысяча клиентов ждут ответа базы, не нуждается в тысяче потоков: он нуждается в одном, который умеет переключаться. Это экономия памяти, а не времени, и для сервисов с большим числом одновременных подключений она решающая.

Разница между этими двумя случаями важнее, чем кажется. В первом вы ускоряете один запрос: пользователь ждёт меньше. Во втором время одного запроса не меняется вовсе — меняется то, сколько запросов сервер выдерживает одновременно. Это разные величины, и путаница между ними приводит к разочарованию: переписали ради скорости, получили пропускную способность, а отклик остался прежним.

Признак, по которому это узнаётся: в коде есть несколько ожиданий, между которыми нет зависимости по данным. Если второй запрос использует результат первого, запускать их вместе нельзя, и асинхронность здесь ничего не даст.

Что она приносит вместе с выигрышем

Каждый плюс оплачивается, и цену стоит знать заранее.

Ошибки сложнее. Раньше падало по очереди и было понятно, где. Теперь падает несколько задач одновременно, и надо решить, что делать с остальными, когда одна из них не удалась: ждать, отменять, продолжать без неё.

Порядок перестаёт быть очевидным. Код читается сверху вниз, а выполняется иначе. Это отдельная сложность при чтении чужого кода и при разборе происшествий.

Появляются состязания. Две задачи, работающие с одними данными, могут перемешаться в местах, где раньше это было невозможно. Ошибки такого рода воспроизводятся плохо и живут долго.

Один заблокированный вызов ломает всё. Если внутри асинхронного кода оказался обычный, блокирующий вызов, он останавливает не только себя, но и все остальные задачи, которые обслуживал этот поток. Такое место найти трудно, а последствия непропорциональны причине.

Чаще всего такой вызов приходит из библиотеки, которая выглядит безобидно: чтение файла, разрешение имени, обращение к системному вызову без асинхронного варианта. Проверяется это не чтением кода, а наблюдением: если под нагрузкой задержки растут скачками при незагруженном процессоре, где-то в цепочке стоит блокирующий вызов.

Отмена оказывается сложнее запуска. Запустить десять задач просто; корректно остановить их, когда пользователь ушёл со страницы или истёк срок ожидания, — отдельная работа, о которой вспоминают, когда в системе накапливаются задачи, которые никому уже не нужны, но которые продолжают занимать ресурсы.

Как решить, нужна ли она

Порядок простой и требует одного измерения.

Посмотрите, чем занята программа под нагрузкой: ждёт или считает. Если процессор загружен, асинхронность не поможет — там нужна другая работа, вплоть до смены языка или алгоритма.

Если ждёт — посмотрите, есть ли независимые ожидания рядом. Нет — асинхронность даст только расходы. Есть — считайте выигрыш: разница между суммой времён и максимумом из них.

Считать стоит на реальных временах, а не на равных. Если один из трёх запросов занимает столько же, сколько два других вместе, выигрыш от параллельного запуска будет вдвое меньше ожидаемого: вы всё равно ждёте самый медленный. Отсюда частое разочарование — параллельность включили, а стало быстрее едва заметно, потому что в наборе есть один тяжёлый вызов, определяющий всё.

И оцените, сколько кода придётся перевести в асинхронный стиль ради этого выигрыша. Если ради двух параллельных запросов асинхронным становится всё приложение, дешевле бывает решить задачу иначе: отдельным потоком, очередью, фоновой задачей.

Полезно посчитать и то, сколько ожиданий вообще можно убрать. Три независимых запроса, запущенных вместе, — выигрыш. Три запроса, которых могло быть два, потому что данные дублируются, — это лишняя работа, которую асинхронность просто сделает быстрее. Убрать лишнее ожидание всегда выгоднее, чем ускорить его.

Позиция редакции

Мы считаем, что асинхронность стоит вводить точечно — там, где рядом стоят независимые ожидания, — и не стоит делать стилем всего приложения по умолчанию. Практический вывод: прежде чем переписывать, посмотрите, загружен ли процессор. Если да, вы лечите не ту болезнь.

Мы неправы, если пишете сервис, обслуживающий много одновременных соединений: там асинхронность не оптимизация, а способ вообще уместиться в память, и вводить её надо с самого начала, а не точечно.

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

Письмо по вторникам

Один разбор недели и короткий список того, что изменилось. Без дайджестов на сорок ссылок.

Начните вводить — материалы появятся здесь.

выбрать · Enter открыть · Esc закрыть