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, футуры и стримы

Многие операции, которые мы поручаем компьютеру, выполняются небыстро. Было бы хорошо заниматься чем-то другим, пока эти долгие процессы идут. Современные компьютеры умеют работать над несколькими операциями сразу двумя способами: параллелизмом и конкурентностью. Но логику программ мы пишем в основном линейно. Хотелось бы описывать, какие операции программа должна выполнить и в каких местах функция может приостановиться, чтобы вместо неё поработала другая часть программы, — не расписывая заранее точно, в каком порядке и как должен выполняться каждый кусочек кода. Асинхронное программирование (asynchronous programming) — это абстракция, которая позволяет описывать код через возможные точки приостановки и результаты, которые появятся когда-нибудь потом, а все детали координации берёт на себя.

Эта глава опирается на главу 16, где для параллелизма и конкурентности использовались потоки, и знакомит с другим подходом к написанию кода: футурами (futures), стримами (streams) и синтаксисом async и await в Rust, которые позволяют выразить, что операции могут быть асинхронными. А также со сторонними крейтами, реализующими асинхронные среды выполнения (runtimes) — код, который управляет выполнением асинхронных операций и координирует его.

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

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

Экспорт видео — пример операции, ограниченной процессором (CPU-bound, или compute-bound): её скорость упирается в то, насколько быстро процессор или видеокарта могут обрабатывать данные и сколько этой мощности можно выделить на операцию. Скачивание видео — пример операции, ограниченной вводом-выводом (I/O-bound): её скорость упирается в скорость ввода и вывода компьютера, и быстрее, чем данные приходят по сети, она не пойдёт.

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

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

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

Чтобы не блокировать главный поток, можно запускать отдельный поток для загрузки каждого файла. Но системные ресурсы, которые тратятся на эти потоки, в какой-то момент станут проблемой. Лучше, чтобы вызов вообще не блокировал, а мы могли описать набор задач, которые программа должна выполнить, и позволить среде выполнения самой выбрать наилучший порядок и способ их выполнения.

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

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

Но прежде чем смотреть, как async работает на практике, нужно ненадолго отвлечься и обсудить разницу между параллелизмом и конкурентностью.

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

До сих пор мы считали параллелизм и конкурентность почти синонимами. Теперь их нужно различать точнее: разница проявится, как только мы возьмёмся за работу.

Посмотрим, как команда может разделить работу над программным проектом. Можно поручить одному участнику несколько задач, каждому по одной задаче или сочетать оба подхода.

Когда один человек работает над несколькими задачами, прежде чем закончить хотя бы одну, — это конкурентность (concurrency). Один из вариантов — держать на компьютере два разных проекта и, когда надоест или застрянешь в одном, переключаться на другой. Вы один человек, так что продвигаться в обеих задачах ровно одновременно не можете, но можете работать в режиме многозадачности: продвигаться то в одной, то в другой, переключаясь между ними (рисунок 17-1).

Схема с двумя прямоугольниками друг над другом, Task A и Task B; ромбы в них — подзадачи. Стрелки идут от A1 к B1, от B1 к A2, от A2 к B2, от B2 к A3, от A3 к A4 и от A4 к B3. Стрелки между подзадачами пересекают границу между Task A и Task B.
Рисунок 17-1: Конкурентная работа — переключение между Task A и Task B

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

Схема с двумя прямоугольниками друг над другом, Task A и Task B; ромбы в них — подзадачи. Стрелки идут от A1 к A2, от A2 к A3, от A3 к A4, от B1 к B2 и от B2 к B3. Ни одна стрелка не пересекает границу между Task A и Task B.
Рисунок 17-2: Параллельная работа — над Task A и Task B работают независимо

В обоих случаях задачи, возможно, придётся согласовывать. Вы думали, что задача, порученная одному человеку, совершенно независима от остальных, а оказалось, что сначала свою задачу должен закончить другой член команды. Часть работы можно было делать параллельно, а часть на самом деле была последовательной (serial): её можно было делать только по очереди, одну задачу за другой, как на рисунке 17-3.

Схема с двумя прямоугольниками друг над другом, Task A и Task B; ромбы в них — подзадачи. В Task A стрелки идут от A1 к A2, от A2 к паре толстых вертикальных линий, похожих на значок паузы, и от этого значка к A3. В Task B стрелки идут от B1 к B2, от B2 к B3, от B3 к A3 и от B3 к B4.
Рисунок 17-3: Частично параллельная работа — над Task A и Task B работают независимо, пока Task A3 не оказывается заблокирована в ожидании результатов Task B3

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

Параллелизм и конкурентность могут и пересекаться. Если вы узнаете, что коллега застрял, пока вы не закончите одну из своих задач, вы, скорее всего, бросите все силы на неё, чтобы «разблокировать» коллегу. Тогда вы с коллегой уже не можете работать параллельно, а вы сами — конкурентно над своими задачами.

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

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

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