[Перевод] 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(data: T) {
use std::rc::Rc;
use std::cell::RefCell;
struct Leak {
cycle: RefCell
Похоже на лютую ересь. Вкратце, можно создавать циклически зависимые считаемые ссылки с помощью Rc
и RefCell
. Итогом будет тот факт, что деструктор типа, положенного внутрь Leak
никогда не будет вызван, хотя ссылок на 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:
mem::forget
должен быть safe- Починить scoped потоки
- Утвердить гарантии деструкторов и утечки
Обратите внимание: Rust никогда не гарантировал отсутствие утечек в принципе. Утечки, равно как и гонки данных — не имеющий четких границ концепт, потому фактически непредотвратимый. Если сложить что-то в HashMap
и никогда его не спросить — тоже в своем роде утечка. Аллоцировать что-то на стеке, после чего запустить бесконечеый цикл — туда же. Любой случай складывания данных в кучу с последующим забыванием об их существовании это самая что ни на есть утечка, которая почему-то именно в данном виде вызывает у людей панический ужас. Ошибкой можно считать данные, у которых забыли вызвать деструктор во время обычного «безопасного» выполнения, потому что с точки зрения статического анализа этих данных не существует.
Что делать?
Таким образом, я был удручен и растерян. Работа с коллекциями в моем понимании всегда подразумевала гарантированное выполнение деструкторов. Были прекрасные возможности как собрать дескриптор для сколь угодно сложного и зависимого типа, действующий прозрачно, но прячущий все внутренности, так и разобрать его корректно при удалении. При работе с временами жизни Rust это гарантировало, однако, что неопределенное состояние данных типа вне этого дескриптора статически невыделяемо! То есть Rust еще более беспечен, чем C!
Каноничный этому пример — Vec::drain_range
:
// Нам правда нужны все эти поля? Хз?
// Пусть пока полежат для примера.
struct DrainRange<'a, T> {
vec: &'a mut Vec,
num_to_drain: usize,
start_pos: usize,
left: *mut T,
right: *mut T,
}
impl Vec {
// Производит итератор, вынимаюший элементы из `self[a..b]`
// Поправка: b не включен в множество, смотри нотацию Rust range
fn drain_range(&mut self, a: usize, b: usize) -> DrainRange {
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 {
if self.left == self.right {
None
} else {
unsafe {
let result = Some(ptr::read(self.left));
// Игнорируем size_of == 0 для простоты
self.left = self.left.offset(1);
result
}
}
}
}
impl<'a, T> DoubleEndedIterator for DrainRange<'a, T> {
fn next_back(&mut self) -> Option {
if self.left == self.right {
None
} else {
unsafe {
// Игнорируем size_of == 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`
}
Не круто. Я порылся в коде коллекций стандартной библиотеки на предмет чего-то, что надеется на деструктор для обеспечения безопасности, и, к счастью, ничего не нашел (что, безусловно, совсем не значит, что его там нет). Если конкретней, искал я нечто, укладывающееся в следующую иерархию гадостей текущего деструктора:
- Деструктора нет — нет утечки: примитивы, указатели
- Утечка системных ресурсов — не особо и проблема: большинство коллекций и умных указателей вокруг примитивов, у которых при утечке просто расходуется куча
- Обычная утечка деструктора — неприятно, но не смертельно: коллекции и умные указатели вокруг структур
- Мир в неопределенном, но безопасном состоянии — уверенный «пыщь» в ногу:
RingBuf::drain
должен чистить коллекцию после того, как его ссылка его drain-итератора заканчивает жить, но это в данный момент гарантировано только деструктором. Сама коллекция при этом консистентна. - Мир сломан — неприемлемо: вышеупомянутый
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) — спасти ситуацию в конструкторе хотя бы частично, если деструктор не включится. Почему такое стремное название? Мм. Я привык представлять жизненный цикл между конструктором и деструктором как процесс пищеварения. Что-то можно съесть (конструктор), обработать и сходить в туалет (деструктор). Если деструктор пропал, туалет уже не светит никогда. В зависимости от жизненной ситуации (деструктора), возможны разнообразные последствия:
- Типы сломаны на структурном уровне — все, конец, уже никто никуда не ходит. Где тут хирург?
- Типы вроде целы, но система уже ими испорчена, и в любой момент сломается, если запор затянется
- Типы большие и сложные — Есть шанс, что внутри ничего неприятного нет
- Типы-безопасные лекарства — При несоблюдении техники безопасности могут показать вам вертолет и привести к более худшим последствиям, но при определенных условиях это можно пережить и вернуться в строй.
- Типы-опасные лекарства — возможны передозировка и летальный исход, должны быть максимально проконтролированы.
Собственно, паттерн «Уж Лучше Обделаться» является следующим отклонением от обычного процесса пищеварения: если вы что-то неожиданное съели, будьте готовы к «преждевременным неприятностям». Обычно неприятности не настигают, и вы успеваете дойти до туалета согласно плану его посещения. Но если они все же настигли, уж лучше обделаться и засмущаться, чем упасть в бессознании и очнуться на столе у хирурга. Паттерн выделяет следующее:
- Это неоптимальный компромисс: никому не хочется очутиться в такой ситуации, но это не наихудшее, что может произойти
- Это незаметно, если происходит согласно плану: если вы предвидели это и специально ушли в глухой лес чтобы увидеть медведя — никто ж ничего не узнает. Ничего не было ^^
- Это не всегда неизбежно: на практике всякая фигня плотно перемешана с нормальными обрабатываемыми данными, и, возможно, в вашем конкретном случае возможно просто не жрать всякую фигню (типа
thread::scoped
) - Это бессмысленно и бесполезно само по себе: наследник и плод уязвимого шаблона RAII.
- Это забавно: я написал про штаны полные конфуза, а вы это прочли (нет, не забавно — прим.пер.)
Да кстати. Vec::drain_range
удалось спасти!