async в Rust и зачем нужен unsafe

async в Rust — это не «потоки полегче», а сгенерированная компилятором стейт-машина, которая без исполнителя не делает ничего; unsafe — не выключатель проверок, а пять точечных разрешений.

Future — тип, описывающий вычисление, которое можно продвигать по шагам вызовом poll; сам по себе он ленив и не выполняется.

Вопрос: «Чем async отличается от потоков и почему у Rust нет встроенного рантайма?»

Что на самом деле проверяет интервьюер

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

Отсюда правило выбора: async — для I/O-bound (сеть, диск, база, много ожидания), потоки — для CPU-bound (счёт, который реально грузит ядро). Смешивать нельзя: блокирующий вызов внутри async-задачи блокирует весь поток исполнителя вместе со всеми задачами на нём. std::thread::sleep в async-коде — классическая ошибка на code review; нужен tokio::time::sleep, а для тяжёлого счёта — spawn_blocking или отдельный пул.

Почему рантайма нет в std: Rust целится и во встраиваемые системы без ОС, и в высоконагруженные серверы, а их планировщики устроены по-разному. Стандарт фиксирует только интерфейс — трейты Future, Waker, Context — а стратегию выполнения оставляет библиотекам: tokio, async-std, smol, embassy. Цена решения — экосистемный раскол: библиотеки часто привязаны к конкретному рантайму.

Вопрос: «Что произойдёт, если не сделать .await?»

Что на самом деле проверяет интервьюер

Знаете ли вы, что фьючи ленивы. В JavaScript вызов async-функции уже запускает работу, и промис живёт сам по себе. В Rust вызов async fn только создаёт объект-стейт-машину; пока его не поллит исполнитель, не выполнится ни одна строка тела.

async fn load_user(id: u32) -> String {
    format!("user #{id}")
}

fn main() {
    load_user(7); // тело функции не выполнится никогда
    println!("main закончился");
}

Предупреждение компилятора:

warning: unused implementer of `Future` that must be used
 --> src/main.rs:6:5
  |
6 |     load_user(7);
  |     ^^^^^^^^^^^^
  |
  = note: futures do nothing unless you `.await` or poll them
  = note: `#[warn(unused_must_use)]` on by default

Вывод:

main закончился

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

Чтобы фьюча выполнилась, нужен исполнитель. Он подключается зависимостью, в std его нет:

[dependencies]
tokio = { version = "1", features = ["full"] }
// требует зависимости tokio, приведённой выше
#[tokio::main]
async fn main() {
    let a = load_user(1);
    let b = load_user(2);

    // обе фьючи продвигаются в одной задаче, конкурентно
    let (x, y) = tokio::join!(a, b);
    println!("{x}, {y}");
}

Здесь же уместно упомянуть две настоящие боли, о которых спрашивают senior. Первая — Pin: стейт-машина может содержать ссылки на собственные поля (self-referential), поэтому после первого poll её нельзя перемещать в памяти; Pin — это тип-обещание «объект больше не переедет», и он всплывает, как только вы пишете свою Future вручную. Вторая — «цветность» функций: async заразен, обычная функция не может дождаться фьючи, поэтому один async-вызов в глубине тянет async вверх по всей цепочке. Трейты с async-методами долго были отдельной болью и решались через async-trait; сегодня базовый случай поддержан языком, но с ограничениями по dyn-совместимости.

Вопрос: «Что можно делать в unsafe и почему это не «выключить безопасность»?»

Что на самом деле проверяет интервьюер

Заученный список и, главное, понимание контракта. unsafe не отключает borrow checker, не отменяет проверки лайфтаймов и типов. Он разрешает ровно пять вещей:

  1. разыменовать сырой указатель *const T / *mut T;
  2. вызвать unsafe-функцию или метод (включая любую функцию из FFI);
  3. реализовать unsafe-трейт (например Send или Sync вручную);
  4. обратиться к изменяемой static-переменной или изменить её;
  5. обратиться к полю union.

Всё остальное внутри блока проверяется как обычно. Смысл ключевого слова — не «разрешаю себе всё», а «компилятор больше не может доказать корректность здесь, доказательство беру на себя».

Код, который не компилируется без unsafe

fn main() {
    let mut x = 10;
    let p: *mut i32 = &mut x;

    *p += 5;
    println!("x = {x}");
}

Ошибка компиляции:

error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block
 --> src/main.rs:5:5
  |
5 |     *p += 5;
  |     ^^^^^^^ dereference of raw pointer
  |
  = note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races

Заметьте: создать сырой указатель безопасно, небезопасно его разыменовать. С блоком код собирается:

fn main() {
    let mut x = 10;
    let p: *mut i32 = &mut x;

    unsafe {
        // SAFETY: p получен из живой изменяемой ссылки на x,
        // выровнен, не null и других ссылок на x сейчас нет.
        *p += 5;
    }

    println!("x = {x}");
}

Вывод:

x = 15

Зачем unsafe вообще существует

Три честные причины: FFI к C-библиотекам (у чужого ABI нет гарантий Rust); реализация базовых примитивов — Vec, Rc, Mutex, slice::split_at_mut внутри написаны через сырые указатели, потому что их инварианты не выражаются в системе типов; прямой доступ к железу и регистрам во встраиваемых системах.

Отсюда главный архитектурный принцип — safe abstraction over unsafe core: маленькое небезопасное ядро с доказанным инвариантом, обёрнутое безопасным API, который невозможно использовать неправильно.

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();
    assert!(mid <= len); // инвариант проверяем ДО unsafe

    unsafe {
        // SAFETY: mid <= len проверено ассертом, поэтому оба среза
        // лежат внутри одного выделения и не пересекаются.
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

Снаружи функция полностью безопасна: при любых аргументах она либо вернёт корректные срезы, либо запаникует. Нарушение контракта внутри unsafe даёт не панику, а undefined behavior — компилятор вправе оптимизировать код исходя из того, что UB не бывает, поэтому симптомы проявятся где угодно и когда угодно. Отсюда практические правила: блок unsafe должен быть минимальным, каждый — с комментарием // SAFETY:, объясняющим, почему инвариант выполнен, а тесты стоит гонять под Miri:

cargo +nightly miri test

Типичные ошибки кандидатов

  • Считать, что async-задачи — это «лёгкие потоки», и запускать в них тяжёлый счёт или std::thread::sleep.
  • Ожидать, что async fn начнёт работать сразу после вызова, как промис в JS.
  • Не знать, что забытый .await — это предупреждение, а не ошибка, и его легко потерять.
  • Говорить «unsafe отключает borrow checker». Не отключает — он даёт пять конкретных разрешений.
  • Оборачивать в unsafe половину функции вместо двух строк и без комментария SAFETY.
  • Путать панику и UB: паника предсказуема и ловится, UB — нет.
  • Отвечать «unsafe в проде не нужен вообще». Ваш Vec уже написан на нём.

Как ответить кратко

«async fn компилируется в стейт-машину, реализующую Future. Future ленив: без .await или spawn не выполнится ничего, компилятор даст только предупреждение. Исполнителя в std нет — язык фиксирует интерфейс, а планировщик отдан tokio и другим, потому что серверу и микроконтроллеру нужны разные рантаймы. Async берут для I/O-bound нагрузки, потоки — для CPU-bound; блокирующий вызов внутри async останавливает весь поток исполнителя. unsafe не выключает проверки: он разрешает разыменовать сырой указатель, вызвать unsafe-функцию, реализовать unsafe-трейт, тронуть mutable static и поле union. Нужен он для FFI, для примитивов вроде Vec и Mutex и для железа; правильный паттерн — маленькое unsafe-ядро под безопасным API, с комментарием SAFETY и проверкой под Miri.»

Что повторить перед собеседованием

Курс закончен, и перед собеседованием стоит прогнать связку целиком: владение и перемещение, три правила заимствования, лайфтаймы и почему они не «время жизни, а связь ссылок», Option/Result и оператор ?, трейты, дженерики и мономорфизацию против dyn, умные указатели Box/Rc/RefCell, и поверх всего — материал этого раздела: Send/Sync, Arc<Mutex<T>>, каналы, ленивость фьюч и границы unsafe. Лучший способ подготовки — не перечитывание, а один небольшой проект, где вы сами упрётесь в E0373, E0382 и poisoning: ответ, за которым стоит собственная отладка, слышно сразу.

Проверьте себя
1. Что произойдёт, если вызвать async fn и не сделать .await?
AФункция выполнится в фоне, как промис в JavaScript
BПрограмма не скомпилируется с ошибкой E0277
CБудет создан объект Future, тело не выполнится, компилятор выдаст лишь предупреждение
DИсполнитель автоматически запустит задачу при завершении main
2. Почему исполнитель async-задач не входит в стандартную библиотеку Rust?
AОн есть в std, но скрыт за nightly-фичей
BСтандарт фиксирует только интерфейс (Future, Waker, Context), а стратегии планирования для сервера и микроконтроллера принципиально разные
CИз-за лицензионных ограничений tokio
DПотому что async в Rust реализован целиком в макросах и рантайм не нужен
3. Что из перечисленного unsafe НЕ разрешает?
AРазыменовать сырой указатель
BОбратиться к изменяемой static-переменной
CРеализовать unsafe-трейт вроде Send или Sync
DНарушить правила заимствования: иметь две изменяемые ссылки на одно значение через обычные ссылки