Привет, Хабр!

У синхронного зависания в Rust есть приятное свойство: поток стоит на конкретной строке, и gdb или дамп покажут вам её. С async всё иначе. Задача, которая «висит», физически не занимает поток, она просто не возвращается в исполнитель. В стеке потока её нет, точнее, там будет исполнитель Tokio, который вежливо ждёт работу, и ни слова о том, какая ваша задача застряла и почему.

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

Разберём, как такие вещи диагностировать — от простых случаев с блокирующим вызовом до тонких дедлоков на честном async‑мьютексе.

Всё на Tokio, потому что это де‑факто стандарт, но принципы переносятся на любой исполнитель.

Три вопроса, с которых стоит начинать

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

  • Загружен ли CPU. Высокая загрузка при висящих запросах — синхронная работа на async‑потоке. Низкая — ожидание чего‑то, что не наступает.

  • Висят все запросы или часть. Все — скорее всего исполнитель встал целиком: все потоки заблокированы. Часть — либо конкретная задача застряла на своём ресурсе, либо проблема в определённом пути кода.

  • Постоянно или под нагрузкой. Постоянное зависание — обычно дедлок в логике. Зависание, появляющееся под нагрузкой, — исчерпание потоков, переполнение каналов, конкуренция за мьютекс.

Эти три вопроса не дают ответа, но резко сужают, куда смотреть дальше. Теперь по механике.

Первое, что нужно понять: как выглядит «завис» в async

Прежде чем ловить, договоримся о механике. Async‑задача — это стейт‑машина, которую исполнитель опрашивает вызовом poll. Задача либо возвращает Ready (готова), либо Pending (не готова, разбудите позже). Когда задача возвращает Pending, она обязана оставить исполнителю способ себя разбудить — waker. Разбудили — задачу снова поставят в очередь на poll.

Зависнуть задача может двумя принципиально разными способами, и лечатся они по‑разному.

  • Первый: задача вернула Pending, но её никто никогда не разбудит. Waker потерян, событие, которого она ждёт, не наступит, или наступило до того, как она подписалась. Задача лежит мёртвым грузом, исполнитель про неё забыл.

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

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

Блокирующий вызов на async‑потоке

Самый частый и самый простой в диагностике случай — второй тип, синхронная работа на потоке исполнителя.

async fn handle_request(path: String) -> Vec<u8> {
    let data = std::fs::read(&path).unwrap();     // блокирующее чтение
    process(data)
}

std::fs::read блокирует поток, пока диск не ответит. У Tokio по умолчанию потоков примерно по числу ядер, и каждый такой вызов выключает один из них из работы. Восемь одновременных чтений на восьмиядерной машине — и весь исполнитель встал: свободных потоков нет, все ждут диск, а остальные задачи, даже готовые, некому опросить.

Симптом характерный: под нагрузкой латентность резко скачет, а профиль CPU при этом низкий — потоки не считают, а ждут. Вот это расхождение «латентность есть, CPU нет» — почти верный признак блокировки на async‑потоке.

Как подтвердить. У Tokio есть флаг сборки, который вставляет проверки на слишком долгое удержание потока:

RUSTFLAGS="--cfg tokio_unstable" cargo build

С включённым tokio-console (о нём ниже) задачи, которые долго не отдают управление, помечаются прямо в интерфейсе — видно, какая именно и сколько держит поток.

Лечится выносом блокирующей работы туда, где блокировка разрешена. Для файлов и сети — асинхронные аналоги:

async fn handle_request(path: String) -> Vec<u8> {
    let data = tokio::fs::read(&path).await.unwrap();
    process(data)
}

Если асинхронного аналога нет (CPU‑тяжёлая работа, синхронная библиотека без альтернативы), её отправляют в отдельный пул для блокирующих задач:

let data = tokio::task::spawn_blocking(move || {
    expensive_sync_computation(input)
}).await.unwrap();

spawn_blocking держит собственный пул потоков, рассчитанный на то, что они будут простаивать в ожидании. Работа на нём не отнимает потоки у основного исполнителя.

Мьютекс из std, удержанный через.await

Тонкий и очень частый дедлок. Выглядит невинно.

use std::sync::Mutex;

async fn update(state: Arc<Mutex<State>>) {
    let mut guard = state.lock().unwrap();
    guard.value = fetch_from_db().await;          // держим std-мьютекс через await
}

Здесь два бага сразу, и оба неприятные.

  • Первый: std::sync::MutexGuard не умеет пересекать .await. Компилятор это обычно ловит — гард не Send, и задачу нельзя отправить в многопоточный исполнитель. Но если задача почему‑то оказалась не Send по другим причинам или исполнитель однопоточный, проверка не сработает.

  • Второй, тот, что доводит до дедлока: пока задача висит на .await внутри fetch_from_db, она держит захваченный std‑мьютекс. Задача приостановилась, поток ушёл выполнять другую задачу — а та полезла за тем же мьютексом и заблокировала поток целиком, синхронно. Теперь первая задача не может продолжиться, потому что её разбудят на том же потоке, который намертво стоит на lock(). Классический клинч.

Правило простое: если блокировку нужно держать через .await, это должен быть async‑мьютекс.

use tokio::sync::Mutex;

async fn update(state: Arc<Mutex<State>>) {
    let mut guard = state.lock().await;
    guard.value = fetch_from_db().await;          // async-мьютекс это переживёт
}

tokio::sync::Mutex при ожидании не блокирует поток — задача корректно приостанавливается, освобождая поток для других. Дедлока не будет.

Но есть нюанс, о котором забывают: async‑мьютекс дороже обычного. Если блокировку не надо держать через .await, а нужно быстро что‑то поменять в памяти, обычный std::sync::Mutex правильнее — просто захватывайте и освобождайте его в пределах одного синхронного участка, не пересекая .await:

async fn increment(state: Arc<std::sync::Mutex<State>>) {
    {
        let mut guard = state.lock().unwrap();
        guard.counter += 1;
    }                                             // гард отпущен ДО await
    notify().await;
}

Явный блок { } тут гарантирует, что гард уничтожится до точки приостановки. Диагностика такого дедлока: если сервис встаёт под конкуренцией за общее состояние, первое, что проверяют, — не держится ли где‑то синхронный мьютекс через .await.

Задача, которую забыли и потеряли

Возврат к первому типу зависания — потерянный waker или брошенная задача.

let (tx, rx) = tokio::sync::oneshot::channel();

tokio::spawn(async move {
    let result = compute().await;
    let _ = tx.send(result);
});

let value = rx.await.unwrap();                    // а если задача паникнула?

Если задача внутри spawn паникует, канал tx уничтожается, не отправив значение. rx.await при этом не виснет — он получит ошибку о закрытом канале. А вот если задачу просто никогда не запланировали или she завершилась, не отправив, — rx.await будет ждать вечно.

Ещё хуже, когда JoinHandle от spawn просто выбрасывают:

tokio::spawn(background_work());                  // handle потерян

Паника внутри такой задачи не всплывёт нигде — она уедет в JoinHandle, который никто не читает. Задача умерла, а вы об этом не узнаете, пока не заметите, что фоновая работа не делается.

Диагностировать потерянные задачи помогает то, что Tokio умеет отдавать список всех живых задач. Но самый практичный инструмент — tokio-console.

tokio‑console: профайлер для задач

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

Подключается инструментированием приложения:

#[tokio::main]
async fn main() {
    console_subscriber::init();
    // остальной код
}

и сборкой с флагом:

RUSTFLAGS="--cfg tokio_unstable" cargo run

В другом терминале запускается сам tokio-console, и вы видите живую картину. Для диагностики зависаний он показывает ровно то, что нужно.

  1. Задача, которая давно не просыпалась и висит в Pending, — кандидат на потерянный waker. Видно, когда её последний раз опрашивали.

  2. Задача, которая держит поток и долго не отдаёт управление, помечается предупреждением — это ваш блокирующий вызов на async‑потоке.

  3. Резко растущее число задач — утечка: где‑то спавнятся задачи, которые не завершаются. Счётчик живых задач ползёт вверх и не падает.

Без tokio-console те же вещи ищутся вслепую, по логам и догадкам. С ним зависшая задача видна в списке, и обычно сразу понятно, какая именно.

select!, который отменил половину работы

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

loop {
    tokio::select! {
        msg = socket.read() => handle(msg),
        _ = interval.tick() => flush_buffer(),
    }
}

select! запускает несколько веток одновременно и берёт ту, что завершилась первой. А остальные — отменяет, то есть роняет их будущие на месте. Если socket.read() успел прочитать полсообщения из сокета, но проиграл гонку тику интервала, эта прочитанная половина пропадает: будущее отменено, состояние внутри него уничтожено, данные из буфера сокета уже забраны и потеряны.

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

Причина в том, что не все будущие безопасно отменять на произвольной точке. Будущее, которое между опросами держит важное состояние (частично прочитанный кадр, захваченную позицию в потоке), при отмене это состояние теряет. В документации Tokio такие места помечаются как «cancellation safe» и «not cancellation safe», и читать это надо до того, как класть вызов в select!.

Диагностический признак: если данные бьются именно в коде с select! и частота зависит от того, как часто срабатывают другие ветки, — почти наверняка отменяется что‑то, что отменять нельзя. Лечится либо использованием методов, помеченных как cancellation safe, либо выносом чувствительного чтения в отдельную задачу, которую select! не может оборвать посередине.

Кооперативная планировка, из‑за которой всё замирает

Более тонкий случай второго типа. Задача честно асинхронная, с .await, но между ними крутит слишком много работы.

async fn process_all(items: Vec<Item>) {
    for item in items {
        heavy_sync_transform(&item);              // тяжёлая синхронная работа
        // ни одного .await на всю итерацию
    }
}

Формально задача асинхронная — она содержит .await где‑то снаружи. Но внутри цикла точек приостановки нет, и пока цикл крутится, задача не отдаёт управление. Миллион элементов — и на всё время обработки поток занят одной задачей, остальные ждут.

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

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

Лечится расстановкой точек уступки:

async fn process_all(items: Vec<Item>) {
    for (i, item) in items.iter().enumerate() {
        heavy_sync_transform(item);
        if i % 100 == 0 {
            tokio::task::yield_now().await;       // отдаём управление
        }
    }
}

yield_now возвращает управление исполнителю, давая другим задачам поработать. Но если работа реально тяжёлая, честнее унести её в spawn_blocking целиком, а не пытаться прослоить yield.

Чужой исполнитель под capot

Коварный дедлок, который возникает при смешивании исполнителей.

fn sync_callback() -> Data {
    futures::executor::block_on(async {          // НЕ исполнитель Tokio
        tokio_based_async_call().await
    })
}

Ситуация типовая: есть синхронный колбэк (в тесте, в FFI, в чужом API), а вызвать надо async‑код на Tokio. В ход идёт block_on из futures или из другого исполнителя — и код зависает намертво, причём непонятно почему: ресурс свободен, конкуренции нет.

Причина глубокая. Tokio использует кооперативную планировку с бюджетом: чтобы одна задача не заморила остальные, исполнитель иногда возвращает Pending не потому что ресурс занят, а чтобы задача уступила. Это нормально для Tokio — он понимает такой Pending и вскоре опросит задачу снова. Но futures::executor::block_on — не Tokio. Он видит Pending и честно засыпает, ожидая, что его разбудят. А будить некому — механизм принадлежит Tokio, которого тут нет.

Правило: async‑код, использующий примитивы Tokio, должен исполняться Tokio. Мост из синхронного кода строят через Handle существующего исполнителя, а не через чужой block_on:

fn sync_callback(handle: tokio::runtime::Handle) -> Data {
    tokio::task::block_in_place(|| {
        handle.block_on(async {
            tokio_based_async_call().await
        })
    })
}

handle.block_on использует настоящий исполнитель Tokio, а block_in_place предупреждает исполнитель, что текущий поток временно заблокируется, чтобы тот перераспределил задачи. Этот дедлок особенно любит прятаться в тестах, где для запуска async‑кода из синхронного теста берут первый попавшийся block_on.

Канал, который переполнился и всё остановил

Ещё один способ получить «висит», не имея ни одного дедлока.

let (tx, mut rx) = tokio::sync::mpsc::channel(100);

// производитель
tokio::spawn(async move {
    loop {
        let item = produce().await;
        tx.send(item).await.unwrap();      // блокируется, когда канал полон
    }
});

Ограниченный канал на сто элементов — это обратное давление, и обычно оно полезно. Но если потребитель по какой‑то причине встал — упал, завис на другом дедлоке, тормозит на медленной операции, — канал за секунды заполняется, и tx.send().await начинает висеть на каждой отправке. Производитель встал следом. Со стороны это выглядит как зависание всей цепочки, хотя первопричина — один застрявший потребитель в конце.

Диагностика через tokio-console показывает это наглядно: задача‑производитель постоянно в состоянии ожидания на отправке, при этом задача‑потребитель либо исчезла (паника), либо сама висит на чём‑то своём. Идти надо от конца цепочки к началу: кто последний в конвейере и почему он не разгребает.

Обратная беда — неограниченный канал. Он send не блокирует никогда, и если потребитель отстаёт, очередь растёт, пока не съест память. Тут симптом не зависание, а рост потребления памяти, и в tokio-console видно, что число задач стабильно, а память ползёт. Правило простое: неограниченный канал допустим только там, где вы доказали, что производитель не обгонит потребителя, — а это почти никогда.

Что делать, когда прод завис прямо сейчас

Отдельно про ситуацию, когда сервис висит в бою, а tokio-console вы заранее не подключили.

  • Первое — снять дамп потоков. Даже без имён задач видно, чем заняты потоки исполнителя. Если они стоят на lock() из std::sync — у вас синхронный мьютекс через .await. Если на syscall чтения или записи — блокирующий ввод‑вывод. Если все потоки в parking и ничего не делают — задачи потеряли wakers или заняты чем‑то, что дамп не показывает.

# если есть доступ к процессу
gdb -p <pid> -batch -ex "thread apply all bt"
  • Второе — метрики исполнителя. Tokio отдаёт их программно: число задач, глубина очереди, число заблокированных потоков. Растущая очередь при простаивающих потоках — блокировка. Растущее число задач — утечка спавнов.

  • Третье, на будущее: не полагаться на диагностику постфактум. Каждый внешний вызов оборачивать таймаутом, чтобы зависание превращалось в ошибку с понятным местом, а не в вечное ожидание:

let result = tokio::time::timeout(
    Duration::from_secs(5),
    external_call()
).await;

match result {
    Ok(data) => process(data),
    Err(_) => return Err("внешний вызов не уложился в таймаут"),
}

Таймаут не чинит причину зависания, но превращает «висит вечно и молчит» в «упало здесь через пять секунд» — а это уже отлаживаемо.

Как подступаться к такому багу

Диагностика зависшего async сводится к одному вопросу в начале: задача ждёт или задача работает. Ответ даёт CPU. Низкая загрузка при растущей латентности — задачи ждут: блокирующий ввод‑вывод, потерянный waker, дедлок на мьютексе. Высокая загрузка — задачи молотят синхронно и не уступают: тяжёлый цикл без .await, чужой исполнитель, забуксовавшая планировка.

Дальше инструмент зависит от того, был ли ты готов заранее. Если да — tokio-console показывает зависшую задачу прямо в списке, с временем последнего пробуждения и предупреждениями про удержание потока. Если нет — дамп потоков плюс метрики исполнителя, и по ним восстанавливаешь картину: на чём стоят потоки, растёт ли очередь, множатся ли задачи.

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

И главный вывод из всего этого на будущее — большую часть таких багов дешевле предотвратить, чем ловить. Async‑мьютекс там, где блокировка живёт через .await. Блокирующие вызовы — только через spawn_blocking. Таймаут на каждый внешний вызов. tokio-console, подключённый до того, как понадобится. Это не устраняет зависания полностью, но переводит их из разряда «сервис молча висит, а стек пустой» в разряд обычных отлаживаемых ошибок.

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

На курсе «Rust‑разработчик. Продвинутый уровень» эти темы разбирают системно и связывают с прикладными задачами — от модели владения до разработки полноценных приложений. Чтобы узнать больше о формате обучения и принять участие в практических разборах, приходите на открытые демо-уроки от преподавателей курса:

  • 9 сентября в 20:00. «Владение, заимствование и ссылки в Rust: как компилятор делает ваш код безопасным». Записаться

  • 23 сентября в 20:00. «Создание кроссплатформенного приложения с GUI на Rust: От идеи до реальности». Записаться

Больше бесплатных уроков августа смотрите в дайджесте.