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, Futures, and Streams

Багато операцій, які ми просимо комп’ютер виконати, можуть займати певний час, щоб завершитися. Було б добре, якби ми могли робити щось інше, поки чекаємо, доки ці довготривалі процеси завершаться. Сучасні комп’ютери пропонують дві техніки для роботи більш ніж над однією операцією одночасно: паралелізм і конкурентність. Логіка наших програм, однак, здебільшого написана лінійно. Ми хотіли б мати змогу задавати операції, які має виконати програма, і точки, в яких функція могла б призупинитися, а натомість могла б виконуватися якась інша частина програми, без потреби заздалегідь точно визначати порядок і спосіб, у яких має виконуватися кожен фрагмент коду. Асинхронне програмування — це абстракція, яка дає змогу нам виражати наш код у термінах потенційних точок призупинення та кінцевих результатів, а далі бере на себе деталі координації за нас.

У цьому розділі ми спираємося на використання потоків для паралелізму і конкурентності з розділу 16, вводячи альтернативний підхід до написання коду: futures, streams у Rust, а також синтаксис async і await, які дають нам змогу виражати, як операції можуть бути асинхронними, і сторонні крейти, що реалізують асинхронні runtime: код, який керує виконанням асинхронних операцій і координує його.

Розгляньмо приклад. Припустімо, ви експортуєте створене вами відео сімейного свята — операцію, яка може тривати від кількох хвилин до кількох годин. Експорт відео використовуватиме стільки потужності CPU і GPU, скільки зможе. Якби у вас було лише одне ядро CPU, а ваша операційна система не призупиняла б цей експорт до його завершення — тобто якби вона виконувала експорт синхронно — ви не могли б робити нічого іншого на своєму комп’ютері, поки виконувалася б ця задача. Це був би доволі дратівливий досвід. На щастя, операційна система вашого комп’ютера може, і робить це, непомітно переривати експорт достатньо часто, щоб ви могли одночасно виконувати іншу роботу.

Тепер припустімо, що ви завантажуєте відео, яким поділилася інша людина, що теж може зайняти певний час, але не потребує стільки CPU. У цьому випадку CPU має чекати, доки дані надійдуть із мережі. Хоча ви можете почати читати дані, щойно вони почнуть надходити, може знадобитися деякий час, щоб з’явилися всі дані. Навіть коли всі дані вже наявні, якщо відео досить велике, може знадобитися принаймні секунда чи дві, щоб завантажити його повністю. Це може звучати не так вже й багато, але для сучасного процесора, який може виконувати мільярди операцій щосекунди, це дуже довгий час. І знову ж таки, ваша операційна система непомітно перерве вашу програму, щоб дозволити CPU виконувати іншу роботу, поки чекає на завершення мережевого виклику.

Експорт відео — це приклад операції, обмеженої CPU або обчислювально обмеженої. Вона обмежена потенційною швидкістю обробки даних комп’ютера в межах CPU або GPU, і тим, яку частину цієї швидкості він може виділити для цієї операції. Завантаження відео — це приклад операції, обмеженої I/O, тому що вона обмежена швидкістю введення-виведення комп’ютера; вона може рухатися лише так швидко, як дані можуть бути надіслані мережею.

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

Наприклад, якщо ми створюємо інструмент для керування завантаженнями файлів, ми повинні мати змогу написати нашу програму так, щоб запуск одного завантаження не блокував UI, і користувачі мали змогу запускати кілька завантажень одночасно. Проте багато API операційних систем для взаємодії з мережею є блокувальними; тобто вони блокують прогрес програми, доки дані, які вони обробляють, не будуть повністю готові.

Примітка: якщо подумати, саме так працює більшість викликів функцій. Однак термін blocking зазвичай уживають для викликів функцій, що взаємодіють із файлами, мережею чи іншими ресурсами на комп’ютері, тому що саме в цих випадках окрема програма виграє від того, що операція є не-blocking.

Ми могли б уникнути блокування нашого головного потоку, запустивши окремий потік для завантаження кожного файла. Однак накладні витрати системних ресурсів, які використовували б ці потоки, зрештою стали б проблемою. Було б краще, якби виклик не блокував узагалі, і натомість ми могли б визначити певну кількість tasks, які наша програма має завершити, і дозволити runtime обрати найкращий порядок і спосіб, у яких їх запускати.

Саме це й дає нам async Rust (скорочено від asynchronous). У цьому розділі ви дізнаєтеся все про async, коли ми розглянемо такі теми:

  • Як використовувати синтаксис Rust async і await та виконувати асинхронні функції з runtime
  • Як використовувати модель async, щоб розв’язати деякі з тих самих завдань, які ми розглядали в розділі 16
  • Як багатопотоковість і async забезпечують взаємодоповнювальні рішення, які в багатьох випадках можна поєднувати

Перш ніж ми побачимо, як async працює на практиці, однак, нам потрібно зробити короткий відступ, щоб обговорити відмінності між паралелізмом і конкурентністю.

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

Досі ми здебільшого вживали паралелізм і конкурентність як взаємозамінні. Тепер нам потрібно точніше розрізняти їх, тому що ці відмінності стануть помітними, коли ми почнемо працювати.

Розгляньте різні способи, якими команда могла б поділити роботу над проєктом із розробки програмного забезпечення. Ви могли б призначити одному учаснику кілька tasks, призначити кожному учаснику по одній task або використати поєднання цих двох підходів.

Коли окрема людина працює над кількома різними tasks до завершення будь-якої з них, це — конкурентність. Один зі способів реалізувати конкурентність схожий на ситуацію, коли на вашому комп’ютері відкрито два різні проєкти, і коли ви нудьгуєте або застрягаєте на одному проєкті, ви перемикаєтеся на інший. Ви — лише одна людина, тож не можете просуватися в обох tasks одночасно, але можете виконувати кілька справ одночасно, просуваючись по одній за раз шляхом перемикання між ними (див. Рисунок 17-1).

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

Коли команда ділить групу tasks так, що кожен учасник бере одну task і працює над нею самостійно, це — паралелізм. Кожна людина в команді може просуватися одночасно (див. Рисунок 17-2).

Схема зі стосами блоків, позначених Task A і Task B, із ромбами всередині, що представляють subtasks. Стрілки вказують від A1 до A2, від A2 до A3, від A3 до A4, від B1 до B2, і від B2 до B3. Жодні стрілки не перетинаються між блоками для Task A і Task B.
Рисунок 17-2: Паралельний робочий процес, де робота над Task A і Task B відбувається незалежно

В обох цих робочих процесах вам може знадобитися координувати різні tasks. Можливо, ви думали, що task, призначена одній людині, є повністю незалежною від роботи всіх інших, але насправді для її завершення спочатку потрібно, щоб інша людина в команді завершила свою task. Частину роботи можна було б виконати паралельно, але частина насправді була послідовною: вона могла відбуватися лише в серії, одна task за іншою, як на Рисунку 17-3.

Схема зі стосами блоків, позначених Task A і Task B, із ромбами всередині, що представляють subtasks. У Task A стрілки вказують від A1 до A2, від A2 до пари товстих вертикальних ліній, схожих на символ “pause”, і від цього символу до A3. У task B стрілки вказують від B1 до B2, від B2 до B3, від B3 до A3, і від B3 до B4.
Рисунок 17-3: Частково паралельний робочий процес, де робота над Task A і Task B відбувається незалежно, доки Task A3 не блокується на результатах Task B3.

Так само ви можете зрозуміти, що одна з ваших власних tasks залежить від іншої вашої task. Тепер ваша конкурентна робота також стала послідовною.

Паралелізм і конкурентність також можуть перетинатися. Якщо ви дізнаєтеся, що ваш колега застряг, доки ви не завершите одну зі своїх tasks, ви, ймовірно, зосередите всі свої зусилля на цій task, щоб «розблокувати» вашого колегу. Ви і ваш співробітник більше не можете працювати паралельно, і ви також більше не можете працювати конкурентно над власними tasks.

Ті самі базові динаміки мають місце і в програмному та апаратному забезпеченні. На машині з одним ядром CPU процесор може виконувати лише одну операцію за раз, але все одно може працювати конкурентно. Використовуючи такі інструменти, як потоки, процеси та async, комп’ютер може призупинити одну діяльність і переключитися на інші, перш ніж зрештою знову повернутися до тієї першої діяльності. На машині з кількома ядрами CPU він також може виконувати роботу паралельно. Одне ядро може виконувати одну task, тоді як інше ядро виконує зовсім не пов’язану з нею, і ці операції насправді відбуваються одночасно.

Виконання async-коду в Rust зазвичай відбувається конкурентно. Залежно від апаратного забезпечення, операційної системи та async runtime, який ми використовуємо (про async runtime ще трохи згодом), ця конкурентність може також використовувати паралелізм під капотом.

Тепер занурмося в те, як async-програмування в Rust насправді працює.