[Перевод] Какую модель памяти следует использовать в языке Rust?
В этой статье рассматривается несколько альтернативных моделей памяти для языка Rust. Надеюсь, эта дискуссия будет ценна всему сообществу Rust — но, в конце концов, это их язык, поэтому и выбор модели памяти тоже за ними.
Эта дискуссия ведется с позиций принятой в Rust безбоязненной конкурентности. Затравкой для нее послужили различные обсуждения, которые я наблюдал и в которых сам участвовал, работая над этой серией статей. Разумеется, мнения у разных членов сообщества тоже разнятся, люди могут обоснованно отстаивать разные варианты решений. Те, кто меня знают, понимают, что эти точки зрения серьезно расходятся с моей. Однако, моя точка зрения продиктована тем, что я давно работаю в условиях максимально возможной производительности, масштабируемости, молниеносного отклика, энергоэффективности, устойчивости и многого другого. В таких условиях определенная перестраховка — выбор не только мудрый, но и жизненно необходимый. В авиации есть примета, что есть пилоты отважные, а есть старые, но отважные пилоты до старости не доживают.
Тем не менее, я рассчитываю, что мой более чем тридцатилетний опыт работы с конкурентностью и моя работа над моделью памяти в C/C++ (безотносительно memory_order_consume
), а также моя роль ведущего специалиста по поддержке модели памяти ядра (LKMM) послужат хорошей отправной точкой, чтобы высказаться о более прозаических задачах, решение которых, я уверен, стремятся поддержать в сообществе Rust.
❯ Но разве в Rust не сформировалась своя модель памяти?
В каком-то роде?
В академических кругах звучали высказывались предположения, что модель памяти Rust должна быть основана на аналогичной модели C/C++ — см., например, здесь. Причем, в Растономиконе это подтверждается, хотя, автора вышеупомянутого текста это, по-видимому, не особенно радует:
Rust весьма топорно берет и заимствует из C++20 модель памяти, предназначенную для работы с атомиками. Дело не в том, что такая модель совершенна или легко понятна, нет. Как раз наоборот, эта модель весьма сложна, и в ней известно несколько изъянов. Поэтому мы имеем дело просто с прагматичным признанием факта, что нормально моделировать атомики никто не умеет. Как минимум, в данном случае можно опереться на имеющийся инструментарий и исследования, проведенные при разработке модели памяти C/C++. (эту модель часто называют «C/C++11» или просто «C11». C просто копирует модель памяти C++, а C++11 — первая версия данной модели, которая, однако, претерпела с тех пор несколько багфиксов.)Модель памяти C++ в основе своей заключается в следующем. Мы стремимся навести мосты между той семантикой, которой хотим, теми оптимизациями, которых хочет компилятор, и рассогласованным хаосом, в который скатывается аппаратное обеспечение. Мы хотели бы просто писать такие программы, которые делают ровно то, что им приказано — причем, делают быстро. Как бы хорошо было тогда.
Попробую заявить, что оптимизации, проводимые компилятором, вызывают гораздо более серьезный рассогласованный хаос, чем в принципе может учинить аппаратная часть. Но этот довод приводится уже много лет подряд, и я сомневаюсь, что сейчас мы на нем сойдемся.
Если абстрагироваться от этого аргумента, то можно заключить, что Растономикон определённо призывает поискать альтернативы той модели памяти, что существует в C/C++. Посмотрим, что с этим можно поделать.
❯ С чего начать?
Давайте для начала избавимся от некоторых кандидатов в такие модели. Предполагается, что Rust нацелен на портируемость, а значит, можно смело исключить любые модели памяти, зависящие от аппаратного обеспечения. Rust все сильнее вовлекается в работу с глубинными уровнями встраиваемых систем — значит, исключаются модели памяти, основанные на динамических языках, в частности, на Java и Javascript. Растет количество моделей, производных от основной модели памяти C/C++, но и их можно представить в рамках этой модели, поэтому такие варианты по отдельности мы рассматривать тоже не будем.
В итоге у нас остается вышеупомянутая модель памяти C/C++, а также, конечно же, LKMM. Вторая — плод амбиций по поводу разработки ядра Linux, имеющихся в сообществе Rust. Поскольку меня особенно интересует ядро Linux, далее мы рассмотрим LKMM, затем перейдем к модели памяти, принятой в C/C++, а завершим пост особой рекомендацией.
❯ Модель памяти, действующая в ядре Linux (LKMM)
В этом разделе будут рассмотрены различные аспекты LKMM, сначала наиболее пугающие, а далее вполне повседневные.
На самом деле управляющие зависимости — не самая безопасная часть LKMM. Будучи ведущим специалистом по поддержке LKMM, я добиваюсь, чтобы люди пользовались управляющими зависимостями — чтобы не просто их припугнуть, а буквально ввергнуть в ужас. Управляющие зависимости хрупкие, поэтому компилятору ничего не стоит их нарушить, ведь он совершенно не учитывает таких зависимостей. Следовательно, до поры до времени управляющие зависимости должны быть исключены из любой модели памяти Rust. Возможно, придет день, и мы научимся внятно сообщать компилятору назначение управляющих зависимостей, но пока этот день не настал.
Адресные зависимости и зависимости данных, носителями которых являются указатели, также сопряжены с некоторым риском, но они оставляют компилятору (не учитывающему зависимостей) гораздо меньше пространства, чтобы что-нибудь поломать. Поэтому работать с ними совсем не так страшно, как с управляющими зависимостями, но определенный страх — сохраняется. Тем не менее, адресные зависимости и зависимости данных чаще всего используются в сочетании с механизмом RCU (чтение-копирование-удаление), а нам пока не известен надежный способ выражать все варианты использования RCU в рамках принятой в Rust модели владения. Поэтому из модели памяти Rust придется исключить и адресные зависимости, и зависимости данных, пока не придет время, и мы не научимся обрабатывать важные варианты использования RCU, либо не найдется какой-нибудь четкий и насущный вариант, в котором явно потребуется прибегать к адресным зависимостям и зависимостям данных. Со временем будет все более целесообразно допускать управляющие зависимости и зависимости данных в режиме Rust unsafe, если в Rust активизируется использование RCU. При этом необходимо держать в уме, что такие вещи как утилизация на основе эпох (EBR) являются конкретными классами реализаций механизма RCU.
На первый взгляд кажется совершенно оправданным поддержать использование READ_ONCE()
и WRITE_ONCE()
в создаваемом на будущее Rust-кодом для ядра Linux, но этот пост посвящен языку Rust в целом, а не только Rust в ядре Linux. Причем, только зависимости (управляющие, адресные и касающиеся данных) препятствуют использованию READ_ONCE()
и WRITE_ONCE()
в ядре Linux — это факт. Они провоцируют поведение OOTA (буквально «появление значений из воздуха»). Кроме того, эти операции (наряду с принятыми в ядре Linux неупорядоченными атомарными операциями «чтение-изменение-запись» (RMW)) реализованы так, чтобы прямо не позволить компилятору предпринимать оптимизации с выносом инвариантов, поскольку в противном случае порядок следования таких операций может измениться. Более того, во всех известных мне базовых аппаратных моделях памяти сохраняется порядок зависимостей. Следовательно, можно ожидать, что эти неупорядоченные операции вполне могут войти в состав модели памяти Rust.
Одно важное достоинство модели памяти — ее инструментарий для анализа конкурентных фрагментов кода, и, если этот инструментарий призван исключить OOTA-подобное поведение, то, к сожалению, абсолютно необходимо, чтобы данный инструментарий «понимал» зависимости. Кроме того, выше мы уже исключили такие зависимости из модели памяти Rust.
Следовательно, в модели памяти Rust нужно ограничиться поддержкой лишь тех атомарных операций ядра Linux, которые обеспечивают упорядочивание. Это будут строгие атомарные операции «чтение-изменение-запись» (RMW) с возвратом значений, и вместе с ними будут применяться варианты RMW _acquire()
и _release()
. Также может быть целесообразно разрешить комбинации неупорядоченных RMW-операций в сочетании с барьерными инструкциями: например, atomic_inc()
за которой идет smp_mb__after_atomic()
. Но было бы еще рациональнее обернуть такую комбинацию в единый примитив, доступный из Rust. Сделав все это, мы получим примитив Rust, который уже не будет неупорядоченным и, следовательно, может быть включен в модель памяти Rust как некая упорядочиваемая сущность. В качестве альтернативы можно было бы переадресовать неупорядоченные атомики в режим Rust unsafe
.
Сложно представить в Rust полезную модель памяти, в которой были бы исключены блокировки.
Следовательно, исходя из LKMM, приходим к модели, поддерживающей упорядоченные атомарные операции и блокировки и, возможно, также включающую неупорядоченные атомики в режиме unsafe.
❯ Модель памяти C/C++
В этом разделе, напротив, будем исходить из модели памяти, принятой в C/C++.
Поскольку обращения к memory_order_relaxed
могут приводить к возникновению результатов «из воздуха», такие обращения, по-видимому, не сочетаются с безбоязненной конкурентностью, завяленной в Rust в качестве цели. Фактически, на практике такие результаты не могут возникать ни в одной из известных мне реализаций, но безбоязненная конкурентность требует точных инструментов, а такие инструменты должны исключать даже теоретические шансы на возникновение результатов из воздуха. Еще один разумный подход допускал бы использование memory_order_relaxed
только в небезопасном (unsafe
) коде Rust.
Операция memory_order_consume
полезна, прежде всего, в сочетании с RCU. Кроме того, во всех известных мне реализациях memory_order_consume
просто доводится до memory_order_acquire
. Следовательно, кажется излишним включать в модель памяти Rust memory_order_consume
. Как и ранее, целесообразной альтернативой кажется допуск ее использования только в небезопасном (unsafe
) коде Rust.
Напротив, memory_order_acquire
, memory_order_release
и memory_order_acq_rel
легко поддаются анализу и десятилетиями активно используются на практике. Хотя, излюбленный вариант упорядочивания памяти memory_order_seq_cst
(последовательно согласованный) бывает сложно анализировать, в некоторых практических случаях обеспечиваемый им строгий порядок абсолютно необходим. В том числе, это касается удручающе большой доли конкурентных алгоритмов, опубликованных как академическими, так и отраслевыми исследователями. Кроме того, десятки лет насчитывают традиция доказательств и инструментарий для обработки последовательной согласованности, невзирая на все сложности. Следовательно, использование всех этих четырех вариантов упорядочивания должно допускаться из безопасного кода Rust.
Беря за основу модель памяти C/C++, как и в случае с LKMM, сложно представить полезную модель памяти Rust, которая исключала бы блокировки.
Таким образом, исходя из модели памяти на базе C/C++, также приходим к модели, поддерживающей как упорядоченные атомарные операции, так и блокировки, возможно, плюс неупорядоченные атомарные операции в режиме unsafe
.
❯ Рекомендация
Имеем: начав с LKMM, мы пришли примерно к тому же результату, что и начав с модели памяти, основанной на C/C++. Следовательно, направление в целом верное. Почему «примерно»? Потому что есть тонкие отличия, которые хорошо просматриваются при сравнении стандартов C и C++ с LKMM.
Вот как можно было бы отреагировать на эту ситуацию: подробно проработать эти модели памяти, один тупиковый случай за другим — и в каждом из этих случаев, отныне и навеки, выбирать альтернативу на Rust. Возможно, лучше подойдет другой вариант: выбрать одну модель и приспосабливать ее по мере необходимости в каждой реальной ситуации, как только таковая возникнет. В настоящее время гораздо больше живых проектов, в которых применяется модель памяти C/C++, чем проектов по модели LKMM. Поэтому, несмотря на мое высокое положение в цеху по поддержке LKMM, я, скрепя сердце, рекомендую поступать следующим образом, опираясь на модель памяти C/C++:
- Блокировки допускаются как в безопасном, так и в небезопасном режиме.
- Атомарные операции, в которых используются
memory_order_acquire
,memory_order_release
,memory_order_acq_rel
, иmemory_order_seq_cst
могут использоваться как в безопасном, так и в небезопасном режиме. - Атомарные операции, использующие
memory_order_relaxed
иmemory_order_consume
, могут использоваться только в небезопасном режиме. - Все атомарные операции в коде Rust должны помечаться; то есть, в Rust следует избегать принятой в C/C++ практики интерпретировать непомеченное упоминание атомарной переменной как обращение
memory_order_seq_cst
к этой переменной. Требуя таких пометок, мы получаем возможность обращаться к конкурентно затрагиваемым разделяемым переменным, которые сразу идентифицируются. Кроме того, выбор любого значенияmemory_order
по умолчанию становится гораздо менее острым.
Все это обеспечивает явную поддержку операций и вариантов упорядочивания, которые давно и активно используются, и для которых давно существуют аналитические инструменты. Кроме того, такой подход обеспечивает условную поддержку тех операций и вариантов упорядочивания, которые также давно и активно используются. Но эта тема заслуживает точного и полного анализа и выходит за рамки данной статьи.
❯ Другие варианты
Можно утверждать, что у memory_order_relaxed
также есть много простых вариантов использования — за одним исключением: конструирование распределенных счетчиков. Однако, учитывая, как сложно проверить весь спектр таких вариантов использования, режим unsafe
в настоящее время представляется самым беспроигрышным. Если принесут плоды какие-то из работ, ведущихся сейчас, призванных обеспечить более прямолинейную проверку обращений к memory_order_relaxed
, то, возможно, memory_order_relaxed
будут допущены в Rust и в безопасном режиме.
Наконец, есть и такая точка зрения, что memory_order_consume
следует полностью исключить, а не просто передислоцировать в небезопасный режим. Однако в Rust уже есть такие библиотеки, во внутренней организации которых предусмотрена работа с RCU. Кроме того, возможность помечать обход указателей RCU должна пригодиться, если в Rust когда-нибудь станут полностью поддерживаться адресные зависимости и зависимости данных.
В противовес вышесказанному, четыре других члена memory_order enum
активно используются, и их анализ — в порядке вещей. Поэтому разумно разрешить использование memory_order_acquire
, memory_order_release
, memory_order_acq_rel
и memory_order_seq_cst
в безопасном коде Rust.
❯ К чему это приведет в ядре Linux?
Оказывается, изменится не так много.
Пожалуйста, не забывайте, что в настоящее время ядро Linux взаимодействует со всем спектром моделей памяти, используемых каким угодно кодом пользовательского пространства, написанным на каких угодно языках, и этот код может при работе использовать широкий спектр моделей памяти с аппаратной основой. Любые необходимые корректировки в настоящее время обрабатываются в коде, специфичном для конкретных архитектур — например, в коде системных вызовов или в коде, обрабатывающем вход в исключения и выход из них. Строгое упорядочивание также предоставляется на более глубоких уровнях ядра, за одним исключением: код переключения контекста, который предусматривает полное упорядочивание, когда процессы переходят с одного ядра ЦП на другое.
Оказывается, что для кода Rust в Linux предусмотрены обертки вокруг любого кода на C, который он мог бы вызывать, и именно посредством этих оберток Rust пользуется теми частями ядра Linux, которые написаны не на Rust. Следовательно, в этих обертках будут содержаться вызовы к любым примитивам упорядочивания памяти, которые могут потребоваться в любой конкретный момент на протяжении всей эволюции моделей памяти в Rust.
Но так ли необходимо, чтобы собственная модель памяти определялась в Rust именно сейчас?
Разумеется, это не мне решать.
Но этот ответ несколько лукав, потому что чем дольше сообщество Rust выжидает, тем больше нынешний «импровизированный» выбор (вся модель памяти C/C++ целиком) превращается в долгосрочный вариант. Перенося на Rust явно проблемные моменты модели памяти C/C++, мы понимаем, что именно эти моменты, скорее всего, будут меняться.
Поэтому я рекомендую взять на вооружение в безопасном режиме те элементы модели памяти C/C++, которые явно не вызывают проблем, а остальные оставить для небезопасного режима. Такой подход позволил бы писать на Rust прозаические конкурентные алгоритмы и при этом не сомневаться, что получившийся у них код сохранит работоспособность и в будущем. Тем же, кто вынужден действовать в более опасных условиях, такой подход позволит заключить не самый надежный код в блоки unsafe
, чтобы этот код воспринимался с должным скептицизмом и вниманием.
Но, опять же, это решение принимать не мне, а сообществу Rust.