Как писать быстрые приложения на Tokio: гайд по асинхронному Rust

Автор, подписавшийся в тексте только именем Russell, опубликовал на блоге проекта dial9 пост «Principles for Fast Tokio Applications», первый черновик того, что он называет «живым документом» лучших практик для асинхронных приложений на Rust-рантайме Tokio. Материал написан по итогам дискуссии на Unconf в рамках конференции RustConf, где обсуждали отладку и бенчмаркинг асинхронных приложений; автор планирует дополнить пост примером приложения, показывающим разобранные проблемы вместе с тем, как они выглядят в трассировке его инструмента dial9.

Первый принцип, сначала убедиться, что проблема вообще есть. Почти в любом реальном Tokio-приложении можно найти «поллы» (poll, промежуток между точками .await, когда код возвращает управление рантайму) длиннее рекомендованных 10, 100 микросекунд, эта цифра взята из поста Alice Ryhl «What is Blocking?». По опыту автора, проблема почти всегда обнаруживается в коде самого приложения, а не в Tokio, и часто, во взаимодействии между компонентами распределённой системы; его инструмент dial9 чаще подтверждает отсутствие проблемы в Tokio, чем находит её. Самая полезная метрика для диагностики, гистограмма schedule latency (недавно добавлена в метрики Tokio): время между готовностью задачи к выполнению и её фактическим опросом планировщиком.

Второй принцип, баланс между частым возвратом управления рантайму (для латентности) и группировкой работы (для пропускной способности). Пример, сервер, поддерживающий конвейеризацию запросов (как Redis): наивная реализация вычитывает все данные из буфера сокета подряд, не отдавая управление рантайму, из-за чего один клиент может надолго заблокировать обработку другого. Явный вызов yield после каждого запроса снижает латентность в этом примере примерно в 10 раз. Признаки проблемы: P99-латентность намного выше P50, отдельные вызовы poll занимают больше времени, чем должна занимать работа внутри них, и на один poll приходится много спанов трассировки.

Третий принцип, группировать работу, чтобы амортизировать накладные расходы планировщика: каждое обращение к общему пулу воркеров или к блокирующему пулу потоков (blocking pool) имеет свою цену. Автор называет tokio::fs потенциально вредным: без io_uring каждая файловая операция выполняется в блокирующем пуле, а каждый вызов spawn_blocking несёт отдельные накладные расходы. Блокирующий пул и глобальная очередь задач, общие для всего рантайма ресурсы: автор наблюдал деградацию производительности начиная примерно с 50 000 блокирующих задач в секунду на 32-ядерном хосте.

Четвёртый принцип, осторожность с мьютексами: воркер, заблокированный на конкурентном мьютексе, может застопорить весь рантайм, если остальные воркеры рано или поздно попытаются взять тот же лок (например, при записи метрик под общим мьютексом). Критические секции в асинхронном коде должны быть предельно короткими. По мнению автора, RWLock почти никогда не подходит, конкуренция на атомарных операциях возникает даже на пути чтения; tokio::sync::Mutex обходится дороже обычного мьютекса и оправдан, только если критическая секция длится миллисекунды.

Пятый принцип, ограничивать параллелизм: Tokio легко порождает больше задач, чем способна обработать остальная система, например, случайное открытие 3000 одновременных соединений к S3 из-за неограниченного разветвления задач. Автор рекомендует простой семафор. Шестой принцип, изолировать воркеры Tokio от остальных потоков ОС: при высокой загрузке системы планировщику ядра может потребоваться 10, 20 мс и больше, чтобы разбудить воркер, а фоновые потоки (например, у tracing_appender) иногда занимают процессор больше 100 мс без переключения. Автор наблюдал этот эффект при постепенной миграции сервиса с Java на Rust в Amazon, когда оба процесса работали на одном хосте: чем меньше работы оставалось Java-процессу, тем быстрее становился Rust-процесс. Решение, закрепить воркеры Tokio и фоновую работу за разными ядрами процессора через cgroups.

Отдельный раздел поста, приёмы «для тех, кто знает, что делает». Блокировка исполнителя иногда допустима: при лёгкой нагрузке механизм work stealing в Tokio компенсирует занятость одного воркера, но перестаёт справляться, если перегружен либо сам рантайм, либо операционная система. Автор отдельно предупреждает: это не работает внутри одной задачи при использовании tokio::join! и tokio::select!, там нет разделения работы между воркерами, и блокировка исполнителя остановит всё, что выполняется в этой задаче. Последний описанный приём, использовать несколько рантаймов, чтобы изолировать нагрузки разного приоритета друг от друга; на этом сохранённый текст поста обрывается.

Ключевые факты

  • Автор Russell выпустил на блоге dial9 первый черновик «живого документа» с принципами производительности асинхронных приложений на Tokio, по итогам дискуссии на Unconf конференции RustConf.
  • Явный yield после каждого запроса в конвейеризированном сервере (пример с Redis) снижает латентность примерно в 10 раз ценой возможной потери части пропускной способности.
  • Общий блокирующий пул Tokio деградирует начиная примерно с 50 000 блокирующих задач в секунду на 32-ядерном хосте; без io_uring файловые операции (tokio::fs) всегда идут через этот пул.
  • RWLock и tokio::sync::Mutex почти никогда не подходят для коротких критических секций в асинхронном коде, конкурентный мьютекс способен застопорить весь рантайм.
  • При высокой загрузке ОС ядро может будить воркер Tokio с задержкой 10, 20 мс и больше; автор наблюдал это при миграции с Java на Rust в Amazon и советует закреплять воркеры за отдельными ядрами через cgroups.

Почему это важно

Пост закрывает разрыв между документацией Tokio и тем, что реально ловят в проде: большинство перечисленных проблем, не баги рантайма, а типовые ошибки взаимодействия прикладного кода с планировщиком, которые почти никогда не видны на локальных бенчмарках. Материал собран по итогам живого обсуждения на RustConf Unconf и заявлен как открытый «живой документ», автор прямо приглашает присылать issue и PR, то есть текст ещё будет дополняться.

Кому это важно

В первую очередь, инженерам, которые пишут и эксплуатируют сетевые сервисы на Rust поверх Tokio: авторам протокольных серверов вроде Redis-подобных бэкендов, командам, мигрирующим сервисы с других языков (в тексте, пример миграции с Java на Rust внутри Amazon), и всем, кто по трассировкам и метрикам разбирает, почему P99-латентность продакшен-сервиса выше ожидаемой.

Как это применить

Автор даёт конкретные диагностические признаки для каждой проблемы и способ её проверить: смотреть гистограмму schedule latency и соотношение P99/P50 в метриках Tokio; в циклах, читающих буферизованные данные (как в примере с Redis), явно вызывать tokio::task::yield_now() для честного разделения времени между клиентами; группировать файловые и другие блокирующие операции в один более крупный блокирующий участок вместо множества мелких spawn_blocking; ограничивать конкурентность семафором вместо неограниченного разветвления задач; держать критические секции мьютексов предельно короткими и не делать I/O или await под локом; при высокой загрузке ОС закреплять воркеры Tokio за отдельными ядрами процессора через cgroups. Инструменты для диагностики, dial9 (трассировка задач автора) и tokio-metrics.

Можно ли доверять

Источник, личный блог проекта dial9, автор представлен только именем Russell, без фамилии и текущего места работы; упоминание Amazon относится к прошлому опыту, а не к текущей позиции. Пост опирается на обсуждение на открытой конференции RustConf (формат Unconf) и ссылается на признанный в сообществе Tokio материал Alice Ryhl «What is Blocking?», но сам является первым черновиком «живого документа» без версии и даты публикации, автор прямо приглашает присылать правки через issue и PR, то есть текст не финализирован.

Риски и подводные камни

Сам автор подчёркивает, что почти любой совет в этой области работает по принципу «it depends», ответ зависит от того, что ещё выполняется на рантайме в этот момент. Например, слишком частый yield увеличивает справедливость между клиентами, но может снизить общую пропускную способность; tokio::sync::Mutex «решает» проблему обычного мьютекса ценой более дорогой блокировки и подверженности отдельным тонким багам (в тексте упомянут FutureLock, без разбора). Отдельно оговорено исключение: правило «блокировка исполнителя иногда допустима» не действует внутри одной задачи с tokio::join! или tokio::select!, там нет разделения работы между воркерами, и блокировка остановит всё, что выполняется в этой задаче. Сохранённый текст источника обрывается на полуслове в разделе про изоляцию нагрузок несколькими рантаймами, что автор написал дальше, по этому материалу не проверить.