Send и Sync в Rust
Два трейта, на которых держится вся потокобезопасность языка. Спрашивают, чем они отличаются, кто их реализует, почему Rc нельзя в поток и когда пишут unsafe impl.
Стержень: Send - про передачу владения между потоками, Sync - про разделяемую ссылку; оба выводятся компилятором автоматически по составу типа.
// Формулировки: «в чём разница Send и Sync?», «почему Rc не Send?», «когда Arc<T> сам является Send?»
Определения и связь
Send означает, что значение можно переместить в другой поток. Sync означает, что на значение можно дать ссылку нескольким потокам одновременно; формально T: Sync тогда и только тогда, когда &T: Send.
Оба трейта пустые - в них нет методов. Компилятор выводит их автоматически: структура получает Send, если все поля Send, и то же с Sync. Проверяются они в границах API: thread::spawn требует Send от замыкания, Arc требует Send + Sync от содержимого.
// Ключевая мысль: трейты ничего не обеспечивают, они лишь фиксируют свойство, которое даёт устройство типа. Mutex<T> получает Sync даже для не-Sync T именно потому, что внутри есть блокировка.
- Send
- значение можно переместить в другой поток
- Sync
- на значение можно дать ссылку из нескольких потоков
Почему Rc не Send
Клонирование Rc это инкремент обычного, неатомарного счётчика. Два потока, сделавшие это одновременно, могут потерять инкремент: счётчик обнулится раньше времени, значение уничтожится, и оставшиеся ссылки станут висячими. Поэтому Rc не Send, и компилятор ловит попытку отправить его в поток ещё на сборке.
Arc делает те же операции атомарно и потому пригоден для многопотока - платя за это на каждом клоне и уничтожении. Сам Arc<T> является Send только когда T: Send + Sync: второму потоку достаётся ссылка на то же значение (нужен Sync), а уничтожить его может любой из потоков (нужен Send).
// Отсюда знаменитая ошибка на Arc<RefCell<T>>: RefCell не Sync, потому что его счётчики заимствований не синхронизированы. Компилятор выдаёт E0277, и это ровно тот случай, когда ошибка спасает от гонки.
- атомарный счётчик
- инкремент, неделимый с точки зрения других потоков
- E0277
- тип не удовлетворяет требуемому трейту (например, Sync)
unsafe impl: обещание, а не механизм
Иногда тип оборачивает сырой указатель на внешний ресурс, и автоматический вывод не срабатывает. Тогда пишут unsafe impl Send for MyType {}, но это обещание, а не выключатель: в коде ничего не меняется, компилятор просто перестаёт возражать.
Прежде чем так делать, стоит проверить три вещи: библиотека под указателем потокобезопасна, нет привязки к потоку-создателю, нет скрытого разделяемого состояния. Ошибка здесь даёт гонку данных, которую borrow checker уже не поймает.
// Обратный приём тоже существует: вставить в структуру PhantomData<*const ()>, чтобы отключить автоматический вывод и запретить передачу типа между потоками.
- unsafe impl
- ручное обещание, что тип безопасен для потоков
- авто-трейт
- трейт, выводимый по составу полей без участия автора
Как отвечать: «В чём разница между Send и Sync?»
Send - значение можно переместить в другой поток, владение уезжает целиком. Sync - на значение можно дать ссылку сразу нескольким потокам; формально это то же самое, что &T: Send. Оба трейта пустые и выводятся компилятором автоматически по полям, а проверяются в границах API вроде thread::spawn или Arc. Хороший пример разницы: Rc не Send, потому что его счётчик неатомарный и гонка сломала бы подсчёт ссылок. А RefCell - Send, но не Sync: перемещать его между потоками можно, а разделять по ссылке нельзя, потому что счётчики заимствований не синхронизированы.
Пример с RefCell - то, что отличает понимание от заучивания: он показывает, что Send и Sync действительно независимы.
На чём валятся
- −Путают Send и Sync местами.
- −Считают, что Arc делает содержимое потокобезопасным - он синхронизирует только счётчик.
- −Не могут привести тип, который Send, но не Sync, а это RefCell.
- −Ставят unsafe impl Send, чтобы «заглушить компилятор», не проверив инварианты.
- −Думают, что Send и Sync где-то реализованы вручную для обычных типов - их выводит компилятор.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- Почему Rc<T> нельзя отправить в другой поток, а Arc<T> — можно?A)Rc не реализует Clone, а без клона передавать нечегоB)Rc хранит данные на стеке создавшего потокаC)Arc копирует данные для каждого потока, а Rc разделяет ихD)Счётчик Rc меняется неатомарно: гонка испортит подсчёт
показать ответ и разбор
+D)Счётчик Rc меняется неатомарно: гонка испортит подсчёт// разбор: Два потока, одновременно клонирующие Rc, могли бы потерять инкремент — счётчик обнулился бы раньше времени, и получилось бы обращение к освобождённой памяти. Поэтому Rc не Send, и компилятор ловит это на этапе сборки. Arc делает те же операции атомарно, платя за это на каждом клоне и drop.
- При каких условиях Arc<T> сам является Send?A)Когда T: Send — этого достаточно, ведь Arc синхронизирует доступB)Когда T: Send + Sync — иначе доступ был бы небезопасенC)Arc — Send по определению: он создан для потоковD)Когда T: Clone, чтобы каждый поток получил свою копию
показать ответ и разбор
+B)Когда T: Send + Sync — иначе доступ был бы небезопасен// разбор: Передавая Arc в поток, вы даёте второму потоку ссылку на то же значение — значит нужен Sync. Send нужен потому, что уничтожить значение может любой из потоков, где счётчик обнулится последним. Отсюда и запрет на Arc<RefCell<T>>: RefCell не Sync, компилятор роняет сборку.
- Кто пишет реализации Send и Sync для обычных типов?A)Автор типа объявляет их вручную в impl-блокеB)Они проставляются макросом derive при объявлении структурыC)Компилятор выводит их автоматически по полям типаD)Стандартная библиотека через сплошную реализацию для всех типов
показать ответ и разбор
+C)Компилятор выводит их автоматически по полям типа// разбор: Это авто-трейты: структура получает Send, если все её поля Send, и то же с Sync. Ручная реализация возможна только через unsafe impl — и это обещание компилятору, что автор обеспечил безопасность сам, обычно вокруг сырых указателей. Обратный приём — вставить PhantomData, чтобы автоматический вывод отключить.
- Тип оборачивает сырой указатель на внешний ресурс. Что означает unsafe impl Send для него?A)Автор берёт ответственность: компилятор это не проверитB)Тип станет потокобезопасным автоматически после такой пометкиC)Компилятор добавит блокировку вокруг доступа к указателюD)Указатель будет копироваться при передаче в другой поток
показать ответ и разбор
+A)Автор берёт ответственность: компилятор это не проверит// разбор: Send и Sync — обещания, а не механизмы: пометка ничего не меняет в коде, она лишь снимает запрет компилятора. Автор обязан доказать себе, что передача в другой поток безопасна — библиотека под указателем потокобезопасна, нет привязки к потоку-создателю, нет скрытого разделяемого состояния. Ошибка здесь даёт гонку, которую borrow checker уже не поймает.
- Откуда у типа берутся Send и Sync, если их никто не писал?A)Их приносит derive, который добавляется к каждой структуре по умолчаниюB)Их добавляет стандартная библиотека сплошной реализацией для всех типов сразуC)Они появляются после первой отправки значения в другой поток и кэшируются компиляторомD)Их выводит компилятор: тип получает трейт, когда все его поля им обладают
показать ответ и разбор
+D)Их выводит компилятор: тип получает трейт, когда все его поля им обладают// разбор: Это авто-трейты: структура получает Send, если все поля Send, и Sync, если все поля Sync. Поэтому достаточно одного Rc или сырого указателя в глубине состава, чтобы тип перестал ходить между потоками. Написать реализацию руками можно только через unsafe impl, и это уже ваше личное обещание компилятору.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.