Ввод-вывод и проблемы с блокировками
Эффективная обработка ввода-вывода (IO) является одной из основных причин использования асинхронного программирования, и большинство асинхронных программ выполняют много операций IO. Корень проблемы с IO заключается в том, что он занимает на порядки больше времени, чем вычисления, поэтому простое ожидание завершения IO вместо выполнения другой работы невероятно неэффективно. В идеале, асинхронное программирование позволяет программе заниматься другой работой во время ожидания IO.
Эта глава представляет собой введение в IO в асинхронном контексте. Мы рассмотрим важное различие между блокирующим и неблокирующим IO, и почему блокирующий IO и асинхронное программирование несовместимы (по крайней мере, без некоторой доли размышлений и усилий). Мы рассмотрим, как использовать неблокирующий IO, затем посмотрим на некоторые проблемы, которые могут возникнуть с IO и асинхронным программированием. Мы также рассмотрим, как операционная система обрабатывает IO, и мельком взглянем на некоторые альтернативные методы IO, такие как io_uring.
Мы закончим рассмотрением других способов блокировки асинхронной задачи (что плохо) и того, как правильно смешивать асинхронное программирование с блокирующим IO или длительными, ресурсоёмкими вычислениями.
Блокирующий и неблокирующий IO
IO реализуется операционной системой; работа по IO происходит в отдельных процессах и/или на специализированном оборудовании, в любом случае вне процесса программы. IO может быть синхронным или асинхронным (также известным как блокирующий и неблокирующий, соответственно). Синхронный IO означает, что программа (или, по крайней мере, поток) ожидает (то есть блокируется) во время выполнения IO и не начинает обработку, пока IO не завершится и результат не будет получен от ОС. Асинхронный IO означает, что программа может продолжать выполнять работу во время выполнения IO и может забрать результат позже. Существует множество различных API ОС для обоих видов IO, хотя больше разнообразия в асинхронной сфере.
Асинхронный IO и асинхронное программирование не связаны по своей сути. Однако асинхронное программирование способствует эргономичному и производительному асинхронному IO, и это является основной мотивацией для асинхронного программирования. Блокировки из-за синхронного IO являются основным источником проблем с производительностью в асинхронном программировании, и мы должны быть осторожны, чтобы избежать их (подробнее об этом ниже).
Стандартная библиотека Rust включает функции и трейты для блокирующего IO. Для неблокирующего IO вы должны использовать специализированные библиотеки, которые часто являются частью асинхронной среды выполнения, например, модуль io в Tokio.
Давайте быстро взглянем на пример (адаптированный из документации Tokio):
#![allow(unused)] fn main() { use tokio::{io::AsyncWriteExt, net::TcpStream}; async fn write_hello() -> Result<(), Box<dyn std::error::Error>> { let mut stream = TcpStream::connect("127.0.0.1:8080").await?; stream.write_all(b"hello world!").await?; Ok(()) } }
write_all — это асинхронный метод IO, который записывает данные в stream. Это может завершиться сразу, но более вероятно, что это займёт некоторое время, поэтому stream.write_all(...).await приведёт к приостановке текущей задачи во время ожидания обработки записи ОС. Планировщик будет запускать другие задачи, и когда запись завершится, он разбудит задачу и запланирует её продолжение.
Однако, если бы мы использовали функцию записи из стандартной библиотеки, асинхронный планировщик не был бы задействован, и ОС приостановила бы весь поток на время завершения IO, что означает, что не только текущая задача приостанавливается, но и никакая другая задача не может быть выполнена с использованием этого потока. Если это произойдёт со всеми потоками в пуле потоков среды выполнения (который в некоторых обстоятельствах может состоять всего из одного потока), то вся программа останавливается и не может прогрессировать. Это называется блокировкой потока (или программы) и очень плохо сказывается на производительности. Важно никогда не блокировать потоки в асинхронной программе, и поэтому вам следует избегать использования блокирующего IO в асинхронной задаче.
Блокировка потока может быть вызвана длительными задачами или задачами, ожидающими блокировок, а также блокирующим IO. Мы обсудим это подробнее в конце этой главы.
Распространённым шаблоном является многократное чтение или запись, и потоки (streams) и приёмники (sinks) (также известные как асинхронные итераторы) являются удобным механизмом для этого. Они рассматриваются в отдельной главе.
Чтение и запись
TODO
- Асинхронные трейты Read и Write
- часть среды выполнения
- Как использовать
- Конкретные реализации
- Сеть против диска
- TCP, UDP
- Файловая система не совсем асинхронна, но io_uring (ссылка на эту главу)
- Практические примеры
- stdout и т.д.
- Каналы (pipe), файловые дескрипторы (fd) и т.д.
- Сеть против диска
Управление памятью
Когда мы читаем данные, нам нужно куда-то их поместить, а когда мы записываем данные, их нужно где-то хранить до завершения записи. В любом случае, то, как управляется эта память, важно.
TODO
- Проблемы с управлением буферами и асинхронным IO
- Различные решения и их плюсы и минусы
- Подход с нулевым копированием (zero-copy)
- Подход с общим буфером
- Вспомогательные крейты, такие как Bytes и т.д.
Продвинутые темы по IO
TODO
- Блочное чтение/запись (buf read/write)
- Read + Write, разделение (split), объединение (join)
- Копирование (copy)
- Симплексные и дуплексные соединения (simplex and duplex)
- Отмена (cancelation)
- Что делать, если нам нужно выполнить синхронный IO? Создать поток или использовать spawn_blocking (см. ниже)
Взгляд ОС на IO
TODO
- Различные виды IO и механизмы, IO с завершением (completion IO), ссылка на главу о completion IO в разделе для продвинутых
- разные среды выполнения могут обеспечивать это
- mio для низкоуровневого интерфейса
Другие блокирующие операции
Как упоминалось в начале главы, неблокирование потоков имеет crucialное значение для производительности асинхронных программ. Блокирующий IO различных видов — это распространённый способ блокировки, но также возможно заблокироваться, выполняя много вычислений или ожидая способом, с которым асинхронный планировщик не координируется.
Ожидание чаще всего вызывается использованием механизмов синхронизации, не осведомлённых об асинхронности, например, использованием std::sync::Mutex вместо асинхронного мьютекса или ожиданием неасинхронного канала. Мы обсудим эту проблему в главе о Каналах, блокировках и синхронизации. Есть и другие способы блокирующего ожидания, и в целом вам нужно найти неблокирующий или иным образом асинхронно-дружественный механизм, например, использовать асинхронную функцию sleep вместо функции из std. Ожидание также может быть активным (busy wait) (по сути, просто цикл без выполнения какой-либо работы, также known как спин-блокировка), вам, вероятно, следует просто избегать этого.
Ресурсоёмкая работа (CPU-intensive)
Выполнение длительной (т.е. ресурсоёмкой или ограниченной производительностью CPU, cpu-bound) работы предотвратит запуск других задач планировщиком. Это является своего рода блокировкой, но не такой плохой, как блокировка на IO или ожидании, потому что, по крайней мере, ваша программа прогрессирует. Однако (без должного внимания и рассмотрения) это, вероятно, будет неоптимальным для производительности по некоторым показателям (например, задержки в хвосте распределения) и, возможно, проблемой корректности, если задачи, которые не могут быть запущены, должны были выполняться в определённое время. Существует мем, что вам просто не следует использовать асинхронный Rust (или универсальные асинхронные среды выполнения, такие как Tokio) для ресурсоёмких задач, но это упрощение. Правильно то, что вы не можете смешивать задачи, ограниченные IO и CPU (или, точнее, длительные и чувствительные к задержкам), без специальной обработки и ожидать хороших результатов.
Для оставшейся части этого раздела мы будем предполагать, что у вас есть смесь чувствительных к задержкам задач и длительных, ресурсоёмких задач. Если у вас нет ничего, что было бы чувствительно к задержкам, то ситуация несколько иная (в основном, проще).
По сути, существует три решения для запуска длительных или блокирующих задач: использовать встроенные возможности среды выполнения, использовать отдельный поток или использовать отдельную среду выполнения.
В Tokio вы можете использовать spawn_blocking для порождения задачи, которая может блокировать. Это работает как spawn для порождения задачи, но запускает задачу в отдельном пуле потоков, оптимизированном для задач, которые могут блокировать (задача, вероятно, будет запущена в своём собственном потоке). Обратите внимание, что это запускает обычный синхронный код, а не асинхронную задачу. Это означает, что задачу нельзя отменить (даже несмотря на то, что её JoinHandle имеет метод abort). Другие среды выполнения предоставляют аналогичную функциональность.
Вы можете создать поток для выполнения блокирующей работы с помощью std::thread::spawn (или аналогичных функций). Это довольно просто. Если вам нужно запустить много задач, вам, вероятно, понадобится какой-то пул потоков или планировщик заданий. Если вы продолжаете создавать потоки и их становится намного больше, чем доступных ядер, вы в конечном итоге пожертвуете пропускной способностью. Rayon — популярный выбор, который позволяет легко запускать и управлять параллельными задачами. Вы можете добиться лучшей производительности с чем-то более специфичным для вашей рабочей нагрузки и/или имеющим некоторое представление о выполняемых задачах.
Вы можете использовать отдельные экземпляры асинхронной среды выполнения для чувствительных к задержкам задач и для длительных задач. Это подходит для задач, ограниченных CPU, но вам всё равно не следует использовать блокирующий IO, даже в среде выполнения для длительных задач. Для задач, ограниченных CPU, это хорошее решение, поскольку оно единственное поддерживает возможность того, что длительные задачи являются асинхронными. Оно также гибкое (поскольку среды выполнения можно настроить для оптимальной работы с типом задач, которые они выполняют; действительно, необходимо приложить некоторые усилия для настройки среды выполнения, чтобы добиться оптимальной производительности) и позволяет вам извлекать выгоду из использования зрелых, хорошо спроектированных подсистем, таких как Tokio. Вы даже можете использовать две разные асинхронные среды выполнения. В любом случае, среды выполнения должны работать в разных потоках.
С другой стороны, вам действительно нужно немного подумать: вы должны убедиться, что запускаете задачи в правильной среде выполнения (что может быть сложнее, чем кажется), и общение между задачами может быть осложнено. Мы обсудим синхронизацию между синхронными и асинхронными контекстами далее, но это может быть ещё сложнее между несколькими асинхронными средами выполнения. Каждая среда выполнения — это своя собственная маленькая вселенная задач, и планировщики полностью независимы. Каналы и блокировки Tokio могут использоваться из разных сред выполнения (даже не-Tokio), но примитивы других сред выполнения могут не работать таким образом.
Поскольку планировщик в каждой среде выполнения не знает о других средах выполнения (а ОС не знает ни о каких асинхронных планировщиках), нет координации или разделения приоритетов планирования, и работа не может быть "украдена" между средами выполнения. Следовательно, планирование задач может быть неоптимальным (особенно если среды выполнения не хорошо настроены для своих рабочих нагрузок). Более того, поскольку всё планирование является кооперативным, длительные задачи всё равно могут испытывать нехватку ресурсов, и задержки могут пострадать. См. следующий раздел о том, как длительные задачи можно сделать более кооперативными.
Как чистый планировщик, использование Tokio для CPU-работы, вероятно, будет иметь несколько более высокие накладные расходы, чем выделенный синхронный пул рабочих. Это неудивительно, если учесть дополнительную работу, необходимую для поддержки асинхронного программирования. На практике для большинства пользователей это вряд ли станет проблемой, но может быть стоит учитывать, если ваш код чрезвычайно чувствителен к производительности.
Для любого из вышеперечисленных решений у вас будут задачи, выполняющиеся в разных контекстах (синхронных и асинхронных, или разных асинхронных средах выполнения). Если вам нужно общаться между задачами, то вам нужно позаботиться о том, чтобы вы использовали правильные комбинации синхронных и асинхронных примитивов (каналы, мьютексы и т.д.) и правильные (блокирующие или неблокирующие) методы для этих примитивов. Для мьютексов и подобных блокировок вам, вероятно, следует использовать асинхронные версии, если вам нужно удерживать блокировку через точку await или защищать ресурс IO (её должно быть можно использовать из синхронных контекстов с помощью блокирующего метода lock), или синхронную версию для защиты данных или там, где блокировку не нужно удерживать через точку await. Асинхронные каналы Tokio можно использовать из синхронного контекста с блокирующими методами, но см. эту документацию для получения некоторых подробностей о том, когда использовать синхронные или асинхронные каналы.
Итак, какое из вышеперечисленных решений вам следует использовать?
- Если вы выполняете блокирующий IO, вам, вероятно, следует использовать
spawn_blocking. Вы не можете использовать вторую среду выполнения или другой пул потоков (по крайней мере, если вам нужна оптимальная производительность). - Если у вас есть поток, который будет работать вечно, вам следует использовать
std::thread::spawn, а не любой вид пула потоков (поскольку он будет использовать один из потоков пула). - Если вы выполняете много CPU-работы, то вам следует использовать пул потоков, либо специализированный, либо вторую асинхронную среду выполнения.
- Если вам нужно запускать длительный асинхронный код, то вам следует использовать вторую среду выполнения.
- Вы можете выбрать использование выделенного потока или
spawn_blocking, потому что это легко и имеет удовлетворительную производительность, даже если более сложное решение является более оптимальным.
Уступка
Длительный код является проблемой, потому что он не даёт планировщику возможности планировать другие задачи. Асинхронная конкурентность кооперативна: планировщик не может вытеснить (pre-empt) задачу, чтобы запустить другую. Если длительная задача не уступает планировщику, то планировщик не может её остановить. Однако, если длительный код уступает планировщику, то другие задачи могут быть запланированы, и тот факт, что задача является длительной, не является проблемой. Это можно использовать как альтернативу использованию другого потока для ресурсоёмкой работы или для ресурсоёмкой работы в её собственной среде выполнения, чтобы (возможно) улучшить производительность.
Уступить легко, просто вызовите функцию yield среды выполнения. В Tokio это yield_now. Обратите внимание, что это отличается от yield_now стандартной библиотеки и ключевого слова yield для уступки из корутины. Вызов yield_now не уступит планировщику, если текущий фьючерс выполняется внутри select или join (см. главу о композиции фьючерсов параллельно); это может быть или не быть тем, что вы хотите.
Знать, когда вам нужно уступить, немного сложнее. Во-первых, вам нужно знать, уступает ли ваша программа неявно. Это может произойти только при .await, поэтому, если вы не awaitите, то вы не уступаете. Но await не уступает планировщику автоматически. Это происходит только если конечный (leaf) фьючерс, который awaitится, находится в состоянии ожидания (pending) или где-то в стеке вызовов есть явный yield. Tokio и большинство асинхронных сред выполнения будут делать это в своих функциях IO и синхронизации, но в общем случае вы не можете знать, уступит ли await, без отладки или изучения исходного кода.
Хорошее эмпирическое правило заключается в том, что код не должен выполняться более 10-100 микросекунд без достижения потенциальной точки уступки.