Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Send и Sync: доказательства корректности конкурентности на этапе компиляции 🟠

Что вы узнаете: как автоматические трейты Send и Sync превращают компилятор в аудитора конкурентности: на этапе компиляции доказывается, какие типы могут переходить между потоками и какие можно разделять, без затрат во время выполнения.

Перекрёстные ссылки: гл. 04 (capability-токены), гл. 09 (phantom-типы), гл. 15 (доказательства через const fn)

Проблема: конкурентный доступ без страховочной сетки

В системном программировании периферия, общие буферы и глобальное состояние используются из нескольких контекстов: главного цикла, обработчиков прерываний, колбэков DMA и рабочих потоков. В C компилятор никак этого не контролирует:

/* Общий буфер датчика: доступ из главного цикла и из ISR */
volatile uint32_t sensor_buf[64];
volatile uint32_t buf_index = 0;

void SENSOR_IRQHandler(void) {
    sensor_buf[buf_index++] = read_sensor();  /* Гонка: чтение и запись buf_index */
}

void process_sensors(void) {
    for (uint32_t i = 0; i < buf_index; i++) {  /* buf_index меняется посреди цикла */
        process(sensor_buf[i]);                   /* данные перезаписаны во время чтения */
    }
    buf_index = 0;                                /* ISR срабатывает между этими строками */
}

Ключевое слово volatile не даёт компилятору оптимизировать чтения, но ничего не делает с гонками данных. Два контекста могут одновременно читать и писать buf_index, давая рваные значения, потерянные обновления или переполнение буфера. Та же проблема возникает и с pthread_mutex_t: компилятор спокойно позволит забыть взять блокировку:

pthread_mutex_t lock;
int shared_counter;

void increment(void) {
    shared_counter++;  /* Упс: забыли pthread_mutex_lock(&lock) */
}

Каждая ошибка конкурентности обнаруживается во время выполнения, обычно под нагрузкой, в продакшене и с перебоями.

Что доказывают Send и Sync

Rust определяет два маркерных трейта, которые компилятор выводит автоматически:

ТрейтДоказываетНеформальный смысл
SendЗначение типа T можно безопасно переместить в другой поток«Это может пересекать границу потока»
SyncРазделяемую ссылку &T можно безопасно использовать из нескольких потоков«Это можно читать из нескольких потоков»

Это авто-трейты: компилятор выводит их, проверяя каждое поле. Структура является Send, если все её поля Send. Структура является Sync, если все её поля Sync. Если хотя бы одно поле отказывается от трейта, отказывается вся структура. Аннотация не нужна, накладных расходов во время выполнения нет: доказательство структурное.

flowchart TD
    STRUCT["Ваша структура"]
    INSPECT["Компилятор проверяет<br/>каждое поле"]
    ALL_SEND{"Все поля<br/>Send?"}
    ALL_SYNC{"Все поля<br/>Sync?"}
    SEND_YES["Send ✅<br/><i>может пересекать границы потоков</i>"]
    SEND_NO["!Send ❌<br/><i>ограничена одним потоком</i>"]
    SYNC_YES["Sync ✅<br/><i>может разделяться между потоками</i>"]
    SYNC_NO["!Sync ❌<br/><i>нет конкурентных ссылок</i>"]

    STRUCT --> INSPECT
    INSPECT --> ALL_SEND
    INSPECT --> ALL_SYNC
    ALL_SEND -->|Да| SEND_YES
    ALL_SEND -->|"Любое поле !Send<br/>(например, Rc, *const T)"| SEND_NO
    ALL_SYNC -->|Да| SYNC_YES
    ALL_SYNC -->|"Любое поле !Sync<br/>(например, Cell, RefCell)"| SYNC_NO

    style SEND_YES fill:#c8e6c9,color:#000
    style SYNC_YES fill:#c8e6c9,color:#000
    style SEND_NO fill:#ffcdd2,color:#000
    style SYNC_NO fill:#ffcdd2,color:#000

Компилятор — это аудитор. В C аннотации потокобезопасности живут в комментариях и документации к заголовочным файлам: это рекомендации, которые никто не проверяет. В Rust Send и Sync выводятся из структуры самого типа. Добавление одного поля Cell<f32> автоматически делает содержащую структуру !Sync. Никаких действий программиста не требуется, и забыть невозможно.

Два трейта связаны ключевым тождеством:

T является Sync тогда и только тогда, когда &T является Send.

Это интуитивно понятно: если разделяемую ссылку можно безопасно отправить в другой поток, то лежащий в её основе тип безопасен для конкурентного чтения.

Типы, которые отказываются от трейтов

Некоторые типы намеренно являются !Send или !Sync:

ТипSendSyncПочему
u32, String, Vec<T>✅✅Нет внутренней изменяемости и сырых указателей
Cell<T>, RefCell<T>✅❌Внутренняя изменяемость без синхронизации
Rc<T>❌❌Счётчик ссылок не атомарный
*const T, *mut T❌❌У сырых указателей нет гарантий безопасности
Arc<T> (где T: Send + Sync)✅✅Атомарный счётчик ссылок
Mutex<T> (где T: Send)✅✅Блокировка сериализует весь доступ

Каждая ❌ в этой таблице — инвариант времени компиляции. Случайно отправить Rc в другой поток нельзя: компилятор это отвергнет.

Дескрипторы периферии, которые не являются Send

Во встраиваемых системах блок регистров периферии находится по фиксированному адресу памяти и должен использоваться только из одного контекста выполнения. Сырые указатели по своей природе !Send и !Sync, поэтому обёртка над ними автоматически лишает содержащий тип обоих трейтов:

/// Дескриптор периферийного UART, отображённого в память.
/// Сырой указатель делает его автоматически !Send и !Sync.
pub struct Uart {
    regs: *const u32,
}

impl Uart {
    pub fn new(base: usize) -> Self {
        Self { regs: base as *const u32 }
    }

    pub fn write_byte(&self, byte: u8) {
        // В реальной прошивке: unsafe { write_volatile(self.regs.add(DATA_OFFSET), byte as u32) }
        println!("UART TX: {:#04X}", byte);
    }
}

fn main() {
    let uart = Uart::new(0x4000_1000);
    uart.write_byte(b'A');  // ✅ Использование в потоке, который его создал

    // ❌ Не скомпилируется: Uart — !Send
    // std::thread::spawn(move || {
    //     uart.write_byte(b'B');
    // });
}

Закомментированный thread::spawn дал бы:

error[E0277]: `*const u32` cannot be sent between threads safely
   |
   |     std::thread::spawn(move || {
   |     ^^^^^^^^^^^^^^^^^^ within `Uart`, the trait `Send` is not
   |                        implemented for `*const u32`

Нет сырого указателя? Используйте PhantomData. Иногда у типа нет сырого указателя, но его всё равно нужно ограничить одним потоком, например индекс файлового дескриптора или дескриптор, полученный из библиотеки на C:

use std::marker::PhantomData;

/// Непрозрачный дескриптор из библиотеки на C. PhantomData<*const ()> делает его
/// !Send + !Sync, хотя внутренний fd — это просто целое число.
pub struct LibHandle {
    fd: i32,
    _not_send: PhantomData<*const ()>,
}

impl LibHandle {
    pub fn open(path: &str) -> Self {
        let _ = path;
        Self { fd: 42, _not_send: PhantomData }
    }

    pub fn fd(&self) -> i32 { self.fd }
}

fn main() {
    let handle = LibHandle::open("/dev/sensor0");
    println!("fd = {}", handle.fd());

    // ❌ Не скомпилируется: LibHandle — !Send
    // std::thread::spawn(move || { let _ = handle.fd(); });
}

Это аналог на этапе компиляции той строки из документации C: «прочитайте, что этот дескриптор не потокобезопасен». В Rust это проверяет компилятор.

Mutex превращает !Sync в Sync

Cell<T> и RefCell<T> дают внутреннюю изменяемость без какой-либо синхронизации, поэтому они !Sync. Но иногда действительно нужно разделять изменяемое состояние между потоками. Mutex<T> добавляет недостающую синхронизацию, и компилятор это учитывает:

Если T: Send, то Mutex<T>: Send + Sync.

Блокировка сериализует весь доступ, поэтому внутренний !Sync-тип становится безопасным для разделения. Компилятор доказывает это структурно: никакой проверки во время выполнения «не забыл ли программист взять блокировку» не нужно:

use std::sync::{Arc, Mutex};
use std::cell::Cell;

/// Кэш показаний датчика с Cell для внутренней изменяемости.
/// Cell<u32> — !Sync: напрямую разделять между потоками его нельзя.
struct SensorCache {
    last_reading: Cell<u32>,
    reading_count: Cell<u32>,
}

fn main() {
    // Mutex делает SensorCache безопасным для разделения: компилятор это доказывает
    let cache = Arc::new(Mutex::new(SensorCache {
        last_reading: Cell::new(0),
        reading_count: Cell::new(0),
    }));

    let handles: Vec<_> = (0..4).map(|i| {
        let c = Arc::clone(&cache);
        std::thread::spawn(move || {
            let guard = c.lock().unwrap();  // Перед доступом обязательно блокируем
            guard.last_reading.set(i * 10);
            guard.reading_count.set(guard.reading_count.get() + 1);
        })
    }).collect();

    for h in handles { h.join().unwrap(); }

    let guard = cache.lock().unwrap();
    println!("Последнее показание: {}", guard.last_reading.get());
    println!("Всего чтений:        {}", guard.reading_count.get());
}

Сравните с версией на C: pthread_mutex_lock — это вызов во время выполнения, который программист может забыть. Здесь система типов делает невозможным доступ к SensorCache в обход Mutex. Доказательство структурное, а единственная затрата во время выполнения — сама блокировка.

Mutex не просто синхронизирует, а доказывает синхронизацию. Mutex::lock() возвращает MutexGuard, который разыменовывается в &T. Получить ссылку на внутренние данные в обход блокировки невозможно. API делает «забыл взять блокировку» структурно невыразимым.

Ограничения функций как теоремы

std::thread::spawn имеет такую сигнатуру:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,

Ограничение Send + 'static — не деталь реализации, а теорема:

«Любое замыкание и возвращаемое значение, переданные в spawn, доказано на этапе компиляции безопасными для запуска в другом потоке, без висячих ссылок.»

Тот же паттерн можно применить к собственным API:

use std::sync::mpsc;

/// Выполняет задачу в фоновом потоке и возвращает её результат.
/// Ограничения доказывают: замыкание и его результат потокобезопасны.
fn run_on_background<F, T>(task: F) -> T
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,
{
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(task());
    });
    rx.recv().expect("фоновая задача завершилась паникой")
}

fn main() {
    // ✅ u32 — Send, замыкание не захватывает ничего не-Send
    let result = run_on_background(|| 6 * 7);
    println!("Результат: {result}");

    // ✅ String — Send
    let greeting = run_on_background(|| String::from("привет из фонового потока"));
    println!("{greeting}");

    // ❌ Не скомпилируется: Rc — !Send
    // use std::rc::Rc;
    // let data = Rc::new(42);
    // run_on_background(move || *data);
}

Раскомментирование примера с Rc даёт точную диагностику:

error[E0277]: `Rc<i32>` cannot be sent between threads safely
   --> src/main.rs
    |
    |     run_on_background(move || *data);
    |     ^^^^^^^^^^^^^^^^^^ `Rc<i32>` cannot be sent between threads safely
    |
note: required by a bound in `run_on_background`
    |
    |     F: FnOnce() -> T + Send + 'static,
    |                        ^^^^ required by this bound

Компилятор возвращает нарушение к точному ограничению и сообщает программисту, почему это нарушение. Сравните с pthread_create из C:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);

void *arg принимает что угодно: потокобезопасное или нет. Компилятор C не может отличить неатомарный счётчик ссылок от обычного целого. Ограничения трейтов в Rust проводят это различие на уровне типов.

Когда использовать доказательства Send/Sync

СценарийПодход
Дескриптор периферии с сырым указателемАвтоматически !Send + !Sync: ничего делать не нужно
Дескриптор из библиотеки на C (целочисленный fd/handle)Добавить PhantomData<*const ()> для !Send + !Sync
Общая конфигурация за блокировкойArc<Mutex<T>>: компилятор доказывает безопасность доступа
Передача сообщений между потокамиmpsc::channel: ограничение Send применяется автоматически
API планировщика задач или пула потоковТребовать F: Send + 'static в сигнатуре
Однопоточный ресурс (например, контекст GPU)PhantomData<*const ()>, чтобы предотвратить разделение
Тип должен быть Send, но содержит сырой указательunsafe impl Send с задокументированным обоснованием безопасности

Сводка стоимости

ЧтоСтоимость во время выполнения
Автоматический вывод Send / SyncТолько время компиляции: 0 байт
Поле PhantomData<*const ()>Нулевой размер: оптимизируется
Проверка !Send / !SyncТолько время компиляции: без проверок во время выполнения
Ограничения функций F: Send + 'staticМономорфизируются: статическая диспетчеризация, без боксинга
Блокировка Mutex<T>Блокировка во время выполнения (неизбежна для разделяемых изменений)
Подсчёт ссылок Arc<T>Атомарные инкременты и декременты (неизбежны для разделяемого владения)

Первые четыре строки имеют нулевую стоимость: они существуют только в системе типов и исчезают после компиляции. Mutex и Arc несут неизбежные затраты во время выполнения, но эти затраты — минимум, который должна платить любая корректная конкурентная программа. Rust лишь следит за тем, чтобы вы их заплатили.

Упражнение: защита DMA-передачи

Спроектируйте DmaTransfer<T>, который удерживает буфер, пока выполняется DMA-передача. Требования:

  1. DmaTransfer должен быть !Send: контроллер DMA использует физические адреса, привязанные к шине памяти этого ядра
  2. DmaTransfer должен быть !Sync: одновременное чтение во время записи DMA увидит рваные данные
  3. Предоставьте метод wait(), который потребляет защиту и возвращает буфер: владение доказывает, что передача завершена
  4. Тип буфера T должен реализовывать маркерный трейт DmaSafe
Пример решения
use std::marker::PhantomData;

/// Маркерный трейт для типов, которые можно использовать как DMA-буферы.
/// В реальной прошивке: тип должен быть repr(C) без паддинга.
trait DmaSafe {}

impl DmaSafe for [u8; 64] {}
impl DmaSafe for [u8; 256] {}

/// Защита, представляющая DMA-передачу в процессе выполнения.
/// !Send + !Sync: её нельзя передать в другой поток или разделить.
pub struct DmaTransfer<T: DmaSafe> {
    buffer: T,
    channel: u8,
    _no_send_sync: PhantomData<*const ()>,
}

impl<T: DmaSafe> DmaTransfer<T> {
    /// Запускает DMA-передачу. Буфер потребляется: никто другой до него не дотронется.
    pub fn start(buffer: T, channel: u8) -> Self {
        // В реальной прошивке: настроить канал DMA, задать источник/приёмник, запустить передачу
        println!("Канал DMA {} запущен", channel);
        Self {
            buffer,
            channel,
            _no_send_sync: PhantomData,
        }
    }

    /// Ждёт завершения передачи и возвращает буфер.
    /// Потребляет self: после этого защиты больше не существует.
    pub fn wait(self) -> T {
        // В реальной прошивке: опрашивать регистр статуса DMA до завершения
        println!("Канал DMA {} завершён", self.channel);
        self.buffer
    }
}

fn main() {
    let buf = [0u8; 64];

    // Запуск передачи: buf перемещается в защиту
    let transfer = DmaTransfer::start(buf, 2);

    // ❌ buf больше недоступен: владение не даёт использовать его во время DMA
    // println!("{:?}", buf);

    // ❌ Не скомпилируется: DmaTransfer — !Send
    // std::thread::spawn(move || { transfer.wait(); });

    // ✅ Ждём в исходном потоке и получаем буфер обратно
    let buf = transfer.wait();
    println!("Буфер возвращён: {} байт", buf.len());
}
flowchart TB
    subgraph compiler["Этап компиляции — автоматически выводимые доказательства"]
        direction TB
        SEND["Send<br/>✅ безопасно перемещать между потоками"]
        SYNC["Sync<br/>✅ безопасно разделять ссылки"]
        NOTSEND["!Send<br/>❌ ограничен одним потоком"]
        NOTSYNC["!Sync<br/>❌ нет конкурентного разделения"]
    end

    subgraph types["Таксономия типов"]
        direction TB
        PLAIN["Примитивы, String, Vec<br/>Send + Sync"]
        CELL["Cell, RefCell<br/>Send + !Sync"]
        RC["Rc, сырые указатели<br/>!Send + !Sync"]
        MUTEX["Mutex&lt;T&gt;<br/>восстанавливает Sync"]
        ARC["Arc&lt;T&gt;<br/>разделяемое владение + Send"]
    end

    subgraph runtime["Рантайм"]
        SAFE["Потокобезопасный доступ<br/>Нет гонок данных<br/>Нет забытых блокировок"]
    end

    SEND --> PLAIN
    NOTSYNC --> CELL
    NOTSEND --> RC
    CELL --> MUTEX --> SAFE
    RC --> ARC --> SAFE
    PLAIN --> SAFE

    style SEND fill:#c8e6c9,color:#000
    style SYNC fill:#c8e6c9,color:#000
    style NOTSEND fill:#ffcdd2,color:#000
    style NOTSYNC fill:#ffcdd2,color:#000
    style PLAIN fill:#c8e6c9,color:#000
    style CELL fill:#fff3e0,color:#000
    style RC fill:#ffcdd2,color:#000
    style MUTEX fill:#e1f5fe,color:#000
    style ARC fill:#e1f5fe,color:#000
    style SAFE fill:#c8e6c9,color:#000

Ключевые выводы

  1. Send и Sync — это доказательства времени компиляции о безопасности конкурентности: компилятор выводит их структурно, проверяя каждое поле. Ни аннотаций, ни накладных расходов во время выполнения, ни явного согласия не нужно.

  2. Сырые указатели автоматически отказываются от трейтов: любой тип, содержащий *const T или *mut T, становится !Send + !Sync. Поэтому дескрипторы периферии естественно ограничены одним потоком.

  3. PhantomData<*const ()> — явный отказ: когда у типа нет сырого указателя, но его всё равно нужно ограничить одним потоком (дескрипторы библиотек на C, контексты GPU), задачу решает phantom-поле.

  4. Mutex<T> восстанавливает Sync с доказательством: компилятор структурно доказывает, что весь доступ проходит через блокировку. В отличие от pthread_mutex_t из C, забыть взять блокировку невозможно.

  5. Ограничения функций — это теоремы: F: Send + 'static в сигнатуре планировщика — обязательство доказательства на этапе компиляции: каждый вызов должен доказать, что его замыкание потокобезопасно. Сравните с void *arg в C, который принимает что угодно.

  6. Паттерн дополняет все остальные техники корректности: typestate доказывает порядок протокола, phantom-типы доказывают права доступа, const fn доказывает инварианты значений, а Send/Sync доказывают безопасность конкурентности. Вместе они покрывают всю поверхность корректности.