сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Async и tokio

Ловушки 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, остальные разбираются в тренажёре.

  1. #rs_async_pitfalls1 / 5
    Что происходит с задачами, когда потребитель канала медленнее производителя?
    A)Сообщения начинают теряться, освобождая место новым
    B)Очередь растёт и съедает память, если канал не ограничен
    C)Производитель падает с ошибкой переполнения очереди
    D)Рантайм притормаживает производителя автоматически
    показать ответ и разбор
    +B)Очередь растёт и съедает память, если канал не ограничен

    // разбор: Безграничный канал ведёт себя тихо ровно до того момента, когда память кончается: очередь растёт, задержки растут вместе с ней. Ограниченный канал даёт обратное давление — send ждёт места, и производитель естественным образом замедляется. Именно поэтому ёмкость выбирают осознанно, а не берут unbounded по умолчанию.

  2. #rs_async_pitfalls2 / 5
    Что делает #[tokio::main] над функцией main?
    A)Разворачивает её в обычную main с рантаймом и block_on
    B)Запускает main в отдельном потоке рантайма
    C)Помечает main как асинхронную для компилятора
    D)Включает асинхронный режим для всех функций крейта
    показать ответ и разбор
    +A)Разворачивает её в обычную main с рантаймом и block_on

    // разбор: Макрос переписывает функцию: создаёт рантайм с настройками по умолчанию и вызывает block_on на теле. Ровно то же можно написать руками через Runtime::new — так делают, когда нужен нестандартный конфиг числа воркеров или имени потоков. Никакого «асинхронного режима» у крейта при этом не появляется.

  3. #rs_async_pitfalls3 / 5
    Сервис на tokio под нагрузкой держит RPS, но p99 скачет. С чего начать разбор?
    A)Увеличить число воркеров рантайма вдвое
    B)Перевести все мьютексы на асинхронные
    C)Найти блокирующие вызовы и долгие вычисления в задачах
    D)Поднять размер буферов у всех каналов
    показать ответ и разбор
    +C)Найти блокирующие вызовы и долгие вычисления в задачах

    // разбор: Скачущий хвост при ровном RPS почти всегда означает, что кто-то занимает воркер: синхронное чтение файла, тяжёлая сериализация, обращение к блокирующему драйверу, удержанная через await блокировка. Такие места ищут по метрикам времени опроса задач и трассировке, а лечат выносом в spawn_blocking. Добавление воркеров лишь размазывает симптом.

  4. #rs_async_pitfalls4 / 5
    Внутри 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 внутри одной задачи, а задачу ведёт один воркер.

  5. #rs_async_pitfalls5 / 5
    Что произойдёт, если собрать десять future в вектор и ни один не дождаться через await?
    A)Все десять операций выполнятся конкурентно, а результаты будут отброшены
    B)Не выполнится ничего: компилятор ограничится предупреждением must_use
    C)Рантайм подхватит их при следующей точке ожидания в этой задаче
    D)Программа запаникует при уничтожении вектора с незавершёнными future
    показать ответ и разбор
    +B)Не выполнится ничего: компилятор ограничится предупреждением must_use

    // разбор: Future ленивы: без опроса тело не начнёт работать. Вектор просто уничтожится, а компилятор напомнит предупреждением про неиспользованное значение. Чтобы операции реально пошли, вектор передают в join_all, в JoinSet или заворачивают каждую в spawn — вот тогда появляется и конкурентность, и результаты.

дальше

Теорию прочитали. Навык ставится повторением

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