EngX Code Review: почни писати код іще краще й побудуй ефективний процес код-рев’ю.

Спільно змінюваний стан у Rust: чи дійсно це корінь усього зла?

Що таке cпільно змінюваний стан і як вирішити цю проблему?


Вступ

На написаня цієї статті мене надихнув відеоролик Тома Кайтчука на YouTube — Why Rust is a significant development in programming languages. Стаття призначена для всіх, хто цікавиться Rust і розробкою ПЗ. Я написала її під час вивчення спільно змінюваних станів у 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. Гонитви даних: гонитви даних — це підмножина умов гонитви. Коли декілька потоків одночасно отримують доступ до одного й того ж спільного стану та змінюють його, можуть виникнути умови гонитви, коли кінцевий результат залежить від часу й порядку виконання потоків. Це може призвести до неочікуваної поведінки, наприклад, до неправильних результатів, викривлення даних чи збої

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.

Матеріали за темою
Стеж за новинами на улюблених платформах