Какой инвариант доступа делает запись через raw pointer из другого потока безопасной?
Запись через raw pointer из другого потока безопасна только при доказанном отсутствии конкурентного несинхронизированного доступа к той же памяти. Для изменяемой памяти это обычно означает либо эксклюзивный доступ одного потока, либо корректную синхронизацию через атомарные операции, mutex или другой примитив, устанавливающий порядок и взаимное исключение.
Сам факт, что указатель содержит действительный адрес, ничего не гарантирует о потокобезопасности. Кроме отсутствия гонки, остаются обязательными обычные инварианты: память должна быть живой, корректно выровненной, и запись не должна нарушать правила инициализации и допустимых значений типа.
Модель Send и Sync в Rust появилась как часть статической защиты многопоточного кода. Она должна была предотвращать передачу между потоками типов, использование которых может привести к гонкам данных или нарушению внутренних инвариантов.
Unsafe Rust нужен в том числе для взаимодействия с кодом, который компилятор не может проанализировать: C-библиотеками, операционной системой и ручным управлением памятью. При этом выход за границы безопасной проверки не отменяет требования к синхронизации — ответственность за них переходит к разработчику.
Предположим, Rust передал внешний указатель библиотеке, которая сохранила его и использует в рабочем потоке. В это же время другой поток Rust может читать или изменять объект, на который указывает этот адрес.
Если хотя бы один доступ является записью и между конфликтующими доступами нет корректной синхронизации, возникает гонка данных. Для обычной памяти это неопределённое поведение, даже если процессор практически всегда выполняет операции в ожидаемом порядке.
Дополнительный риск создаёт время жизни объекта: синхронизация не спасает указатель, если исходное значение уже уничтожено. Поэтому контракт FFI должен отдельно описывать, кто владеет памятью и когда внешний поток прекращает её использовать.
Главный инвариант — конфликтующие обращения к одной области памяти не должны выполняться конкурентно без установленного механизма синхронизации. Если внешний поток единолично владеет правом записи, Rust не должен одновременно читать или изменять этот объект. Если доступ разделяется, его нужно организовать через подходящий примитив.
Атомарные типы подходят для отдельных атомарных значений и задают правила видимости между потоками. Mutex или аналогичный примитив подходит для составного состояния, когда нужно защитить несколько полей как единое целое. Простое объявление указателя, использование volatile или надежда на естественную атомарность машинной записи не заменяют синхронизацию.
Raw pointer не несёт в себе информации о владении, времени жизни, эксклюзивности или потокобезопасности. Обёртка с unsafe impl Send может сообщить компилятору, что тип разрешено передавать между потоками, но это лишь утверждение разработчика; оно не добавляет блокировок и не проверяет контракт внешней библиотеки.
Для безопасного решения нужно установить полный контракт:
Минимальная схема синхронизации может выглядеть так:
В реальном FFI указатель может храниться внутри внешнего кода, поэтому Mutex должен защищать не только Rust-операции, но и весь протокол доступа. Если C-библиотека обращается к памяти самостоятельно и не предоставляет точки синхронизации, Rust не может безопасно использовать тот же объект параллельно, пока контракт не будет изменён или доступ не будет передан одному владельцу.
C-библиотека запускает рабочий поток и получает указатель на состояние соединения. Изначально Rust продолжал обновлять это состояние напрямую, пока поток C читал его. На тестах ошибка проявлялась редко, но под нагрузкой возникали повреждённые составные значения и нестабильное завершение процесса.
Вариант с volatile не подходит: он может быть нужен для специальных операций с памятью, но не устанавливает взаимное исключение и не делает составное состояние согласованным. Вариант с атомарными полями подходит только после доказательства, что каждое поле действительно может обновляться независимо; для нескольких связанных полей он легко оставляет промежуточное состояние видимым читателю.
Выбранное решение — передать владельцу C явное право единоличного доступа, а взаимодействие с Rust выполнять через потокобезопасные сообщения. Если совместный доступ неизбежен, состояние помещают под mutex или используют атомарную структуру с тщательно определённым протоколом памяти. Освобождение объекта выполняют только после сигнала о завершении рабочего потока и его присоединения.
Send, чтобы его можно было безопасно использовать в другом потоке?Нет. Send описывает допустимость передачи значения между потоками, но не гарантирует безопасность операций с памятью, на которую оно указывает. Для raw pointer это утверждение должно опираться на внешний контракт: корректное время жизни объекта, отсутствие запрещённых алиасов и согласованный протокол доступа. Ручная реализация Send без такого доказательства лишь скрывает проблему от компилятора.
Нет. Атомарность отдельного поля не распространяется автоматически на остальные поля структуры. Если читатель получает структуру частями, он может увидеть комбинацию значений из разных состояний. Нужны либо атомарный протокол публикации с корректным порядком памяти, либо блокировка, либо неизменяемый снимок, чья публикация и время жизни также синхронизированы.
Нет, одного сигнала недостаточно: необходимо дождаться доказанного завершения всех обращений. Обычно внешний поток присоединяют или получают иной гарантированный сигнал завершения, после чего только освобождают объект. Иначе поток может выполнить уже запланированное обращение после освобождения, что превращает валидный ранее raw pointer в висячий указатель.