Рантайм 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, остальные разбираются в тренажёре.
- Из каких частей состоит асинхронный рантайм?A)Планировщик задач и сборщик мусора для завершённых futureB)Пул потоков и очередь сообщений между нимиC)Интерпретатор состояний и таблица активных соединенийD)Исполнитель для задач и реактор для событий ввода-вывода
показать ответ и разбор
+D)Исполнитель для задач и реактор для событий ввода-вывода// разбор: Исполнитель опрашивает готовые задачи, реактор через epoll или kqueue ждёт готовности сокетов и таймеров и будит нужные задачи через waker. В tokio к этому добавлен многопоточный планировщик с воровством задач и отдельный пул для блокирующих вызовов. Сборщика мусора нет — задачи живут по обычным правилам владения.
- Когда однопоточный рантайм (current_thread) уместнее многопоточного?A)Когда нужна максимальная пропускная способность сервисаB)Когда задачи держат не-Send состояние или нагрузка невеликаC)Когда в задачах есть блокирующие вызовы файловой системыD)Когда задач очень много: один поток экономит переключения контекста
показать ответ и разбор
+B)Когда задачи держат не-Send состояние или нагрузка невелика// разбор: Многопоточный планировщик может перенести задачу между воркерами, поэтому требует Send. Однопоточный это требование снимает — удобно для Rc и не-Send библиотек, для тестов и CLI. Пропускная способность у него ниже: параллельности по ядрам нет, а один блокирующий вызов останавливает всё.
- В асинхронный обработчик попал тяжёлый счётный цикл на 200 мс. Что произойдёт в многопоточном рантайме?A)Планировщик вытеснит задачу и продолжит остальныеB)Задача переедет в отдельный поток автоматическиC)Воркер занят целиком: его задачи ждут, растёт хвостD)Рантайм создаст дополнительный воркер под нагрузку
показать ответ и разбор
+C)Воркер занят целиком: его задачи ждут, растёт хвост// разбор: Планирование кооперативное: пока задача не дошла до await, воркер принадлежит ей. Другие задачи этого воркера ждут, и p99 разъезжается — при этом сервис выглядит живым. Лечится выносом счётной работы в spawn_blocking или в пул rayon, а для длинных циклов помогает периодический yield_now.
- Почему std::thread::sleep внутри async-задачи — ошибка?A)Он усыпляет весь воркер вместе с остальными его задачамиB)Он завершает задачу по таймауту рантаймаC)Он вызывает панику: рантайм запрещает блокирующие вызовыD)Он не компилируется в асинхронном контексте без unsafe
показать ответ и разбор
+A)Он усыпляет весь воркер вместе с остальными его задачами// разбор: Обычный sleep блокирует поток операционной системы — задача не отдаёт управление, и весь воркер стоит, хотя работы полно. Замер это подтверждает: две задачи по 200 мс на одном воркере складываются последовательно. Асинхронный аналог tokio::time::sleep возвращает Pending и освобождает воркер.
- Что делает tokio::task::spawn_blocking?A)Помечает задачу как приоритетную, чтобы планировщик отдал ей воркер целикомB)Запрещает другим задачам исполняться, пока блокирующий вызов не завершитсяC)Превращает синхронный вызов в асинхронный, разбивая его на шаги с точками ожиданияD)Выполняет замыкание на отдельном пуле блокирующих потоков, не занимая воркеры
показать ответ и разбор
+D)Выполняет замыкание на отдельном пуле блокирующих потоков, не занимая воркеры// разбор: У tokio два пула: небольшой основной для асинхронных задач и отдельный, растущий, для блокирующей работы. spawn_blocking отправляет замыкание во второй, поэтому чтение файла или тяжёлая сериализация не останавливают воркеры. Проверка это подтверждает: два блокирующих сна по 60 мс через spawn_blocking укладываются в 60 мс, а внутри обычных задач сложились бы.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.