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

7. Исполнители и рантаймы 🟡

Что вы узнаете:

  • Что делает исполнитель: опрашивает future и эффективно засыпает
  • Шесть основных рантаймов: mio, io_uring, tokio, async-std, smol, embassy
  • Дерево решений для выбора подходящего рантайма
  • Почему важен дизайн библиотек, не привязанных к рантайму

Что делает исполнитель

У исполнителя две задачи:

  1. Опрашивать future, когда они готовы продвинуться дальше
  2. Эффективно засыпать, когда ни один future не готов (используя API уведомлений ввода-вывода ОС)
graph TB
    subgraph Executor["Исполнитель (например, tokio)"]
        QUEUE["Очередь задач"]
        POLLER["Опрос I/O<br/>(epoll/kqueue/io_uring)"]
        THREADS["Пул рабочих потоков"]
    end

    subgraph Tasks["Задачи"]
        T1["Задача 1<br/>(HTTP-запрос)"]
        T2["Задача 2<br/>(запрос к БД)"]
        T3["Задача 3<br/>(чтение файла)"]
    end

    subgraph OS["Операционная система"]
        NET["Сетевой стек"]
        DISK["Дисковый ввод-вывод"]
    end

    T1 --> QUEUE
    T2 --> QUEUE
    T3 --> QUEUE
    QUEUE --> THREADS
    THREADS -->|"poll()"| T1
    THREADS -->|"poll()"| T2
    THREADS -->|"poll()"| T3
    POLLER <-->|"регистрация/уведомление"| NET
    POLLER <-->|"регистрация/уведомление"| DISK
    POLLER -->|"пробуждение задач"| QUEUE

    style Executor fill:#e3f2fd,color:#000
    style OS fill:#f3e5f5,color:#000

mio: базовый слой

mio (Metal I/O) — это не исполнитель, а самая низкоуровневая кроссплатформенная библиотека уведомлений о вводе-выводе. Она оборачивает epoll (Linux), kqueue (macOS/BSD) и IOCP (Windows).

#![allow(unused)]
fn main() {
// Условное использование mio (упрощённо):
use mio::{Events, Interest, Poll, Token};
use mio::net::TcpListener;

let mut poll = Poll::new()?;
let mut events = Events::with_capacity(128);

let mut server = TcpListener::bind("0.0.0.0:8080")?;
poll.registry().register(&mut server, Token(0), Interest::READABLE)?;

// Цикл событий — блокируется, пока что-нибудь не произойдёт
loop {
    poll.poll(&mut events, None)?; // Спим до события ввода-вывода
    for event in events.iter() {
        match event.token() {
            Token(0) => { /* у сервера новое соединение */ }
            _ => { /* другой ввод-вывод готов */ }
        }
    }
}
}

Большинство разработчиков никогда не работают с mio напрямую — tokio и smol строятся поверх него.

io_uring: завершение как основа

io_uring в Linux (ядро 5.1+) — это принципиальный сдвиг по сравнению с моделью ввода-вывода на основе готовности, которую используют mio/epoll:

На основе готовности (epoll / mio / tokio):
  1. Спрашиваем: «Можно ли читать из этого сокета?»  → epoll_wait()
  2. Ядро: «Да, готов»                                → событие EPOLLIN
  3. Приложение: read(fd, buf)                        → может всё ещё ненадолго заблокироваться!

На основе завершения (io_uring):
  1. Отправляем: «Прочитай из этого сокета в этот буфер»  → SQE
  2. Ядро: выполняет чтение асинхронно
  3. Приложение: получает готовый результат с данными     → CQE
graph LR
    subgraph "Модель готовности (epoll)"
        A1["Приложение: готово?"] --> K1["Ядро: да"]
        K1 --> A2["Приложение: теперь read()"]
        A2 --> K2["Ядро: вот данные"]
    end

    subgraph "Модель завершения (io_uring)"
        B1["Приложение: прочитай это за меня"] --> K3["Ядро: работает..."]
        K3 --> B2["Приложение: получил результат + данные"]
    end

    style B1 fill:#c8e6c9,color:#000
    style B2 fill:#c8e6c9,color:#000

Проблема владения: io_uring требует, чтобы ядро владело буфером до завершения операции. Это конфликтует со стандартным трейтом AsyncRead в Rust, который заимствует буфер. Поэтому в tokio-uring другие трейты ввода-вывода:

#![allow(unused)]
fn main() {
// Стандартный tokio (на основе готовности) — заимствует буфер:
let n = stream.read(&mut buf).await?;  // buf заимствован

// tokio-uring (на основе завершения) — забирает владение буфером:
let (result, buf) = stream.read(buf).await;  // buf передан внутрь и возвращён обратно
let n = result?;
}
// Cargo.toml: tokio-uring = "0.5"
// ЗАМЕТЬТЕ: только Linux, требуется ядро 5.1+

fn main() {
    tokio_uring::start(async {
        let file = tokio_uring::fs::File::open("data.bin").await.unwrap();
        let buf = vec![0u8; 4096];
        let (result, buf) = file.read_at(buf, 0).await;
        let bytes_read = result.unwrap();
        println!("Прочитано байт: {}: {:?}", bytes_read, &buf[..bytes_read]);
    });
}
Аспектepoll (tokio)io_uring (tokio-uring)
МодельУведомление о готовностиУведомление о завершении
Системные вызовыepoll_wait + read/writeПакетное кольцо SQE/CQE
Владение буферомПриложение сохраняет (&mut buf)Передача владения (move buf)
ПлатформаLinux, macOS (kqueue), Windows (IOCP)Только Linux 5.1+
Zero-copyНет (копирование в пользовательском пространстве)Да (зарегистрированные буферы)
ЗрелостьГотов к продакшенуЭкспериментальный

Когда использовать io_uring: высокопроизводительный файловый ввод-вывод или сеть, где узким местом становятся накладные расходы системных вызовов (базы данных, движки хранения, прокси на 100k+ соединений). Для большинства приложений правильный выбор — стандартный tokio с epoll.

tokio: рантайм «всё в комплекте»

Доминирующий асинхронный рантайм в экосистеме Rust. Используется в Axum, Hyper, Tonic и большинстве продакшен-серверов на Rust.

// Cargo.toml:
// [dependencies]
// tokio = { version = "1", features = ["full"] }

#[tokio::main]
async fn main() {
    // Запускает многопоточный рантайм с планировщиком work-stealing
    let handle = tokio::spawn(async {
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        "done"
    });

    let result = handle.await.unwrap();
    println!("{result}");
}

Возможности tokio: таймеры, ввод-вывод, TCP/UDP, Unix-сокеты, обработка сигналов, примитивы синхронизации (Mutex, RwLock, Semaphore, каналы), fs, process, интеграция с tracing.

async-std: зеркало стандартной библиотеки

Повторяет API std в асинхронных версиях. Менее популярен, чем tokio, но проще для начинающих.

// Cargo.toml:
// [dependencies]
// async-std = { version = "1", features = ["attributes"] }

#[async_std::main]
async fn main() {
    use async_std::fs;
    let content = fs::read_to_string("hello.txt").await.unwrap();
    println!("{content}");
}

smol: минималистичный рантайм

Небольшой асинхронный рантайм без зависимостей. Отлично подходит для библиотек, которые хотят async, но не хотят тянуть tokio.

// Cargo.toml:
// [dependencies]
// smol = "2"

fn main() {
    smol::block_on(async {
        let result = smol::unblock(|| {
            // Выполняет блокирующий код в пуле потоков
            std::fs::read_to_string("hello.txt")
        }).await.unwrap();
        println!("{result}");
    });
}

embassy: async для встраиваемых систем (no_std)

Асинхронный рантайм для встраиваемых систем. Без выделения памяти в куче, std не нужен.

// Работает на микроконтроллерах (например, STM32, nRF52, RP2040)
#[embassy_executor::main]
async fn main(spawner: embassy_executor::Spawner) {
    // Мигаем светодиодом через async/await — RTOS не нужна!
    let mut led = Output::new(p.PA5, Level::Low, Speed::Low);
    loop {
        led.set_high();
        Timer::after(Duration::from_millis(500)).await;
        led.set_low();
        Timer::after(Duration::from_millis(500)).await;
    }
}

Дерево решений по выбору рантайма

graph TD
    START["Выбор рантайма"]

    Q1{"Пишете<br/>сетевой сервер?"}
    Q2{"Нужна экосистема tokio<br/>(Axum, Tonic, Hyper)?"}
    Q3{"Пишете<br/>библиотеку?"}
    Q4{"Встраиваемая система /<br/>no_std?"}
    Q5{"Нужны минимальные<br/>зависимости?"}

    TOKIO["🟢 tokio<br/>Лучшая экосистема, самый популярный"]
    SMOL["🔵 smol<br/>Минимальный, без привязки к экосистеме"]
    EMBASSY["🟠 embassy<br/>Ориентирован на встраиваемые, без выделений"]
    ASYNC_STD["🟣 async-std<br/>API как у std, хорош для обучения"]
    AGNOSTIC["🔵 независимый от рантайма<br/>Используйте только крейт futures"]

    START --> Q1
    Q1 -->|Да| Q2
    Q1 -->|Нет| Q3
    Q2 -->|Да| TOKIO
    Q2 -->|Нет| Q5
    Q3 -->|Да| AGNOSTIC
    Q3 -->|Нет| Q4
    Q4 -->|Да| EMBASSY
    Q4 -->|Нет| Q5
    Q5 -->|Да| SMOL
    Q5 -->|Нет| ASYNC_STD

    style TOKIO fill:#c8e6c9,color:#000
    style SMOL fill:#bbdefb,color:#000
    style EMBASSY fill:#ffe0b2,color:#000
    style ASYNC_STD fill:#e1bee7,color:#000
    style AGNOSTIC fill:#bbdefb,color:#000

Сравнительная таблица рантаймов

Характеристикаtokioasync-stdsmolembassy
ЭкосистемаДоминирующаяНебольшаяМинимальнаяВстраиваемая
Многопоточность✅ Work-stealing✅✅❌ (одно ядро)
no_std❌❌❌✅
Таймер✅ Встроенный✅ ВстроенныйЧерез async-io✅ На основе HAL
Ввод-вывод✅ Собственные абстракции✅ Зеркало std✅ Через async-io✅ Драйверы HAL
Каналы✅ Богатый набор✅Через async-channel✅
Порог входаСреднийНизкийНизкийВысокий (железо)
Размер бинарникаБольшойСреднийМаленькийКрошечный
🏋️ Упражнение: сравнение рантаймов (нажмите, чтобы раскрыть)

Задача: напишите одну и ту же программу на трёх разных рантаймах (tokio, smol и async-std). Программа должна:

  1. Получить URL (имитируется через sleep)
  2. Прочитать файл (имитируется через sleep)
  3. Вывести оба результата

Это упражнение показывает, что код async/await одинаков — меняется только настройка рантайма.

🔑 Решение
// ----- версия для tokio -----
// Cargo.toml: tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
    let (url_result, file_result) = tokio::join!(
        async {
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            "Ответ с URL"
        },
        async {
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            "Содержимое файла"
        },
    );
    println!("URL: {url_result}, Файл: {file_result}");
}

// ----- версия для smol -----
// Cargo.toml: smol = "2", futures-lite = "2"
fn main() {
    smol::block_on(async {
        let (url_result, file_result) = futures_lite::future::zip(
            async {
                smol::Timer::after(std::time::Duration::from_millis(100)).await;
                "Ответ с URL"
            },
            async {
                smol::Timer::after(std::time::Duration::from_millis(50)).await;
                "Содержимое файла"
            },
        ).await;
        println!("URL: {url_result}, Файл: {file_result}");
    });
}

// ----- версия для async-std -----
// Cargo.toml: async-std = { version = "1", features = ["attributes"] }
#[async_std::main]
async fn main() {
    let (url_result, file_result) = futures::future::join(
        async {
            async_std::task::sleep(std::time::Duration::from_millis(100)).await;
            "Ответ с URL"
        },
        async {
            async_std::task::sleep(std::time::Duration::from_millis(50)).await;
            "Содержимое файла"
        },
    ).await;
    println!("URL: {url_result}, Файл: {file_result}");
}

Ключевой вывод: асинхронная бизнес-логика одинакова во всех рантаймах. Различаются только точка входа и API таймеров/ввода-вывода. Поэтому написание библиотек, не привязанных к рантайму (использующих только std::future::Future), так ценно.

Ключевые выводы — исполнители и рантаймы

  • Работа исполнителя: опрашивать future при пробуждении и эффективно засыпать, используя API ввода-вывода ОС
  • tokio — выбор по умолчанию для серверов; smol — для минимального отпечатка; embassy — для встраиваемых систем
  • Ваша бизнес-логика должна зависеть от std::future::Future, а не от конкретного рантайма
  • io_uring (Linux 5.1+) — будущее высокопроизводительного ввода-вывода, но экосистема ещё созревает

См. также: Гл. 8 — Глубокое погружение в Tokio — специфика tokio, Гл. 9 — Когда Tokio не подходит — альтернативы