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, не отменяет проверки лайфтаймов и типов. Он разрешает ровно пять вещей:
- разыменовать сырой указатель
*const T/*mut T; - вызвать
unsafe-функцию или метод (включая любую функцию из FFI); - реализовать
unsafe-трейт (напримерSendилиSyncвручную); - обратиться к изменяемой
static-переменной или изменить её; - обратиться к полю
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: ответ, за которым стоит собственная отладка, слышно сразу.