История вопроса
Трейт UnwindSafe был представлен в Rust 1.9 вместе с std::panic::catch_unwind для решения проблем безопасности исключений, унаследованных из C++ и других языков с обработкой исключений. В Rust паники вызывают разворачивание стека, что гарантирует выполнение реализаций Drop, но это не гарантирует, что структуры данных останутся в согласованном состоянии, если паника прерывает логическую операцию. Трейт был разработан для отметки типов, которые могут находиться в активном состоянии через границу catch_unwind без риска неопределенного поведения или логических ошибок.
Проблема
Когда изменяемая ссылка (&mut T) пересекает границу catch_unwind, и T содержит внутреннюю изменяемость (например, RefCell или Cell), паника может оставить T в логически несогласованном состоянии. Например, если паника происходит между RefCell::borrow_mut и неявным удалением результата RefMut гард, внутренний счетчик заимствований RefCell остается увеличенным. После того, как catch_unwind захватывает панику и выполнение возобновляется, RefCell кажется изменяемо заимствованным, в то время как гард, который уменьшал бы счетчик, был удален во время разворачивания. Это "отравленное" состояние представляет собой нарушение безопасности исключений, так как последующие операции с RefCell вызовут панику или будут работать некорректно, фактически повреждая состояние программы, что безопасный код не может обнаружить или восстановить.
Решение
UnwindSafe служит консервативным маркерным трейтом: он автоматически реализуется для большинства типов, но явно исключается для &mut T и любых агрегатов, содержащих его. Запрещая &mut T реализовывать UnwindSafe, система типов предотвращает передачу изменяемых ссылок в catch_unwind, если программист явно не обернет их в AssertUnwindSafe. Эта обертка является небезопасным контрактом, в котором программист утверждает, что обернутый тип либо не имеет внутренней изменяемости, либо что он вручную проверил безопасность исключений. Этот архитектурный выбор принуждает явное согласие на потенциально опасный паттерн, обеспечивая, чтобы случайное использование изменяемого состояния с внутренней изменяемостью через границы паники было поймано на этапе компиляции.
use std::panic::{catch_unwind, AssertUnwindSafe}; use std::cell::RefCell; fn main() { let shared = RefCell::new(vec![1, 2, 3]); // Это не компилируется, потому что &mut RefCell не является UnwindSafe: // let _ = catch_unwind(|| { // let mut borrow = shared.borrow_mut(); // borrow.push(4); // panic!("interrupted"); // }); // Явное согласие с небезопасным признанием: let result = catch_unwind(AssertUnwindSafe(|| { let mut borrow = shared.borrow_mut(); borrow.push(4); panic!("interrupted"); })); // После паники shared может находиться в недопустимом состоянии заимствования, // но мы явно признали этот риск с помощью AssertUnwindSafe. println!("Recovered: {:?}", result.is_err()); }
Описание проблемы
Высокопроизводительный HTTP-сервер, построенный на hyper, должен изолировать паники в определенных обработчиках запросов, чтобы предотвратить завершение всего процесса из-за одного неправильно построенного запроса. Сервер управляет пулом соединений с использованием RefCell (для повышения производительности в однопоточном режиме), чтобы отслеживать активные соединения с базой данных на поток. Архитектура оборачивает каждый обработчик запросов в catch_unwind, чтобы захватывать паники и логировать их корректно. Во время нагрузочного тестирования сервер сталкивается с паникой в обработчике, который удерживает изменяемое заимствование пула соединений RefCell. Когда catch_unwind захватывает панику, внутренний флаг заимствования пула остается установленным на "изменяемо заимствовано", поскольку гард RefMut был удален во время разворачивания, не выполнив свою логику уменьшения. Последующие запросы в том же потоке пытаются заимствовать пул, вызывая панику во время выполнения из-за уже заимствованного состояния, что в конечном итоге приводит к краху потока и потере состояния пула.
Решение 1: Устранить catch_unwind и позволить завершение процесса
Этот подход полностью устраняет проблему безопасности исключений, позволяя процессу завершаться при любой панике, принимая, что доступность вторична по сравнению с корректностью в этом конкретном контексте.
Плюсы: Полностью устраняет опасения по поводу безопасности исключений; отсутствует риск повреждения состояния; просто реализовать.
Минусы: Неприемлемо для производственной доступности; один вредоносный или ошибочный запрос завершает всю службу; нарушает требования к надежности.
Решение 2: Заменить RefCell на Mutex и использовать отравление
Заменить пул, основанный на RefCell, на Mutex<Pool> и использовать обнаружение отравления с помощью мьютексов в Rust.
Плюсы: Mutex обнаруживает паники в удерживаемых потоках и помечает себя как отравленный, позволяя последующим попыткам заблокировать обнаруживать повреждение через PoisonError; стандартная библиотека обеспечивает встроенную безопасность.
Минусы: Mutex вводит накладные расходы на синхронизацию, ненужные для однопоточных асинхронных исполнителей; требует перенастройки пула соединений, чтобы он был Send; отравление требует явной логики обработки для повторной инициализации пула.
Решение 3: Обернуть обработчики в AssertUnwindSafe с проверкой состояния
Сохраните RefCell для производительности, но оберните обработчик в AssertUnwindSafe и реализуйте пользовательский гард удаления, который сбрасывает состояние RefCell, если происходит паника.
Плюсы: Сохраняются преимущества производительности RefCell; позволяет изолировать панику; возможно реализовать логику восстановления.
Минусы: Требуется unsafe код для взаимодействия с AssertUnwindSafe; крайне сложно гарантировать безопасность исключений для всех путей кода; легко пропустить крайние случаи, когда состояние остается поврежденным.
Выбранное решение и обоснование
Команда выбрала Решение 2 (Mutex с отравлением) для общего пула соединений, в то время как Решение 3 использовалось только для временных буферов, специфичных для запросов, которые можно легко повторно инициализировать. Явный механизм отравления Mutex предоставляет надежный, стандартизированный способ обнаружения повреждения без необходимости небезопасного аудита каждой возможной точки паники. Небольшие накладные расходы на производительность были приняты в обмен на гарантию безопасности.
Результат
Сервер успешно изолирует паники в обработчиках запросов, не рискуя повреждением состояния. Когда обработчик вызывает панику, удерживая блокировку пула, мьютекс помечается как отравленный, и сервер обнаруживает это при следующем доступе, отбрасывая поврежденный локальный пул и создавая новый. Это гарантирует, что не происходит неопределенного поведения и что служба остается доступной даже при враждебных входных данных.
Почему catch_unwind требует UnwindSafe, даже если Rust выполняет деструкторы во время паник?
Многие кандидаты предполагают, что, поскольку реализации Drop выполняются во время разворачивания, безопасность исключений гарантирована. Однако UnwindSafe решает логическое состояние данных, а не только утечку ресурсов. Паника может прервать последовательность операций (например, обновляя поле длины перед соответствующими данными), оставляя объект в временно несогласованном состоянии. Деструктор выполняется в этом разрушенном состоянии, потенциально распространяя повреждение. UnwindSafe гарантирует, что либо тип не может быть сломан прерыванием (неизменяемые данные), либо программист признает риск. Он предотвращает возобновление выполнения с объектами, которые нарушают свои собственные инварианты.
В чем разница между UnwindSafe и автотрейтами Send/Sync?
Хотя Send и Sync также являются автотрейтами, они используют положительное рассуждение: &T является Send, если T является Sync, и &mut T является Send, если T является Send. UnwindSafe использует отрицательное рассуждение: &mut T никогда не является UnwindSafe, независимо от T. Кроме того, AssertUnwindSafe действует как выходное устройство на уровне значений (похожее на unsafe impl, но для конкретных значений), в то время как нарушения Send/Sync обычно требуют unsafe impl на уровне типов. UnwindSafe также парная с RefUnwindSafe для разделяемых ссылок, создавая двойную систему трейтов, похожую на Send/Sync, но отличную от них.
Как флаг заимствования RefCell создает небезопасность с паниками и почему у Mutex нет таких же проблем с UnwindSafe?
RefCell полагается на флаг заимствования во время выполнения. Если паника происходит между borrow_mut() и Drop гарда, флаг остается установленным, но гард исчезает. Когда выполнение возобновляется, RefCell кажется заимствованным, но фактически заимствования нет. Это логическая ошибка, которая вызывает паники в будущем. Mutex избегает этого, реализуя отравление: если паника происходит, когда блокировка удерживается, Mutex помечает себя как отравленный. Последующие вызовы lock() возвращают ошибку, указывающую на то, что предыдущий поток вызвал панику. Это делает повреждение явным и обнаруживаемым, в то время как повреждение RefCell является тихим. Поэтому MutexGuard фактически является !UnwindSafe, но механизм отравления предоставляет безопасный путь восстановления, который отсутствует у RefCell.