Программирование RustUnsafe и памятьинженер по системному программированию на Rust

Какой инвариант доступа делает запись через raw pointer из другого потока безопасной?

Какой инвариант доступа делает запись через raw pointer из другого потока безопасной?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Запись через raw pointer из другого потока безопасна только при доказанном отсутствии конкурентного несинхронизированного доступа к той же памяти. Для изменяемой памяти это обычно означает либо эксклюзивный доступ одного потока, либо корректную синхронизацию через атомарные операции, mutex или другой примитив, устанавливающий порядок и взаимное исключение.

Сам факт, что указатель содержит действительный адрес, ничего не гарантирует о потокобезопасности. Кроме отсутствия гонки, остаются обязательными обычные инварианты: память должна быть живой, корректно выровненной, и запись не должна нарушать правила инициализации и допустимых значений типа.

Исторический контекст

Модель Send и Sync в Rust появилась как часть статической защиты многопоточного кода. Она должна была предотвращать передачу между потоками типов, использование которых может привести к гонкам данных или нарушению внутренних инвариантов.

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

Постановка проблемы

Предположим, Rust передал внешний указатель библиотеке, которая сохранила его и использует в рабочем потоке. В это же время другой поток Rust может читать или изменять объект, на который указывает этот адрес.

Если хотя бы один доступ является записью и между конфликтующими доступами нет корректной синхронизации, возникает гонка данных. Для обычной памяти это неопределённое поведение, даже если процессор практически всегда выполняет операции в ожидаемом порядке.

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

Подробное решение

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

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

Raw pointer не несёт в себе информации о владении, времени жизни, эксклюзивности или потокобезопасности. Обёртка с unsafe impl Send может сообщить компилятору, что тип разрешено передавать между потоками, но это лишь утверждение разработчика; оно не добавляет блокировок и не проверяет контракт внешней библиотеки.

Для безопасного решения нужно установить полный контракт:

  • объект живёт дольше всех операций внешнего потока;
  • указатель остаётся валидным для своего типа;
  • освобождение выполняется только после завершения всех обращений;
  • конкурентный доступ либо исключён, либо синхронизирован;
  • внешний код не создаёт дополнительных несогласованных алиасов.

Минимальная схема синхронизации может выглядеть так:

use std::sync::{Arc, Mutex}; use std::thread; let value = Arc::new(Mutex::new(0u32)); let worker_value = Arc::clone(&value); let handle = thread::spawn(move || { let mut guard = worker_value.lock().unwrap(); *guard += 1; }); handle.join().unwrap(); assert_eq!(*value.lock().unwrap(), 1);

В реальном FFI указатель может храниться внутри внешнего кода, поэтому Mutex должен защищать не только Rust-операции, но и весь протокол доступа. Если C-библиотека обращается к памяти самостоятельно и не предоставляет точки синхронизации, Rust не может безопасно использовать тот же объект параллельно, пока контракт не будет изменён или доступ не будет передан одному владельцу.

Ситуация из практики

C-библиотека запускает рабочий поток и получает указатель на состояние соединения. Изначально Rust продолжал обновлять это состояние напрямую, пока поток C читал его. На тестах ошибка проявлялась редко, но под нагрузкой возникали повреждённые составные значения и нестабильное завершение процесса.

Вариант с volatile не подходит: он может быть нужен для специальных операций с памятью, но не устанавливает взаимное исключение и не делает составное состояние согласованным. Вариант с атомарными полями подходит только после доказательства, что каждое поле действительно может обновляться независимо; для нескольких связанных полей он легко оставляет промежуточное состояние видимым читателю.

Выбранное решение — передать владельцу C явное право единоличного доступа, а взаимодействие с Rust выполнять через потокобезопасные сообщения. Если совместный доступ неизбежен, состояние помещают под mutex или используют атомарную структуру с тщательно определённым протоколом памяти. Освобождение объекта выполняют только после сигнала о завершении рабочего потока и его присоединения.

Что кандидаты часто упускают

  1. Достаточно ли сделать указатель Send, чтобы его можно было безопасно использовать в другом потоке?

Нет. Send описывает допустимость передачи значения между потоками, но не гарантирует безопасность операций с памятью, на которую оно указывает. Для raw pointer это утверждение должно опираться на внешний контракт: корректное время жизни объекта, отсутствие запрещённых алиасов и согласованный протокол доступа. Ручная реализация Send без такого доказательства лишь скрывает проблему от компилятора.

  1. Делает ли атомарная запись безопасным одновременное чтение всей структуры через raw pointer?

Нет. Атомарность отдельного поля не распространяется автоматически на остальные поля структуры. Если читатель получает структуру частями, он может увидеть комбинацию значений из разных состояний. Нужны либо атомарный протокол публикации с корректным порядком памяти, либо блокировка, либо неизменяемый снимок, чья публикация и время жизни также синхронизированы.

  1. Можно ли освободить объект сразу после отправки сигнала внешнему потоку прекратить работу?

Нет, одного сигнала недостаточно: необходимо дождаться доказанного завершения всех обращений. Обычно внешний поток присоединяют или получают иной гарантированный сигнал завершения, после чего только освобождают объект. Иначе поток может выполнить уже запланированное обращение после освобождения, что превращает валидный ранее raw pointer в висячий указатель.