Ловушки async в Rust
Финальный блок - про то, что ломается в проде. Мьютексы через await, безграничные каналы, скачущий хвост задержек. Вопросы задают в форме разбора инцидента, и отвечать надо тоже практикой.
Стержень: почти все асинхронные беды сводятся к одному - что-то заняло воркер или очередь выросла без ограничения.
// Формулировки: «можно ли держать std-мьютекс через await?», «tokio::sync::Mutex или обычный?», «p99 скачет - что смотришь?»
Мьютекс и await
Обычный std::sync::MutexGuard не Send: он привязан к потоку, который взял блокировку. Задача, удерживающая его через await, не соберётся для многопоточного рантайма, и это хорошо, потому что сама идея плохая. Пока задача ждёт ответа сети, блокировка держится, и остальные стоят в очереди.
tokio::sync::Mutex решает именно этот случай: ожидание идёт через планировщик, guard можно держать через await. Платой становится более дорогая синхронизация, поэтому документация tokio прямо советует по умолчанию брать обычный мьютекс, а асинхронный - только когда без удержания через await не обойтись.
// Правильная привычка: сузить секцию так, чтобы guard умирал до await. Взял данные под замком, отпустил, дальше работаешь с копией или результатом.
let val = {
let g = state.lock().unwrap();
g.value.clone()
}; // guard умер
send(val).await;- std-мьютекс в async
- годится, если guard не живёт через await
- tokio::sync::Mutex
- асинхронный мьютекс для удержания через ожидание
Очереди и хвост задержек
Безграничный канал под медленным потребителем ведёт себя тихо ровно до того момента, когда кончается память: очередь растёт, задержка растёт вместе с ней. Ограниченный канал даёт обратное давление - send ждёт места, и производитель замедляется до темпа потребителя. Ёмкость выбирают осознанно, а не берут unbounded по привычке.
Скачущий p99 при ровном RPS (requests per second) почти всегда означает занятый воркер: синхронное чтение файла, тяжёлая сериализация, блокирующий драйвер, удержанная через await блокировка. Ищут это по метрикам рантайма и трассировке, а лечат выносом в spawn_blocking. Добавление воркеров лишь размазывает симптом.
// #[tokio::main] тут ни при чём это просто сахар: он строит рантайм и вызывает block_on на теле функции. При нестандартных настройках рантайм собирают через Builder вручную.
- обратное давление
- замедление производителя при заполненной очереди
- хвост задержек
- p99 и выше: там видно занятые воркеры и очереди
Как отвечать: «Сервис на tokio держит RPS, но p99 скачет. С чего начнёшь?»
Сначала ищу, что занимает воркер. Скачущий хвост при ровном RPS - почти всегда признак того, что какая-то задача не отдаёт управление: синхронное чтение файла, тяжёлая сериализация, блокирующий драйвер базы или блокировка, удержанная через await. Смотрю метрики рантайма - время опроса задач, длину очереди, и трассировку по стадиям запроса, чтобы понять, где именно копится время. Найденное выношу в spawn_blocking или заменяю асинхронным аналогом. Увеличивать число воркеров не спешу: это маскирует проблему, а не устраняет её.
Ты идёшь от гипотезы к измерению, а не наоборот, и явно отвергаешь популярный неправильный ход. Так отвечает человек, который разбирал такие инциденты.
На чём валятся
- −Держат std-мьютекс через await.
- −Берут tokio::sync::Mutex по умолчанию и платят за это на каждой короткой секции.
- −Ставят безграничный канал и получают рост памяти вместо замедления.
- −Лечат хвост увеличением числа воркеров.
- −Считают, что #[tokio::main] делает что-то большее, чем создание рантайма и block_on.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- Что происходит с задачами, когда потребитель канала медленнее производителя?A)Сообщения начинают теряться, освобождая место новымB)Очередь растёт и съедает память, если канал не ограниченC)Производитель падает с ошибкой переполнения очередиD)Рантайм притормаживает производителя автоматически
показать ответ и разбор
+B)Очередь растёт и съедает память, если канал не ограничен// разбор: Безграничный канал ведёт себя тихо ровно до того момента, когда память кончается: очередь растёт, задержки растут вместе с ней. Ограниченный канал даёт обратное давление — send ждёт места, и производитель естественным образом замедляется. Именно поэтому ёмкость выбирают осознанно, а не берут unbounded по умолчанию.
- Что делает #[tokio::main] над функцией main?A)Разворачивает её в обычную main с рантаймом и block_onB)Запускает main в отдельном потоке рантаймаC)Помечает main как асинхронную для компилятораD)Включает асинхронный режим для всех функций крейта
показать ответ и разбор
+A)Разворачивает её в обычную main с рантаймом и block_on// разбор: Макрос переписывает функцию: создаёт рантайм с настройками по умолчанию и вызывает block_on на теле. Ровно то же можно написать руками через Runtime::new — так делают, когда нужен нестандартный конфиг числа воркеров или имени потоков. Никакого «асинхронного режима» у крейта при этом не появляется.
- Сервис на tokio под нагрузкой держит RPS, но p99 скачет. С чего начать разбор?A)Увеличить число воркеров рантайма вдвоеB)Перевести все мьютексы на асинхронныеC)Найти блокирующие вызовы и долгие вычисления в задачахD)Поднять размер буферов у всех каналов
показать ответ и разбор
+C)Найти блокирующие вызовы и долгие вычисления в задачах// разбор: Скачущий хвост при ровном RPS почти всегда означает, что кто-то занимает воркер: синхронное чтение файла, тяжёлая сериализация, обращение к блокирующему драйверу, удержанная через await блокировка. Такие места ищут по метрикам времени опроса задач и трассировке, а лечат выносом в spawn_blocking. Добавление воркеров лишь размазывает симптом.
- Внутри slow() стоит std::thread::sleep на 50 мс. Сколько займёт этот join!?
async fn slow() { std::thread::sleep(ms(50)); } tokio::join!(slow(), slow());A)Около 50 мс: join! опрашивает оба конкурентноB)Около 50 мс, но только на многопоточном рантаймеC)Около 100 мс: блокирующий сон не отдаёт управлениеD)Паника: блокирующие вызовы в async-функции запрещеныпоказать ответ и разбор
+C)Около 100 мс: блокирующий сон не отдаёт управление// разбор: join! даёт конкурентность только на точках ожидания: пока future не дошёл до await, исполнитель занят им целиком. Обычный sleep не отдаёт управление, поэтому два таких future складываются последовательно — замер даёт 100 мс против 51 мс с tokio::time::sleep. Многопоточный рантайм не спасает: join! опрашивает оба future внутри одной задачи, а задачу ведёт один воркер.
- Что произойдёт, если собрать десять future в вектор и ни один не дождаться через await?A)Все десять операций выполнятся конкурентно, а результаты будут отброшеныB)Не выполнится ничего: компилятор ограничится предупреждением must_useC)Рантайм подхватит их при следующей точке ожидания в этой задачеD)Программа запаникует при уничтожении вектора с незавершёнными future
показать ответ и разбор
+B)Не выполнится ничего: компилятор ограничится предупреждением must_use// разбор: Future ленивы: без опроса тело не начнёт работать. Вектор просто уничтожится, а компилятор напомнит предупреждением про неиспользованное значение. Чтобы операции реально пошли, вектор передают в join_all, в JoinSet или заворачивают каждую в spawn — вот тогда появляется и конкурентность, и результаты.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.