Глава 17
Основы асинхронного программирования: Async, Await, Futures и Streams
Многие операции, которые мы поручаем компьютеру, могут занимать значительное время. Было бы удобно, если бы мы могли делать что-то еще, пока ожидаем завершения этих длительных процессов. Современные компьютеры предлагают две техники для выполнения более одной операции одновременно: параллелизм и конкурентность. Однако логика наших программ записывается в основном линейным образом. Мы хотели бы иметь возможность указывать операции, которые должна выполнить программа, и точки, в которых функция может приостановиться, а другая часть программы может работать вместо нее, без необходимости заранее точно определять порядок и способ выполнения каждого фрагмента кода. Асинхронное программирование — это абстракция, которая позволяет нам выражать наш код в терминах потенциальных точек приостановки и конечных результатов, беря на себя заботу о деталях координации.
Эта глава развивает использование потоков из Главы 16 для параллелизма и конкурентности, представляя альтернативный подход к написанию кода: futures, streams и синтаксис async/await в Rust, которые позволяют нам выражать, как операции могут быть асинхронными, а также сторонние крейты, реализующие асинхронные рантаймы — код, который управляет и координирует выполнение асинхронных операций.
Рассмотрим пример. Допустим, вы экспортируете созданное вами видео семейного праздника — операция, которая может занять от нескольких минут до нескольких часов. Экспорт видео будет использовать максимум мощности CPU и GPU. Если бы у вас было только одно ядро CPU, и ваша операционная система не приостанавливала бы этот экспорт до его завершения — то есть, если бы она выполняла экспорт синхронно — вы не могли бы делать ничего другого на вашем компьютере, пока эта задача выполняется. Это был бы довольно неприятный опыт. К счастью, операционная система вашего компьютера может и действительно невидимо прерывает экспорт достаточно часто, чтобы позволить вам одновременно выполнять другую работу.
Теперь предположим, вы загружаете видео, которым поделился кто-то другой — это тоже может занять некоторое время, но не занимает столько времени CPU. В этом случае CPU должен ждать поступления данных из сети. Хотя вы можете начать читать данные, как только они начнут поступать, может потребоваться некоторое время, чтобы они все прибыли. Даже когда все данные получены, если видео достаточно большое, его загрузка может занять хотя бы секунду или две. Это может показаться незначительным, но для современного процессора, который может выполнять миллиарды операций в секунду, это очень долго. Опять же, ваша операционная система невидимо прервет вашу программу, чтобы позволить CPU выполнять другую работу в ожидании завершения сетевого вызова.
Экспорт видео — пример операции, ограниченной по CPU (compute-bound). Она ограничена потенциальной скоростью обработки данных внутри CPU или GPU и тем, какая часть этой скорости может быть выделена для операции. Загрузка видео — пример операции, ограниченной вводом-выводом (I/O-bound), потому что она ограничена скоростью ввода и вывода компьютера; она может выполняться только так быстро, как данные могут быть переданы по сети.
В обоих этих примерах невидимые прерывания операционной системы обеспечивают форму конкурентности. Однако эта конкурентность происходит только на уровне всей программы: операционная система прерывает одну программу, чтобы позволить другим программам выполнять работу. Во многих случаях, поскольку мы понимаем наши программы на гораздо более детальном уровне, чем операционная система, мы можем заметить возможности для конкурентности, которые ОС увидеть не может.
Например, если мы создаем инструмент для управления загрузкой файлов, мы должны иметь возможность написать нашу программу так, чтобы запуск одной загрузки не блокировал пользовательский интерфейс, и пользователи могли бы запускать несколько загрузок одновременно. Однако многие API операционной системы для взаимодействия с сетью являются блокирующими; то есть они блокируют выполнение программы до тех пор, пока обрабатываемые ими данные не будут полностью готовы.
Примечание: Если задуматься, именно так работают большинство вызовов функций. Однако термин "блокирующий" обычно зарезервирован для вызовов функций, которые взаимодействуют с файлами, сетью или другими ресурсами на компьютере, потому что именно в этих случаях отдельная программа выиграла бы от того, чтобы операция была неблокирующей.
Мы могли бы избежать блокировки нашего основного потока, порождая выделенный поток для загрузки каждого файла. Однако, накладные расходы на системные ресурсы, используемые этими потоками, в конечном итоге стали бы проблемой. Было бы предпочтительнее, если бы вызов изначально не блокировался, и вместо этого мы могли бы определить ряд задач, которые мы хотим, чтобы наша программа выполнила, и позволить рантайму выбрать наилучший порядок и способ их выполнения.
Именно это и дает нам абстракция async (сокращение от asynchronous) в Rust. В этой главе вы узнаете все об async, так как мы рассмотрим следующие темы:
- Как использовать синтаксис async и await в Rust и выполнять асинхронные функции с помощью рантайма.
- Как использовать асинхронную модель для решения некоторых из тех же задач, которые мы рассматривали в Главе 16.
- Как многопоточность и async предлагают взаимодополняющие решения, которые во многих случаях можно комбинировать.
Прежде чем мы увидим, как async работает на практике, нам нужно сделать небольшое отступление, чтобы обсудить различия между параллелизмом и конкурентностью.
Параллелизм и Конкурентность
До сих пор мы рассматривали параллелизм и конкурентность как в основном взаимозаменяемые понятия. Теперь нам нужно точнее разграничить их, поскольку различия проявятся, когда мы начнем работать.
Рассмотрим различные способы, которыми команда могла бы разделить работу над программным проектом. Вы могли бы назначить одному участнику несколько задач, назначить каждому участнику по одной задаче или использовать комбинацию этих двух подходов.
Когда один человек работает над несколькими разными задачами до завершения любой из них, это конкурентность (concurrency). Один из способов реализовать конкурентность похож на то, как если бы у вас на компьютере были открыты два разных проекта, и когда вам наскучило или вы застряли на одном проекте, вы переключаетесь на другой. Вы всего один человек, поэтому не можете одновременно прогрессировать в обеих задачах, но можете работать в режиме многозадачности, продвигаясь по одной задаче за раз, переключаясь между ними (см. Рисунок 17-1).
Рисунок 17-1: Конкурентный рабочий процесс, переключение между Задачей А и Задачей Б
Когда команда разделяет группу задач, поручая каждому участнику по одной задаче для самостоятельной работы, это параллелизм (parallelism). Каждый человек в команде может работать одновременно (см. Рисунок 17-2).
Рисунок 17-2: Параллельный рабочий процесс, где работа над Задачей А и Задачей Б происходит независимо
В обоих этих рабочих процессах вам, возможно, придется координировать действия между разными задачами. Может быть, вы думали, что задача, назначенная одному человеку, полностью независима от работы всех остальных, но на самом деле она требует, чтобы другой человек в команде сначала закончил свою задачу. Часть работы можно было выполнять параллельно, но часть на самом деле была последовательной: ее можно было выполнять только серийно, одна задача за другой, как на Рисунке 17-3.
Рисунок 17-3: Частично параллельный рабочий процесс, где работа над Задачей А и Задачей Б происходит независимо до тех пор, пока Задача A3 не заблокирована в ожидании результатов Задачи B3.
Точно так же вы можете осознать, что одна из ваших собственных задач зависит от другой вашей задачи. Теперь ваша конкурентная работа также стала последовательной.
Параллелизм и конкурентность также могут пересекаться друг с другом. Если вы узнаете, что ваш коллега не может продвигаться, пока вы не закончите одну из своих задач, вы, вероятно, сосредоточите все свои усилия на этой задаче, чтобы "разблокировать" коллегу. Вы и ваш коллега больше не можете работать параллельно, и вы также больше не можете работать конкурентно над своими собственными задачами.
Такая же базовая динамика проявляется в программном и аппаратном обеспечении. На машине с одним ядром CPU процессор может выполнять только одну операцию за раз, но он все равно может работать конкурентно. Используя такие инструменты, как потоки (threads), процессы и async, компьютер может приостановить одну активность и переключиться на другие, прежде чем в конечном итоге вернуться к первой активности. На машине с несколькими ядрами CPU он также может выполнять работу параллельно. Одно ядро может выполнять одну задачу, в то время как другое ядро выполняет совершенно несвязанную задачу, и эти операции фактически происходят одновременно.
Запуск асинхронного кода в Rust обычно происходит конкурентно. В зависимости от оборудования, операционной системы и используемого нами асинхронного рантайма (подробнее о рантаймах чуть позже), эта конкурентность также может использовать параллелизм под капотом.
Теперь давайте углубимся в то, как на самом деле работает асинхронное программирование в Rust.