# Async/Await у Rust: Tokio, Futures та асинхронна конкурентність > Детальний розбір асинхронного програмування в Rust: від принципів роботи Future до практичних патернів з Tokio, каналів mpsc та обмеження паралелізму через семафори. - Published: 2026-04-30 - Updated: 2026-04-30 - Author: SharpSkill - Tags: rust, async, tokio, futures, concurrency - Reading time: 12 min --- Асинхронне програмування у Rust принципово відрізняється від аналогічних механізмів у більшості інших мов. Якщо JavaScript, Python чи C# надають вбудований рантайм для виконання асинхронного коду, Rust обирає інший шлях — zero-cost abstractions. Ф'ючерси (futures) у Rust є лінивими: вони не виконують жодної роботи, доки їх не опитає (poll) зовнішній рантайм. Це означає, що розробник має свідомо обрати та підключити асинхронний рантайм — і саме тут на сцену виходить Tokio, найпопулярніший рантайм для rust async await. Такий підхід дає максимальний контроль над продуктивністю та розподілом ресурсів, але потребує глибшого розуміння внутрішніх механізмів. Ця стаття послідовно розкриває кожен аспект асинхронності у Rust — від трейту Future до реальних патернів конкурентної обробки HTTP-запитів. > Rust futures є лінивими за замовчуванням. На відміну від Promise у JavaScript, створення Future не запускає жодного обчислення. Виконання починається лише тоді, коли рантайм (наприклад, Tokio) починає опитувати (poll) ф'ючерс. ## Як влаштований трейт Future у Rust Щоб зрозуміти rust futures explained на глибинному рівні, варто почати з визначення трейту `Future`. На відміну від Promise у JavaScript, який одразу починає виконання, або Task у C#, що планується на пул потоків, ф'ючерс у Rust — це стейт-машина, яку рантайм опитує через метод `poll`. ```rust // core::future::Future trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll; } ``` Метод `poll` повертає `Poll::Ready(value)`, коли результат готовий, або `Poll::Pending`, коли операція ще не завершена. У випадку `Pending` рантайм зберігає `Waker` із контексту `cx` і повторює опитування лише тоді, коли ф'ючерс сигналізує про готовність. Такий механізм забезпечує ефективне використання процесорного часу без зайвого busy-waiting. Компілятор Rust автоматично перетворює кожну `async fn` на стейт-машину, що реалізує трейт `Future`. Кожна точка `.await` стає точкою переривання, де стейт-машина може повернути `Pending` і передати контроль рантайму. ## Налаштування Tokio як асинхронного рантайму Tokio — це де-факто стандартний асинхронний рантайм для Rust. Він забезпечує багатопотоковий планувальник задач, таймери, мережеві примітиви та механізми синхронізації. Для початку роботи достатньо додати залежність у `Cargo.toml`. ```toml # Cargo.toml [dependencies] tokio = { version = "2", features = ["rt-multi-thread", "macros", "net", "time"] } ``` Макрос `#[tokio::main]` перетворює функцію `main` на точку входу асинхронного рантайму. Під капотом він створює багатопотоковий планувальник і запускає передану async-функцію як кореневу задачу. ```rust // main.rs #[tokio::main] async fn main() { let result = fetch_data().await; println!("Got: {result}"); } async fn fetch_data() -> String { // Simulate async I/O tokio::time::sleep(std::time::Duration::from_millis(100)).await; String::from("data loaded") } ``` Цей приклад ілюструє базовий rust tokio tutorial: визначення асинхронної функції, виклик через `.await` та запуск через макрос рантайму. Функція `fetch_data` моделює асинхронну операцію вводу-виводу через `tokio::time::sleep`. ## Спавн задач та структурована конкурентність Одна з ключових переваг Tokio — можливість запускати незалежні задачі через `tokio::spawn`. Кожна задача виконується конкурентно на пулі потоків рантайму. Це критично важливий патерн для будь-якого rust concurrency interview. ```rust // concurrent_tasks.rs use tokio::task::JoinHandle; #[tokio::main] async fn main() { // Spawn two independent tasks let handle_a: JoinHandle = tokio::spawn(async { expensive_computation("dataset_a").await }); let handle_b: JoinHandle = tokio::spawn(async { expensive_computation("dataset_b").await }); // Await both results let (result_a, result_b) = ( handle_a.await.expect("task A panicked"), handle_b.await.expect("task B panicked"), ); println!("Results: {result_a}, {result_b}"); } async fn expensive_computation(name: &str) -> u32 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; println!("{name} done"); 42 } ``` `tokio::spawn` повертає `JoinHandle`, через який можна отримати результат задачі або перехопити паніку. Обидві задачі виконуються одночасно, тому загальний час виконання дорівнює часу найдовшої задачі, а не сумі. Важливий нюанс: задачі, запущені через `tokio::spawn`, повинні бути `'static` — вони не можуть запозичувати дані з батьківського контексту. Для передачі даних використовують `Arc`, `Clone` або переміщення володіння через `move`. ## Об'єднання ф'ючерсів: tokio::join! та tokio::select! Коли кілька асинхронних операцій потрібно виконати конкурентно в межах однієї задачі, Tokio надає два потужних макроси: `join!` та `select!`. ### tokio::join! — очікування всіх результатів Макрос `tokio::join!` запускає кілька ф'ючерсів одночасно і чекає завершення кожного з них. ```rust // join_example.rs use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { // All three run concurrently, total time ~200ms (not 600ms) let (users, orders, inventory) = tokio::join!( fetch_users(), fetch_orders(), fetch_inventory() ); println!("Users: {}, Orders: {}, Stock: {}", users.len(), orders.len(), inventory); } async fn fetch_users() -> Vec { sleep(Duration::from_millis(200)).await; vec!["Alice".into(), "Bob".into()] } async fn fetch_orders() -> Vec { sleep(Duration::from_millis(150)).await; vec!["ORD-001".into()] } async fn fetch_inventory() -> u32 { sleep(Duration::from_millis(100)).await; 84 } ``` Усі три ф'ючерси опитуються конкурентно в одній задачі. Загальний час виконання становить приблизно 200 мс (час найдовшої операції), а не 450 мс (сума всіх). ### tokio::select! — реакція на перший результат Макрос `select!` чекає завершення будь-якого з ф'ючерсів і скасовує решту. Це ідеальний інструмент для патернів fallback або таймаутів. ```rust // select_example.rs use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { tokio::select! { val = fetch_from_cache() => { println!("Cache hit: {val}"); } val = fetch_from_database() => { println!("DB result: {val}"); } } } async fn fetch_from_cache() -> String { sleep(Duration::from_millis(5)).await; "cached_value".into() } async fn fetch_from_database() -> String { sleep(Duration::from_millis(50)).await; "db_value".into() } ``` У цьому прикладі `select!` поверне результат з кешу (5 мс), оскільки він готовий раніше за базу даних (50 мс). Ф'ючерс бази даних буде автоматично скасований (dropped). ## Обробка помилок у асинхронному Rust Асинхронний код у Rust використовує ті самі механізми обробки помилок, що й синхронний: `Result` та оператор `?`. Проте в реальних застосунках часто виникає потреба об'єднувати помилки з різних джерел. ```rust // error_handling.rs use std::io; #[derive(Debug)] enum AppError { Network(reqwest::Error), Parse(serde_json::Error), Io(io::Error), } async fn load_config(url: &str) -> Result { let response = reqwest::get(url) .await .map_err(AppError::Network)?; let text = response.text() .await .map_err(AppError::Network)?; let config: Config = serde_json::from_str(&text) .map_err(AppError::Parse)?; Ok(config) } #[derive(serde::Deserialize)] struct Config { db_url: String, port: u16, } ``` Патерн із кастомним enum помилки дозволяє зберігати інформацію про джерело помилки та використовувати оператор `?` для елегантного поширення. У продакшн-коді часто застосовують крейти `thiserror` або `anyhow` для зменшення бойлерплейту. ## Канали для асинхронної комунікації Tokio надає кілька типів каналів для безпечного обміну даними між задачами. Найпоширенішим є `mpsc` (multiple producer, single consumer) — обмежений канал з кількома відправниками та одним отримувачем. ```rust // channel_example.rs use tokio::sync::mpsc; #[tokio::main] async fn main() { // Bounded channel with capacity 32 let (tx, mut rx) = mpsc::channel::(32); // Producer task let producer = tokio::spawn(async move { for i in 0..5 { tx.send(format!("message-{i}")).await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } // tx dropped here, closing the channel }); // Consumer reads until channel closes while let Some(msg) = rx.recv().await { println!("Received: {msg}"); } producer.await.unwrap(); } ``` Обмежений канал з ємністю 32 забезпечує зворотний тиск (backpressure): якщо отримувач не встигає обробляти повідомлення, відправник блокується на `send().await`. Коли всі відправники знищуються (dropped), канал закривається і `recv()` повертає `None`. Tokio також надає `broadcast`, `watch` та `oneshot` канали для різних сценаріїв комунікації. ## Реальний патерн: конкурентний HTTP з обмеженням паралелізму Один з найпоширеніших практичних сценаріїв — масова обробка HTTP-запитів з обмеженням кількості одночасних з'єднань. Семафор Tokio ідеально підходить для цієї задачі. ```rust // rate_limited_fetcher.rs use std::sync::Arc; use tokio::sync::Semaphore; async fn fetch_all(urls: Vec, max_concurrent: usize) -> Vec> { let semaphore = Arc::new(Semaphore::new(max_concurrent)); let mut handles = Vec::new(); for url in urls { let sem = Arc::clone(&semaphore); let handle = tokio::spawn(async move { // Acquire permit before making request let _permit = sem.acquire().await.unwrap(); reqwest::get(&url) .await .map(|r| r.status().to_string()) .map_err(|e| e.to_string()) // permit dropped here, allowing next task to proceed }); handles.push(handle); } let mut results = Vec::new(); for handle in handles { results.push(handle.await.unwrap()); } results } ``` Семафор з `max_concurrent` дозволами гарантує, що одночасно виконується не більше заданої кількості запитів. Коли задача отримує дозвіл через `sem.acquire().await`, вона виконує запит. Після завершення дозвіл автоматично повертається (через Drop), і наступна задача може продовжити роботу. Цей патерн часто зустрічається на rust concurrency interview як приклад практичного застосування примітивів синхронізації. ## Pin та Unpin: чому це важливо Трейт `Future` вимагає `Pin<&mut Self>` у методі `poll`. Це пов'язано з тим, що стейт-машини, згенеровані компілятором з async-функцій, можуть містити самопосилання (self-referential structures). Якщо таку структуру перемістити в пам'яті, внутрішні посилання стануть невалідними. `Pin<&mut T>` гарантує, що значення не буде переміщене в пам'яті після закріплення. Більшість типів реалізують маркерний трейт `Unpin`, що означає: їх безпечно переміщувати навіть після закріплення. Проте ф'ючерси, створені через `async {}`, зазвичай не є `Unpin`, тому для роботи з ними використовується `Box::pin()` або макрос `pin!`. На практиці розробники рідко взаємодіють з `Pin` безпосередньо — компілятор і `.await` приховують цю складність. Проте розуміння концепції є критичним для роботи з trait objects (`Box`) та написання власних ф'ючерсів. ## Продуктивність та spawn_blocking Асинхронний рантайм Tokio оптимізований для операцій вводу-виводу. Кожен потік рантайму виконує кооперативну багатозадачність: задачі добровільно передають контроль на кожному `.await`. Якщо задача виконує тривале обчислення без `.await`, вона блокує весь потік і всі інші задачі на ньому. Для CPU-інтенсивних операцій Tokio надає `spawn_blocking`, який виконує код на окремому пулі потоків, не блокуючи асинхронний планувальник. ```rust // spawn_blocking_example.rs #[tokio::main] async fn main() { let hash = tokio::task::spawn_blocking(|| { // CPU-intensive work runs on a blocking thread compute_hash(b"large dataset") }) .await .unwrap(); println!("Hash: {hash}"); } fn compute_hash(data: &[u8]) -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); data.hash(&mut hasher); format!("{:x}", hasher.finish()) } ``` Ключові характеристики продуктивності Rust async: - Ф'ючерси не алокують пам'ять на купі за замовчуванням (zero-cost). - Стейт-машини компілюються в ефективний нативний код без накладних витрат на рантайм. - `tokio::spawn` створює легковагі задачі (десятки байтів проти кілобайтів для OS-потоків). - `spawn_blocking` ізолює CPU-інтенсивний код від асинхронного планувальника. ## Висновки Асинхронне програмування у Rust поєднує безпеку на етапі компіляції з максимальною продуктивністю під час виконання. Ось ключові тези для закріплення: - Ф'ючерси у Rust є лінивими — виконання починається лише при опитуванні рантаймом, на відміну від eagerly-evaluated Promise у JavaScript. - Tokio забезпечує багатопотоковий рантайм з планувальником work-stealing, таймерами та мережевими примітивами. - `tokio::spawn` створює незалежні конкурентні задачі, а `join!` об'єднує ф'ючерси в межах однієї задачі. - `select!` реагує на перший завершений ф'ючерс, скасовуючи решту — ідеально для таймаутів та fallback-стратегій. - Обробка помилок використовує стандартний `Result` з оператором `?` для елегантного поширення. - Канали `mpsc` забезпечують безпечну комунікацію між задачами з механізмом backpressure. - Семафори обмежують паралелізм для контрольованого доступу до зовнішніх ресурсів. - `Pin` гарантує коректність самопосилальних стейт-машин, але зазвичай прихований за `.await`. - `spawn_blocking` виносить CPU-інтенсивні операції на окремий пул потоків. Знання цих концепцій є обов'язковим для успішного проходження rust concurrency interview та побудови надійних високонавантажених систем на Rust. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/uk/blog/rust/rust-async-await-tokio-futures-concurrency