До стабилизации Нелекальных временных рамок (NLL) в Rust 2018 компилятор обеспечивал строгие лексические области для заимствований, что делало выражения вроде vec.push(vec.len()) незаконными, поскольку изменяемое заимствование, требуемое push, казалось, конфликтовало с неизменяемым заимствованием, необходимым для len. Сообщество признало это ограничение чрезмерно консервативным, поскольку изменяемый доступ на самом деле не используется до выполнения тела метода, создавая теоретическое окно, где неизменяемая проверка остается безопасной. Это привело к введению двухфазных заимствований, уточнения проверки заимствований, которое различает резервирование изменяемого заимствования и его фактическую активацию.
Основная проблема заключается в примирении гарантии алиасинга XOR мутации языка Rust с эргономичным дизайном API, особенно когда вызов метода требует &mut self, но его аргументы нуждаются в &self на том же объекте. Без специализированной обработки проверка заимствований отмечала бы это как нарушение второго правила изменяемого заимствования, заставляя разработчиков вручную упорядочивать операции с временными переменными. Проблема требует механизма, который откладывает применение изменяемой эксклюзивности до момента фактической мутации, при этом гарантируя, что промежуточные неизменяемые доступы не могут пережить переход или создать висячие ссылки.
Двухфазные заимствования функционируют, рассматривая изменяемое заимствование в вызове метода как "резервирование" в течение оценки аргументов, "активируя" его в полное изменяемое заимствование только после завершения оценки и входа в тело метода. В фазе резервирования компилятор разрешает ограниченные неизменяемые заимствования (в частности, те, которые происходят от autoref на получателе), отслеживая, что ожидается изменяемая активация. Это реализуется в MIR (Среднем уровне представления), где компилятор проверяет, что нет конфликтующих использований между точкой резервирования и точкой активации, обеспечивая безопасность через статический анализ, а не инструментирование во время выполнения.
Рассмотрим менеджера сетевых буферов, ответственного за агрегацию пакетов перед передачей. Система должна добавить заголовок, размер которого зависит от текущей длины буфера: buffer.append_header(buffer.current_len()). Здесь append_header требует изменяемого доступа для расширения буфера, в то время как current_len требует только неизменяемой проверки.
Разработчик мог бы извлечь длину в отдельное связывание перед мутацией: let len = buffer.current_len(); buffer.append_header(len);. Этот подход работает во всех версиях Rust и полностью избегает сложных правил компилятора. Однако он вводит многословие и создает окно, где длина теоретически может устареть, если код будет реорганизован для включения конкуренции, хотя в однопоточных контекстах это в чистом виде стилевое беспокойство. Основной недостаток – сниженная эргономика и потенциальная ситуация, когда временная переменная может пережить свою необходимость, загромождая область видимости.
Оборачивание буфера в RefCell позволит осуществлять как неизменяемые, так и изменяемые заимствования во время выполнения через методы borrow() и borrow_mut(). Это устраняет конфликты времени компиляции, откладывая проверки на время выполнения, что может привести к панике при нарушении. Хотя это гибко, оно вводит накладные расходы на подсчет ссылок и валидацию во время выполнения, нарушая принцип абстракции без затрат, критичный для высокопроизводительного сетевого кода. Кроме того, это смещает ошибки с гарантий времени компиляции на потенциальные сбои времени выполнения, уменьшая надежность.
Команда использовала двухфазные заимствования, структурировав append_header как метод, принимающий &mut self, доверяя проверке заимствований NLL автоматически обрабатывать резервирование. Это позволило естественно выразить логику без временных переменных или накладных расходов на время выполнения. Компилятор проверил, что current_len завершается до активации изменяемого заимствования, обеспечивая безопасность. Это решение было выбрано, поскольку оно поддерживало абстракции без затрат, обеспечивая при этом чистый, поддерживаемый синтаксис, который точно отражал предполагаемый поток данных.
Реализация скомпилировалась без ошибок на Rust 1.63+, достигнув оптимальной производительности, идентичной коду с ручным упорядочиванием. Менеджер буферов успешно обрабатывал трафик 10Gbps без накладных расходов на выделение, продемонстрировав, что двухфазные заимствования решают проблему эргономики, не компрометируя гарантии безопасности Rust. Кодовая база оставалась свободной от сложности внутренней изменяемости, упрощая будущие проверки на безопасность памяти.
Как влияет двухфазное заимствование на явные операции разыменования и перегрузку операторов?
Многие кандидаты предполагают, что двухфазные заимствования применимы повсеместно ко всем изменяемым ссылкам, но они конкретно ограничены ситуациями autoref в получателях вызова метода. При явном разыменовании через *vec или использовании операторных трейтов, таких как IndexMut, проверка заимствований не применяет двухфазную логику, немедленно активируя изменяемое заимствование. Это ограничение существует, потому что метод autoref предоставляет четкую точку резервирования (место вызова метода), где компилятор может отслеживать переходы состояния, тогда как произвольные операции разыменования не имеют этой семантической границы. Понимание этого различия предотвращает путаницу, когда аналогично выглядящий код не компилируется.
Почему компилятор запрещает двухфазные заимствования, когда получатель реализует Drop?
Кандидаты часто упускают из виду, что типы, реализующие Drop, имеют семантику деструктора, что усложняет фазу резервирования. Если существует изменяемое резервирование, когда деструктор выполняется (например, при паниках или сложном управлении потоком), частично инициализированное состояние может нарушить ожидания Drop относительно действительного self. Поэтому компилятор ограничивает двухфазные заимствования для типов с пользовательскими деструкторами, если они не являются Copy, что гарантирует, что активация изменяемого заимствования не может мешать выполнению кода дропирования. Это предотвращает тонкие ошибки, когда фаза резервирования может наблюдать частично перемещенное или недействительное состояние во время распаковки стека.
Что отличает фазу "резервирования" от фазы "активации" с точки зрения разрешенных операций?
Во время фазы резервирования компилятор разрешает только неизменяемые использования получателя, происходящие из вызова метода autoref, в частности, позволяя оценку аргументов. Тем не менее, кандидаты часто упускают из виду, что создание дополнительных именованных ссылок на получателя или передача его в другие функции во время оценки аргументов запрещены. Фаза активации начинается ровно в тот момент, когда управление входит в тело метода, в этот момент все неизменяемые заимствования из оценки аргументов должны завершиться. Это создает строгую линейную временную шкалу: резервирование → неизменяемая оценка аргументов → активация → выполнение метода. Нарушение этой последовательности, например, путем хранения ссылки в переменной, которая переживает точку активации, приводит к ошибке компиляции для поддержания гарантий исключительности.