Почему Rust не оптимизирует код с двумя mutable ссылками? Разбор для новичков

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Загадка компилятора 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 не идеален: Даже с noalias LLVM может отказаться от оптимизации, если не видит достаточных доказательств (например, из-за вызова внешней функции).
  • Совместимость с 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 ссылка может быть получена из того же                

Блоги

Рекомендаций по книгам