Программирование RustUnsafe и памятьСтарший инженер по разработке на Rust

На границе FFI C функции передали указатель, полученный из изменяемой ссылки. Какое поведение C кода сохран...

На границе FFI C-функции передали указатель, полученный из изменяемой ссылки. Какое поведение C-кода сохраняет инвариант уникального доступа Rust?

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

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

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

Сам факт передачи raw pointer не передаёт автоматически владение объектом и не отменяет правил Rust. Безопасность обеспечивается договорённостью FFI-границы и должна быть доказана вызывающим кодом.

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

Rust стремится проверять владение, время жизни и отсутствие конфликтующих доступов на этапе компиляции. Внешний код на C не участвует в этой проверке, поэтому взаимодействие с ним выполняется через unsafe и явно заданные инварианты.

Raw pointers нужны для низкоуровневых операций, взаимодействия с ABI и существующими библиотеками. Они позволяют передать адрес без автоматического создания обычной ссылки, но вместе с этим переносят ответственность за корректность доступа на программиста.

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

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

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

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

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

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

Передача raw pointer не является передачей владения. Если требуется передать владение буфером, это должно быть выражено отдельным FFI-контрактом: например, определёнными функциями освобождения, правилами единственного освобождения и согласованным представлением памяти.

Минимальная модель корректного поведения выглядит так:

extern "C" fn c_process(p: *mut u8, n: usize) { unsafe { for i in 0..n { *p.add(i) = (*p.add(i)).wrapping_add(1); } } } fn call_foreign(data: &mut [u8]) { c_process(data.as_mut_ptr(), data.len()); }

В примере C-функция получает доступ только к переданному диапазону и завершает работу до возврата из call_foreign. Реальный FFI-код должен дополнительно учитывать ABI, представление типов и требования вызываемой библиотеки; пример показывает именно правило эксклюзивного доступа.

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

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

Rust-обёртка передаёт C-библиотеке изменяемый буфер для заполнения. Библиотека предлагает два варианта: заполнить буфер синхронно до возврата или сохранить указатель и завершить запись позже в фоновом потоке.

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

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

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

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

  1. Достаточно ли того, что C-код не изменяет объект, а только читает его через указатель?

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

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

  1. Можно ли C-функции сохранить указатель, если сам буфер ещё не освобождён?

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

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

  1. Что меняется, если C-функция запускает фоновый поток и сразу возвращает управление Rust?

Изменяется не только время жизни указателя, но и модель конкурентного доступа. После возврата Rust может получить возможность использовать буфер, а фоновый поток C продолжит обращаться к нему; без синхронизации это создаёт конфликт, а при освобождении или перемещении буфера — ещё и использование недействительной памяти.

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