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

При синхронном обратном вызове из C в Rust какой инвариант нужно проверить перед повторным входом в код, уд...

При синхронном обратном вызове из C в Rust какой инвариант нужно проверить перед повторным входом в код, удерживающий &mut?

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

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

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

Сам факт помещения вызова в unsafe не отменяет этот инвариант: unsafe лишь переносит доказательство корректности с компилятора на программиста.

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

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

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

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

Предположим, Rust передал C указатель на состояние, одновременно удерживая &mut этого состояния. C немедленно вызывает callback Rust, а callback снова обращается к тому же объекту через контекст или глобальный указатель.

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

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

До вызова нужно установить контракт реентерабельности:

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

Самый простой вариант — завершить область действия &mut до FFI-вызова и передать только данные, доступ к которым действительно разрешён. Однако одного преобразования ссылки в raw pointer недостаточно: raw pointer ослабляет статические проверки, но не отменяет требований к фактическому алиасингу.

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

Блокировка также не является универсальным решением. Если callback вызывается синхронно в том же потоке, а callback пытается повторно захватить невзаимную блокировку, возможна взаимная блокировка. Кроме того, блокировка должна защищать именно тот объект и тот способ доступа, которые составляют единый инвариант.

Безопасная обёртка может скрыть unsafe, только если она сама контролирует ABI, время жизни контекста, возможность callback и отсутствие конфликтующего доступа. Если контракт C неизвестен или допускает произвольную реентерабельность, безопасный интерфейс Rust не должен выдавать вызывающему коду обычные ссылки, чья уникальность может быть нарушена извне.

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

Библиотека журналирования принимает контекст пользователя и синхронно вызывает callback при записи сообщения. В первом варианте Rust передаёт указатель на структуру журнала из метода, который удерживает &mut self; callback повторно вызывает метод журнала через этот же контекст. Такой вариант опасен: внешний вызов создаёт повторный доступ к состоянию во время действия уникальной ссылки.

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

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

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

  1. Достаточно ли того, что callback вызывается в том же потоке?

Нет. Проблема реентерабельности не сводится к гонке между потоками. Повторный доступ в одном потоке также нарушает эксклюзивность активной &mut, если callback обращается к той же памяти.

  1. Снимает ли преобразование &mut T в raw pointer требование уникальности?

Нет. Оно только меняет представление указателя и отключает часть статической проверки. Программист по-прежнему обязан доказать, что все чтения и записи через этот указатель совместимы с действующими ссылками и временем жизни объекта.

  1. Можно ли разрешить callback, если он гарантированно только читает состояние?

Не автоматически. Активная &mut предоставляет эксклюзивный доступ, поэтому параллельное или реентерабельное чтение той же памяти тоже может конфликтовать с её действием. Безопасность возможна лишь после изменения модели доступа: например, уникальная ссылка должна быть завершена до callback, либо состояние должно использовать корректный механизм совместного доступа с явно доказанными правилами.