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

C библиотека изменяет объект через raw pointer, пока Rust хранит на него &T. Какой инвариант безопасности н...

C-библиотека изменяет объект через raw pointer, пока Rust хранит на него &T. Какой инвариант безопасности нарушается?

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

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

Нарушается инвариант неизменяемости данных через &T: пока существует shared-ссылка, значение не должно изменяться, кроме случаев, явно разрешённых через UnsafeCell. Сам факт, что C получил raw pointer, не отменяет обязательства Rust-кода соблюдать этот инвариант. Если C изменяет объект без разрешённого механизма внутренней изменяемости или синхронизации, поведение программы становится неопределённым.

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

Модель ссылок Rust разделяет доступ на совместный и уникальный. &T допускает одновременное чтение, а &mut T предоставляет эксклюзивный доступ; это позволяет компилятору оптимизировать обращения к памяти, не предполагая произвольных изменений между ними.

Raw pointers нужны для низкоуровневого кода и FFI, где такие гарантии нельзя автоматически проверить. Однако переход к raw pointer не снимает инварианты исходной ссылки: если указатель получен из &T, внешний код не получает право произвольно менять объект.

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

Типичный риск возникает, когда Rust передаёт C адрес объекта, продолжая использовать shared-ссылку. C может записать новое значение через *mut T, а Rust затем прочитает объект через прежнюю &T или полагается на то, что его содержимое не менялось.

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

use std::cell::UnsafeCell; struct State { value: i32, } fn pass_to_c(state: &State) { let p = state as *const State as *mut State; // C-запись через p нарушает инвариант &State. } struct Interior { value: UnsafeCell<i32>, }

В первом случае UnsafeCell отсутствует, поэтому изменение value через C несовместимо с существующей &State. Во втором случае внутреннее изменение может быть допустимым, но только при соблюдении дополнительных правил доступа и синхронизации.

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

Ссылка &T означает не просто «адрес, по которому можно читать». Она также утверждает, что объект корректен, живёт достаточно долго и не изменяется через конфликтующий доступ в течение времени действия ссылки. Raw pointer, созданный из этой ссылки, наследует практический контекст её использования: преобразование указателя не превращает запрещённую запись в разрешённую.

Если C должен изменять объект, безопаснее передавать ему *mut T, полученный из корректного уникального доступа, например из &mut T, и не использовать одновременно конфликтующие ссылки Rust. Такой контракт должен включать срок действия указателя, допустимость записи и отсутствие конкурирующих обращений.

Для специально предназначенного внутреннего изменения применяется UnsafeCell<T>. Он сообщает компилятору, что содержимое может изменяться даже при наличии &UnsafeCell<T>. Но UnsafeCell не предоставляет автоматически потокобезопасность, блокировку, атомарность, проверку границ или корректность операций C.

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

Практическое правило: перед передачей адреса в C нужно определить, какая модель доступа действует на всём интервале вызова. Если C только читает, &T может быть совместима с контрактом. Если C пишет, нужен уникальный доступ либо явно предусмотренная внутренняя изменяемость; при этом Rust не должен сохранять конфликтующие ссылки, которыми он продолжает пользоваться.

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

Библиотека C принимает контекст и при вызове обратной функции обновляет поле состояния. Изначально Rust передал адрес объекта, полученный из &Context, потому что C объявлял параметр как указатель и разработчик счёл запись через него деталью реализации.

Рассматривались варианты:

  • Передать *mut Context, преобразовав его из &Context. Это не решает проблему: меняется тип указателя, но не нарушенный инвариант shared-ссылки.
  • Передать &mut Context на время вызова. Это корректно, если C не сохраняет указатель и во время вызова нет других обращений к объекту.
  • Поместить изменяемое поле в UnsafeCell. Это подходит, если внутреннее изменение действительно является частью дизайна и Rust-код соблюдает правила синхронизации и времени жизни.
  • Хранить состояние отдельно за mutex и передавать C непрямой дескриптор. Это добавляет накладные расходы, но лучше подходит для длительных или многопоточных callback-сценариев.

Выбрано разделение состояния и передача временного уникального доступа для синхронной операции. В результате C не получил возможность менять объект, пока Rust удерживает конфликтующую &Context, а граница FFI получила явно проверяемый контракт.

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

  1. Достаточно ли преобразовать &T в *mut T, чтобы C получил право записи?

    Нет. Такое преобразование меняет представление адреса, но не создаёт уникальный доступ и не отменяет инвариант &T. Запись через полученный указатель остаётся нарушением правил, если объект не использует механизм вроде UnsafeCell и отсутствует иной корректный контракт доступа.

  2. Делает ли UnsafeCell<T> любые записи из C безопасными?

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

  3. Можно ли безопасно создать &T после того, как C завершил запись через raw pointer?

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