Эхо-сервер на Tokio выдаёт миллионы запросов в секунду, и цифра выглядит убедительно ровно до встречи с настоящим трафиком. Настоящий трафик это TLS, парсинг JSON, походы в PostgreSQL, кэши, медленные клиенты и всплески в три часа ночи. Синтетический benchmark измеряет пустой конвейер, продакшен проверяет весь. Разница между ними безобидна до первого инцидента, когда сервис, показывавший в тесте миллион rps, задыхается на пяти тысячах реальных запросов. Ниже разбор того, как Tokio ведёт себя под нагрузкой, где у него проседают задержки и какие инструменты показывают правду раньше, чем пользователи.

Что измеряет синтетический benchmark и что он не измеряет

Классический тест укладывается в одну строку:

wrk -t12 -c400 -d30s http://127.0.0.1:8080/plaintext

Echo и plaintext внутри Tokio тестируют почти чистый планировщик: приём соединения, запись буфера, отправка. Работа рантайма в таком цикле занимает микросекунды, и разница между фреймворками часто укладывается в единицы процентов. Реальный конвейер в сотни раз тяжелее: рукопожатие TLS, декодирование JSON, валидация полей, запрос в базу на 5-30 мс, сериализация ответа, лог. Средняя задержка из wrk не отвечает на вопрос, который задаёт бизнес: что получит пользователь на редком, но тяжёлом запросе.

Отсюда первый конфликт цифр. Синтетика рапортует о средней latency в десятки микросекунд, продакшен про p99 в десятки и сотни миллисекунд. Среднее прячет хвост: тысяча ответов по 5 мс и один ответ на три секунды дают красивое среднее и подвисший мобильный клиент. Рабочая метрика для сервиса с людьми на другом конце это гистограмма и перцентили p99 и p999, а не среднее арифметическое по логу доступа.

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

Устройство рантайма Tokio и где у него лимиты

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

let rt = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(8)
    .max_blocking_threads(256)
    .enable_all()
    .build()?;

Число воркеров по умолчанию равно числу ядер, пул блокирующих потоков рассчитан на несколько сотен, в сборке по умолчанию до 512. Задачи кооперативные: каждый poll получает бюджет порядка 128 операций планирования, после чего задача отдаёт поток, даже если не завершилась. Ввод-вывод крутится на epoll через mio, таймеры живут в иерархическом колесе. Всё это работает безупречно, пока задачи действительно кооперативные и неблокирующие. Как только одна из них засыпает в системном вызове, красивая архитектура начинает буксовать самым незаметным образом.

Задача в Tokio стоит недорого: одна аллокация и десятки байт служебной памяти, поэтому сто тысяч конкурентных задач для рантайма рутина, а не подвиг. Для утилит, сервисов с одной зависимостью и CLI-инструментов есть однопоточный рантайм new_current_thread(), экономящий потоки и очереди там, где параллелизм не нужен. Стек веб-сервиса собирается из зрелых крейтов, ветка 1.x у Tokio живёт с конца 2020 года, hyper добрался до 1.0 в декабре 2023 года, axum 0.7 вышел одновременно с ним:

[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "timeout"] }
tracing = "0.1"
tracing-subscriber = "0.3"

Зрелость стека сомнений не вызывает, вопросы начинаются на уровне привычек кода.

Отдельная ловушка поджидает на границе с синхронным миром: попытка создать Runtime::new() внутри обработчика ради пары await оборачивается новым пулом потоков на каждый вызов. Рантайм живёт один на весь процесс, а для разового ожидания из синхронного кода есть Handle::block_on, который использует уже существующий планировщик.

Блокирующие вызовы в асинхронных задачах как главная причина деградации

Злейший враг async рантайма это блокирующий вызов внутри задачи. Синхронный драйвер базы вроде diesel на 20 мс, чтение большого файла через std::fs, хеширование пароля, распаковка архива: каждый такой вызов усыпляет воркер целиком. Восемь воркеров, один из которых спит в bcrypt, теряют восьмую часть мощности без единого сообщения об ошибке. Мониторинг продолжает показывать зелёный цвет, а p99 медленно ползёт вверх, потому что очередь у оставшихся воркеров растёт.

Проверка пароля уходит в пул блокирующих потоков и оборачивается в таймаут:

#[derive(Deserialize)]
struct Login {
    user: String,
    password: String,
}

async fn login(Json(form): Json<Login>) -> StatusCode {
    let verify = tokio::task::spawn_blocking(move || {
        verify_password(&form.password)
    });

    match tokio::time::timeout(Duration::from_millis(750), verify).await {
        Ok(res) => res
            .map(|_| StatusCode::OK)
            .unwrap_or(StatusCode::UNAUTHORIZED),
        Err(_) => StatusCode::GATEWAY_TIMEOUT,
    }
}

Внешние API защищают семафором, иначе очередь к партнёрскому сервису в момент его замедления съест память:

static API_LIMIT: Semaphore = Semaphore::const_new(32);

async fn call_partner_api(client: &Client) -> Result<Bytes, Error> {
    let permit = API_LIMIT.acquire().await?;
    let resp = client.get(PARTNER_URL).send().await?;
    drop(permit);
    Ok(resp.bytes().await?)
}

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

Чисто вычислительные задачи вроде пересчёта рекомендаций уносят из Tokio вовсе: пул rayon или отдельный сервис. Внутри воркера такой цикл на 200 мс поднимает p99 для всех задач его очереди, потому что кооперативность не поможет, если задача просто не выходит из функции.

Очереди обратное давление и рост хвостовых задержек

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

let (tx, mut rx) = tokio::sync::mpsc::channel::<Job>(1024);

tokio::spawn(async move {
    while let Some(job) = rx.recv().await {
        process(job).await;
    }
});

match tx.try_send(job) {
    Ok(()) => {}
    Err(TrySendError::Full(_)) => {
        respond_with_retry_after(Duration::from_secs(5));
    }
    Err(TrySendError::Closed(_)) => {}
}

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

let listener = TcpListener::bind("0.0.0.0:8080").await?;
loop {
    let (socket, _) = listener.accept().await?;
    tokio::spawn(handle(socket));
}

Хвостовые задержки растут и от мультиплексирования: несколько запросов в одном HTTP/2 соединении выстраиваются за самым тяжёлым, и это учитывают при чтении графиков. Правило простое: всё, что копится без ограничения, рано или поздно станет инцидентом.

Ретраи без паузы превращают замедление партнёра в лавину. Если десять тысяч клиентов получат таймаут и мгновенно повторят запрос, нагрузка удвоится именно тогда, когда партнёр хуже всего готов её принять. Лечится экспоненциальной задержкой с джиттером и общим дедлайном на попытку. Массовую часть проблемы закрывает TimeoutLayer из tower-http: один middleware ставит потолок времени на все обработчики, а ручная обработка остаётся там, где у ошибки есть свой смысловой ответ.

Наблюдение за рантаймом через tokio-console и метрики

Tokio поставляется с собственным профилировщиком задач. На старте инициализируется подписчик консоли, а сборка ведётся с флагом нестабильного API:

RUSTFLAGS="--cfg tokio_unstable" cargo build --release
fn main() {
    console_subscriber::init();
    // дальше запуск сервера
}

Подключившись утилитой tokio-console, разработчик видит живой список задач: время пробуждения, длительность poll, ожидания. Задача, которая будится чаще всех или живёт подозрительно долго, вычисляется за минуты, а не за дни слепого разбора логов. В свежих версиях рантайма появилась выгрузка состояния задач, а метод Runtime::metrics() выдаёт счётчики живых задач и парковок. Дополняют картину гистограммы в системах метрик: границы бакетов подбирают под ожидаемые задержки, иначе p999 останется статистической фикцией внутри одного широкого интервала.

Для трассировки расходов подходит макрос instrument, превращающий функцию в span с аргументами:

#[tracing::instrument(skip(pool))]
async fn get_user(pool: &PgPool, id: i64) -> Result<User, Error> {
    let row = sqlx::query!("select name, email from users where id = $1", id)
        .fetch_one(pool)
        .await?;
    Ok(row.into())
}

Span показывает, где запрос провёл время: в базе, в сериализации или в очереди к семафору. Без трассировки инженер видит только итоговую цифру в логе и вынужден гадать о причине.

Профилирование и аллокатор скрытые резервы под реальной нагрузкой

Когда рантайм чист, а сервис всё равно упирается в потолок, смотрят на CPU и память. Профиль снимается штатными средствами системы:

perf record -F 99 -p $(pgrep -f app) -g
perf report --no-children

Горячие точки в парсере или сериализаторе правятся точечно, а не заменой фреймворка. Второй резерв это аллокатор. Дефолтный системный malloc под высокой конкурентностью спорит за глобальные блокировки, и замена его на mimalloc или jemalloc иногда даёт заметный прирост без изменения кода:

[dependencies]
mimalloc = "0.1"
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

Замена аллокатора без профиля это лотерея: на сервисах с редкими крупными выделениями выигрыша не будет, на потоке мелких короткоживущих структур он бывает двузначным. Между mimalloc и jemalloc разница тоже заметнее, чем кажется по списку фич: первый экономнее по памяти и быстрый на старте, второй увереннее держит огромные долгоживущие процессы. Выбор делается замером на реальном профиле нагрузки, а не по отзывам. Сначала профиль, потом решение, иначе настройка превращается в суеверие.

Практические правила настройки Tokio для продакшена

Опыт инцидентов сворачивается в короткий список правил, который стоит держать перед глазами при ревью конфигурации сервиса:
1. измерять перцентили p99 и p999 вместо средней задержки;
2. выносить блокирующие операции в spawn_blocking и ограничивать их семафором;
3. ставить таймауты на внешние вызовы и обрабатывать отказы явно;
4. использовать ограниченные очереди, чтобы давление возвращалось клиенту;
5. подключать tokio-console и метрики рантайма до инцидента, а не после;
6. менять аллокатор и число воркеров только по итогам профиля.

Файловые дескрипторы проверяются до релиза: ulimit -n 65536 для сервиса с десятками тысяч соединений это гигиена, а не роскошь. Число воркеров под CPU-тяжёлую смесь иногда осознанно урезают, чтобы оставить ядра соседним процессам контейнера. Синтетический тест при всём этом не потерял ценности: он ловит регрессии между сборками и сравнивает версии рантайма. Просто его место в конвейере CI, а не в отчёте о мощности продакшена. Реальное поведение Tokio определяется не скоростью планировщика, а дисциплиной вокруг него: блокирующие вызовы под контролем, очереди с пределами, таймауты на границах и метрики, которые видят хвост. Замеры до и после каждой правки фиксируются в CI на сценарии, близком к реальному: иначе команда будет оптимизировать то, что легко измерить, а не то, что болит. В большинстве расследований виноватым оказывается не рантайм, а недисциплинированный код рядом с ним, и это хорошая новость: чинить нужно своё, а не переписывать платформу.