Утечка

Управление ресурсами на основе владения предназначено для упрощения композиции. Вы получаете ресурсы, создавая объект, и отпускаете ресурсы, удаляя его. Из-за того, что удаление производится за вас, вы не можете забыть отпустить ресурсы, и это происходит настолько быстро, насколько это возможно! Конечно, все прекрасно, у вас нет никаких проблем.

На самом деле все ужасно и мы должны попытаться решить появившиеся более экзотические проблемы.

Многие люди полагают, что Rust устраняет утечку ресурсов. На практике, это в основном правда. Вы бы очень удивились, увидев, что у программы на Безопасном Rust утекают ресурсы в неконтролируемом направлении.

Однако с теоретической точки зрения все абсолютно не так, независимо от того, как вы смотрите на это. В самом строгом смысле, "утечка" настолько абстрактна, насколько и неизбежна. Довольно просто инициализировать коллекцию вначале программы, наполнить её кучей объектов с деструкторами и затем войти в бесконечный цикл, который никогда не обращается к ней. Коллекция будет бесполезно храниться в памяти, удерживая свои драгоценные ресурсы до окончания программы (в этот момент все эти ресурсы все равно будут собраны сборщиком ОС).

Можем ограничить определение утечки: невозможность вызова деструктора у значения, которое уже недоступно. Rust не борется с ней. На самом деле у Rust даже есть функция для осуществления такой утечки: mem::forget. Эта функция съедает полученное значение и не вызывает его деструктор.

Раньше mem::forget помечалась unsafe в качестве статической индикации того, что ошибка при вызове деструктора это чаще всего неправильный подход (хотя он и полезен в некотором особом случае в небезопасном коде). В то же время в целом это считали непригодной ситуацией: есть много способов получить ошибки при вызове деструктора в безопасном коде. Самым известным примером является создание цикла из указателей подсчёта-ссылок (RC), использующих внутреннюю изменяемость.

В безопасном коде разумно предполагать, что утечка самого деструктора не происходит, потому что любая программа с такой утечкой неправильна. Однако небезопасный код не может полагаться на то, что вызов деструктора является безопасным. Для большинства типов это не играет роли: если сам деструктор утёк, то тип по определению недоступен, поэтому это и не важно, не так ли? Например, если утекает Box<u8>, то вы тратите память впустую, но это вряд ли нарушит безопасность памяти.

Мы должны быть очень осторожны с утечкой деструкторов в прокси типах. Это типы, управляющие доступом к определённому объекту, но на самом деле не владеющие им. Прокси объекты встречаются редко. Прокси объекты, о которых надо волноваться, встречаются ещё реже. И все же рассмотрим три интересных примера из стандартной библиотеки:

  • vec::Drain
  • Rc
  • thread::scoped::JoinGuard

Опустошение (Drain)

drain - это API коллекций, который передаёт владение данными из контейнера, не уничтожая сам контейнер. Это позволяет нам заново использовать место расположения Vec после передачи владения всего содержимого. Он создаёт итератор (Drain), который возвращает содержимое Vec по значению.

Теперь представьте Drain в середине итерации: некоторые значения уже перемещены, некоторые ещё нет. Это означает, что часть Vec - это абсолютно неинициализированные данные! Каждый раз перед удалением значения мы могли бы сдвигать назад все элементы Vec, но это сильно скажется на производительности.

Вместо этого можно сделать так, чтобы Drain восстанавливал хранилище данных Vec, когда удаляется. Он должен закончить итерирование, переместить оставшиеся в векторе элементы ближе к началу хранилища и изменить len у Vec. Он даже будет безопасен при размотке! Элементарно!

Теперь представим следующее:

let mut vec = vec![Box::new(0); 4];

{
    // начало опустошения, vec больше не доступен
    let mut drainer = vec.drain(..);

    // вытаскиваем два элемента и тут же их уничтожаем
    drainer.next();
    drainer.next();

    // избавляемся от drainer, но не вызываем его деструктор
    mem::forget(drainer);
}

// Ой, vec[0] удален, мы читаем указатель на освобожденную память!
println!("{}", vec[0]);

Это точно не хорошо. К сожалению, мы застряли между молотом и наковальней: поддержка согласованного состояния имеет неподъёмную цену (и обесценит любые преимущества API). Несогласованное состояние даёт нам Неопределённое Поведение в безопасном коде (делает API несостоятельным).

Так что же нам делать? Можем выбрать тривиальное согласованное состояние: установить длину Vec в 0 вначале итерации, и поменять её при необходимости в деструкторе. Таким образом, если все выполняется нормально, мы получим предсказуемое поведение с небольшими накладными расходами. Но если у кто-то наберётся наглости и он выполнит mem::forget в середине итерации, все утечёт ещё сильнее (и возможно оставит Vec в неожиданном, но при этом согласованном состоянии). Из-за того, что mem::forget безопасен, все остальное тоже абсолютно безопасно. Мы называем утечки, вызывающие ещё большие утечки, усилением утечек.

Rc

Rc нам интересен, потому что, на первый взгляд, он вообще не является прокси значением. В конце концов он управляет данными, на которые указывает, и удаляет их после удаления всех Rc. Утечка в Rc не кажется особо опасной. Она оставит счётчик ссылок в постоянном значении, что не даст данным удалиться или освободиться, но это же очень похоже на Box, не правда ли?

Нет.

Представим упрощённую реализацию Rc:

struct Rc<T> {
    ptr: *mut RcBox<T>,
}

struct RcBox<T> {
    data: T,
    ref_count: usize,
}

impl<T> Rc<T> {
    fn new(data: T) -> Self {
        unsafe {
            // Правда было бы здорово, если бы heap::allocate так работал?
            let ptr = heap::allocate::<RcBox<T>>();
            ptr::write(ptr, RcBox {
                data: data,
                ref_count: 1,
            });
            Rc { ptr: ptr }
        }
    }

    fn clone(&self) -> Self {
        unsafe {
            (*self.ptr).ref_count += 1;
        }
        Rc { ptr: self.ptr }
    }
}

impl<T> Drop for Rc<T> {
    fn drop(&mut self) {
        unsafe {
            (*self.ptr).ref_count -= 1;
            if (*self.ptr).ref_count == 0 {
                // удаляем данные и освобождаем их
                ptr::read(self.ptr);
                heap::deallocate(self.ptr);
            }
        }
    }
}

В коде содержится неявное и неуловимое предположение: ref_count подходит по размеру к usize, потому что количество RC в памяти не может быть больше, чем usize::MAX. Но это само по себе подразумевает, что ref_count точно отражает количество Rc в памяти, что, как мы знаем, не всегда правда с mem::forget. Используя mem::forget, мы можем переполнить ref_count, и затем опустить его до 0 оставшимися Rc. Дальше можем счастливо использовать-после-освобождения внутренние данные. Плохо Плохо Не Хорошо.

Можно исправить это, просто проверяя ref_count и выполняя что-то. Позиция стандартной библиотеки - просто вызвать abort, потому что программа ужасно ухудшается в таком случае. К тому же, бог ты мой, это все настолько нелепо.

thread::scoped::JoinGuard

Обратите внимание: это API удалено из std. Для получения большей информации про это вы можете обратиться к задаче #24292.

Мы оставили тут эту секцию так как мы считаем по-прежнему важным этот пример, не зависимо от того, является ли это API частью std или нет.

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

pub fn scoped<'a, F>(f: F) -> JoinGuard<'a>
    where F: FnOnce() + Send + 'a

Здесь f - это замыкание, выполняемое в другом потоке. Выражение F: Send +'a означает, что F замыкается на данных, которые живут 'a, и либо он владеет данными, либо данные реализуют Sync (подразумевая, что &data реализует Send).

Из-за того, что у JoinGuard есть время жизни, он держит все данные замыкания заимствованными в потоке родителе. Это означает, что JoinGuard не может жить дольше, чем данные, с которыми работает другой поток. Когда JoinGuard в действительности удаляется, он блокирует родительский поток, гарантируя, что дочерний поток удалится до того, как данные замыкания выйдут из области видимости родительского потока.

Использование выглядит так:

let mut data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
{
    let guards = vec![];
    for x in &mut data {
        // Перемещаем изменяемую ссылку в замыкание, и выполняем его в
        // другом потоке. У замыкания границы времени жизни совпадают с
        // временем жизни изменяемой ссылки `x`, которую мы храним в нем.
        // Возвращаемому сторожевому значению (guard) в свою очередь присвоено
        // время жизни замыкания, и он также изменяемо заимствует `data`, как
        // сделал `x`. Это означает, что у нас нет доступа к `data`, пока
        // сторожевое значение не уйдет.
        let guard = thread::scoped(move || {
            *x *= 2;
        });
        // сохраняем сторожевое значение потока на будущее.
        guards.push(guard);
    }
    // Все сторожевые значения удаляются здесь, заставляя завершаться потоки
    // (текущий поток блокируется здесь пока другие потоки не завершатся).
    // Когда потоки завершились, заимствование заканчивается и данные становятся
    // опять доступными в текущем потоке.
}
// данные определенно будут изменены здесь.

В принципе все нормально работает! Система владения Rust отлично гарантирует это! ...кроме одного - она ожидает, что вызываемый деструктор должен быть безопасным.

let mut data = Box::new(0);
{
    let guard = thread::scoped(|| {
        // Это в самом лучшем случае гонка данных. В худшем -
        // использование-после-освобождения.
        *data += 1;
    });
    // Из-за того что guard забыт, заимствование заканчивается без
    // блокировки текущего потока.
    mem::forget(guard);
}
// Итак, Box удаляется здесь, в то время как поток из области видимости выше
// может попытаться получить доступ к нему.

Бум. Здесь выполнение деструктора было базовой штукой в API, но в итоге было сделано совсем по-другому.