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

Рантайм tokio: как устроен асинхронный Rust

Рантайм

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

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

// Формулировки: «почему нужен tokio?», «из чего состоит рантайм?», «что будет от блокирующего вызова в задаче?»

Контракт в std, исполнитель снаружи

В стандартной библиотеке есть Future, Poll и Waker - этого достаточно, чтобы компилятор умел async/await. А кто именно опрашивает задачи, следит за таймерами и сокетами, решает рантайм: tokio, smol, async-std, embassy для встраиваемых систем.

Это осознанное решение. Серверу нужен многопоточный планировщик с воровством задач, микроконтроллеру - крошечный исполнитель без операционной системы. Единый встроенный рантайм устроил бы не всех и утащил бы в стандартную библиотеку огромный кусок инфраструктуры.

// Устроен рантайм из двух частей: исполнитель крутит готовые задачи, реактор через epoll или kqueue ждёт готовности сокетов и будит нужные задачи через waker.

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

Кооперативность и её цена

Планирование кооперативное: пока задача не дошла до await, воркер принадлежит ей целиком. Вытеснения нет. Поэтому счётный цикл на 200 миллисекунд внутри обработчика останавливает и все остальные задачи этого воркера - сервис выглядит живым, а p99 разъезжается.

Проверено на tokio: две задачи, одна из которых делает std::thread::sleep на 200 мс, укладываются последовательно - блокирующий вызов держит воркер. Асинхронный tokio::time::sleep возвращает Pending и освобождает его.

// Лечение стандартное: счётную работу - в spawn_blocking или в пул rayon, длинные циклы разбавлять tokio::task::yield_now, синхронные драйверы заменять асинхронными.

кооперативность
управление отдаётся только на точках ожидания
spawn_blocking
перенос блокирующей работы в отдельный пул потоков

Многопоточный и однопоточный

Многопоточный планировщик раскладывает задачи по воркерам и может перенести задачу с одного на другой - отсюда требование Send ко всему, что живёт через await. Это самый частый источник загадочных ошибок компиляции в async-коде.

Рантайм current_thread крутит всё в одном потоке: требование Send снимается, можно держать Rc и не-Send библиотеки, удобно для тестов и CLI. Плата - нет параллелизма по ядрам, и один блокирующий вызов останавливает вообще всё.

current_thread
однопоточный рантайм без требования Send
воркер
поток рантайма, исполняющий задачи

Как отвечать: «Что произойдёт, если в асинхронном обработчике сделать тяжёлое вычисление?»

Оно займёт воркер целиком: планирование кооперативное, вытеснения нет, и пока задача не дошла до await, остальные задачи этого воркера просто ждут. Снаружи это выглядит коварно - сервис жив, RPS держится, а p99 разъезжается. Лечу переносом счётной работы в spawn_blocking или в пул rayon, а если цикл длинный и разбить его нельзя, вставляю yield_now, чтобы периодически отдавать управление. То же касается блокирующих вызовов: обычный thread::sleep или синхронный драйвер базы держат воркер так же надёжно, как вычисление.

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

На чём валятся

  • Ждут, что планировщик вытеснит долгую задачу.
  • Ставят синхронный драйвер базы в асинхронный сервис.
  • Не знают, что Send требуется из-за переноса задач между воркерами.
  • Считают, что #[tokio::main] делает крейт «асинхронным» - он лишь строит рантайм и вызывает block_on.
  • Берут многопоточный рантайм там, где нужен current_thread, и потом борются с Send.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.

  1. #rs_runtime1 / 5
    Из каких частей состоит асинхронный рантайм?
    A)Планировщик задач и сборщик мусора для завершённых future
    B)Пул потоков и очередь сообщений между ними
    C)Интерпретатор состояний и таблица активных соединений
    D)Исполнитель для задач и реактор для событий ввода-вывода
    показать ответ и разбор
    +D)Исполнитель для задач и реактор для событий ввода-вывода

    // разбор: Исполнитель опрашивает готовые задачи, реактор через epoll или kqueue ждёт готовности сокетов и таймеров и будит нужные задачи через waker. В tokio к этому добавлен многопоточный планировщик с воровством задач и отдельный пул для блокирующих вызовов. Сборщика мусора нет — задачи живут по обычным правилам владения.

  2. #rs_runtime2 / 5
    Когда однопоточный рантайм (current_thread) уместнее многопоточного?
    A)Когда нужна максимальная пропускная способность сервиса
    B)Когда задачи держат не-Send состояние или нагрузка невелика
    C)Когда в задачах есть блокирующие вызовы файловой системы
    D)Когда задач очень много: один поток экономит переключения контекста
    показать ответ и разбор
    +B)Когда задачи держат не-Send состояние или нагрузка невелика

    // разбор: Многопоточный планировщик может перенести задачу между воркерами, поэтому требует Send. Однопоточный это требование снимает — удобно для Rc и не-Send библиотек, для тестов и CLI. Пропускная способность у него ниже: параллельности по ядрам нет, а один блокирующий вызов останавливает всё.

  3. #rs_runtime3 / 5
    В асинхронный обработчик попал тяжёлый счётный цикл на 200 мс. Что произойдёт в многопоточном рантайме?
    A)Планировщик вытеснит задачу и продолжит остальные
    B)Задача переедет в отдельный поток автоматически
    C)Воркер занят целиком: его задачи ждут, растёт хвост
    D)Рантайм создаст дополнительный воркер под нагрузку
    показать ответ и разбор
    +C)Воркер занят целиком: его задачи ждут, растёт хвост

    // разбор: Планирование кооперативное: пока задача не дошла до await, воркер принадлежит ей. Другие задачи этого воркера ждут, и p99 разъезжается — при этом сервис выглядит живым. Лечится выносом счётной работы в spawn_blocking или в пул rayon, а для длинных циклов помогает периодический yield_now.

  4. #rs_runtime4 / 5
    Почему std::thread::sleep внутри async-задачи — ошибка?
    A)Он усыпляет весь воркер вместе с остальными его задачами
    B)Он завершает задачу по таймауту рантайма
    C)Он вызывает панику: рантайм запрещает блокирующие вызовы
    D)Он не компилируется в асинхронном контексте без unsafe
    показать ответ и разбор
    +A)Он усыпляет весь воркер вместе с остальными его задачами

    // разбор: Обычный sleep блокирует поток операционной системы — задача не отдаёт управление, и весь воркер стоит, хотя работы полно. Замер это подтверждает: две задачи по 200 мс на одном воркере складываются последовательно. Асинхронный аналог tokio::time::sleep возвращает Pending и освобождает воркер.

  5. #rs_runtime5 / 5
    Что делает tokio::task::spawn_blocking?
    A)Помечает задачу как приоритетную, чтобы планировщик отдал ей воркер целиком
    B)Запрещает другим задачам исполняться, пока блокирующий вызов не завершится
    C)Превращает синхронный вызов в асинхронный, разбивая его на шаги с точками ожидания
    D)Выполняет замыкание на отдельном пуле блокирующих потоков, не занимая воркеры
    показать ответ и разбор
    +D)Выполняет замыкание на отдельном пуле блокирующих потоков, не занимая воркеры

    // разбор: У tokio два пула: небольшой основной для асинхронных задач и отдельный, растущий, для блокирующей работы. spawn_blocking отправляет замыкание во второй, поэтому чтение файла или тяжёлая сериализация не останавливают воркеры. Проверка это подтверждает: два блокирующих сна по 60 мс через spawn_blocking укладываются в 60 мс, а внутри обычных задач сложились бы.

дальше

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

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