Привет, Хабр!
Сегодня рассмотрим Send и Sync. Не «что это такое» (это вы в book прочитаете за пять минут), а как именно компилятор принимает решения, почему &mut T внезапно Sync, и что происходит, когда вы пишете unsafe impl Send.
Определения
Начнём с того, что записано в исходниках стандартной библиотеки:
pub unsafe auto trait Send { } pub unsafe auto trait Sync { }
Четыре слова, а сколько смысла! Давайте разберём каждое.
unsafe — реализовать этот трейт вручную можно только в unsafe-блоке.
auto — компилятор реализует этот трейт автоматически. Сам rustc смотрит на поля вашей структуры и решает.
trait — это трейт, но маркерный: у него нет методов, нет ассоциированных типов, нет ничего. Он существует только чтобы пометить тип. Компилятор использует эту метку при проверке типов в момент компиляции.
Send — тип можно передать в другой поток. Именно передать: переместить владение через thread::spawn(move || {...}).
Sync — на тип можно безопасно ссылаться из нескольких потоков одновременно.
Алгоритм автовывода
Когда вы объявляете структуру, rustc запускает примерно такой алгоритм:
1. Для каждого auto trait (Send, Sync, Unpin, ...): 2. Для каждого поля структуры: 3. Проверить: реализует ли тип поля этот auto trait? 4. Если ВСЕ поля реализуют — структура тоже реализует. 5. Если хотя бы одно поле НЕ реализует — структура НЕ реализует.
Посмотрим, как это работает:
// Все поля Send + Sync. Значит, и структура тоже struct UserData { name: String, // Send + Sync ✓ age: u32, // Send + Sync ✓ tags: Vec<String>, // Send + Sync ✓ } // А здесь нет use std::rc::Rc;ь struct CachedUser { name: String, // Send + Sync ✓ cache: Rc<Vec<u8>>, // Send ✗, Sync ✗ } // CachedUser: !Send, !Sync
Компилятор генерирует для вашей структуры неявный impl. Для UserData это выглядит концептуально так:
// Это не настоящий код — это то, что компилятор «думает» unsafe impl Send for UserData where String: Send, // ✓ u32: Send, // ✓ Vec<String>: Send, // ✓ { } unsafe impl Sync for UserData where String: Sync, u32: Sync, Vec<String>: Sync, { }
Для дженериков правило распространяется рекурсивно:
struct Wrapper<T> { inner: T, } // Wrapper<T>: Send, если T: Send // Wrapper<T>: Sync, если T: Sync // Проверяем: fn assert_send<T: Send>() {} fn assert_sync<T: Sync>() {} fn main() { assert_send::<Wrapper<String>>(); // ✓ — String: Send assert_sync::<Wrapper<String>>(); // ✓ — String: Sync // assert_send::<Wrapper<Rc<i32>>>(); // ✗ — Rc: !Send // Ошибка компиляции, и слава богу }
Как компилятор не зацикливается на рекурсии
Допустим, у вас рекурсивная структура:
struct List<T> { data: T, next: Option<Box<List<T>>>, }
Компилятор хочет проверить: List<T>: Send? Для этого ему нужно, чтобы Option<Box<List<T>>>: Send. Для этого -Box<List<T>>: Send. Для этого -List<T>: Send. Замкнутый круг.
Поможет коиндукция. В отличие от обычных трейтов, для auto traits компилятор допускает циклические проверки. Если цикл замыкается, значит, всё хорошо, тип реализует трейт, при условии, что все не-рекурсивные поля тоже его реализуют. Это поведение зашито в solver трейтов внутри rustc.
Кто НЕ Send и НЕ Sync
Разберём некоторые типы.
Rc — ни Send, ни Sync
use std::rc::Rc; use std::thread; fn main() { let data = Rc::new(42); // Не скомпилируется: // thread::spawn(move || { // println!("{}", data); // }); }
Rc хранит счётчик ссылок. Обычный, не атомарный. Если два потока одновременно вызовут clone() оба прочитают текущее значение счётчика (скажем, 2), оба запишут 3. Но реальное значение должно быть 4. Классическая гонка данных
Arc<T> решит эту проблему атомарными операциями на счётчике.
Cell и RefCell — Send, но не Sync
use std::cell::Cell; fn main() { let cell = Cell::new(42); // cell.set(100); // Можно менять через &Cell<T>! }
Cell позволяет мутировать значение через разделяемую ссылку (&self). Если два потока одновременно вызовут set() — это снова гонка данных. Поэтому Cell: !Sync.
Но Cell: Send — и это ок. Вы можете передать Cell в другой поток. В каждый момент времени им владеет ровно один поток, и проблемы нет. Проблема возникает только при разделяемом доступе.
MutexGuard — Sync, но не Send
А вот это неочевидный случай. MutexGuard — гард, который вы получаете после mutex.lock().
use std::sync::Mutex; fn main() { let mutex = Mutex::new(vec![1, 2, 3]); let guard = mutex.lock().unwrap(); // guard — это MutexGuard, через который мы работаем с данными // Нельзя отправить guard в другой поток! // На POSIX-системах мьютекс должен разблокироваться // в том же потоке, где был заблокирован. }
Почему !Send? На платформах с POSIX threads есть требование: мьютекс должен разблокироваться в том же потоке, в котором был заблокирован. Если вы отправите MutexGuard в другой поток и он там будет уничтожен, поведение не определено.
Почему Sync? Потому что &MutexGuard — это иммутабельная ссылка. Вы можете отправить её в другой поток, и максимум, что тот поток сможет — прочитать данные. Drop от ссылки не вызывает разблокировку мьютекса.
Сырые указатели — !Send и !Sync
struct RawWrapper { ptr: *mut u8, } // RawWrapper: !Send, !Sync — автоматически
Сырые указатели (*const T, mut T) помечены как !Send и !Sync. Сам по себе указатель — это просто число. Но если бы указатели были Send, то любая структура с mut T внутри автоматически стала бы Send, а разработчик такой структуры, скорее всего, не думал о потокобезопасности.
UnsafeCell — корень всего зла
Каждый тип с interior mutability в Rust построен поверх UnsafeCell<T>. Это единственный легальный способ получить &mut T из &T.
use std::cell::UnsafeCell; struct MyCell<T> { value: UnsafeCell<T>, } impl<T> MyCell<T> { fn new(value: T) -> Self { MyCell { value: UnsafeCell::new(value) } } fn set(&self, val: T) { // гарантируем, что доступ однопоточный unsafe { *self.value.get() = val; } } fn get(&self) -> T where T: Copy { unsafe { *self.value.get() } } }
UnsafeCell<T>: !Sync. Это основное ограничение. Именно поэтому Cell, RefCell, и все остальные обёртки с interior mutability не Sync. Цепочка простая: содержишь UnsafeCell то получаешь не Sync.
А вот Mutex<T> тоже внутри использует UnsafeCell. Но Mutex<T>: Sync (при условии T: Send). Как так? Потому что у Mutex есть явный unsafe impl Sync, в котором разработчики стандартной библиотеки гарантируют, что синхронизация обеспечена на уровне ОС.
Ручная реализация: unsafe impl Send/Sync
Иногда автовывод не справляется. Вот вы пишете структуру с сырым указателем внутри, но точно знаете, что она потокобезопасна.
use std::ptr::NonNull; /// Потокобезопасная обёртка над аллоцированным значением. /// Единственный владелец данных — эта структура. struct OwnedPtr<T> { ptr: NonNull<T>, } impl<T> OwnedPtr<T> { fn new(value: T) -> Self { let boxed = Box::new(value); let raw = Box::into_raw(boxed); // SAFETY: Box::into_raw гарантирует non-null OwnedPtr { ptr: unsafe { NonNull::new_unchecked(raw) } } } fn as_ref(&self) -> &T { // SAFETY: указатель валиден на протяжении жизни OwnedPtr unsafe { self.ptr.as_ref() } } fn as_mut(&mut self) -> &mut T { // SAFETY: &mut self гарантирует эксклюзивный доступ unsafe { self.ptr.as_mut() } } } impl<T> Drop for OwnedPtr<T> { fn drop(&mut self) { // SAFETY: ptr был создан из Box::into_raw, вызываем drop ровно один раз unsafe { drop(Box::from_raw(self.ptr.as_ptr())); } } } // SAFETY: OwnedPtr — единственный владелец данных. // Если T можно безопасно отправить в другой поток, // то и OwnedPtr<T> можно. unsafe impl<T: Send> Send for OwnedPtr<T> {} // SAFETY: &OwnedPtr<T> даёт только &T (через as_ref). // Если &T безопасно разделять между потоками (T: Sync), // то и &OwnedPtr<T> безопасно разделять. unsafe impl<T: Sync> Sync for OwnedPtr<T> {}
Обратите внимание на where-баунды: Send for OwnedPtr<T> where T: Send. Мы не говорим «OwnedPtr всегда Send» — мы говорим «OwnedPtr Send, если внутренний тип Send». Точно так же работает Box<T> в дефолт библиотеке.
Перед тем как писать unsafe impl Send соблюдаем простые правила:
Если тип даёт
&Tиз&self(черезDeref, геттер, или как-то ещё) —Syncтолько приT: Sync.Если тип даёт
&mut Tиз&self— нужна внешняя синхронизация, иначе!Sync.Если тип передаёт владение
T—Sendтолько приT: Send.Если
Dropделает что-то потокоспецифичное (какMutexGuard) —!Send.
Негативные имплементации: impl !Send
Иногда нужно запретить автовывод. Допустим, все поля вашей структуры — Send, но по семантике она потоко-небезопасна.
На nightly:
#![feature(negative_impls)] struct ThreadLocalToken { id: u64, } impl !Send for ThreadLocalToken {} impl !Sync for ThreadLocalToken {}
На stable этой фичи нет. Обходной путь добавить поле с типом, который не Send:
use std::marker::PhantomData; struct ThreadLocalToken { id: u64, // PhantomData<*mut ()> делает структуру !Send и !Sync, // потому что *mut () — !Send, !Sync. // При этом PhantomData — ZST, не занимает памяти. _not_send: PhantomData<*mut ()>, }
Если нужно запретить только Sync, но оставить Send то просто используем PhantomData<Cell<()>>:
use std::cell::Cell; use std::marker::PhantomData; struct SendButNotSync { data: u64, _not_sync: PhantomData<Cell<()>>, // Cell: Send, !Sync // Значит, структура: Send, !Sync }
Есть и крейт negative-impl, который эмулирует негативные имплементации на stable через процедурный макрос:
use negative_impl::negative_impl; pub struct MyToken(u64); #[negative_impl] impl !Send for MyToken {}
Под капотом он генерирует impl с невыполнимым условием, но результат тот же.
PhantomData и его влияние на Send/Sync
PhantomData<T> — zero-sized тип, который притворяется полем типа T для целей статического анализа. Компилятор обрабатывает его ровно как обычное поле при проверке auto traits.
use std::marker::PhantomData; struct Slice<'a, T: 'a> { ptr: *const T, len: usize, _marker: PhantomData<&'a T>, } // Без PhantomData: !Send, !Sync (из-за *const T) // С PhantomData<&'a T>: всё ещё !Send, !Sync (из-за *const T) // Нужен unsafe impl, чтобы это исправить: unsafe impl<'a, T: Sync> Send for Slice<'a, T> {} unsafe impl<'a, T: Sync> Sync for Slice<'a, T> {}
Таблица вариантов PhantomData и их влияния:
| Send? | Sync? | Когда использовать |
|---|---|---|---|
| если | если | Тип владеет |
| если | если | Тип содержит ссылку на |
| нет | нет | Запретить Send и Sync |
| да | нет | Запретить только Sync |
| да | да | Вариантность без влияния на Send/Sync |
Send/Sync в async: здесь-то и начинается боль
Если вы работаете с tokio или любым другим многопоточным рантаймом вы столкнётесь с тем, что Future должен быть Send. А Future это state machine, сгенерированная компилятором, и её Send-ность зависит от того, что лежит внутри.
use std::rc::Rc; async fn broken_task() { let data = Rc::new(42); // Rc: !Send tokio::time::sleep(std::time::Duration::from_secs(1)).await; println!("{}", data); // data живёт через .await — значит, она становится // частью state machine Future. Future: !Send. } // Не скомпилируется с tokio::spawn: // tokio::spawn(broken_task()); // Потому что tokio::spawn требует Future: Send
Фикс — сделать так, чтобы !Send значение не жило через .await:
async fn fixed_task() { { let data = Rc::new(42); println!("{}", data); } // data дропнут до .await tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Или просто заменить Rc на Arc:
use std::sync::Arc; async fn also_fixed() { let data = Arc::new(42); tokio::time::sleep(std::time::Duration::from_secs(1)).await; println!("{}", data); }
Проверка Send на этапе компиляции
Полезный паттерн — статическая проверка, что ваш Future действительно Send:
fn require_send<T: Send>(_t: &T) {} async fn my_task() { // ... } fn check() { let fut = my_task(); require_send(&fut); // Ошибка компиляции, если !Send }
Или макрос для использования в тестах:
macro_rules! assert_send { ($t:ty) => { const _: () = { fn _assert_send<T: Send>() {} fn _check() { _assert_send::<$t>(); } }; }; } assert_send!(String); assert_send!(Vec<u8>); // assert_send!(Rc<i32>); // Ошибка компиляции
Потокобезопасный кеш
Давайте соберём всё вместе и напишем простой потокобезопасный кеш с TTL:
use std::collections::HashMap; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; /// Запись кеша с временем жизни. struct CacheEntry<V> { value: V, expires_at: Instant, } /// Потокобезопасный кеш с TTL. /// Send + Sync автоматически, потому что: /// - Arc<RwLock<...>>: Send + Sync /// - HashMap<K, CacheEntry<V>>: Send + Sync (если K, V: Send + Sync) /// - Duration: Send + Sync pub struct TtlCache<K, V> { store: Arc<RwLock<HashMap<K, CacheEntry<V>>>>, default_ttl: Duration, } // Компилятор выведет это автоматически, но проверим явно: const _: () = { fn assert_send_sync<T: Send + Sync>() {} fn check() { assert_send_sync::<TtlCache<String, String>>(); assert_send_sync::<TtlCache<u64, Vec<u8>>>(); } }; impl<K, V> Clone for TtlCache<K, V> { fn clone(&self) -> Self { TtlCache { store: Arc::clone(&self.store), default_ttl: self.default_ttl, } } } impl<K, V> TtlCache<K, V> where K: Eq + std::hash::Hash + Clone, V: Clone, { pub fn new(default_ttl: Duration) -> Self { TtlCache { store: Arc::new(RwLock::new(HashMap::new())), default_ttl, } } pub fn get(&self, key: &K) -> Option<V> { let store = self.store.read().expect("RwLock poisoned"); store.get(key).and_then(|entry| { if Instant::now() < entry.expires_at { Some(entry.value.clone()) } else { None // Протухло. Ленивая очистка — при следующей записи. } }) } pub fn insert(&self, key: K, value: V) { self.insert_with_ttl(key, value, self.default_ttl); } pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) { let mut store = self.store.write().expect("RwLock poisoned"); store.insert(key, CacheEntry { value, expires_at: Instant::now() + ttl, }); } /// Удаляет просроченные записи. Вызывайте периодически. pub fn evict_expired(&self) { let mut store = self.store.write().expect("RwLock poisoned"); let now = Instant::now(); store.retain(|_, entry| entry.expires_at > now); } }
Теперь используем из нескольких потоков:
use std::thread; use std::time::Duration; fn main() { let cache = TtlCache::new(Duration::from_secs(5)); // Запускаем 4 потока-писателя let mut handles = vec![]; for i in 0..4 { let cache = cache.clone(); handles.push(thread::spawn(move || { for j in 0..100 { let key = format!("thread-{}-key-{}", i, j); cache.insert(key, i * 1000 + j); } })); } // И 4 потока-читателя for i in 0..4 { let cache = cache.clone(); handles.push(thread::spawn(move || { for j in 0..100 { let key = format!("thread-{}-key-{}", i, j); let _ = cache.get(&key); } })); } for handle in handles { handle.join().unwrap(); } println!("Все потоки завершены, кеш цел, UB нет. Красота."); }
Компилятор пропустил этот код без единого unsafe. Потому что все типы внутри TtlCache — Send + Sync, и компилятор это доказал за нас.
Антипаттерны
1. «Я обернул в Mutex, значит, всё супер»
use std::sync::Mutex; use std::rc::Rc; // Не скомпилируется! // fn share_it(m: Mutex<Rc<Vec<u8>>>) { // // Mutex<T>: Sync только если T: Send // // Rc: !Send → Mutex<Rc<...>>: !Sync // }
Mutex<Rc<T>> — бессмысленная конструкция. Если вы залочите мьютекс и клонируете Rc внутри, вы получите Rc, живущий снаружи лока. Два потока, два Rc с общим счётчиком, без синхронизации. Используйте Mutex<Arc<T>>, если вам правда нужна такая схема.
2. Забыли, что Future захватывает !Send значение через .await
use std::cell::RefCell; async fn bad() { let cell = RefCell::new(0); some_async_fn().await; // cell живёт через await! *cell.borrow_mut() = 42; } // Future этой функции: !Send. tokio::spawn откажет.
3. unsafe impl Send без проверки контракта
struct Danger { ptr: *mut Vec<String>, } // НЕ ДЕЛАЙТЕ ТАК без серьёзных обоснований! // unsafe impl Send for Danger {} // Кто владеет Vec? Есть ли другие указатели? // Что происходит при drop? Кто аллоцировал?
Каждый unsafe impl Send — это контракт, который вы подписываете кровью.
Если хотите углубиться — читайте Rustonomicon, секцию про Send и Sync. А если совсем хочется хардкора — исходники трейт-солвера в rustc_trait_selection.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

