У меня есть достаточно сложное приложение, которое разделено на небольшое количество достаточно сложных, сильно взаимозависимых структур. Сейчас у нас просто Arc
и Weak
разбросаны довольно хаотично. Я бы хотел удалить их и предположить, что есть лучший способ выполнить то, что я пытаюсь сделать, но пока не нашел хорошего решения.
Все рассматриваемые подразделы имеют возможность изменения внутренней части, поэтому не нужно беспокоиться о mut
здесь. Однако каждая из частей взаимодействует друг с другом через центральный App
, экземпляр которого создается при запуске и уничтожается при завершении работы. Единственное исключение из этого правила — интеграционные тесты, где одновременно может существовать несколько App
и быть уничтожены до завершения среды выполнения.
Изначально это был просто гигантский статический/глобальный синглтон, который упрощал задачу, но не подходил для тестов.
Текущая структура выглядит (примерно) следующим образом:
struct App {
things: ThingManager,
stuff: StuffManager,
actions: ActionManager,
}
impl App {
fn new() -> Arc<App> {
// App instantiates each of the Thing/Stuff/Actions, and gives them a weak reference to itself
let app = Arc::new(App { ThingManager::new(), StuffManager::new, ActionManager::new() })
app.things.set_app(&app)
app.stuff.set_app(&app)
app.actions.set_app(&app)
}
}
// Each of the Manager's look like:
struct ThingManager {
app: OnceCell<Weak<App>>,
// Other fields
}
impl ThingManager {
fn set_app(&self, app: &Arc<App>) {
self.app.set(Arc::downgrade(app))
}
fn get_app(&self) -> Arc<App> {
self.app.get().upgrade()
}
// And unfortunately touch other parts of the app
fn complex_logic(&self) {
let app = self.get_app();
let some_stuff = app.stuff.find_specific_stuff();
let action = app.action.do_something(some_stuff);
// etc
}
}
Я рассматривал возможность использования таких вещей, как распределители арен (bumpalo, Generational-arena), но каждый раз, когда я начинаю реализацию, я в конечном итоге загрязняю свои структуры сроками жизни <'app>
, и они очень быстро распространяются по всему коду (многие функции занимают App
, и необходимо указать время жизни и т. д.).
Я знаю, что мой ThingManager будет жить ровно столько же, сколько и мое приложение, и когда приложение будет удалено, я также хотел бы удалить менеджеры. Как мне это четко представить? Я не в восторге от использования небезопасных блоков, но если бы они были ограничены по объему (ручная установка/удаление) и были «безопасными», при условии, что мои ограничения сохраняются (менеджеры не переживут приложение и т. д.), я бы не стал быть слишком против...
При профилировании огромное количество времени тратится на атомарные операции, начиная с (я полагаю) обновления/удаления Arc
. Учитывая степень взаимозависимости между частями, вызов чего-либо на ThingManager
делает get_app()
с ним что-то маленькое, вызывает StuffManager
, который делает свое собственное get_app()
, и связывает большое количество раз (а затем отбрасывает большое количество Arc<App>
в конце цепь). Теоретически у нас может быть просто &App
или (что-то вроде) один Arc<App>
в каждом Manager
, чтобы нам не приходилось обновлять/удалять каждый вызов функции...
Ах я вижу. Я чувствую, что ту архитектуру, которую вы здесь пытаетесь создать, действительно трудно реализовать на Rust. Я не уверен, что ваш способ разделения является самым разумным, честно говоря, для меня слова «подразделенный» и «сильно взаимозависимый» противоречат друг другу. Если вы что-то разделите, оно не должно быть сильно взаимозависимым, потому что в конечном итоге вы ничего не получите. Похоже, вы структурировали код, но если он действительно настолько взаимозависим, вы не можете обновить одну часть, не затрагивая другую, поэтому вы также можете объединить их.
Я конечно понимаю, что ваш пример сильно минимизирован, поэтому не уверен, насколько ценен этот совет для вашего реального проекта.
Другое решение, также часто используемое в C++, — это так называемое внедрение зависимостей. Хитрость в том, что вы не храните зависимые объекты в классе, а передаете их в функции при вызове функций с использованием интерфейсов. Это не только решает эту проблему, но и делает интерфейсы между вашими частями макетируемыми, поэтому их можно тестировать самостоятельно. Это очень важно; модуль должен быть тестируемым сам по себе, иначе нет смысла делать его модулем. Недостатком является то, что это немного усложняет API ваших менеджеров.
В конце концов, самый важный вопрос, который я бы себе задал: есть ли способ реструктурировать код так, чтобы зависимости вашей структуры стали деревом? Любой вид циклической цепочки зависимостей проблематичен и в целом его сложно поддерживать.
Да, полностью согласен с противоречием по поводу разделенности/взаимозависимости, это определенно корень проблемы. Раньше я использовал внедрение зависимостей, но не был знаком с этим в Rust. Но вы как бы заметили интересную деталь: мой App
, по сути, просто действует как инструмент внедрения зависимостей (хотя и уродливый)...
Некоторая путаница в посте может быть связана с вызовом вещей Manager
, они, вероятно, ближе к обычной зависимости. Представьте, что один из них — это DbConnection, другой — Cache, один — WorkerThread и т. д. WorkerThread может нуждаться в DbConnection, DbConnection может кэшировать данные, WorkerThread может также просматривать (тот же) кеш и т. д. Мой непосредственный предполагаю (без данных...), что инструмент глубокой инъекции будет использовать ту же самую Arc
структуру под капотом, но я обязательно осмотрюсь и немного протестирую.
Если вы используете многопоточность, вам действительно нужна эта сложность, потому что потоки могут жить дольше, чем main
, а ржавчина предотвращает что-либо подобное, чтобы избежать неопределенного поведения (Rust гарантирует отсутствие неопределенного поведения как одну из своих основных целей). Если у вас есть только один поток, который выполняет upgrade
, вместо этого вы можете использовать Rc/Weak
, что требует гораздо меньше накладных расходов.
Да, некоторая сложность определенно связана с многопоточностью (и тем, как изначально туда попали Arc
). И полностью понять природу потоков/данных, живущих за пределами основного. Однако в этом конкретном случае я не уверен, как объяснить ржавчине: «Я понимаю последствия, я позабочусь о том, чтобы это просуществовало столько, сколько вам нужно», и принять на себя связанный с этим риск UB. Может быть, я просто смирюсь с этим и разбросаю вокруг несколько небезопасных/необработанных указателей.
Еще одно замечание по поводу упомянутого вами разделения/взаимозависимости: в конце концов, все эти менеджеры в приложении на самом деле являются частью приложения, все они представляют собой один и тот же объект. Разделение проводилось только в целях чистоты кода (общий/похожий код, .get() в базе данных отличался от .get() в кэше, но с тем же именем функции). Не знаю, как это согласовать здесь, но по сути я хочу, чтобы все эти части были небольшой частью более крупного объекта (и, следовательно, времени жизни), я просто не знаю, как это сделать в ржавчине чисто...
(Разделение из-за длины) Обычно я бы сказал: «О, вместо подструктур каждая часть может быть просто признаком приложения (DbTrait, CacheTrait и т. д.), но в CacheTrait есть хорошая инкапсуляция, есть свои собственные поля (карты , списки и т. д.) вместо того, чтобы размещать их все на верхнем уровне в App. Возможно, у App есть Cache (тупая структура) и CacheTrait, который обращается только к этой одной структуре?
«и принять на себя связанный с этим риск UB» — нет. Это сведет на нет всю цель использования Rust. Даже unsafe
не предназначен для сценариев «просто сделай это», а вместо этого означает «Я, программист, гарантирую, что соблюдаю правила надежности вручную». «Риск» означает лишь то, что Rust как язык программирования может оказаться неподходящим инструментом для этой работы :/
Тем не менее, не всем многопоточным программам нужен Arc
. std::thread::scope отлично работает с обычными ссылками без каких-либо накладных расходов. std::thread::scope
сообщает компилятору Rust, что данный поток завершится до того, как охватит его область действия (например, main
), ослабляя часть защитного поведения компилятора. Боюсь, вам просто нужно соответствующим образом реструктурировать свою программу. Хотя я все еще считаю, что наличие двунаправленных ссылок — это красный флаг, и должны быть лучшие способы структурировать ваш код.
Полностью согласен, что нельзя просто разбрасывать небезопасные вещи. Я думаю, что 99,9% кодовой базы — это правильный выбор (из соображений безопасности), однако возникают проблемы, по существу, в верхней/нижней части приложения (запуск/снятие). Вместо «взять на себя риск UB» мне, вероятно, следовало сказать: «Я позабочусь о том, чтобы эти две вещи прожили достаточно долго друг для друга».
К сожалению, я не знаю, как вам помочь, не зная больше о вашем проекте, который выходит за рамки StackOverflow. Может быть, спросите на форуме Rust или в субреддите Rust, чтобы получить дополнительную информацию по конкретному проекту?
Я ценю всю помощь и позволяю мне поделиться с вами некоторыми идеями. Я поиграю с несколькими идеями и посмотрю, что у меня получится. Спасибо!
Если несколько App
используются только в тестах, возможно, утечка допустима? В этом случае вы можете просто поставить Arc<App>
вместо Weak<App>
.
Я не знаю вашего варианта использования в деталях, но похоже, что следующий дизайн может быть разумным:
struct App {
things: ThingManagerInner,
stuff: StuffManagerInner,
actions: ActionManagerInner,
}
impl App {
fn new() -> App {
App {
things: ...,
stuff: ...,
actions: ...,
}
}
fn things(&self) -> ThingManager<'_> {
ThingManager { app: self }
}
}
// Each of the Manager's look like:
struct ThingManagerInner {
// Other fields
other_field: usize,
}
struct ThingManager<'a> {
app: &'a App,
// optionally add other references here as necessary if they are conditional based on app, deeply nested, inside a Vec...
}
impl Deref for ThingManager<'_> {
type Target = ThingManagerInner;
fn deref(&self) -> Self::Target {
&self.app.things
}
}
impl ThingManager<'_> {
// And unfortunately touch other parts of the app
fn complex_logic(&self) {
let some_stuff = self.app.stuff().find_specific_stuff(self.other_field);
let action = self.app.action().do_something(some_stuff);
// etc
}
}
В Rust вы обычно хотите ориентировать построение структуры на данные, а не на поведение. Затем можно построить слои поведения сверху.
Как правило, вам не нужен unsafe
ни для чего (кроме FFI), и, как правило, это скорее пустяк, чем что-либо еще, учитывая дополнительные гарантии, которые приходится предоставлять при написании небезопасного кода на Rust по сравнению с C (алиасинг из-за оптимизация noalias, ковариация/инвариантность, контрвариантность...) и тот факт, что всегда есть лучший дизайн, который сохраняет максимальную производительность, оставаясь при этом полной безопасностью.
Я думаю, что это примерно то, что я искал (не имея возможности это объяснить). По сути, это просто очень тонкая оболочка вокруг базовой структуры, которая при необходимости подключается к приложению. Спасибо!
Что именно не так с кодом, который вы показываете? Хотя на самом деле это не Rust-y, мне кажется, что он работает нормально и работает.