Pull to refresh

Leakpocalypse: Rust может неприятно удивить

Reading time10 min
Views9.6K
Original author: Alexis Beingessner

Прим. пер.: Кто-то должен был сделать перевод этой статьи, несмотря на то, что она достаточно стара (2015 год), поскольку она показывает очень важную особенность работы с памятью в Rust — с помощью безопасного (не помеченного как unsafe) кода можно создавать утечки памяти. Это должно отрезвлять народ, верящий во всемогущность borrow checker'а.
Спойлер — внутри про невозможность отслеживания циклических ссылок, а также старые болезни некоторых типов из std, на момент перевода благополучно вылеченные.
Несмотря на наличие в Книге главы про безопасный код (спасибо за напоминание ozkriff), а также разъяснительной статьи русскоязычного сообщества (спасибо за напоминание mkpankov), я решил выполнить перевод для наглядной демонстрации серьезности непонимания возможностей управления памятью Rust.
Вероятнее всего, данная статья ранее не переводилась по причине весьма специфических терминов автора, которые НЛО в тираж не пропустит. По этой причине перевод не совсем дословный.


Утечкокалипсис


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


Итак, баг:


С помощью циклических ссылок можно упустить JoinGuard, вследствие чего поток области видимости (scoped thread) может получить доступ к уже освобожденной области памяти.

Крайне серьезное заявление, поскольку все используемые API помечены как безопасные, что (по изначальному ожиданию авторов языка — прим.пер.) обязано исключить такое поведение безопасного кода в принципе.


Основной фокус приходится на API thread::scoped, который создает поток с доступом к окну стека другого потока, безопасность которого гарантирована компилятором статически. Идея в основе безопасности состоит в JoinGuard, возвращаемом thread::scoped, чей деструктор блокирует выполнение ожидающего и владеющего стеком потока; соответственно, ничто более, переданное в ожидаемый поток, не может пережить данный JoinGuard. Это позволяет реализовывать довольно полезные вещи вроде:


use std::vec::Vec;
use std::thread;
fn increment_elements(slice: &mut [u32]) {
    for a in slice.iter_mut() { *a += 1; }
}

fn main() {
    let mut v = Vec::new();
    for i in (0..100) { v.push(i); }

    let mut threads = Vec::new();
    for slice in v.chunks_mut(10) {
        threads.push(thread::scoped(move || {
            increment_elements(slice);
        }));
    }
    // JoinGuard'ы `threads` здесь дропаются, и `main` будет заблокирован
    // пока не завершится последний из дочерних потоков
}

Здесь выполняется некая полезная работа для каждого из десяти элементов массива в отдельном потоке (ну, типа как. Здесь просто пример, представьте там полезную нагрузку сами.). Магическим образом Rust способен статически гарантировать безопасность доступа к данным даже не зная, что именно происходит в дочерних потоках! Ему нужно просто видеть массив (Vec) JoinGuard'ов потоков, которые заимствуют v, соответственно v не может умереть раньше, чем потоки завершатся. (А если точнее, Rust и про массив не знает особо, ему достаточно, что threads заимствует v, даже если threads вообще пуст).


И это очень прикольно. И увы, неверно.


Предполагается, что деструкторы (здесь и далее — реализации Drop — прим.пер.) гарантированно исполняются в безопасном, не помеченном unsafe коде. Что уж говорить, ваш покорный слуга, как и многие в сообществе выросли с верой в данный постулат. В конце концов, у нас даже есть отдельная функция, которая специально удаляет связь с элементом без вызова деструктора, mem::forget, и во имя данного постулата она намеренно помечена как unsafe!


Как оказалось, это всего лишь отголоски старых вариантов API. На самом деле mem::forget в стандартной библиотеке в некоторых местах используется в безопасном коде. И если почти все из этих мест были позже отнесены как ошибки имплементации, то одно оставшееся достаточно фундаментально. Это rc::Rc.


Rc у нас — умный указатель со счетчиком ссылок. Он весьма прост — клади в конструктор Rc::new данные для разделения между несколькими ссылками да пользуйся. Клонируешь (clone()) Rc — счетчик увеличивается. Удаляешь (drop()) клон — счетчик уменьшается. Все работает только за счет borrow checker'а — отслеживание времен жизни гарантирует, что ссылки на данные освобождены до момента удаления Rc, через который они (ссылки) получены.


Сам по себе Rc вполне торт — благодаря принципу работы счетчика мы работает только со ссылками на внутренности, сами данные остаются на месте в плане освобождения памяти, прочесть что-то вне этой памяти невозможно… кроме случаев внутренней изменяемости (internal mutability). Хотя раздача копий ссылок на данные в Rust подразумевает неизменяемость (данных, не ссылок), сами данные все же возможно изменять, в качестве исключения из правил. Для этих целей служит Cell, чьи внутренности можно менять даже в случае множественного доступа. Собственно, поэтому типы Cell помечены как непотокобезопасные. Для потоков есть sync::Mutex.


А теперь смешаем Rc с drop и напишем безопасный forget:


fn safe_forget<T>(data: T) {
    use std::rc::Rc;
    use std::cell::RefCell;

    struct Leak<T> {
        cycle: RefCell<Option<Rc<Rc<Leak<T>>>>>,
        data: T,
    }

    let e = Rc::new(Leak {
        cycle: RefCell::new(None),
        data: data,
    });
    *e.cycle.borrow_mut() = Some(Rc::new(e.clone())); // Привет, цикл ссылок
}

Похоже на лютую ересь. Вкратце, можно создавать циклически зависимые считаемые ссылки с помощью Rc и RefCell. Итогом будет тот факт, что деструктор типа, положенного внутрь Leak<T> никогда не будет вызван, хотя ссылок на Rc у нас больше нет. В принципе невызов деструктора сам по себе не страшен, можно же завершить программу извне или работать в бесконечном цикле. Но не в данном случае — Rc нам сказал, что вызывал деструктор. Это даже можно проверить:


fn main() {
    struct Foo<'a>(&'a mut i32);

    impl<'a> Drop for Foo<'a> {
        fn drop(&mut self) {
            *self.0 += 1;
        }
    }

    let mut data = 0;

    {
        let foo = Foo(&mut data);
        safe_forget(foo);
    }

    // проверяем вдоль и поперек, что ссылок на данные нигде нет
    // иначе строка внизу упадет при компиляции
    data += 1;

    println!("{:?}", data); // а тут 1, а должно быть 2
}

Оно работает. Теперь надо бы разобраться в проблеме из исходного тикета, вызвавшего панику. Уменьшим пример из тикета до следующего:


fn main() {
    let mut v = Ok(4);
    if let &mut Ok(ref v) = v {
        let jg = thread::scoped(move || {
            println!("{}", v); // чтение из дочернего потока
        });
        safe_forget(jg); // JoinGuard забыт, деструктор не вызовется, join бит
    }
    *v = Err("foo"); // одновременное изменение данных из нескольких потоков - гонка
}

Мы раскрыли неопределенное поведение.
И это бесспорно дыра в стандартной библиотеке Rust, поскольку невозможность use-after-free и гонок данных вроде как обещана и статически гарантирована, а мы тривиальным примером добились и того, и другого. Возникает вопрос — что именно сделано неверно. Исходный тикет предсказуемо бросает тень на thread::scoped, так как создан разработчиком компилятора, который 100% в курсе возможности утечки деструктора из "безопасного" кода.


Это немедленно породило волну фрустрации в сообществе — до этого момента они видели утечки только в unsafe. thread::scoped стабилизирован. mem::forget помечен как unsafe, только лишь потому, что никак, никогда, ни в коем случае и совсем нельзя течь не из-под unsafe!
После разбирательства в истоках бага было выявлено стечение обстоятельств:


  • Шаринг счетчиком ссылок
  • Внутренняя изменяемость
  • Rc принимает нестатичные данные (не 'static)

при которых, и никак более, баг проявляется. Это немедленно привело к появлению личностей (увы, меня тоже), требующих в той или иной форме запрета существания любых комбинаций данных обстоятельств на уровне компилятора. Чтобы вы понимали уровень отчаяния — даже появился настойчивый запрос на внутренний типаж Leak, помечающий возможность утечки деструктора в safe.


Внимание. Релиз 1.0 через три недели, внедрять что-либо вышеупомянутое просто нет времени. Выводы сделаны единственно верные — вынуть mem::forget из unsafe (поскольку он ничего скрыто опасного не делает), переделать thread::scoped. Соответствующие RFC:



Обратите внимание: Rust никогда не гарантировал отсутствие утечек в принципе. Утечки, равно как и гонки данных — не имеющий четких границ концепт, потому фактически непредотвратимый. Если сложить что-то в HashMap и никогда его не спросить — тоже в своем роде утечка. Аллоцировать что-то на стеке, после чего запустить бесконечеый цикл — туда же. Любой случай складывания данных в кучу с последующим забыванием об их существовании это самая что ни на есть утечка, которая почему-то именно в данном виде вызывает у людей панический ужас. Ошибкой можно считать данные, у которых забыли вызвать деструктор во время обычного "безопасного" выполнения, потому что с точки зрения статического анализа этих данных не существует.


Что делать?


Таким образом, я был удручен и растерян. Работа с коллекциями в моем понимании всегда подразумевала гарантированное выполнение деструкторов. Были прекрасные возможности как собрать дескриптор для сколь угодно сложного и зависимого типа, действующий прозрачно, но прячущий все внутренности, так и разобрать его корректно при удалении. При работе с временами жизни Rust это гарантировало, однако, что неопределенное состояние данных типа вне этого дескриптора статически невыделяемо! То есть Rust еще более беспечен, чем C!
Каноничный этому пример — Vec::drain_range:


// Нам правда нужны все эти поля? Хз? 
// Пусть пока полежат для примера.
struct DrainRange<'a, T> {
    vec: &'a mut Vec<T>,
    num_to_drain: usize,
    start_pos: usize,
    left: *mut T,
    right: *mut T,
}

impl<T> Vec<T> {
    // Производит итератор, вынимаюший элементы из `self[a..b]`
    // Поправка: b не включен в множество, смотри нотацию Rust range
    fn drain_range(&mut self, a: usize, b: usize) -> DrainRange<T> {
        assert!(a <= b, "invalid range");
        assert!(b <= self.len(), "index out of bounds");

        DrainRange {
            left: self.ptr().offset(a as isize),
            right: self.ptr().offset(b as isize),
            start_pos: a,
            num_to_drain: b - a,
            vec: self,
        }
    }
}

impl<'a, T> Drop for DrainRange<'a, T> {
    fn drop(&mut self) {
        // Поудалять все лишнее
        for _ in self { }

        let ptr = self.vec.ptr();
        let backshift_src = self.start_pos + self.num_to_drain;
        let backshift_dst = self.start_pos;

        let old_len = self.vec.len();
        let new_len = old_len - self.num_to_drain;
        let to_move = new_len - self.start_pos;

        unsafe {
            // Сдвинуть все элементы назад!
            ptr::copy(
                ptr.offset(backshift_src as isize),
                ptr.offset(backshift_dst as isize),
                to_move,
            );

            // Сказать Vec, что у него используется только эта часть элементов
            self.vec.set_len(new_len);
        }
    }
}

// Для законченности примера
impl<'a, T> Iterator for DrainRange<'a, T> {
    type Item = T;

    fn next(&mut self) -> Option<T> {
        if self.left == self.right {
            None
        } else {
            unsafe {
                let result = Some(ptr::read(self.left));
                // Игнорируем size_of<T> == 0 для простоты
                self.left = self.left.offset(1);
                result
            }
        }
    }
}

impl<'a, T> DoubleEndedIterator for DrainRange<'a, T> {
    fn next_back(&mut self) -> Option<T> {
        if self.left == self.right {
            None
        } else {
            unsafe {
                // Игнорируем size_of<T> == 0 для простоты
                self.right = self.right.offset(-1);
                Some(ptr::read(self.right))
            }
        }
    }
}

Тут даже корректно работает unwind, класс! А деструктор не работает, смотрите:


fn main() {
    let vec = vec![Box::new(1)];
    {
        let drainer = vec.drain_range(0, 1);
        // вынуть и дропнуть `box 1` - память, куда он указывает, очистится
        drainer.next();
        safe_forget(drainer);
    }
    println!("{}", vec); // use-after-free памяти, куда указывал `box 1`
}

Не круто. Я порылся в коде коллекций стандартной библиотеки на предмет чего-то, что надеется на деструктор для обеспечения безопасности, и, к счастью, ничего не нашел (что, безусловно, совсем не значит, что его там нет). Если конкретней, искал я нечто, укладывающееся в следующую иерархию гадостей текущего деструктора:


  1. Деструктора нет — нет утечки: примитивы, указатели
  2. Утечка системных ресурсов — не особо и проблема: большинство коллекций и умных указателей вокруг примитивов, у которых при утечке просто расходуется куча
  3. Обычная утечка деструктора — неприятно, но не смертельно: коллекции и умные указатели вокруг структур
  4. Мир в неопределенном, но безопасном состоянии — уверенный "пыщь" в ногу: RingBuf::drain должен чистить коллекцию после того, как его ссылка его drain-итератора заканчивает жить, но это в данный момент гарантировано только деструктором. Сама коллекция при этом консистентна.
  5. Мир сломан — неприемлемо: вышеупомянутый Vec::drain_range

В общем случае, следует отодвигать зависимость от деструктора настолько дальше согласно этой иерархии, насколько практически возможно, с исключением кода из последней категории из всех API, не помеченных unsafe.
Данная иерархия базируется на предположении, что текущий деструктор в работающей программе — баг. Ни более, ни менее, код приложения обязан предполагать, что в произвольном его месте или при адекватных в нем условиях утечек не будет. В качестве обычной практики в Rust сторонние библиотеки единолично обязаны подтверждать невозможность утечек.


Факт наличия кода, возможно провоцирующего утечку, в API, наподобие Rc, не должен расцениваться как баг API или его реализации. Более того, если утечка возможна лишь при определенных, созданных конечным пользователем условиях. Несмотря на извлечение mem::forget из unsafe и возможность его вызовов в безопасном коде, в общих случаях необходимо помнить, что последствия его вызова небезопасны.


Rust может заставить обделаться неприятно удивить


(скорее всего этот участок и есть причина, почему данная статья мало цитируется — прим.пер.)


Как же нас обезопасить использование Vec::drain_range и передвинуть его выше по иерархии? Добавлением следующей строки в конструктор drain_range. Все вот так просто!


// Убеждаем Vec, что он пуст за пределами минимальной границы.
unsafe { self.set_len(a); }

Теперь, если DrainRange утечет, у нас утекут деструкторы элементов, которые нужно повынимать, поскольку мы совсем потеряли сдвигаемые в начало вектора значения. Это вполне относимо к категории "неопределенное, но безопасное состояние", ну, еще прибавляются возможные и разнообразные другие утечки. Все еще хреново, но уже большой прогресс относительно use-after-free, происходившего ранее!


Это то, что я называю паттерном "Уж Лучше Обделаться" (Pre-Poop Your Pants (PPYP) pattern) — спасти ситуацию в конструкторе хотя бы частично, если деструктор не включится. Почему такое стремное название? Мм. Я привык представлять жизненный цикл между конструктором и деструктором как процесс пищеварения. Что-то можно съесть (конструктор), обработать и сходить в туалет (деструктор). Если деструктор пропал, туалет уже не светит никогда. В зависимости от жизненной ситуации (деструктора), возможны разнообразные последствия:


  1. Типы сломаны на структурном уровне — все, конец, уже никто никуда не ходит. Где тут хирург?
  2. Типы вроде целы, но система уже ими испорчена, и в любой момент сломается, если запор затянется
  3. Типы большие и сложные — Есть шанс, что внутри ничего неприятного нет
  4. Типы-безопасные лекарства — При несоблюдении техники безопасности могут показать вам вертолет и привести к более худшим последствиям, но при определенных условиях это можно пережить и вернуться в строй.
  5. Типы-опасные лекарства — возможны передозировка и летальный исход, должны быть максимально проконтролированы.

Собственно, паттерн "Уж Лучше Обделаться" является следующим отклонением от обычного процесса пищеварения: если вы что-то неожиданное съели, будьте готовы к "преждевременным неприятностям". Обычно неприятности не настигают, и вы успеваете дойти до туалета согласно плану его посещения. Но если они все же настигли, уж лучше обделаться и засмущаться, чем упасть в бессознании и очнуться на столе у хирурга. Паттерн выделяет следующее:


  • Это неоптимальный компромисс: никому не хочется очутиться в такой ситуации, но это не наихудшее, что может произойти
  • Это незаметно, если происходит согласно плану: если вы предвидели это и специально ушли в глухой лес чтобы увидеть медведя — никто ж ничего не узнает. Ничего не было ^^
  • Это не всегда неизбежно: на практике всякая фигня плотно перемешана с нормальными обрабатываемыми данными, и, возможно, в вашем конкретном случае возможно просто не жрать всякую фигню (типа thread::scoped)
  • Это бессмысленно и бесполезно само по себе: наследник и плод уязвимого шаблона RAII.
  • Это забавно: я написал про штаны полные конфуза, а вы это прочли (нет, не забавно — прим.пер.)

Да кстати. Vec::drain_range удалось спасти!

Tags:
Hubs:
+26
Comments5

Articles

Change theme settings