что такое heap в расте
Работа с кучей в Rust
Продолжаем изучать Rust нетрадиционным способом. В этом раз будем разбираться в нюансах работы с кучей, для их понимания понадобятся: сырые указатели, выделение памяти в куче, размер экземпляра типа, запись значений в кучу и чтение из нее, const и static, unit-like структуры, переопределение глобального аллокатора.
Это, определенно, overkill для одной статьи, а вот половину списка вполне можно освоить.
Сырые указатели (Raw Pointers)
Выделение и освобождение памяти
Размер экземпляра типа
Научившись выделять память полезно понимать, на что она пойдет. По отношению к размеру типы бывают:
1. Sized Types. Их размер известен во время компиляции и можно создать экземпляр типа. Несколько примеров, где размер экземпляра больше нуля:
Пора сказать пару слов про кортеж (tuple). Это структура с безымянными полями:
2. Zero Sized Types (ZST). Подмножество Sized, размер экземпляра типа равен нулю, но все еще можно его создать.
. Подавать layout таких типов в функции выделения памяти категорически нельзя
Ну т.е. можно, но результатом будет undefined behavior.
3. Empty Types. Экзотические типы, экземпляров которых не существует.
NeverType (на текущий момент тип не «стабилизирован»):
4. Dynamically Sized Types (DSTs). Размер таких типов неизвестен во время компиляции:
Rust не примет такую запись:
Интересный вопрос — почему, ведь можно посчитать размер памяти, которая требуется для «Hello there!»? Есть требование, что все экземпляры Sized-типа должны иметь одинаковый размер, вот ему-то значения str и не соответствуют (т.е. единого размера нет), так что — &str и DST.
Далее, если интересно, см.:
Запись / чтение
Теперь у нас все готово для того, чтобы отправлять переменные в Сумрак и выводить обратно.
Для того чтобы посмотреть, когда же вызывается деструктор, реализуем Drop для Point :
Все вместе при запуске дает результат:
Т.е. сначала записываем, затем читаем, и только потом вызывается деструктор у прочитанного значения.
Бесстрашная защита. Безопасность памяти в Rust
В прошлом году Mozilla выпустила Quantum CSS для Firefox, который стал кульминацией восьми лет разработки Rust — безопасного для памяти языка системного программирования. Потребовалось более года, чтобы переписать основной компонент браузера на Rust.
До сих пор все основные браузерные движки написаны на C++, в основном по соображениям эффективности. Но с большой производительностью приходит большая ответственность: программисты C++ должны вручную управлять памятью, что открывает ящик Пандоры уязвимостей. Rust не только устраняет такие ошибки, но его методы также предотвращают гонки данных, позволяя программистам более эффективно внедрять параллельный код.
Что такое безопасность памяти
Когда мы говорим о создании безопасных приложений, то часто упоминаем безопасность памяти. Неофициально мы имеем в виду, что ни в каком состоянии программа не может получить доступ к недействительной памяти. Причины нарушений безопасности:
Подобные нарушения могут привести к неожиданному сбою или изменению предполагаемого поведения программы. Потенциальные последствия: утечка информации, выполнение произвольного кода и удалённое выполнение кода.
Управление памятью
Управление памятью имеет решающее значение для производительности и безопасности приложений. В этом разделе рассмотрим базовую модель памяти. Одно из ключевых понятий — указатели. Это переменные, в которых хранятся адреса памяти. Если мы перейдём по этому адресу, то увидим там некоторые данные. Поэтому мы говорим, что указатель является ссылкой на эти данные (или указывает на них). Так же, как домашний адрес говорит людям, где вас найти, адрес памяти показывает программе, где найти данные.
Всё в программе находится по определенным адресам памяти, включая инструкции кода. Неправильное использование указателей может привести к серьёзным уязвимостям, включая утечку информации и выполнение произвольного кода.
Выделение/освобождение
Когда мы создаём переменную, то программа должна выделить достаточно места в памяти для хранения данных этой переменной. Поскольку у каждого процесса ограниченный объём памяти, конечно, нужен способ освобождения ресурсов. Когда память освобождается, то становится доступной для хранения новых данных, но старые данные живут там до тех пор, пока ячейка не будет перезаписана.
Буферы
Буфер — это непрерывная область памяти, в которой хранится несколько экземпляров одного типа данных. Например, фраза «Мой кот — бэтмен» сохранится в 16-байтовый буфер. Буферы определяются начальным адресом и длиной. Чтобы не повредить данные в соседней памяти, важно убедиться, что мы не читаем и не записываем за пределы буфера.
Поток управления
Программы состоят из подпрограмм, которые выполняются в определённом порядке. В конце подпрограммы компьютер переходит к сохранённому указателю на следующую часть кода (который называется адресом возврата). При переходе на адрес возврата происходит одна из трех вещей:
Как языки обеспечивают безопасность памяти
Все языки программирования принадлежат разным частям спектра. С одной стороны спектра — такие языки, как C/C++. Они эффективны, но требуют ручного управления памятью. С другой стороны — интерпретируемые языки с автоматическим управлением памятью (например, подсчёт ссылок и сборка мусора (GC)), но они расплачиваются производительностью. Даже языки с хорошо оптимизированной сборкой мусора не могут сравниться по производительности с языками без GC.
Ручное управление памятью
Некоторые языки (например, C) требуют от программистов вручную управлять памятью: когда и сколько выделять памяти, когда её освобождать. Это даёт программисту полный контроль над тем, как программа использует ресурсы, обеспечивая быстрый и эффективный код. Но такой подход подвержен ошибкам, особенно в сложных кодовых базах.
Ошибки, которые легко сделать:
Подходящая инструкция по безопасности для тех, кто управляет памятью вручную
Умные указатели
Умные указатели снабжены дополнительной информацией, чтобы предотвратить неправильное управление памятью. Они используются для автоматического управления памятью и проверки границ. В отличие от обычного указателя, умный указатель способен самоуничтожиться и не будет ждать, пока программист удалит его вручную.
Есть разные варианты такой конструкции, которая обёртывает исходный указатель в несколько полезных абстракций. Некоторые умные указатели подсчитывают ссылки на каждый объект, а другие реализуют политику определения контекста (scoping policy) для ограничения времени жизни указателя определёнными условиями.
При подсчёте ссылок ресурсы освобождаются при удалении последней ссылки на объект. Базовые реализации подсчёта ссылок страдают от низкой производительности, повышенного потребления памяти и их трудно использовать в многопоточных средах. Если объекты ссылаются друг на друга (циклические ссылки), то подсчёт ссылок для каждого объекта никогда не достигнет нуля, так что требуются более сложные методы.
Сборка мусора
В некоторых языках (например, Java, Go, Python) реализована сборка мусора. Часть среды выполнения, которая называется сборщиком мусора (GC), отслеживает переменные и определяет недоступные ресурсы в графе ссылок между объектами. Как только объект становится недоступен, GC освобождает базовую память для повторного использования в будущем. Любое выделение и освобождение памяти происходит без явной команды программиста.
Хотя GC гарантирует, что память всегда используется корректно, он освобождает память не самым эффективным способом — иногда последнее использование объекта происходит гораздо раньше, чем сборщик мусора освободит память. Издержки производительности бывают непомерно высоки для критически важных приложений: чтобы избежать падения производительности, приходится использовать иногда в 5 раз больше памяти.
Владение
В Rust для обеспечения высокой производительности и безопасности памяти используется концепция владения (ownership). Более формально, это пример аффинной типизации. Весь код Rust следует определённым правилам, которые позволяют компилятору управлять памятью без потери времени выполнения:
Когда переменная выходит за пределы области видимости, Rust освобождает эту память. В следующем примере переменные s1 и s2 выходят за пределы области, обе пытаются освободить одну и ту же память, что приводит к ошибке double-free. Чтобы предотвратить это, при переносе значения из переменной предыдущий владелец становится недействительным. Если затем программист попытается использовать недопустимую переменную, компилятор отклонит код. Этого можно избежать, создав глубокую копию данных или используя ссылки.
Другой набор правил borrow checker’а относится к времени жизни переменных. Rust запрещает использование неинициализированных переменных и висячих указателей на несуществующие объекты. Если скомпилировать код из примера ниже, r будет ссылаться на память, которая освобождается, когда x выходит за пределы области видимости: возникает висячий указатель. Компилятор отслеживает все области и проверяет допустимость всех переносов, иногда требуя от программиста явного указания времени жизни переменной.
Модель владения обеспечивает прочную основу для корректного доступа к памяти, предотвращая неопределённое поведение.
Уязвимости памяти
Основные последствия уязвимой памяти:
В лучшем случае при ошибке памяти приложение аварийно завершит работу. В худшем случае злоумышленник получит контроль над программой через уязвимость (что может привести к дальнейшим атакам).
Злоупотребления освобождённой памятью (use-after-free, double free)
Этот подкласс уязвимостей возникает, когда какой-либо ресурс освобождён, но на его адрес по-прежнему сохранилась ссылка. Это мощный хакерский метод, который может привести к доступу за пределы диапазона, утечке информации, выполнению кода и многому другому.
Языки со сборкой мусора и подсчётом ссылок предотвращают использование недопустимых указателей, уничтожая только недоступные объекты (что может привести к снижению производительности), а языки с ручным управлением подвержены этой уязвимости (особенно в сложных кодовых базах). Инструмент borrow checker в Rust не позволяет уничтожать объекты, пока на него существуют ссылки, так что эти баги устраняются на этапе компиляции.
Неинициализированные переменные
Если переменная используется до инициализации, то в этой памяти могут быть любые данные, включая случайный мусор или ранее отброшенные данные, что приводит к утечке информации (их иногда называют недействительными указателями). Чтобы предотвратить эти проблемы, в языках с управлением памятью часто используется процедура автоматической инициализации после выделения памяти.
Как и в C, большинство переменных в Rust изначально не инициализированы. Но в отличие от C вы не можете их прочитать до инициализации. Следующий код не скомпилируется:
Пример 3: Использование неинициализированной переменной
Нулевые указатели
Когда приложение разыменовывает указатель, который оказывается нулевым, обычно он просто обращается к мусору и вызывает сбой. В некоторых случаях эти уязвимости могут привести к выполнению произвольного кода (1, 2, 3). В Rust есть два типа указателей: ссылки и необработанные указатели (raw pointers). Ссылки безопасны, а вот необработанные указатели могут стать проблемой.
Rust предотвращает разыменование нулевого указателя двумя способами:
Что делать, если нельзя избежать указателей, допускающих нулевое значение (например, при взаимодействии с кодом на другом языке)? Попытайтесь изолировать ущерб. Разыменование необработанных указателей должно происходить в изолированном unsafe-блоке. В нём ослаблены правила Rust и разрешены некоторые операции, которые могут вызвать неопределённое поведение (например, разыменование необработанного указателя).
— Всё, чего касается borrow chekcer… а что насчёт вон того тёмного места?
— Это unsafe-блок. Никогда не ходи туда, Симба
Переполнение буфера
Мы обсудили уязвимости, которых можно избежать, ограничив доступ к неопределённой памяти. Но проблема в том, что переполнение буфера неправильно обращается не к неопределённой, а к легально выделенной памяти. Как и баг use-after-free, такой доступ может стать проблемой, потому что обращается к освобождённой памяти, где по-прежнему содержится конфиденциальная информация, которая уже не должна существовать.
Переполнение буфера просто означает доступ за пределы области (out-of-bounds). Из-за того, как буферы хранятся в памяти, они часто приводят к утечке информации, которая может содержать конфиденциальные данные, в том числе пароли. В более серьёзных случаях возможны уязвимости ACE/RCE путём перезаписи указателя инструкции.
Пример 4: Переполнение буфера (код C)
Простейшая защита от переполнения буфера — всегда при доступе к элементам требовать проверки границ, но это приводит к снижению производительности.
Что делает Rust? Встроенные типы буферов в стандартной библиотеке требуют проверки границ для любого случайного доступа, но также предоставляют интерфейсы API итератора, чтобы ускорить последовательные обращения. Это гарантирует, что чтение и запись за пределами границ для этих типов невозможны. Rust продвигает шаблоны, которые требуют проверки границ только в тех местах, где почти наверняка придётся вручную размещать их в C/C++.
Безопасность памяти — только полдела
Нарушения безопасности приводят к уязвимостям, таким как утечка данных и удалённое выполнение кода. Существуют разные способы защитить память, в том числе умные указатели и сборка мусора. Вы даже можете формально доказать безопасность памяти. Хотя некоторые языки смирились с падением производительности ради безопасности памяти, концепция владения в Rust обеспечивает безопасность и минимизирует накладные расходы.
К сожалению, ошибки памяти — это лишь часть истории, когда мы говорим о написании безопасного кода. В следующей статье рассмотрим потокобезопасность и атаки на параллельный код.
Эксплуатация уязвимостей памяти: дополнительные ресурсы
Ключевые возможности Rust
Rust — новый язык программирования, разрабатываемый корпорацией Mozilla. Главная цель разработчиков — создание безопасного практичного языка для параллельных вычислений. Первая версия языка была написана Грэйдоном Хором в 2006 году, а в 2009 году к разработке подключилась Mozilla. С тех пор изменения претерпел и сам компилятор, изначально написанный на OCaml: он был успешно переписан на Rust с использованием LLVM в качестве back-end.
Основным продуктом, разрабатываемым на Rust, является новый веб-движок Servo, разработка которого также ведется Mozilla. В 2013 году к разработке Rust и Servo присоединилась корпорация Samsung Electronics, при активном участии которой код движка Servo был портирован на ARM архитектуру. Поддержка языка столь серьезными игроками IT индустрии не может не радовать и дает надежду на его дальнейшее активное развитие и совершенствование.
Целевая аудитория
Сначала я планировал написать вводную статью, которая бы рассматривала язык с самых основ, начиная с объявления переменных и заканчивая функциональными возможностями и особенностями модели памяти. С одной стороны, подобный подход позволил бы охватить как можно большую целевую аудиторию, с другой стороны, статья с похожим содержанием была бы неинтересна людям, имеющим неплохой опыт работы с языками типа C++ или Java, и не позволила бы включить в нее более глубокий анализ основных особенностей Rust, то есть именно того, что делает его привлекательным.
Поэтому я решил не описывать детально такие базовые вещи, как создание переменных, циклы, функции, замыкания и все остальное, что понятно из кода. Основная масса не совсем очевидных особенностей будет описана по мере необходимости, в процессе разбора основных возможностей Rust. В итоге статья посвящена описанию двух из трех основных возможностей языка: безопасной работе с памятью и написанию параллельных приложений. К сожалению, на момент написания статьи сетевая подсистема находилась в активной разработке, что делало включение описания работы с ней в статью совершенно бессмысленным.
Терминология
По большому счету, это одна из двух-трех доступных статей, посвященных Rust, на русском языке, поэтому какой-либо устоявшейся русской терминологии нет и мне приходится брать наиболее подходящие эквиваленты, уже знакомые по другим языкам программирования. Для удобства дальнейшего чтения документации и статей на английском языке при первом появлении русскоязычного термина в скобках приводится английский эквивалент.
Наибольшее количество проблем вызвали термины Box и Pointer. По своим свойствам что Box, что Pointer больше всего напоминают умные указатели из C++, поэтому я решил использовать термин «указатели». Таким образом, Owned boxes превратились в Уникальные указатели, а Borrowed pointers во Временные указатели.
Работа с памятью
Принципы работы с памятью – это первая из ключевых возможностей Rust, которая выгодно отличает этот язык как от языков с полным доступом к памяти (типа C++), так и от языков с полным контролем за памятью со стороны GC (типа Java). Дело в том, что, с одной стороны, Rust предоставляет разработчику возможность контролировать, где размещать данные, вводя разделение по типам указателей и обеспечивая контроль за их использованием на этапе компиляции. C другой стороны, механизм подсчета ссылок, который в окончательной версии языка будет заменен полноценным GC, обеспечивает автоматическое управление ресурсами.
Использование стека
Так, код (1) разместит объект типа Point на стеке задачи, в которой будет вызван. При копировании подобного объекта (2) будет скопирован не указатель на объект x, а вся структура типа Point.
Для информации: переменные
Как можно увидеть из примера выше, ключевое слово let используется в Rust для создания переменных. По умолчанию все переменные константные и для создания изменяемой переменной необходимо добавлять ключевое слово mut. Таким образом, создание изменяемой переменной типа Point могло бы выглядеть следующим образом let mut x = Point
Крайне важно помнить при работе с переменными, что константными оказываются именно данные, и за попытками изменить их «обманом» пристально следит компилятор.
Так, вполне можно (1) создать изменяемую переменную, указывающую на константные данные, но вот попытка (2) изменить сами данные закончится ошибкой на этапе компиляции. А вот изменение значения переменной, хранящей адрес константного объекта Point и созданной ранее, является допустимым (3).
Разделяемые указатели
Разделяемые указатели используются в качестве указателей на объекты, располагающиеся в локальной куче задачи. У каждой задачи есть собственная локальная куча, и указатели на расположенные в ней объекты никогда не могут быть переданы за ее пределы. Для создания разделяемых указателей используется унарный оператор @
В отличие от стековых объектов, при копировании копируется исключительно указатель, а не данные. Именно из этого свойства и пошло название данного типа указателей, так как поведение их очень похоже на shared_ptr из языка C++.
Также необходимо отметить тот факт, что невозможно создать структуру, содержащую указатель на собственный тип (классический пример – односвязный список). Для того чтобы компилятор разрешил подобную конструкцию, необходимо обернуть указатель в тип Option (1).
Уникальные указатели
Уникальные указатели реализуют семантику владения, благодаря чему объект может адресовать только один уникальный указатель. C++ разработчики наверняка найдут общие черты между уникальными указателями Rust и классом unique_ptr из STL.
Присвоение (1) указателю new_p указателя p приводит к тому, что new_p начинает указывать на созданный ранее объект типа Point, а указатель p деинициализируется. В случае попытки работы с деинициализированными переменными (2) компилятор генерирует ошибку use of moved value и предлагает сделать копию переменной вместо присвоения указателя с последующей деинициализацией исходного.
Благодаря явному созданию копии (1), new_p указывает на копию созданного ранее объекта типа Point, а указатель p не изменяется. Для того, что бы к структуре Point можно было применить метод clone, структура должна быть объявлена с использованием атрибута #[deriving(Clone)].
Временные указатели
Временные указатели – указатели которые могут указывать на объект, размещенный в любом из возможных типов памяти: стеке, локальном или хипе обмена, а также на внутренний член любой структуры данных. На физическом уровне временные указатели представляют собой типичные Си указатели и, как следствие, не отслеживаются сборщиком мусора и не привносят никаких дополнительных накладных расходов. В то же время, их основным отличием от Си указателей являются дополнительные проверки, проводимые на этапе компиляции для гарантии возможности безопасного использования. Для создания временных указателей используется унарный оператор &
Объект типа Point был создан (1) на стеке и временный указатель был сохранен в on_the_stack. Данный код аналогичен следующему:
Типы, отличные от стековых, приводятся к временным указателям автоматически, без использования оператора взятия адреса, что позволяет упростить написание функций (1), если тип указателя не имеет значения.
А теперь небольшая иллюстрация того, как можно получить временный указатель на внутренний элемент структуры данных.
Контроль времени жизни временных указателей довольно объемная и не совсем устоявшаяся тема. При желании с ней можно подробно ознакомится в статье Rust Borrowed Pointers Tutorial и Lifetime Notation.
Разыменование указателей
Для доступа к значениям, адресованным при помощи указателей, необходимо проводить операцию разыменования (Dereferencing pointers). При доступе к полям структурированных объектов разыменование производится автоматически.
Преобразование между указателями
Практически сразу после начала работы с Rust возникает вопрос: «Как преобразовать объект, адресуемый при помощи уникального указателя, к разделяемому или наоборот?» Ответ на данный вопрос краткий и поначалу несколько обескураживающий: никак. Если хорошо подумать над ним, то становится очевидно, что каких-либо средств подобного преобразования нет и быть не может, так как объекты находятся в разных кучах и подчиняются разным правилам, у объектов могут быть графы зависимостей, автоматическое отслеживание которых также затруднительно. Поэтому, при необходимости преобразования между указателями, которое является ни чем иным как перемещением объектов между кучами, необходимо создавать копии объектов, для чего можно воспользоваться сериализацией.
Задачи
Вторая ключевая возможность Rust – написание параллельных приложений. В плане возможностей для написания параллельных приложений Rust напоминает Erlang с его моделью акторов и обменом сообщениями между ними и Limbo с его каналами. При этом разработчику предоставляется возможность выбирать: хочет ли он копировать память при отправке сообщения или просто передать владение объектом. А при совместной работе нескольких задач с одним и тем же объектом можно легко организовать доступ один-писатель-много-читателей. Для создаваемых задач есть возможность выбрать наиболее подходящий планировщик или написать собственный.
Для информации: do-синтаксис
Перед тем как перейти к описанию работы с задачами, желательно ознакомиться с do-синтаксисом, который используется в Rust для упрощения работы с функциями высшего порядка. В качестве примера можно взять функцию each, передающую указатель (1) на каждый из элементов массива в функцию op.
При помощи функции each, используя do-синтаксис (1), можно вывести на экран каждый из элементов массива, не забывая о том, что в лямбду будет передано не значение, а указатель, который необходимо разыменовать (2) для доступа к данным:
Так как do-синтаксис является синтаксическим сахаром, то запись ниже эквивалентна записи с использованием do-синтаксиса.
Запуск задачи на выполнение
Создать и выполнить задачу в Rust очень просто. Код, относящийся к работе с задачами, сосредоточен в модуле std::task, а простейшим способом создания и старта задачи является вызов функции spawn из этого модуля.
Функция spawn принимает замыкание в качестве аргумента и запускает его на выполнение в виде задачи (не стоит забывать о том, что задачи в Rust реализованы поверх зеленых потоков). Для того чтобы получить текущую задачу, в рамках которой выполняется код, можно воспользоваться методом get_task() из модуля task. С учетом того, что в рамках задачи выполняются замыкания, не сложно предположить 3 способа запустить задачу на выполнение: передав адрес функции (1), создав замыкание «на месте» (2) или, что более верно с точки зрения идеологии языка, воспользовавшись do-синтаксисом (3).
Взаимодействие между задачами
Обмен сообщениями на низком уровне
Самым широко используемым на данный момент способом взаимодействия между задачами является модуль std::comm. Код из std::comm хорошо отлажен, неплохо задокументирован и довольно прост в использовании. Основой механизма обмена сообщениями std::comm являются потоки, манипуляция с которыми происходит посредством каналов и портов. Поток представляет собой однонаправленный механизм связи, в котором порт используется для отправки сообщения, а канал – для приема отправленной информации. Простейший пример использования потока выглядит следующим образом:
Отдельного внимания заслуживает класс шаблонного параметра Send, который означает возможность передачи при помощи потока только объектов, поддерживающих пересылку за пределы текущей задачи.
Для получения данных из потока можно воспользоваться функцией recv(), которая либо вернет данные, либо заблокирует задачу до их появления. Глядя на пример, приведенный выше, закрадывается подозрение, что он совершенно бесполезен, так как какого-то практического смысла в отправке сообщений при помощи потоков в рамках одной задачи нет. Так что стоит перейти к более практичным вещам, таким как использование потоков для передачи информации между задачами.
Первое, на что стоит обратить внимание при работе с потоками, это необходимость передавать значения, адресуемые уникальными указателями, а функция from_fn() (1) как раз создает такой массив. Так как поток является однонаправленным, то для передачи запроса (2) и получения ответа (3) понадобятся два потока. При помощи функции recv() данные считываются из потока (4), а при отсутствии таковых поток заблокирует задачу до их появления. Для отправки результата клиенту используется функция send() (5), принадлежащая не серверному, а клиентскому потоку; аналогичным образом необходимо поступить с данными для отправки серверной задаче: они записываются (6) при помощи функции send(), относящейся к серверному порту. В самом конце результат, переданный серверной задачей, считывается (7) из клиентского потока.
Таким образом, для отправки сообщений серверу и приема сообщений на стороне сервера используется поток server_chan, server_port. В силу однонаправленности потока, для получения результата вычислений сервера был создан клиентский поток, состоящий из пары client_chan, client_port.
Совместное использование потока
Хотя поток является однонаправленным механизмом передачи данных, это не приводит к необходимости создавать новый поток для каждого из желающих отправить данные, так как существует механизм, обеспечивающий работу в режиме «один-получатель-много-отправителей».
Для этого, как и для схемы «один-читатель-один-писатель», необходимо создать серверный (2) и клиентский (3) потоки и запустить серверную задачу (3). Логика серверной задачи предельно проста: считать (5) данные из серверного канала, переданные клиентом (9), вывести сообщение о получении запроса на экран и отправить результирующее количество полученных запросов print_hello (5) в клиентский поток. Так как писателей несколько, то необходимо внести изменения в тип серверного порта, преобразовав (7) его к SharedChan вместо Chan, и для каждого из писателей создать уникальную копию порта (8) посредствам метода clone(). Дальнейшая работа с портом ничем не отличается от предыдущего примера: метод send() используется для отправки данных серверу (9) с той лишь разницей, что теперь данные отправляются из нескольких задач одновременно.
Кроме иллюстрации метода совместной работы с потоком, данный пример показывает способ отправки нескольких разных типов сообщений при помощи одного потока. Так как тип передаваемых потоком данных задается на этапе компиляции, для передачи данных разных типов необходимо либо воспользоваться серриализацией с последующей передачей бинарных данных (данный метод описан ниже в разделе «Пересылка объектов»), либо передавать перечисление (1). По своим свойствам перечисления в Rust похожи на объединения из языка C или тип Variant, в той или иной форме присутствующий почти во всех высокоуровневых языках программирования.
Пересылка объектов
В тех случаях, когда необходимость пересылать значения, адресуемые исключительно уникальными указателями, становится проблемой, на помощь приходит модуль flatpipes. Данный модуль позволяет отправлять и принимать любые бинарные данные в виде массива или объекты, поддерживающие сериализацию.
Как видно из примера, работать с flatpipes предельно просто. Структура, объекты которой будут передаваться посредством flatpipes, должна быть объявлена сериализуемой (1) и десериализуемой (2). Создание flatpipes (3) технически ничем не отличается от создания обычных потоков, так же как прием (4) и отправка (5) сообщений при помощи канала и порта. Главным же отличием flatpipes от потока является создание глубокой копии объекта на отправляющей стороне и построение нового объекта на принимающей стороне. Благодаря такому подходу, накладные расходы при работе с flatpipes, по сравнению с обычными потоками, возрастают, но возможности по пересылке данных между задачами увеличиваются.
Высокоуровневая абстракция обмена сообщениями
В большинстве приведенных выше примеров создаются два потока: один для отправки данных на сервер, второй для получения данных с сервера. Подобный подход не привносит какой-то ощутимой пользы да и просто замусоривает код. В связи с этим был создан модуль extra::comm, являющийся высокоуровневой абстракцией над std::comm и содержащий в себе DuplexStream, позволяющий организовать двунаправленное общение в рамках одного потока. Само собой, если заглянуть в исходный код DuplexStream, станет ясно, что это не более чем удобная надстройка над парой стандартных потоков.
При работе с DuplexStream создается (1) единственная пара из двух двунаправленных потоков, оба из которых могут использоваться как для отправки, так и для получения сообщений. Объект server захватывается контекстом задачи и используется для получения (2) и отправки (3) сообщений в задаче сервера, а объект client – в задаче клиента (4,5). Принцип работы с DuplexStream ничем не отличается от работы с обычными потоками, но позволяет сократить количество вспомогательных объектов.
Модуль Arc
Совместное использование уникальных указателей с доступом только на чтение
Теперь объект должен относиться не только к классу Send, как это было в случае с потоком, но еще и к классу Freeze, что гарантирует отсутствие каких бы то ни было изменяемых полей или указателей на изменяемые поля внутри объекта T (такие объекты в Rust носят название deeply immutable objects).
Пусть в данном примере нет работы с потоками, но он вполне достаточен для иллюстрации работы с Arc, так как наглядно демонстрирует основной функционал этого модуля – возможность одновременно обращаться к одним и тем же данным из разных задач. Так, для совместного использования одного и того же массива, обернутого в Arc (1), надо создать клон Arc обертки (2), что сделает возможным обращение к данным как из новой (3), так и из основной (4) задач.
R/W доступ к уникальным указателям
Модуль RWArc вызывает у меня двоякие эмоции. С одной стороны, благодаря RWArc можно реализовать широко распространенную и хорошо известную большинству разработчиков концепцию “много читателей один писатель”, что, наверное, хорошо, так как концепция широко известна. С другой стороны, совместный доступ к памяти, причем не RO доступ, который был описан чуть ранее, а RW доступ, чреват проблемами с взаимоблокировками, от которых Rust как раз и должен защитить разработчиков. Лично для себя я пришел к следующему выводу: о модуле знать надо, но использовать его без крайней необходимости не стоит.
В приведенном выше примере создается (1) массив, обернутый в RWArc, благодаря чему к нему можно обращаться как на чтение (4), так и на запись (6). Кардинальное отличие примера работы с RWArc от всех предыдущих примеров – использование замыканий в функциях read() (3) и write() (5) в качестве аргумента. Чтение и запись данных, обернутых в RWArc, можно производить только в этих функциях. И, как обычно, необходимо создать копию (2) объекта для доступа к нему из замыкания, так как в противном случае оригинал станет недоступным.
Как такое вообще возможно?
Да, именно такой вопрос возникает после того, как узнаешь о том, что модули Arc и RWArc присутствуют в Rust. На первый взгляд они противоречат концепции работы с памятью в Rust в целом, и принципам работы уникальных указателей в частности. Не являясь создателем или разработчиком данного языка, я могу только лишь рассказать о том, благодаря чему подобное поведение возможно. В составе языка Rust имеется ключевое слово unsafe, позволяющее писать код, работающий с памятью напрямую, вызывать такие небезопасные с точки зрения управления памятью функции, как malloc, free, и использовать адресную арифметику. Именно эта возможность используется для обхода встроенной в Rust защиты памяти и обеспечения совместного доступа к одному и тому же объекту. Весь код, относящийся к данной функциональности, помечен как «COMPLETELY UNSAFE» и не должен использоваться конечными пользователями напрямую.
Вместо заключения
Хотя прямо сейчас язык Rust не пригоден для промышленного использования, на мой взгляд, он обладает большим потенциалом. Очень может быть, что через несколько лет Rust сможет составить конкуренцию таким замечательным языкам-динозаврам, как C и C++, как минимум в областях, связанных с написанием сетевых и параллельных приложений. В крайнем случае, я очень на это надеюсь.
Что касается статьи, то считать ее законченной, скорее всего, нельзя: во-первых, синтаксис языка наверняка претерпит еще ряд изменений, а, во-вторых, должна завершиться работа над третей из ключевых возможностей языка – поддержкой сетевых взаимодействий. Как только эта функциональность придет в более или менее завершенное состояние, я обязательно о ней напишу.