Прокачайся в код-ревью: для первых 50 участников — курс бесплатный

время чтения: 6 мин

Общее изменяемое состояние в Rust: действительно ли это корень всех зол?

Автор статьи — Systems Engineer в EPAM Ирине Кокилашвили.

Введение

На написание этой статьи меня вдохновил видеоролик Тома Кайтчука на YouTube — Why Rust is a significant development in programming languages. Статья предназначена для всех, кто интересуется Rust и разработкой ПО. Я написала ее в процессе изучения общего изменяемого состояния (англ. shared mutable state) в Rust и хочу поделиться своими знаниями.

Выражаю искреннюю благодарность своим коллегам Андрею, Илье и Антону, которые уделили время рецензированию и дали ценный фидбек на эту статью.

Сборка мусора и управление памятью

Часто после успешного выполнения компьютерной задачи происходит выход на новый уровень абстракции, что позволяет разрабатывать последующие программы без полного погружения в исходную проблему. Если задача остается невыполненной, она может всплыть в измененном виде на следующем уровне абстракции.

Рассмотрим пример управления памятью и сборки мусора. Сборка мусора — это процесс, в ходе которого среда выполнения автоматически определяет и освобождает память, больше не нужную программе. Это позволяет сосредоточиться на логике приложения, не беспокоясь об управлении памятью.

Хотя внедрение сборщика мусора позволило частично решить задачи, связанные с памятью, возникли другие, — например, потенциальные проблемы с производительностью. Стоит также упомянуть о сложностях при комбинировании различных режимов работы сборщика мусора. К примеру, при интеграции библиотек Java и Go и согласовании их работы в приложении на Python «безудержное веселье» вам гарантировано.

Общее изменяемое состояние

Одна из фундаментальных проблем в программировании — это понимание того, как управлять общим изменяемым состоянием, то есть данными, которые могут изменяться и используются в нескольких местах.

Deep JavaScript определяет общее изменяемое состояние как «состояние, при котором два или более объекта могут изменять одни и те же данные».

В разных языках программирования есть разные подходы к решению этой проблемы.

Функциональные языки программирования

В функциональных языках программирования попытались решить эту проблему путем запрета на изменяемость, когда структуры и переменные данных не могут быть изменены после их создания. Это позволяет избежать проблем, связанных с управлением общим изменяемым состоянием, поскольку разные части программы не могут одновременно изменять одни и те же данные.

Функциональные языки программирования также помогают предотвратить появление багов и делают поведение программы более предсказуемым. Это связано с тем, что состояние программы не меняется постоянно благодаря доступу нескольких потоков или процессов к общим изменяемым данным.

Рассмотрим пример на Haskell из поста Immutability is Awesome в блоге Monday Morning Haskell:

Пример на Haskell

В этом фрагменте кода мы вызываем обратную функцию массива числа a. При вызове функции значение a не изменяется. Вызов обратной функции выдает ожидаемый результат, но на исходную переменную не влияет.

Следует отметить, что Haskell предоставляет инструменты для внедрения ограниченной изменяемости, в том числе общей, например StRef, IORef, MVar, TVar и т. д., но по своей сути это функциональный язык. Запрет в Haskell на общее изменяемое состояние устраняет многие распространенные проблемы, связанные с изменяемым состоянием, но создает другие.

Объектно-ориентированные языки

В отличие от функциональных языков программирования, в объектно-ориентированных языках попытались решить проблему общего изменяемого состояния путем инкапсуляции состояния в классы для ограничения общего доступа. Java — объектно-ориентированный язык программирования, где попытались это сделать именно таким образом.

Под инкапсуляцией понимают скрытие внутреннего состояния объекта и возможность взаимодействия с ним только определенными методами. Это позволяет ограничить доступ к изменяемому состоянию разных частей программы и снизить риск возникновения конфликтов и проблем с синхронизацией.

Приведем пример на Java, частично сгенерированный с помощью ChatGPT, с инкапсуляцией и ограничением на доступ к изменяемому состоянию:

Пример на Java с инкапсуляцией и ограничением на доступ к изменяемому состоянию

В данном примере класс Counter инкапсулирует состояние счетчика и разрешает только методы для increment, decrement и getCount. Методы синхронизированы, а это означает, что одновременно их может выполнять только один поток. Это позволяет избежать проблем с синхронизацией, когда несколько потоков пытаются одновременно изменить счетчик.

Класс Main создает экземпляр класса Counter и вызывает методы increment и decrement для изменения счетчика. Метод getCount вызывается для получения текущего значения счетчика.

Опять же, инкапсуляция состояния Counter в классе ограничивает общий доступ к изменяемому состоянию исключительно для методов, определенных в классе. Использование синхронизированных методов также позволяет предотвратить проблемы с синхронизацией при одновременном изменении счетчика разными потоками.

Целью инкапсуляции и синхронизации в данном случае является контроль и управление доступом к изменяемому состоянию для безопасного и последовательного выполнения изменений.

Даже при наличии в Java механизмов синхронизации такие связанные с параллелизмом баги, как гонки данных (о них речь пойдет ниже) или взаимоблокировки, все равно могут возникнуть при неправильном использовании средств защиты, поэтому следует внимательно относиться к методам кодирования и дизайна.

Важно отметить, что гонки данных в Java не такие серьезные, как, скажем, в C++, поскольку у Java сильная модель памяти, а это означает, что ни один поток никогда не увидит разорванного значения. Гонки данных, конечно, нежелательны в любом языке, но в Java они имеют определенную семантику, в то время как в C++ явно способствуют неопределенному поведению.

«Общее изменяемое состояние — корень всех зол»

Описанные выше подходы могут помочь решить многие проблемы, связанные с общим изменяемым состоянием, но они не могут полностью устранить все глубинные проблемы. В результате последние возникают самым неожиданным образом, например, в виде гонок данных, проблем с производительностью или необходимости в сборке мусора.

1. Гонки данных: гонки данных — это подмножество состояний гонки (англ. race conditions). Когда несколько потоков одновременно получают доступ к одному и тому же общему изменяемому состоянию и изменяют его, может возникнуть так называемое состояние гонки, когда окончательный результат зависит от времени и порядка выполнения потоков. Это может привести к неожиданному поведению, например, к неправильным результатам, искажению данных или сбоям.

2. Утечки памяти: неправильное управление общим изменяемым состоянием может привести к утечкам памяти, когда из нее не удаляются больше не нужные объекты. Это приводит к пустой трате памяти и потенциальным проблемам с производительностью.

3. Сборка мусора: в языках без ручного управления памятью сборка мусора необходима для освобождения памяти от объектов, на которые больше не ссылается ни одна часть программы. Несмотря на то, что сборка мусора необходима, она может вызывать сложноустранимые проблемы с производительностью, так как общее изменяемое состояние усложняет отслеживание того, какие объекты еще используются, а какие могут быть безопасно удалены. Создание большого количества новых копий структур данных для обновления также может привести к увеличению нагрузки.

Общее изменяемое состояние в Rust

Хотя Rust — это не первый язык, где попытались решить проблему общего изменяемого состояния, он все же выделяется на фоне таких языков, как Cyclone и Ada, где были предприняты аналогичные попытки. Отличительные особенности Rust, включая семантику владения и заимствования, способствовали широкому распространению этого языка среди программистов.

В ветке обсуждения на ycombinator Санхён Со написал об общем изменяемом состоянии и Rust следующее:

«Общее изменяемое состояние — это зло. Функциональные языки программирования подумали: а давайте обойдемся без изменяемого состояния. Rust подумал: а давайте обойдемся без общего доступа к состоянию. Rust — это не функциональный язык программирования. Это другой подход к одной и той же проблеме».

Система владения Rust устанавливает строгие правила доступа к объектам и их изменения, предотвращая гонки данных и другие проблемы с параллелизмом. Кроме того, она обеспечивает эффективное управление памятью без необходимости сборки мусора. Для правильного распределения и освобождения памяти используются статический анализ и контроль в процессе выполнения.

В Rust существуют правила, определяющие, что переменные перемещаются в конец каждой функции, и это предотвращает их использование вызывающей стороной более одного раза. С помощью своего borrow checker Rust создает временную ссылочную переменную для исходного значения, чтобы базовое значение не сбросилось, пока ссылка используется.

«Сбросить» — значит переместить или удалить ресурсы, связанные со значением. Это фундаментальная концепция управления памятью в Rust, поскольку она гарантирует, что ненужные ресурсы будут должным образом удалены.

Следует отметить, что даже в безопасном коде на Rust можно добиться утечки памяти с помощью таких функций, как mem::forget, Box::leak или неудачно спроектированного цикла ссылок между умными указателями Rc.

Есть «безопасный» и «небезопасный» код. Безопасный Rust предназначен для обеспечения безопасности памяти, в то время как небезопасный Rust позволяет разработчикам при необходимости обойти некоторые проверки безопасности языка. Утечка памяти считается в Rust безопасной, поскольку не приводит к неопределенному поведению.

Пример Эндрю Притчарда в его публикации Shared Mutability in Rust демонстрирует, как работает изменяемость в Rust:

Пример демонстрирует, как работает изменяемость в Rust

В этом коде хорошо видна концепция заимствования и времени жизни, обеспечивающая безопасный и контролируемый доступ к данным. В коде определены две структуры, P2 и View, и показано, как структура View используется для создания управляемого представления данных экземпляра P2.

Аннотации времени жизни гарантируют, что ссылки остаются действительными и находятся в пределах предполагаемой области применения. Тем не менее, экземпляры View не обновляются автоматически, когда изменяются базовые данные в P2.

В целом, в Rust общее изменяемое состояние — вовсе не обязательно корень всех зол.

Недостатки Rust

Несмотря на то, что у Rust есть масса достоинств, ему присущи и определенные недостатки.

Одна из сложностей связана с поддержкой асинхронного программирования, которое в языках со сборкой мусора происходит достаточно безболезненно. Хотя в Rust есть возможности асинхронного программирования, сложность возникает из-за модели владения и заимствования, которая делает обработку асинхронных операций гораздо более трудной, чем в других языках.

В Rust также есть мощные функции для работы с общей памятью, но когда программы на Rust взаимодействуют с внешними ресурсами или системами, работать с этими аспектами приходится отдельно.

В своем посте When Rust hurts Роман Кашицын пишет: «Помните, что пути — это обычные указатели, в том числе в Rust. Большинство операций с файлами по своей сути небезопасны и могут приводить к гонкам данных (в широком смысле), если неправильно синхронизировать доступ к файлам. Например, по состоянию на февраль 2023 года у меня уже шесть лет как присутствует concurrency bug в rustup».

Выводы

Rust, благодаря характерным для него безопасности и контролю, — это отличный вариант языка для системного программирования и приложений, где безопасность памяти крайне важна. Однако разработчики должны разбираться в таких тонкостях Rust, как модель владения и заимствования, асинхронный код и небезопасный код.

Мнения, выраженные в статьях на сайте, принадлежат исключительно авторам и могут не совпадать с мнением редакции или участников Anywhere Club.