Хабрахабр

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

Прим. пер.: Кто-то должен был сделать перевод этой статьи, несмотря на то, что она достаточно стара (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 удалось спасти!

Теги
Показать больше

Похожие статьи

Кнопка «Наверх»
Закрыть