Введение: Загадка компилятора Rust
Представьте: вы пишете на Rust, вы — настоящий фанат безопасности памяти. Вы знаете, что два mutable reference (&mut T) на одни и те же данные никогда не могут существовать одновременно. Это одно из главных правил заимствования (borrowing rules). Компилятор Rust строго следит за этим.
Казалось бы, если компилятор уверен, что два &mut не пересекаются (не являются алиасами), он должен использовать это знание для агрессивной оптимизации. Например, переставлять операции, кешировать значения в регистрах, не перечитывать память. Но вот парадокс: Rust-компилятор (LLVM) часто этого не делает. Почему? Ведь это кажется таким логичным!
Этот вопрос собрал тысячи просмотров на Stack Overflow. И сегодня мы, как настоящие детективы, разберемся в этой загадке. Мы не просто прочитаем ответ — мы поймем глубинные причины, связанные с C-наследием, LLVM и тем, как работает оптимизация. Поехали!
1. Золотое правило Rust: Два &mut — это всегда разные данные
Напомним базу. В Rust есть два вида ссылок:
- Неизменяемая (
&T): данных может быть сколько угодно, но их нельзя менять. - Изменяемая (
&mut T): данных может быть только одна, но её можно менять.
Это правило — noalias для &mut. Компилятор, по идее, может предположить: "Если я вижу два &mut i32, они точно указывают на разные места в памяти".
Но давайте посмотрим на простой пример, который демонстрирует проблему:
fn example(x: &mut i32, y: &mut i32) -> i32 {
*x = 42;
*y = 10;
*x // Ожидаем 42, но...
}
Здесь всё очевидно: x и y — разные переменные. Rust гарантирует, что они не пересекаются. Компилятор мог бы просто вернуть 42, не выполняя присваивания. Но так ли это на самом деле?
2. Проблема: LLVM и его "слепота" к гарантиям Rust
Главный герой нашей истории — LLVM (Low Level Virtual Machine). Rust использует LLVM как бэкенд для генерации машинного кода. LLVM — это мощный оптимизатор, но он не знает о правилах Rust. Он знает только о правилах C/C++.
В C/C++ есть ключевое слово restrict (в C99), которое говорит компилятору: "Эта ссылка уникальна, никто другой на эти данные не указывает". Но в C/C++ restrict — это обещание программиста, а не гарантия компилятора. Если программист нарушит обещание — undefined behavior.
Rust же даёт железобетонную гарантию. Но чтобы передать эту гарантию LLVM, Rust-фронтенд должен явно пометить &mut атрибутом noalias в LLVM IR (промежуточном представлении).
И вот тут начинается самое интересное. Rust помечает &mut как noalias только в определенных случаях. А в некоторых — нет. Почему?
2.1. Историческая проблема: UnsafeCell и Unsafe код
Внутри Rust есть тип UnsafeCell<T>. Это строительный блок для Cell, RefCell, Mutex и других типов, которые позволяют внутреннюю мутабельность (interior mutability).
Если у вас есть &mut UnsafeCell<i32>, то через него можно получить сырой указатель и изменить данные даже через неизменяемую ссылку. Это ломает гарантию noalias!
Чтобы избежать undefined behavior, Rust-компилятор не ставит noalias на &mut, если за ним может скрываться UnsafeCell. Но проблема глубже.
3. Реальный пример: почему оптимизация не срабатывает
Вернемся к функции example. Казалось бы, всё просто. Но давайте посмотрим на её LLVM IR (упрощенно):
define i32 @example(i32* nocapture noalias %x, i32* nocapture noalias %y) {
store i32 42, i32* %x
store i32 10, i32* %y
%ret = load i32, i32* %x
ret i32 %ret
}
Здесь noalias есть! LLVM видит это и может оптимизировать: заменить load на константу 42. И действительно, на современных версиях Rust (с 1.70+) эта оптимизация работает.
Но изменим пример чуть-чуть:
fn example_complex(x: &mut i32, y: &mut i32) -> i32 {
*x = 42;
if some_condition() {
*y = 10;
}
*x
}
Здесь уже сложнее. LLVM может не быть уверен, что some_condition() не меняет x через глобальное состояние. Или если y — это поле структуры, которая содержит x внутри себя (через UnsafeCell).
Главная причина, почему Rust не оптимизирует всегда:
- Безопасность прежде всего: Rust не может рисковать undefined behavior, если есть хоть малейшая возможность алиасинга через
UnsafeCell. - LLVM не идеален: Даже с
noaliasLLVM может отказаться от оптимизации, если не видит достаточных доказательств (например, из-за вызова внешней функции). - Совместимость с C ABI: Для FFI (вызова C-функций) Rust вынужден снимать
noalias, чтобы не нарушить соглашения.
4. Глубинная причина: UnsafeCell и "дыра" в гарантиях
Ключевой момент: правила заимствования Rust — это семантическое правило, а не физическое. Компилятор верит, что программист их не нарушает. Но UnsafeCell позволяет обойти эти правила в unsafe коде.
Представьте:
use std::cell::UnsafeCell;
fn bad_example(cell: &mut UnsafeCell<i32>) {
let ptr = cell.get();
unsafe { *ptr = 5; }
// Другая mutable ссылка может быть получена из того же