Потоки, Send и Sync: что обещают эти трейты
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Потоки, Send и Sync: что обещают эти трейты
Весь блок про async был про то, как один поток ведёт тысячу дел сразу, не блокируя ни одно. Это конкурентность на честном слове кооперации: задача сама уступает ход на
.await, и пока она держит поток, никто другой не работает. Сегодня открываем блок про настоящую параллельность. Несколько потоков крутятся одновременно на разных ядрах, по-настоящему в одну и ту же секунду, и никто никому ход не уступает. Это даёт скорость и сразу же новую опасность: два потока тянутся к одной ячейке памяти. Rust отвечает на эту опасность двумя трейтами,SendиSync, и проверяет их на этапе компиляции. Гонок данных в безопасном Rust не бывает по построению, и сегодня мы разберём, как именно компилятор это гарантирует.
Идея
В async-блоке у нас был один поток и кооперативная конкурентность: задачи делили его по очереди. Поток это другое. Поток исполняется параллельно остальным, ОС сама раскидывает потоки по ядрам, и два потока могут писать в память в одну и ту же наносекунду.
Отсюда главная опасность многопоточности, ради которой существует половина этого блока. Если два потока читают и пишут одну ячейку памяти без согласования, получается гонка данных: результат зависит от того, кто успел первым, и в худшем случае ты читаешь полузаписанное значение или портишь указатель. В C это неопределённое поведение, в Go и Java это коварный плавающий баг, который воспроизводится раз в неделю на проде.
Rust выбрал радикальный путь: гонку данных он делает невыразимой. Не “ловит в рантайме”, не “предупреждает”, а просто не компилирует код, в котором она возможна. Держится это на двух маркерных трейтах. Send отвечает на вопрос “можно ли отдать владение этим значением другому потоку”, Sync отвечает на вопрос “можно ли дать двум потокам ссылку на это значение сразу”. Компилятор проверяет их автоматически на каждой границе потока. Сегодня разберём, что эти трейты значат и почему Rc через границу потока не проходит, а Arc проходит.
Поток это владение, унесённое в сторону
Запустить поток это std::thread::spawn. Он принимает замыкание, начинает крутить его на новой нити и возвращает JoinHandle, по которому потом можно дождаться результата:
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let mut sum = 0u64;
for i in 0..1_000 {
sum += i;
}
sum // значение замыкания становится результатом потока
});
println!("главный поток занят своими делами");
// join блокирует, пока воркер не закончит, и отдаёт его результат
let result = handle.join().expect("поток паниковал");
println!("воркер насчитал {result}");
}
Интересное начинается, когда потоку нужны внешние данные. Замыкание захватывает их по ссылке, а это сразу проблема: поток живёт своей жизнью и может пережить функцию, которая его запустила. Если он одолжит ссылку на локальную переменную, та умрёт раньше потока, и ссылка повиснет. Поэтому вот это не компилируется:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
// ошибка компиляции: closure may outlive the current function,
// but it borrows `data`, which is owned by the current function
let handle = thread::spawn(|| {
println!("{data:?}"); // одолжили ссылку на data
});
drop(data); // ничто не мешает уронить data, пока поток ещё жив
handle.join().unwrap();
}
Лекарство в сигнатуре spawn: замыкание обязано быть 'static, то есть не держать ничьих заёмных ссылок. Самый прямой способ это выполнить, отдать потоку владение данными через move:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
// data перенесена внутрь потока, теперь поток ею владеет
data.iter().sum::<i32>()
});
// здесь data уже недоступна: владение ушло в поток
println!("сумма: {}", handle.join().unwrap());
}
'static тут не значит “живёт вечно”. Оно значит “не зависит от чужого времени жизни”: либо владеет данными сам, либо ссылается только на то, что живёт до конца программы. Поток получил собственный Vec, и кому когда умирать, он больше ни от кого не зависит.
Полная сигнатура spawn проговаривает оба требования к замыканию:
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
'static мы только что разобрали: данные не должны зависеть от чужого времени жизни. А вот и Send: и захваченные данные, и возвращаемое значение обязаны быть пригодны к переносу между потоками. К этому трейту сейчас и придём.
Scoped-потоки: одолжить, не отдавая
Требование 'static честное, но иногда обидное. Если поток гарантированно завершится раньше, чем умрут данные, то перенос владения или копия не нужны: можно просто одолжить ссылку. Ровно для этого есть std::thread::scope. Потоки, запущенные внутри scope, обязаны завершиться до выхода из него, поэтому компилятор разрешает им брать обычные ссылки на локальные переменные:
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4];
thread::scope(|s| {
// оба потока одалживают data по ссылке, без move и без 'static
s.spawn(|| {
let half = &data[..2];
println!("первая половина: {}", half.iter().sum::<i32>());
});
s.spawn(|| {
let half = &data[2..];
println!("вторая половина: {}", half.iter().sum::<i32>());
});
// на выходе из scope оба потока гарантированно завершены
});
// data снова доступна целиком: ссылки потоков уже мертвы
println!("всего: {}", data.iter().sum::<i32>());
}
Заметь, оба потока держат неизменяемые ссылки &data одновременно, и это нормально: borrow checker и тут работает как обычно, много &T можно. А вот два &mut data сразу он не пропустит даже внутри scope, ровно по тому же правилу эксклюзивности, что разбирали в начале раздела. Разделяемая запись из нескольких потоков потребует синхронизации, и это тема следующего урока про Mutex и Arc.
Send и Sync: контракт двух трейтов
Теперь к сердцу урока. Send и Sync это маркерные трейты: в них нет ни одного метода, они только метки. Смысл такой:
Send: значение этого типа можно безопасно передать во владение другому потоку. ПеренёсTв другой поток, и ничего не сломается.Sync: на значение этого типа можно безопасно дать ссылку нескольким потокам сразу. ФормальноT: Syncровно тогда, когда&T: Send. То есть “поделиться ссылкой между потоками” это то же самое, что “передать&Tв другой поток”.
Главная их особенность в том, что они авто-трейты. Ты их не пишешь руками: компилятор сам выдаёт Send структуре, у которой все поля Send, и Sync той, у которой все поля Sync. В стандартной библиотеке они объявлены примерно так:
// упрощённо, как это выглядит в std
pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}
unsafe тут потому, что ручная реализация это обещание компилятору, которое он сам проверить не может: ты под свою ответственность заявляешь, что тип безопасен для потоков. Поэтому подавляющее большинство типов получают оба трейта бесплатно и правильно, а думать о них приходится только на границе с unsafe-кодом и сырыми указателями.
Дальше эти трейты работают так. Сигнатура spawn требует F: Send, а значит и все захваченные замыканием данные должны быть Send. Если ты попытаешься утащить в поток не-Send тип, компилятор остановит сборку с прямым сообщением, какой именно тип виноват. Вот этот механизм и ловит классическую ошибку.
Почему Rc не Send, а Arc Send
Возьмём типичную попытку: один набор данных надо посчитать в нескольких потоках с разными коэффициентами. Коллега тянется к Rc, умному указателю с подсчётом ссылок из RU4, и упирается в компилятор:
use std::rc::Rc;
use std::thread;
let shared = Rc::new(vec![1_i64, 2, 3]);
let mut handles = Vec::new();
for &m in &[10_i64, 20, 30] {
let shared = Rc::clone(&shared);
handles.push(thread::spawn(move || {
shared.iter().map(|&x| x * m).sum::<i64>()
}));
}
// ошибка: `Rc<Vec<i64>>` cannot be sent between threads safely
Компилятор отказывается переносить Rc в поток, потому что у Rc неатомарный счётчик ссылок. Rc::clone это “прибавь единицу к счётчику”, а drop это “отними единицу, и если ноль, освободи память”. Если два потока сделают clone или drop одновременно, две операции “прибавь единицу” наложатся друг на друга и счётчик собьётся: одна из прибавок потеряется. Дальше либо память освободят дважды (use-after-free), либо не освободят никогда. Это и есть гонка данных на самом счётчике, поэтому Rc помечен как не-Send и не-Sync, и граница потока для него закрыта в принципе.
Лекарство, std::sync::Arc: тот же подсчёт ссылок, но счётчик атомарный (A это atomic). Атомарная прибавка неделима: два потока могут звать clone одновременно, и счётчик всё равно сосчитает обе. Arc<T> получает Send и Sync, когда сам T их имеет, и спокойно едет в поток:
use std::sync::Arc;
use std::thread;
fn weighted_sums(data: Vec<i64>, multipliers: &[i64]) -> Vec<i64> {
let shared = Arc::new(data);
let mut handles = Vec::new();
for &m in multipliers {
let shared = Arc::clone(&shared); // клон указателя, а не вектора
handles.push(thread::spawn(move || {
shared.iter().map(|&x| x * m).sum::<i64>()
}));
}
handles.into_iter().map(|h| h.join().unwrap()).collect()
}
Arc::clone копирует указатель и атомарно увеличивает счётчик, а не копирует вектор: данные одни на всех, у каждого потока своя ручка к ним. Когда последний Arc уронят, вектор освободится один раз. За эту безопасность платишь атомарными операциями на каждом clone и drop, они дороже обычного инкремента. Поэтому правило простое: внутри одного потока бери Rc (дёшево), через границу потока бери Arc (безопасно). Компилятор сам не даст перепутать.
Чтобы увидеть гонку своими глазами, разверни один clone в три микрошага: прочитать счётчик в регистр, прибавить единицу, записать обратно. Переплети эти шаги у двух потоков и сравни, что станет с числом в неатомарном Rc и в атомарном Arc.
Гонка данных это не то же, что гонка состояний
Тут важно не переоценить, что именно гарантирует Rust. Send и Sync закрывают гонку данных, то есть неупорядоченный доступ к памяти, который ломает сами байты. Но они ничего не обещают про гонку состояний, то есть про логическую неупорядоченность.
Можно написать программу, где каждый доступ к памяти безупречен, типы Send и Sync на месте, всё компилируется, и при этом она считает неправильно или зависает в дедлоке, потому что два потока сделали верные операции в неудачном порядке. Rust это не ловит и ловить не обещает: порядок это уже логика твоего алгоритма, а не свойство памяти. Граница проходит ровно тут: безопасный Rust гарантирует отсутствие гонок данных и неопределённого поведения, но корректность порядка операций остаётся на тебе. Поэтому дальше в блоке появятся Mutex, атомики и порядки памяти: это инструменты, которыми ты управляешь уже не безопасностью байтов, а порядком.
Каналы передают владение между потоками
Часто потокам не нужно делить одну память, достаточно передавать друг другу значения. Для этого в std есть канал mpsc (multi-producer, single-consumer). Это та же идея, что каналы и горутины в Go, только связь с системой типов прямее: послать в канал можно лишь то, что Send, и при отправке значение переезжает во владение получателя.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<i32>();
let producer = thread::spawn(move || {
for i in 0..5 {
tx.send(i * i).unwrap(); // значение уезжает в канал
}
// tx роняется здесь, и канал закрывается
});
// итерация по rx сама заканчивается, когда все передатчики уронены
let collected: Vec<i32> = rx.iter().collect();
producer.join().unwrap();
println!("{collected:?}"); // [0, 1, 4, 9, 16]
}
Обрати внимание на завершение: нигде нет флага “стоп”. Цикл rx.iter() живёт, пока существует хоть один передатчик; как только producer доработал и tx уронили, канал закрылся, итерация кончилась сама. Это идиома, которую стоит запомнить: жизнь канала привязана к жизни его концов, а не к ручному сигналу.
Практика
Пять задач, лесенкой от простого spawn до Arc и каналов. Везде только std, без сторонних крейтов.
Сначала перенос владения и 'static: посчитай сумму вектора в нескольких потоках, отдав каждому свой кусок.
Теперь то же самое, но без копий: scoped-потоки одолжат срез по ссылке.
Дальше история Send: почини код коллеги, заменив Rc на Arc.
Конвейер из двух потоков на каналах mpsc, с сохранением порядка.
И крайний случай 'static: превратить рантайм-строку в &'static str через Box::leak и понять цену этой сделки.
Что унести из урока
Потоки в Rust это настоящая параллельность, и вместе с ней приходит риск гонки данных. Ответ языка держится на трёх вещах. Первое: spawn требует Send + 'static, поэтому поток либо владеет своими данными (move), либо берёт ссылки только через scope, который гарантирует, что поток умрёт раньше данных. Второе: Send это “можно передать владение в другой поток”, Sync это “можно дать ссылку нескольким потокам сразу”; оба авто-трейта компилятор расставляет сам, и именно они закрывают границу потока для опасных типов вроде Rc. Третье и главное по смыслу: Rust исключает гонку данных, но не гонку состояний; корректность порядка операций остаётся твоей работой, и инструменты для неё (Mutex, атомики, порядки памяти) ждут впереди.
Дальше начинаем строить эту синхронизацию руками. Сначала самый ходовой инструмент разделяемой записи: Arc<Mutex<T>>, его цена и его ловушки.