Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Основы асинхронного программирования: async, await, фьючерсы и потоки

Многие операции, которые мы поручаем компьютеру, могут выполняться долго. Было бы здорово, если бы мы могли заниматься другим, пока ждём завершения этих длительных процессов. Современные компьютеры предлагают два способа работы над несколькими операциями одновременно: параллелизм и конкурентность. Однако как только мы начинаем писать программы, вовлекающие параллельные или конкурентные операции, мы быстро сталкиваемся с новыми вызовами, присущими асинхронному программированию, где операции могут завершаться не в том порядке, в котором были запущены. Эта глава основывается на использовании потоков для параллелизма и конкурентности из Главы 16, представляя альтернативный подход к асинхронному программированию: фьючерсы и потоки Rust, синтаксис async и await, который их поддерживает, а также инструменты для управления и координации между асинхронными операциями.

Рассмотрим пример. Допустим, вы экспортируете созданное вами видео семейного праздника — операция, которая может занять от минут до часов. Экспорт видео будет использовать как можно больше мощности CPU и GPU. Если бы у вас был только один ядерный процессор и ваша операционная система не приостанавливала бы этот экспорт до его завершения — то есть, если бы он выполнялся синхронно — вы не смогли бы делать ничего другого на компьютере, пока эта задача выполняется. Это было бы очень неприятно. К счастью, операционная система вашего компьютера может и действительно невидимо прерывает экспорт достаточно часто, чтобы позволить вам выполнять другую работу одновременно.

Теперь представьте, что вы скачиваете видео, которым поделился кто-то ещё. Это тоже может занять время, но не использует так много времени CPU. В этом случае процессору приходится ждать прибытия данных из сети. Как только данные начинают поступать, вы можете начать их чтение, но на появление всех данных может уйти некоторое время. Даже когда все данные уже получены, если видео довольно большое, на их загрузку может уйти по крайней мере секунда или две. Это может показаться незначительным, но для современного процессора, способного выполнять миллиарды операций в секунду, это очень долго. Снова ваша операционная система невидимо прервёт вашу программу, чтобы позволить CPU выполнять другую работу в ожидании завершения сетевого вызова.

Экспорт видео — это пример процессорно-зависимой (или вычислительно-зависимой) операции. Он ограничен потенциальной скоростью обработки данных внутри CPU или GPU и тем, какую часть этой скорости можно посвятить операции. Скачивание видео — это пример операции, зависимой от ввода-вывода, потому что оно ограничено скоростью ввода-вывода компьютера; оно может идти только так быстро, как данные могут передаваться по сети.

В обоих этих примерах невидимые прерывания операционной системы обеспечивают форму конкурентности. Однако эта конкурентность происходит только на уровне всей программы: операционная система прерывает одну программу, чтобы дать возможность другим программам выполнить работу. Во многих случаях, поскольку мы понимаем наши программы на гораздо более детальном уровне, чем операционная система, мы можем заметить возможности для конкурентности, которые операционная система не видит.

Например, если мы создаём инструмент для управления загрузками файлов, мы должны уметь писать программу так, чтобы запуск одной загрузки не блокировал интерфейс пользователя, и пользователи должны иметь возможность запускать несколько загрузок одновременно. Однако многие API операционной системы для взаимодействия с сетью являются блокирующими; то есть они блокируют выполнение программы до тех пор, пока обрабатываемые ими данные не будут полностью готовы.

Примечание: Так работает большинство вызовов функций, если подумать об этом. Однако термин блокирующий обычно сохраняется для вызовов функций, взаимодействующих с файлами, сетью или другими ресурсами компьютера, потому что именно в этих случаях отдельной программе было бы полезно, чтобы операция была неблокирующей.

Мы могли бы избежать блокировки нашего главного потока, создавая отдельный поток для загрузки каждого файла. Однако накладные расходы на эти потоки в конце концов станут проблемой. Было бы предпочтительнее, если бы вызов изначально не блокировал. Также было бы лучше, если бы мы могли писать в том же прямом стиле, который используем в блокирующем коде, подобном этому:

let data = fetch_data_from(url).await;
println!("{data}");

Это именно то, что даёт нам абстракция async (сокращение от asynchronous) в Rust. В этой главе вы узнаете всё об async, когда мы рассмотрим следующие темы:

  • Как использовать синтаксис async и await в Rust
  • Как использовать асинхронную модель для решения некоторых из тех же задач, которые мы рассматривали в Главе 16
  • Как многопоточность и async предоставляют взаимодополняющие решения, которые можно комбинировать во многих случаях

Прежде чем мы увидим, как async работает на практике, нам нужно сделать небольшой экскурс, чтобы обсудить различия между параллелизмом и конкурентностью.

Параллелизм и конкурентность

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

Рассмотрим разные способы, которыми команда может распределить работу над программным проектом. Если каждый член команды берёт одну задачу и работает над ней в одиночку, это параллелизм. Каждый человек в команде может прогрессировать абсолютно одновременно (см. Рисунок 17-2).

В обоих этих рабочих процессах вам, возможно, придётся координировать разные задачи. Может оказаться, что задача, назначенная одному человеку, на самом деле зависит от завершения задачи другим членом команды. Часть работы может выполняться параллельно, но часть была фактически серийной: она могла происходить только последовательно, одна задача за другой, как на Рисунке 17-3.

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

Параллелизм и конкурентность также могут пересекаться. Если вы узнаёте, что коллега застрял, пока вы не завершите одну из своих задач, вы, вероятно, сосредоточите все свои усилия на этой задаче, чтобы “разблокировать” коллегу. Вы и ваш коллега больше не можете работать параллельно, и вы также больше не можете работать конкурентно над своими собственными задачами.

Те же базовые динамики проявляются и в программном и аппаратном обеспечении. На машине с одним ядерным процессором CPU может выполнять только одну операцию за раз, но он всё ещё может работать конкурентно. Используя такие инструменты, как потоки, процессы и async, компьютер может приостановить одну активность и переключиться на другие, прежде чем в конечном итоге вернуться к той первой активности снова. На машине с несколькими ядрами CPU он также может выполнять работу параллельно. Одно ядро может выполнять одну задачу, в то время как другое ядро выполняет совершенно несвязанную, и эти операции фактически происходят одновременно.

При работе с async в Rust мы всегда имеем дело с конкурентностью. В зависимости от аппаратного обеспечения, операционной системы и асинхронного рантайма, который мы используем (об асинхронных рантаймах shortly), эта конкурентность также может использовать параллелизм под капотом.

Теперь давайте погрузимся в то, как на практике работает асинхронное программирование в Rust.