Русский
Русский
English
Статистика
Реклама

Rust

Перевод Портируем Quake 3 на Rust

14.06.2021 20:18:14 | Автор: admin


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

Тестирование на небольших программах командной строки постепенно устаревает, поэтому для переноса мы выбрали ничто иное, как Quake 3. Стоит ли говорить, что буквально через пару дней мы уже были первыми, кто играл в Rust-версию этого популярного шутера.

Подготовка: исходники Quake 3


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

Для начала мы убедились, что сможем воссоздать проект как есть:

$ make release

Сборка ioquake3 создает несколько библиотек и исполняемых файлов:

$ tree --prune -I missionpack -P "*.so|*x86_64". build     debug-linux-x86_64         baseq3            cgamex86_64.so          # клиент             qagamex86_64.so         # игровой сервер            uix86_64.so             # ui         ioq3ded.x86_64              # исполняемый файл выделенного сервера         ioquake3.x86_64             # основной исполняемый файл         renderer_opengl1_x86_64.so  # модуль рендеринга opengl1         renderer_opengl2_x86_64.so  # модуль рендеринга opengl2

Клиентскую, серверную и UI библиотеки можно собрать в виде Quake VM либо как нативные общие библиотеки x86. Мы предпочли второй вариант. Переносить VM на Rust и использовать версии QVM было бы существенно проще, но задачей было протестировать C2Rust максимально тщательно.

Сосредоточились мы на UI, игровом сервере, клиенте, модуле рендеринга OpenGL1 и основном исполняемом файле. Можно было также перевести модуль OpenGL2, но мы решили его не трогать, так как он активно использует файлы шейдеров .glsl, которые система сборки включает в исходники Си в виде строковых литералов. Конечно, можно было добавить поддержку скрипта кастомной сборки для встраивания кода GLSL в строки Rust после транспиляции, но нас остановило отсутствие надежного автоматического способа транспилировать эти автогенерируемые временные файлы.

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

Транспиляция


Чтобы сохранить используемую в Quake 3 структуру каталогов и не прибегать к изменению ее исходного кода, нужно было создать в точности такие же двоичные файлы, что и в нативной сборке, то есть четыре общие библиотеки и один двоичный файл. Поскольку C2Rust использует для сборки файлов Cargo, каждому исполняемому файлу требуется собственный контейнер Rust с соответствующим файлом Cargo.toml. Чтобы C2Rust на выходе создал для каждого исполняемого файла контейнер, ему нужно предоставить список двоичных файлов вместе с соответствующими им объектными или исходными файлами, а также вызов компоновщика, определяющего прочие детали, такие как зависимости.

Тем не менее мы вскоре столкнулись с ограничением, относящимся к тому, как C2Rust перехватывает внутренний процесс сборки: C2Rust получает на входе файл базы данных компиляции, который содержит список команд компиляции, выполняемых при сборке. Однако эта база данных содержит только команды компиляции, но не вызовы компоновщика.

В большинстве инструментов, создающих такую базу данных, подобное ограничение присутствует преднамеренно, например cmake с CMAKE_EXPORT_COMPILE_COMMANDS, bear и compiledb. Насколько нам известно, единственным инструментом, включающим команды компоновки, является build-logger из CodeChecker, который мы не задействовали только потому, что узнали о нем после написания собственных оберток (приводятся ниже). Это означало невозможность использовать файл compile_commands.json, создаваемый любым типовым инструментом для транспиляции мультибинарной программы Си.

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

$ make release

Мы добавили обертки для перехвата процесса сборки:

$ make release CC=/path/to/C2Rust/scripts/cc-wrappers/cc

Обертки создают каталог с файлами JSON, по одному файлу за вызов. Второй скрипт агрегирует все эти файлы в новый compile_commands.json, который содержит уже и команды компиляции, и команды сборки. Мы расширили C2Rust для считывания компонующих команд из базы данных и создания отдельного контейнера для каждого связанного двоичного файла. Кроме того, теперь C2Rust считывает зависимости каждого исполняемого файла и автоматически добавляет их в файл build.rs его контейнера.

В качестве облегчения процесса все исполняемые файлы можно собрать за раз, если они будут находиться в одном рабочем пространстве. C2Rust производит высокоуровневый файл рабочего пространства Cargo.toml, позволяя собирать проект одной командой cargo build в каталоге quake3-rs:

$ tree -L 1. Cargo.lock Cargo.toml cgamex86_64 ioquake3 qagamex86_64 renderer_opengl1_x86_64 rust-toolchain uix86_64$ cargo build --release

Исправление недочетов


При первой попытке собрать переносимый код возникла пара проблем с исходниками Quake 3, которые C2Rust не мог корректно обработать или не обрабатывал совсем.

Указатели на массивы


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

int array[1024];int *p;// ...if (p >= &array[1024]) {   // error...}

Стандарт Си (загляните, например, в C11, Section 6.5.6) допускает указатели на элемент, выходящий за границу массива. Проблема же в том, что Rust это запрещает даже при получении только адреса элемента. Примеры этого шаблона мы нашли в функции AAS_TraceClientBBox.

Компилятор Rust также отметил похожий, но уже действительно ошибочный пример в G_TryPushingEntity, где условие представлено как >, а не >=. После этого условия выходящий за границы указатель разыменовывался, что являлось реальной ошибкой безопасности памяти.

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

Члены динамических массивов


На первый проверочный запуск игры Rust отреагировал паникой:

thread 'main' panicked at 'index out of bounds: the len is 4 but the index is 4', quake3-client/src/cm_polylib.rs:973:17

Заглянув в cm_polylib.c, мы заметили разыменовывание поля p в следующей структуре:

typedef struct{intnumpoints;vec3_tp[4];// переменный размер} winding_t;

Поле p здесь это более ранняя несовместимая с C99 версия члена массива переменной длины, которая до сих пор принимается gcc. C2Rust распознает членов динамических массивов с синтаксисом С99 (vec3_t p[]) и реализует простую эвристику для попутного обнаружения более ранних версий этого шаблона (массивов с размером 0 и 1 в конце структур; в исходниках ioquake3 мы нашли несколько таких).

Панику удалось устранить, изменив вышеприведенную структуру на синтаксис C99:

typedef struct{intnumpoints;vec3_tp[];// переменный размер} winding_t;

Реализация автоматического исправления этого шаблона для общих случаев (массивы с размером не 0 или 1) оказалась бы слишком сложной, поскольку пришлось бы различать членов стандартных массивов от членов массивов с произвольными размерами. Вместо этого мы рекомендуем корректировать исходный код Си вручную как сделали сами в случае с ioquake3.

Связанные операнды во встроенном ассемблере


Еще одним источником сбоев был встроенный в Си код ассемблера из системного заголовка /usr/include/bits/select.h:

# define __FD_ZERO(fdsp)                                            \  do {                                                              \    int __d0, __d1;                                                 \    __asm__ __volatile__ ("cld; rep; " __FD_ZERO_STOS               \                          : "=c" (__d0), "=D" (__d1)                \                          : "a" (0), "0" (sizeof (fd_set)           \                                          / sizeof (__fd_mask)),    \                            "1" (&__FDS_BITS (fdsp)[0])             \                          : "memory");                              \  } while (0)

Он определяет внутреннюю версию макроса __FD_ZERO. Это определение вызывает редкий граничный случай встроенного ассемблерного кода gcc: связанные входные/выходные операнды разного размера.

Выходной операнд =D (_d1) привязывает регистр edi к переменной _d1 в качестве 32-битного значения, в то время как 1 (&__FDS_BITS (fdsp)[0]) привязывает тот же регистр к адресу fdsp->fds_bits в качестве 64-битного указателя. gcc и clang исправляют это несоответствие, взамен используя 64-битный регистр rdi и затем усекая его значение перед присваиванием к _d1. Rust же по умолчанию использует семантику LLVM, которая оставляет этот случай неопределенным. В отладочных сборках (не релизных, которые работали корректно) оба операнда присваивались регистру edi, обуславливая преждевременное усечение указателя до 32 бит еще до достижения встроенного кода ассемблера, что и вызывало сбои.

Поскольку rustc передает встроенный ассемблерный код Rust в LLVM с минимальными изменениями, мы решили исправить этот частный случай в C2Rust. Для этого мы реализовали новый контейнер c2rust-asm-casts, корректирующий проблему через систему типов Rust с помощью типажа (trait) и вспомогательных функций, которые автоматически расширяют и усекают значения связанных операндов до внутреннего размера, достаточного для хранения обоих операндов.

Вышеприведенный код корректно транспилируется в следующее:

let mut __d0: c_int = 0;let mut __d1: c_int = 0;// Ссылка на выходное значение первого операндаlet fresh5 = &mut __d0;// Внутреннее хранилище для первого связанного операндаlet fresh6;// Ссылка на выходное значение второго операндаlet fresh7 = &mut __d1;// Внутреннее хранилище для второго операндаlet fresh8;// Входное значение первого операндаlet fresh9 = (::std::mem::size_of::<fd_set>() as c_ulong).wrapping_div(::std::mem::size_of::<__fd_mask>() as c_ulong);// Входное значение второго операндаlet fresh10 = &mut *fdset.__fds_bits.as_mut_ptr().offset(0) as *mut __fd_mask;asm!("cld; rep; stosq"     : "={cx}" (fresh6), "={di}" (fresh8)     : "{ax}" (0),       // Приведение входных операндов к внутреннему типу хранилища       // с дополнительным нулевым или знаковым расширением       "0" (AsmCast::cast_in(fresh5, fresh9)),       "1" (AsmCast::cast_in(fresh7, fresh10))     : "memory"     : "volatile");// Приведение операндов к внешнему типу (с выведением типов) и усечениеAsmCast::cast_out(fresh5, fresh9, fresh6);AsmCast::cast_out(fresh7, fresh10, fresh8);

Обратите внимание, что код выше не требует типов для любых входных или выходных значений инструкций ассемблера, полагаясь на вывод типов Rust (главным образом типов fresh6 и fresh8).

Выравнивание глобальных переменных


Последним источником сбоев была следующая глобальная переменная, где хранится константа SSE:
static unsigned char ssemask[16] __attribute__((aligned(16))) ={"\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x00\x00\x00\x00"};

На данный момент Rust поддерживает атрибут выравнивания для типов структур, но не глобальных переменных, то есть элементов static. Мы продолжаем искать универсальный способ решения этой проблемы в Rust или C2Rust, но для ioquake3 пока что решили ее вручную с помощью небольшого патча. Этот патч заменяет Rust-эквивалент ssemask на:

#[repr(C, align(16))]struct SseMask([u8; 16]);static mut ssemask: SseMask = SseMask([    255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0,]);

Запуск quake3-rs


Выполнение cargo build --release генерирует двоичные файлы, но все они генерируются в target/release с использованием структуры каталогов, не распознаваемой бинарником ioquake3. Мы написали скрипт, который создает символические ссылки на текущую директорию, чтобы дублировать верную структуру каталогов (включая ссылки на файлы .pk3, содержащие ресурсы игры):

$ /path/to/make_quake3_rs_links.sh /path/to/quake3-rs/target/release /path/to/paks

Путь /path/to/paks должен указывать на каталог, содержащий файлы .pk3.

Ну а теперь пора запускать игру! При запуске нужно передать команду +set vm_game 0 и пр., чтобы загрузить эти модули как общие библиотеки Rust, а не ассемблерный код QVM, а также команду cl_renderer, чтобы использовать OpenGL1.

$ ./ioquake3 +set sv_pure 0 +set vm_game 0 +set vm_cgame 0 +set vm_ui 0 +set cl_renderer "opengl1"

Иии



Перед нами рабочая Rust-версия Quake3!



Вот видео, в котором мы транспилируем игру, загружаем ее и недолго тестируем игровой процесс:


Транспилированный исходный код лежит в ветке transpiled нашего репозитория. Там также есть ветка refactored, содержащая те же файлы, но уже с примененными командами рефакторинга.

Инструкции по транспиляции


Если вы захотите повторить аналогичный процесс переноса и запуска Quake 3, то имейте в виду, что вам понадобятся либо оригинальные ресурсы игры, либо скачанные из интернета демоверсии таких ресурсов. Помимо этого, потребуется установить C2Rust (минимальная необходимая ночная версия Rust на момент написания это nightly-2019-12-05, но мы рекомендуем заглянуть в репозиторий C2Rust или на сайт crates.io на предмет наличия последней):

$ cargo +nightly-2019-12-05 install c2rust

а также копии репозиториев C2Rust и ioquake3:

$ git clone git@github.com:immunant/c2rust.git$ git clone git@github.com:immunant/ioq3.git

В качестве альтернативы установке c2rust вышеприведенной командой вы можете собрать C2Rust вручную с помощью cargo build --release. В обоих случаях вам все равно потребуется репозиторий C2Rust, так как там находятся скрипты оберток компилятора, необходимые для транспиляции ioquake3.

Мы предоставляем скрипт, который автоматически транспилирует код Си и применяет патч ssemask. Чтобы его использовать, выполните из верхнего уровня репозитория ioq3следующую команду:

$ ./transpile.sh </path/to/C2Rust repository> </path/to/c2rust binary>

Эта команда должна создать подкаталог quake3-rs, содержащий код Rust, где можно будет последовательно выполнять cargo build --release и остальные ранее описанные шаги.


Подробнее..

Перевод Юмористичный обзор Rust с перспективы JavaScript

16.06.2021 20:20:54 | Автор: admin

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

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

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

Хорошие новости


Современный Rust оказывается весьма схож с JavaScript. Переменные объявляются через let, функции выглядят очень похоже, типы уже не чужды, так как мы привыкли к TypeScript, присутствуют async/await, да и в общем формируется весьма знакомое ощущение.

Плохие новости


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

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

Управлению памятью быть!


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

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

Когда речь заходит об управлении памятью, то Rust как бы говорит: Ничего не знаю реальные шеф-повара сами за собой убирают. И на то есть хорошая причина, потому что сборщик мусора несет в себе собственный набор неочевидных проблем, которые могут навредить в самый неожиданный момент. Хотя в то же время, обогащенный опытом других языков, Rust признает, что заставлять программиста управлять памятью столь же разумно, сколь поручить Дугласу Адамсу написать Звездолет Титаник.

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



Как розу ты ни назови, а запах ее столь же сладок


верно сказал Шекспир. Хотя, как быть с не столь ароматными запашками? Сохраняется ли верность его слов в обратном случае? Учитывая многовековую традицию использования людьми эвфемизмов, можно с уверенностью заключить, что нет.


Корабль длиной 55 метров, замаскированный в Тихом океане под вид маленького острова для избежания обнаружения во время Второй мировой войны. Сработало

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

На землях Вестероса (смею я сказать ВестеRust?) есть крохотные, небольшие, а также крупные феоды. Загвоздка в том, что все они оккупированы Ланнистерами. Внутренне феоды занимаются своими собственными делами, а когда им требуются товары извне, то они берут на себя долг, чтобы эти товары получить. В последствии долг необходимо возвращать Богам Вестероса. Rust подобен королеве драконов этого мира он узрит свысока все нюансы и проследит, чтобы все долги перед Богами были уплачены.

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

Может ли возникнуть кризис?


Посмотрим, как это работает.



Здесь у нас две области: внешняя main и внутренняя, будем звать ее inner scope, для демонстрации. В этом случае владение работает так:

  1. main владеет a и b
  2. a хочет поработать в inner scope, поэтому main передает a во владение inner scope
  3. inner scope делает свои дела с a и завершается
  4. Скрытый код Rust отбрасывает a
  5. main делает свои дела с b и тоже завершается
  6. Rust отбрасывает b

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

Что, если у нас будет такой код?



Здесь область main хочет снова использовать a, но мы сказали, что Rust уже ее отбросил по завершении inner scope.

Не даст ли программа сбой и не сгорит ли, когда достигнет этой точки выполнения?



Да, так и будет. Но, как спартанцы ответили отцу Александра, королю Филиппу II Македонскому: если она этой точки достигнет.

Абсолютный бюрократ


Компилятор Rust является гордым послушником традиции Легизма, настолько верным, что Хан Фей-цзы с восторгом бы объявил вне закона все остальные языки. Ничто не происходит в землях Rust, пока компилятор не скрепит это действие печатью Утверждаю. Он будет тщательно проверять каждую мелочь, оценивая, насколько безопасен запуск программы, и только при удовлетворении всех требований выдаст-таки исполняемый файл.



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

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

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

Rust RPG


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

Помните пример с dbg!()? Это макрос, представляющий грубый эквивалент console.log из JS. Давайте создадим собственную типизированную переменную и выведем ее в консоль.



Мы создали struct, которая, по сути, является типом. Затем мы создали объект этого типа. В завершении мы запросили вывод созданного объекта.



Ну дела. Наш игрок до такой степени нуб, что даже не может предоставить отладочную информацию. Правда! Просто удивительно

Ключевой момент здесь в том, что ваши рукотворные переменные все начинают в пешках без всякого снаряжения. А вот где вступает в игру и само снаряжение (на языке Rust называемое типажи (Trait)).

Нажмем F.



На этот раз работает. Единственное отличие в появившейся сверху строке. Здесь мы снаряжаем Noob типажом Debug. Теперь наш игрок готов к выходу в консоль какое достижение!

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

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

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

Типаж Copy означает, что вы берете раздел памяти и memcpy его в другое место, работая непосредственно с байтами. Хорошо, значит String не имеет типажа Copy, нужно ли нам просто сообщить компилятору, чтобы он его присвоил? К сожалению, нет. В целях безопасного использования Copy является слишком низкоуровневой для применения к String.

Конечно же, Rust бы не был полезен, если бы история на этом завершалась. Есть более явный типаж, который проделывает практически то же самое Clone. String обладает типажом Clone, значит нам просто нужно использовать его вместо Copy.

В качестве примера немного подправим код:



Здесь компилятор видит, что нам нужно использовать a внутри inner scope, но теперь он также видит, что мы научились все делать правильно, задействовав вместо фактической a ее клона. Итак, получается следующее:

  1. a принадлежит main
  2. Создается a.clone и одалживается в inner scope
  3. inner scope делает свои дела и завершается
  4. Rust отбрасывает a.clone
  5. main без проблем использует a, потому что a всегда оставалась в ее владении

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

Прежде чем завершать пост, считаю необходимым вкратце проговорить еще кое-что. Мы рассмотрели владение и то, как оно реализуется в области, но правда в том, что это было всего лишь упрощение. Для отслеживания владения Rust задействует принцип времени жизни (Lifetimes). Просто, получается, что чаще всего времена жизни и области совпадают. Хотя иногда компилятору все же требуется помощь. Следовательно, мы можем работать напрямую с временами жизни, а в некоторых случаях даже обязаны.

Конец?


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

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


Подробнее..

Перевод Оптимизируем производительность JavaScript (V8) vs AssemblyScript (WebAssembly)

28.04.2021 14:06:12 | Автор: admin


Чтобы повысить производительность web-приложений, используйте WebAssembly в связке с AssemblyScript, чтобы переписать критически важные для производительности компоненты web-приложения, написанные на JavaScript. И это действительно поможет?, спросите вы.

К сожалению, на этот вопрос нет чёткого ответа. Всё зависит от того, как вы будете их использовать. Вариантов много: в одних случаях ответ будет отрицательный, в других положительный. В одной ситуации лучше выбрать JavaScript вместо AssemblyScript, а в другой наоборот. На это влияет множество различных условий.

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

Кто я такой и почему занимаюсь этой темой?


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

Мне по-настоящему нравится язык AssemblyScript. Я даже в какой-то момент начал помогать разработчикам финансово. У них небольшая команда, в которой каждый серьёзно увлечён этим проектом. AssemblyScript очень молодой язык, похожий на TypeScript и способный компилироваться в WebAssembly (Wasm). Именно в этом и заключается одно из его преимуществ. Раньше, чтобы использовать Wasm, web-разработчик должен был учить чуждые ему языки типа С, C++, C#, Go или Rust, так как в WebAssembly изначально могли компилироваться именно такие высокоуровневые языки со статической типизацией.

Хотя AssemblyScript (ASC) и похож на TypeScript (TS), он не связан с этим языком и не компилируется в JS. Схожесть в синтаксисе и семантике нужна, чтобы облегчить процесс портирования с TS на ASC. Такое портирование в основном сводится к добавлению аннотаций типов.

Мне всегда было интересно взять код на JS, портировать его на ASC, скомпилировать в Wasm и сравнить производительность. Когда мой коллега Ингвар прислал мне фрагмент JavaScript-кода для размытия изображений, я решил использовать его. Я провёл небольшой эксперимент, чтобы понять, стоит ли более глубоко изучать эту тему. Оказалось, стоит. В результате появилась эта статья.

Чтобы лучше познакомиться с AssemblyScript, можно изучить материалы на официальном сайте, присоединиться к каналу в Discord или посмотреть вводное видео на моём Youtube-канале. А мы идём дальше.

Преимущества WebAssembly


Как я уже писал выше, долгое время главной задачей Wasm оставалась возможность компиляции кода, написанного на высокоуровневых языках общего назначения. Например, в Squoosh (онлайн-инструмент для обработки изображений) мы используем библиотеки из экосистемы C/C ++ и Rust. Изначально эти библиотеки не были предназначены для использования в web-приложениях, но благодаря WebAssembly это стало возможным.

Кроме того, согласно распространённому мнению, компиляция исходного кода в Wasm нужна ещё и потому, что использование Wasm-бинарников позволяет ускорить работу web-приложения. Я соглашусь, как минимум, с тем, что в идеальных (лабораторных) условиях WebAssembly и JavaScript-бинарники могут обеспечить примерно равные значения пиковой производительности. Вряд ли это возможно на боевых web-проектах.

На мой взгляд, разумнее рассматривать WebAssembly как один инструментов оптимизации средних, рабочих значений производительности. Хотя недавно у Wasm появилась возможность использовать SIMD-инструкции и потоки с разделяемой памятью. Это должно повысить его конкурентоспособность. Но в любом случае, как я писал выше, всё зависит от конкретной ситуации и начальных условий.

Ниже рассмотрим несколько таких условий:

Отсутствие разогрева


JS-движок V8 обрабатывает исходный код и представляет его в виде абстрактного синтаксического дерева (АСТ). Основываясь на построенном АСТ, оптимизированный интерпретатор Ignition генерирует байткод. Полученный байткод забирает компилятор Sparkplug и на выходе выдаёт пока ещё не оптимизированный машинный код, с большим объёмом футпринта. В процессе исполнения кода V8 собирает информацию о формах (типах) используемых объектов и затем запускает оптимизирующий компилятор TurboFan. Он формирует низкоуровневые машинные инструкции, оптимизированные для целевой архитектуры с учётом собранной информации об объектах.

С тем, как устроены JS-движки можно разобраться, изучив перевод этой статьи.


Пайплайн JS-движка. Общая схема

С другой стороны, в WebAssembly используется статическая типизация, поэтому из него можно сразу сгенерировать машинный код. У движка V8 есть потоковый компилятор Wasm под названием Liftoff. Он, как и Ignition, помогает быстро подготовить и запустить неоптимизированный код. И после этого просыпается всё тот же TurboFan и оптимизирует машинный код. Он будет работать быстрее, чем после компиляции Liftoff, но для его генерации потребуется больше времени.

Принципиальное отличие пайплайна JavaScript от пайплайна WebAssembly: движку V8 незачем собирать информацию об объектах и типах, так как у Wasm типизация статическая и всё известно заранее. Это экономит время.

Отсутствие деоптимизации


Машинный код, который TurboFan генерирует для JavaScript, можно использовать только до тех пор, пока сохраняются предположения о типах. Допустим, TurboFan сгенерировал машинный код, например, для функции f с числовым параметром. Тогда, встретив вызов этой функции с объектом вместо числа, движок опять задействует Ignition или Sparkplug. Это называется деоптимизацией.

У WebAssembly в процессе выполнения программы типы измениться не могут. Поэтому в такой деоптимизации нет необходимости. А сами типы, которые поддерживает Wasm, органично переводятся в машинный код.

Минимизация бинарников для больших проектов


Wasm изначально спроектирован с учётом формата компактных бинарных файлов. Поэтому такие бинарники быстро загружаются. Но во многих случаях они всё-таки получаются больше, чем хотелось бы (по крайней мере, с точки зрения объёмов, принятых в сети). Однако с помощью gzip или brotli эти файлы хорошо сжимаются.

За годы своей жизни JavaScript много чего научился делать из коробки: массивы, объекты, словари, итераторы, обработка строк, прототипное наследование и так далее. Всё это встроено в его движок. А язык С++, например, может похвастаться гораздо большим размахом. И каждый раз, когда вы используете любую из таких абстракций языка при компиляции в WebAssembly, соответствующий код из-под капота должен быть включен в ваш бинарный файл. Это одна из причин разрастания двоичных файлов WebAssembly.

Wasm толком ничего не знает про C++ (или любой другой язык). Поэтому среда исполнения Wasm не предоставляет стандартную библиотеку C++ и компилятору её приходится добавлять в каждый бинарный файл. Но такой код достаточно подключить всего один раз. Поэтому для более крупных проектов это не сильно влияет на результирующий размер Wasm-бинарника, который в итоге часто оказывается меньше других бинарников.

Понятно что, не во всех случаях можно принять взвешенное решение, сравнивая только размеры бинарников. Если, например, исходный код на AssemblyScript скомпилировать в Wasm, то бинарник действительно получится очень компактным. Но насколько быстро он будет работать? Я поставил перед собой задачу сравнить разные варианты JS- и ASC-бинарников сразу по двум критериям скорость работы и размер.

Портирование на AssemblyScript


Как я уже писал, TypeScript и ASC сильно похожи по синтаксису и семантике. Легко предположить, что есть сходство и с JS.Поэтому портирование в основном сводится к добавлению аннотаций типов (или к замене типов). Для начала портируем glur, JS-библиотеку для размытия изображений.

Сопоставление типов данных


Встроенные типы AssemblyScript реализованы по аналогии с типами виртуальной машины Wasm (WebAssembly VM). Если в TypeScript, например, тип Number реализован как 64-битное число с плавающей запятой (по стандарту IEEE754), то в ASC есть целый ряд числовых типов: u8, u16, u32, i8, i16, i32, f32 и f64. Кроме того, в стандартной библиотеке AssemblyScript можно обнаружить распространённые составные типы данных (string, Array, ArrayBuffer, Uint8Array и так далее), которые, с определёнными оговорками, присутствуют в TypeScript и JavaScript. Рассматривать здесь таблицы соответствия типов AssemblyScript, TypeScript и Wasm VM я не буду, это тема другой статьи. Единственное, что хочу отметить: в ASC реализован тип StaticArray, для которого я не нашёл аналогов в JS и WebAssembly VM.

Переходим, наконец, к нашему примеру кода из библиотеки glur.

JavaScript:

function gaussCoef(sigma) {if (sigma < 0.5)sigma = 0.5;var a = Math.exp(0.726 * 0.726) / sigma;/* ... more math ... */return new Float32Array([a0, a1, a2, a3,b1, b2,left_corner, right_corner]);}AssemblyScript:function gaussCoef(sigma: f32): Float32Array {if (sigma < 0.5)sigma = 0.5;let a: f32 = Mathf.exp(0.726 * 0.726) / sigma;/* ... more math ... */const r = new Float32Array(8);const v = [a0, a1, a2, a3,b1, b2,left_corner, right_corner];for (let i = 0; i < v.length; i++) {r[i] = v[i];}return r;}


Фрагмент кода на AssemblyScript содержит дополнительный цикл в конце, так как нет возможности инициализировать массив через конструктор. В ASC не реализована перегрузка функций, поэтому в данном случае у нас есть только один конструктор Float32Array (lengthOfArray: i32). В AssemblyScript есть callback-функции, но отсутствуют замыкания, поэтому нет возможности использовать .forEach() для заполнения массива значениями. Вот и пришлось использовать обычный цикл for для копирования по одному элементу.

Возможно вы заметили, что во фрагменте кода наAssemblyScript я вызываю функции не из библиотеки Math, а из Mathf. Дело в том, что первая предназначена для 64-битных чисел с плавающей запятой, а вторая для 32-битных. Я мог бы использовать Math и каждый раз выполнять приведение типов. Но операции для чисел с двойной точностью всё-таки работают чуть медленнее, а мне это не нужно, так как всюду использую тип f32. Хотя в принципе можно было сделать и так. В данном случае это не является узким местом.

На всякий случай: следите за знаками


Мне потребовалось много времени, чтобы понять: выбор типов очень важен. Размытие изображения включает операции свёртки, а это целая куча циклов for, пробегающих все пиксели. Наивно было думать, что, если все индексы пикселей положительны, счётчики цикла тоже будут положительными. Зря я выбрал для них тип u32 (32-битное целое без знака). Если какой-либо из этих циклов будет бежать в обратном направлении, он станет бесконечным (программа зациклится):

let j: u32;// ... many many lines of code ...for (j = width  1; j >= 0; j--) {// ...}


Других сложностей при портировании я не обнаружил.

Бенчмарки с командной оболочкой d8


Хорошо, фрагменты кода на двух языках готовы. Теперь можно компилировать ASC в Wasm и запускать первые тесты производительности.

Несколько слов про d8: это командная оболочка для движка V8 (сам он не имеет своего интерфейса), позволяющая выполнять все необходимые действия как с Wasm, так и с JS. В принципе, d8 можно сравнить с Node, которому вдруг отрубили стандартную библиотеку и остался только чистый ECMAScript. Если у вас нет скомпилированной версии V8 на локале (как её скомпилировать описано здесь), использовать d8 вы не сможете. Чтобы установить d8, используйте инструмент jsvu.

Однако, поскольку в заголовке этого раздела есть слово Бенчмарки, я считаю важным дать здесь своего рода дисклеймер: полученные мною цифры и результаты относятся к коду, который я написал на выбранных мною языках, запущенном на моем компьютере ( MacBook Air M1 2020 года), используя созданные мной тестовые скрипты. Результаты в лучшем случае являются приблизительными ориентирами. Поэтому было бы опрометчиво на их основе давать обобщённые количественные оценки производительности AssemblyScript с WebAssembly или JavaScript с V8.

У вас может возникнуть ещё один вопрос: почему я выбрал d8 и не стал запускать скрипты в браузере или Node? Я считаю, что и браузер, и Node, скажем так, недостаточно стерильны для моих экспериментов. Помимо необходимой стерильности, d8 даёт возможность управлять пайплайном движка V8. Я могу зафиксировать любой сценарий оптимизации и использовать, например, только Ignition, только Sparkplug или Liftoff, чтобы характеристики производительности не изменились в середине теста.

Методика эксперимента


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

Посмотрите, что получилось:



С одной стороны, я обрадовался, что Liftoff выдал более быстрый код в сравнении с Ignition и Sparkplug. Но то, что AssemblyScript, скомпилированный в Wasm с применением оптимизации, оказался в несколько раз медленнее связки JavaScript TurboFan, меня озадачило.

Хотя позже я всё-таки признал, что силы изначально не равны: над JS и его движком V8 много лет работает огромная команда инженеров, реализующих оптимизацию и другие умные вещи. AssemblyScript относительно молодой проект с небольшой командой. Компилятор ASC, сам по себе, однопроходный и перекладывает все усилия по оптимизации на библиотеку Binaryen. Это означает, что оптимизация выполняется на уровне байт-кода Wasm VM после того, как большая часть семантики высокого уровня уже скомпилирована. V8 имеет здесь явное преимущество. Однако код размытия очень прост это обычные арифметические действия со значениями из памяти. Казалось, с этой задачей ASC и Wasm должны были справиться лучше. В чём же тут дело?

Копнём глубже


Я быстро проконсультировался с умными ребятами из команды V8 и с не менее умными парнями из команды AssemblyScript (спасибо Дэниелу и Максу!). Выяснилось, что при компиляции ASC не запускается проверка границ (граничных значений).

V8 может в любой момент посмотреть исходный JS-код и разобраться в его семантике. Он использует эту информацию для повторной или дополнительной оптимизации. Например, у вас есть объект типа ArrayBuffer, содержащий набор бинарных данных. В этом случае V8 ожидает, что наиболее разумным будет не просто хаотично бегать по ячейкам памяти, а использовать итератор посредством цикла for ...of.

for (<i>variable</i> of <i>iterableObject</i>) {<i>statement</i>}

Семантика этого оператора гарантирует, что мы никогда не выйдем за границы массива. Соответственно, компилятор TurboFan не занимается проверкой граничных значений. Но перед компиляцией ASC в Wasm семантика языка AssemblyScript не используется для такой оптимизации: вся оптимизация выполняется на уровне виртуальной машины WebAssembly.

К счастью, у ASC всё-таки есть козырь в рукаве аннотация unchecked(). Она указывает на то, какие значения нужно проверять на возможность выхода за границы.

prev_prev_out_r = prev_src_r * coeff[6];

line[line_index] = prev_out_r;

Предыдущие 2 строки нужно переписать так:

+ prev_prev_out_r = prev_src_r * unchecked(coeff[6]);

+ unchecked(line[line_index] = prev_out_r);

Да, есть кое-что ещё. В AssemblyScript типизированные массивы (Uint8Array, Float32Array и так далее) реализованы по образу и подобию ArrayBuffera. Однако из-за отсутствия высокоуровневой оптимизации для доступа к элементу массива с индексом i каждый раз приходится обращаться к памяти дважды: первый раз для загрузки указателя на первый элемент массива и второй раз для загрузки элемента по смещению i*sizeOfType. То есть приходится обращаться к массиву как к буферу (через указатель). В случае с JS чаще всего этого не происходит, так как V8 удаётся сделать высокоуровневую оптимизацию доступа к массиву, используя однократное обращение к памяти.

Для повышения производительности в AssemblyScript реализованы статические массивы (StaticArray). Они похожи на Array, только имеют фиксированную длину. А следовательно пропадает необходимость хранения указателя на первый элемент массива. Если есть возможность используйте эти массивы для ускорения работы ваших программ.

Итак, я взял связку AssemblyScript TurboFan (она работала быстрее) и назвал её naive. Затем я добавил к ней две оптимизации, о которых говорил в этом разделе, и получил вариант под названием optimized.



Намного лучше! Мы существенно продвинулись. Хотя AssemblyScript всё ещё работает медленнее, чем JavaScript. Неужели это всё, что мы можем сделать? [спойлер: нет]

Ох уж эти умолчания


Ребята из команды AssemblyScript также рассказали мне, что флаг --optimize эквивалентен -O3s. Он хорошо оптимизирует скорость работы, но не доводит её до максимума, так как одновременно препятствует разрастанию бинарного файла. Флаг -O3 оптимизирует только скорость и делает это до конца. Использовать -O3s по умолчанию вроде бы правильно, так как в web-разработке принято сокращать размеры бинарников, но стоит ли оно того? По крайней мере, в этом конкретном примере ответ отрицательный: -O3s экономит ничтожные ~ 30 байт, но закрывает глаза на существенное падение производительности:



Один единственный флаг оптимизатора просто переворачивает игру: наконец-то, AssemblyScript обогнал JavaScript (в этом конкретном тестовом примере!).

Я больше не буду указывать в таблице флаг O3, но будьте уверены: отныне и до конца статьи он будет с нами незримо.

Сортировка методом пузырька


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

  • портировал код, добавив типы;
  • запустил тест;
  • оптимизировал;
  • снова запустил тест.

(Создание и заполнение массива для пузырьковой сортировки я не тестировал).



Мы сделали это снова! На этот раз с ещё большим выигрышем по скорости: оптимизированный AssemblyScript почти в два раза быстрее, чем JavaScript. Но и это ещё не всё. Далее меня опять ждут взлёты и падения. Пожалуйста, не переключайтесь!

Управление памятью


Некоторые из вас, возможно, заметили, что в обоих этих примерах толком не показана работа с памятью. В JavaScript V8 берёт на себя всё управление памятью (и сборку мусора) за вас. В WebAssembly, с другой стороны, вы получаете кусок линейной памяти, и вам нужно решить, как его использовать (или, скорее, Wasm должен решить). Насколько сильно изменится наша таблица, если мы будем интенсивно использовать динамическую память?

Я решил взять новый пример с реализацией двоичной кучи. В процессе тестирования я заполняю кучу 1 миллионом случайных чисел (любезно предоставленных Math.random()) и pop() возвращает их обратно, проверяя, находятся ли числа в порядке возрастания. Общая схема работы осталась той же: портировать JS-код в ASC, скомпилировать с конфигурацией naive, запустить тесты, оптимизировать и снова запустить тесты:



В 80 раз медленнее, чем JavaScript с TurboFan?! И в 6 раз медленнее, чем с Ignition! Что же пошло не так?

Настройка среды исполнения


Все данные, которые мы генерируем в AssemblyScript, должны храниться в памяти. Но нам нужно убедиться, что мы не перезаписываем что-либо ещё, что уже там хранится. Поскольку AssemblyScript стремится подражать поведению JavaScript, у него тоже есть сборщик мусора и при компиляции он добавляет этот сборщик в модуль WebAssembly. ASC не хочет, чтобы вы беспокоились о том, когда выделять, а когда освобождать память.

В таком режиме (он называется incremental) он работает по умолчанию. При этом в Wasm-модуль добавляется всего около 2 КБ в архиве gzip. AssemblyScript также предлагает альтернативные режимы minimal и stub. Режимы можно выбирать с помощью флага --runtime. Minimal использует тот же аллокатор памяти, но более лёгковесный сборщик мусора, который не запускается автоматически, а должен быть вызван вручную. Это может понадобиться при разработке высокопроизводительных приложений (например, игр), где вы хотите контролировать момент, когда сборщик мусора приостановит вашу программу. В режиме stub в Wasm-модуль добавляется очень мало кода (~ 400 Б в формате gzip). Он работает быстро, так как используется резервный аллокатор (подробнее про аллокаторы написано здесь).

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

Давайте посмотрим, как будет работать наш модуль в разных режимах:



И minimal, и stub резко приблизили нас к уровню производительности JavaScript. Интересно, почему? Как упоминалось выше, minimal и incremental используют один и тот же аллокатор. У обоих также есть сборщик мусора, но minimal не запускает его, если он не вызван явно (а я его как раз не вызываю). Значит, всё дело в том, что incremental запускает сборку мусора автоматически, и зачастую делает это без необходимости. Ну и зачем это нужно, если он должен отслеживать всего лишь один массив?

Проблема с распределением памяти


Несколько раз запустив Wasm-модуль в режиме отладки (--debug), я обнаружил, что скорость работы замедляется из-за библиотеки libsystem_platform.dylib. Она содержит примитивы уровня ОС для работы с потоками и управления памятью. Вызовы в эту библиотеку выполняются из __new() и __renew(), которые, в свою очередь, вызываются из Array#push:

[Bottom up (heavy) profile]:

ticks parent name

18670 96.1% /usr/lib/system/libsystem_platform.dylib

13530 72.5% Function: *~lib/rt/itcms/__renew

13530 100.0% Function: *~lib/array/ensureSize

13530 100.0% Function: *~lib/array/Array#push

13530 100.0% Function: *binaryheap_optimized/BinaryHeap#push

13530 100.0% Function: *binaryheap_optimized/push

5119 27.4% Function: *~lib/rt/itcms/__new

5119 100.0% Function: *~lib/rt/itcms/__renew

5119 100.0% Function: *~lib/array/ensureSize

5119 100.0% Function: *~lib/array/Array#push

5119 100.0% Function: *binaryheap_optimized/BinaryHeap#push


Ясно: здесь проблема с управлением памятью. Но JavaScript каким-то образом умудряется быстро обрабатывать постоянно растущий массив. Так почему же этого не может AssemblyScript? К счастью, исходники стандартной библиотеки AssemblyScript есть в открытом доступе, так что давайте взглянем на эту зловещую функцию push () класса Array:

export class Array<T> {// ...push(value: T): i32 {var length = this.length_;var newLength = length + 1;ensureSize(changetype<usize>(this), newLength, alignof<T>());// ...return newLength;}// ...}


Пока всё верно: новая длина массива равна текущей длине, увеличенной на 1. Далее следует вызов функции ensureSize (), чтобы убедиться, что в буфере достаточно места (Capacity) для нового элемента.

function ensureSize(array: usize, minSize: usize, alignLog2: u32): void {// ...if (minSize > <usize>oldCapacity >>> alignLog2) {// ...let newCapacity = minSize << alignLog2;let newData = __renew(oldData, newCapacity);// ...}}


Функция ensureSize (), в свою очередь, проверяет: Capacity меньше, чем новый minSize? Если да, выделяет новый буфер размером minSize с помощью функции__renew(). Это влечёт за собой копирование всех данных из старого буфера в новый буфер. По этой причине наш тест с заполнением массива 1 миллионом значений (один элемент за другим), приводит к перераспределению большого количества памяти и создаёт много мусора.

В других библиотеках (как std::vec в Rust или slices в Go), новый буфер имеет вдвое большую ёмкость (Capacity), чем старый, что помогает сделать процесс работы с памятью не таким затратным и медленным. Я работаю над этой проблемой в ASC, и пока единственное решение это создать собственный CustomArray , с собственной оптимизацией работы с памятью.



Теперь incremental работает так же быстро, как minimal и stub. Но JavaScript в этом тестовом примере всё равно остаётся лидером. Вероятно, я мог бы сделать больше оптимизаций на уровне языка, но это не статья о том, как оптимизировать сам AssemblyScript. Я и так уже погрузился достаточно глубоко.

Есть много простых оптимизаций, которые мог бы сделать за меня компилятор AssemblyScript. С этой целью команда ASC работает над высокоуровневым оптимизатором IR (Intermediate Representation) под названием AIR. Сможет ли это сделать работу быстрее и избавить меня от необходимости каждый раз вручную оптимизировать доступ к массиву? Скорее всего. Будет ли он быстрее, чем JavaScript? Трудно сказать. Но в любом случае мне было интересно побороться за ASC, оценить возможности JS и увидеть, чего может достичь более зрелый язык с очень умными инструментами компиляции.

Rust & C++


Я переписал код на Rust, максимально идиоматично (как мог), и скомпилировал его в WebAssembly. Он оказался быстрее, чем AssemblyScript (naive), но медленнее, чем наш оптимизированный AssemblyScript с CustomArray . Далее я оптимизировал модуль, скомпилированный из Rust примерно по той же схеме, что и AssemblyScript. С такой оптимизацией Wasm-модуль на базе Rust работает быстрее, чем наш оптимизированный AssemblyScript, но всё же медленнее, чем JavaScript.

Я применил тот же подход к C ++, для компиляции в WebAssembly использовал Emscripten. К моему удивлению, даже первый вариант без оптимизации оказался не хуже JavaScript.


Здесь URL картинки нет. Я сам делал скриншот

Версии, помеченные как idiomatic (идиоматические), в любом случае создавались под влиянием исходного кода JS. Я пытался использовать свои знания идиом Rust, C++, но в моей голове прочно сидела установка, что я занимаюсь портированием. Я уверен, что тот, у кого больше опыта в этих языках мог бы реализовать задачу с нуля, и код выглядел бы иначе.

Я почти уверен, что модули с Rust и C ++ могли бы работать ещё быстрее. Но у меня не хватило глубоких знаний этих языков, чтобы выжать из них больше.

Опять про размеры бинарников


Посмотрим на размеры бинарных файлов после сжатия с помощью gzip. По сравнению с Rust и C++, бинарники AssemblyScript действительно намного легче по весу.



И всё-таки рекомендации


Я писал об этом в начале статьи, повторю и сейчас: результаты в лучшем случае являются приблизительными ориентирами. Поэтому было бы опрометчиво на их основе давать обобщённые количественные оценки производительности. Например, нельзя сказать, что Rust во всех случаях в 1,2 раза медленнее JavaScript. Эти числа очень сильно зависят от кода, который я написал, оптимизаций, которые я применил, и машины, которую я использовал.

И всё-таки, я думаю, есть несколько общих рекомендаций, которые мы можем извлечь, чтобы помочь вам лучше разбираться в теме и принимать более обоснованные решения:

  • Компилятор Liftoff в связке с AssemblyScript будет генерировать Wasm-код, который выполняется значительно быстрее, чем код, который выдаёт Ignition или SparkPlug в связке с JavaScript. Если вам нужна производительность и нет времени на разогрев JS-движка, WebAssembly лучший вариант.
  • V8 действительно хорошо компилирует и оптимизирует JavaScript-код. Хотя WebAssembly может работать быстрее, чем JavaScript, вполне вероятно, что для этого вам придётся заниматься оптимизацией вручную.
  • Более медленный язык и компилятор, с годами обросший разнообразными способами оптимизации, даст фору шустрому языку и молодому компилятору.
  • Модули AssemblyScript, как правило, весят намного меньше, чем Wasm-модули, скомпилированные из других языков. В этой статье бинарник с AssemblyScript не был меньше бинарника с JavaScript, но для более крупных модулей ситуация обратная, как утверждает команда разработки ASC.

Если вы мне не верите (а вы и не обязаны) и хотите самостоятельно разобраться в коде тестовых примеров, вот они.



Наши серверы можно использовать для разработки на WebAssembly.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Chipmunk обновления

09.04.2021 10:09:31 | Автор: admin

Короткий обзор очередных обновлений смотрелки логов chipmunk. Много исправлений, много корректировок и немного фишек, в том числе запрашиваемых сообществом.


Последний релиз 2.16.2 вышел пару дней назад и содержит довольно много исправлений, кроме того оптимизирована работа ряда функций. Нет смысла останавливаться на полном перечне изменений, но стоит упомянуть о том, что могло бы быть полезно вам.

О чём это вообще?

Это о программе chipmunk, позволяющей открывать файлы любых размеров и быстро по ним искать. Можно сохранять свои фильтры, можно стоить графики, мерить время и много чего другого, что не будет лишним при анализе логов. Здесь больше.

В деталях

Появилось новое приложение для боковой панели Shell. Фактически это простая запускалка консольных команд. Работает крайне просто вбиваем команду смотрим вывод. Таким образом вы можете проанализировать в chipmunk вывод от любой интересующей вас команды, будь то adb logcat, journalctl или tail.

Приложение боковой панели - ShellПриложение боковой панели - Shell

Кстати о последнем, мы получали от сообщества вопросы о поддержке обновления открытого файла и это будет реализовано в версии 3.0 вместе с миграцией всего ядра на rust. Однако, уже сейчас вы можете просто запустить команду tail -f name_of_live_logfile и получать живой вывод. Естественно, если при этом у вас будет активный поиск, то и результаты его будут обновляться автоматически.

Кроме того, Shell позволяет редактировать переменные окружения. Вы можете изменить или добавить переменные, сохранить всё в ваш профайл и после просто его выбрать. Может быть весьма удобно, если какая-то из ваших консольных программ берёт данные из окружения.

Блиц

  • DLT коннектор теперь понимает и UDP, и TCP, и IPv4 и IPv6. Последние не требуют каких-либо галочек и переключателей, тип адреса будет определён автоматически. Также можно подключиться к нескольким multicast точкам.

  • Исправили рендер графиков. Теперь все отрисовывается корректно и при изменении размеров окна, и при переключении между сессиями.

  • От встроенного терминала решили отказаться. Он не несёт в себе никакого функционала, который можно было бы с пользой интегрировать с chipmunk, но добавляет головной боли с точки зрения зависимостей и стабильности работы программы.

  • Научили chipmunk обновляться через прокси (соответствующие настройки можно найти в меню Settings/General/Network

  • Добавили поддержку копирования и экспорта результатов поиска.

Пожалуй это самое важное. Я не рискну сейчас анонсировать точные сроки по версии 3.0 с ядром на rust, скажу лишь то, что мы рассчитываем на этот год. И это будет легче достигнуть с Вашей поддержкой ведь каждая новая звёздочка на github это не только ценная обратная связь, но и наша ответственность перед Вами!

Скачать без рекламы и регистрации )

Спасибо. Тепла, добра и света!

Подробнее..

Перевод Языки любимые и языки страшные. Зелёные пастбища и коричневые поля

07.05.2021 14:19:42 | Автор: admin


Результаты опроса Stack Overflow являются отличным источником информации о том, что происходит в мире разработки. Я просматривал результаты 2020 года в поисках некоторых идей, какие языки добавить в нашу документацию по контейнерным сборкам, и заметил кое-что интересное о типах языков. Мне кажется, это не часто встречается в различных дискуссиях о предпочтениях разработчиков.

В опросах есть категории Самые страшные языки программирования (The Most Dreaded Programming Languages) и Самые любимые языки. Оба рейтинга составлены на основе одного вопроса:

На каких языках вы провели обширную работу по разработке за последний год, и на каких хотите работать в следующем году? (Если вы работаете с определённым языком и намерены продолжать это делать, пожалуйста, установите оба флажка).

Страшный язык это такой, с которым вы активно работаете в текущем году, но не хотите продолжать его использовать. Любимый язык тот, который вы широко используете и хотите продолжать использовать. Результаты интересны тем, что отражают мнения людей, которые активно используют каждый язык. Не учитываются мнения типа Я слышал, что Х это круто, когда люди высоко оценивают вещи, которые они НЕ используют, потому что они слышали, что это новый тренд. Обратное тоже правда: люди, которые выражают отвращение к какому-то языку, реально широко используют его. Они боятся языка не потому, что слышали о его сложности, а потому, что им приходится работать с ним и испытывать настоящую боль.

Топ-15 страшных языков программирования:
VBA, Objective-C, Perl, Assembly, C, PHP, Ruby, C++, Java, R, Haskell, Scala, HTML, Shell и SQL.

Топ-15 любимых языков программирования:
Rust, TypeScript, Python, Kotlin, Go, Julia, Dart, C#, Swift, JavaScript, SQL, Shell, HTML, Scala и Haskell.

В списке есть закономерность. Заметили?

Худший код тот, что написан до меня


Старый код хуже всего. Если кодовая база в активной разработке более трёх лет, то она уже непоследовательная. На простой первый слой накладываются особые случаи и оптимизация производительности, а также различные ветви, управляемые параметрами конфигурации. Реальный код эволюционирует, чтобы соответствовать своей нише, одновременно он становится всё сложнее и труднее для понимания. Причина проста, и я впервые услышал эту фразу от Джоэла Спольски.

Причина, по которой [разработчики] считают старый код бардаком, заключается в кардинальном, фундаментальном законе программирования: читать код труднее, чем писать его.

Джоэл Спольски Грабли, на которые не стоит наступать

Назовём это Законом Джоэла. Из этой посылки вытекает многое. Почему большинство разработчиков думают, что унаследованный ими код это бардак, и хотят выбросить его и начать всё сначала? Потому что написание чего-то нового проще для мозга, чем тяжёлая работа по пониманию существующей кодовой базы, по крайней мере, на начальном этапе. Почему попытки переписать код часто обречены на провал? Потому что многие мусорные артефакты это жизненно важные небольшие улучшения, которые накапливаются с течением времени. Без какого-то конкретного плана по рефакторингу вы в конечном итоге вернётесь к тому, с чего начали.


Scott Adams Understood

Легко понять код, который вы пишете. Вы его выполняете и совершенствуете по ходу дела. Но трудно понять код, просто прочитав его постфактум. Если вы вернётесь к своему же старому коду то можете обнаружить, что он непоследовательный. Возможно, вы выросли как разработчик и сегодня бы написали лучше. Но есть вероятность, что код сложен по своей сути и вы интерпретируете свою боль от понимания этой сложности как проблему качества кода. Может, именно поэтому постоянно растёт объём нерассмотренных PR? Ревью пул-реквестов работа только на чтение, и её трудно сделать хорошо, когда в голове ещё нет рабочей модели кода.

Вот почему вы их боитесь


Если реальный старый код незаслуженно считают бардаком, то может и языки программирования несправедливо оцениваются? Если вы пишете новый код на Go, но должны поддерживать обширную 20-летнюю кодовую базу C++, то способны ли справедливо их ранжировать? Думаю, именно это на самом деле измеряет опрос: страшные языки, вероятно, будут использоваться в существующих проектах на коричневом поле. Любимые языки чаще используются в новых проектах по созданию зелёных пастбищ. Давайте проверим это.1

Сравнение зелёных и коричневых языков


Индекс TIOBE измеряет количество квалифицированных инженеров, курсов и рабочих мест по всему миру для языков программирования. Вероятно, есть некоторые проблемы в методологии, но она достаточно точна для наших целей. Мы используем индекс TIOBE за июль 2016 года, самый старый из доступных в Wayback Machine, в качестве прокси для определения языков, накопивших много кода. Если язык был популярным в 2016 году, скорее всего, люди поддерживают написанный на нём код.

Топ-20 языков программирования в списке TIOBE по состоянию на июль 2016 года: Java, C, C++, Python, C#, PHP, JavaScript, VB.NET, Perl, ассемблер, Ruby, Pascal, Swift, Objective-C, MATLAB, R, SQL, COBOL и Groovy. Можем использовать это в качестве нашего списка языков, которые с большей вероятностью будут использоваться в проектах по поддержке кода. Назовём их коричневыми языками. Языки, не вошедшие в топ-20 в 2016 году, с большей вероятностью будут использоваться в новых проектах. Это зелёные языки.


Из 22 языков в объединённом списке страшных/любимых 63% коричневых

Коричневый язык: язык, который вы, скорее всего, будете использовать в поддержке программного обеспечения.

Java, C, C++, C#, Python, PHP, JavaScript, Swift, Perl, Ruby, Assembly, R, Objective-C, SQL


Зелёный язык: язык, который вы с большей вероятностью будете использовать в новом проекте.

Go, Rust, TypeScript, Kotlin, Julia, Dart, Scala и Haskell

У TIOBE и StackOverflow разные представления о том, что такое язык программирования. Чтобы преодолеть это, мы должны нормализовать два списка, удалив HTML/CSS, шелл-скрипты и VBA.2

Конечно, простое деление на зелёные и коричневые упускает много нюансов, в том числе по размеру полей. Я ожидаю, что больше зелёных пастбищ должно быть на Swift, чем на Objective-C, но и нынешняя методика, кажется, охватывает всё, что нам нужно. В этом списке гораздо больше коричневых языков, чем зелёных, но это вполне ожидаемо, ведь новых языков ежегодно появляется относительно немного.

Теперь можно ответить на вопрос: люди действительно боятся языков или же они просто боятся старого кода? Или скажем иначе: если бы Java и Ruby появились сегодня, без груды старых приложений Rails и старых корпоративных Java-приложений для поддержки, их всё ещё боялись бы? Или они с большей вероятностью появились бы в списке любимых?

Страшные коричневые языки



Страшные языки на 83% коричневые

Топ страшных языков почти полностью коричневый: на 83%. Это более высокий показатель, чем 68% коричневых языков в полном списке.

Любимые зелёные языки



Любимые языки на 54% зелёные

Среди любимых языков 54% зелёных. В то же время в полном списке всего лишь 36% языков являются зелёными. И каждый зелёный язык есть где-то в списке любимых.

Ещё один недостаток человеческого характера заключается в том, что все хотят строить и никто не хочет заниматься обслуживанием.

Курт Воннегут

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

Другими словами, Rust, Kotlin и другие зелёные языки пока находятся на этапе медового месяца. Любовь к ним может объясняться тем, что программистам не надо разбираться с 20-летними кодовыми базами.

Устранение предвзятости




Некоторые новые или исторически менее популярные языки программирования могут быть лучше, чем старые или более распространённые языки, но наша способность судить о них кажется довольно предвзятой. В частности, если язык новый или ранее не использовался, то у него некий ангельский образ. А чем дольше используется язык, тем более дьявольский лик он приобретает в глазах разработчиков. Думаю, причина в том, что никому не нравится поддерживать чужой код. А также из-за Закона Джоэла: читать в реальном мире очень сложно. Создавать что-то новое вот это весело, а для этого чаще используются новые языки.

Цикл хайпа языков программирования


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


Цикл хайпа языков программирования

У меня под рукой нет данных, но я отчётливо помню, что Ruby был самым популярным языком в 2007 году. И хотя сегодня у него больше конкурентов, но сегодня Ruby лучше, чем тогда. Однако теперь его боятся. Мне кажется, теперь у людей на руках появились 14-летние приложения Rails, которые нужно поддерживать. Это сильно уменьшает привлекательность Ruby по сравнению с временами, когда были одни только новые проекты. Так что берегитесь, Rust, Kotlin, Julia и Go: в конце концов, вы тоже лишитесь своих ангельских крылышек.3



1. Сначала я придумал критерии. Я не искал данных, подтверждающих первоначальную идею.

Была мысль определять статус зелёного или коричневого по дате создания языка, но некоторые старые языки нашли применение только относительно недавно.

Вот методика измерения TIOBE, а их исторические данные доступны только платным подписчикам, поэтому Wayback Machine. [вернуться]

2. HTML/CSS не являются тьюринг-полными языками, по этой причине TIOBE не считает их полноценными языками программирования. Шелл-скрипты измеряются отдельно, а VBA вообще не исследуется, насколько я понял. [вернуться]

3. Не все коричневые языки внушают страх: Python, C#, Swift, JavaScript и SQL остаются любимыми. Хотелось бы услышать какие-нибудь теории о причине этого феномена. Кроме того, Scala и Haskell два языка, к которым я питаю слабость единственные зелёные языки в страшном списке. Это просто шум или есть какое-то обоснование??? [вернуться]
Подробнее..

Макросы в Rust. macro_rules

02.04.2021 22:17:15 | Автор: admin

Я долго откладывал этот день, но вечно откладывать было нельзя. Что ж, время пришло. Пора наконец разобраться с макросами в Rust. Ну или хотя бы начать.

Давайте сразу определимся, зачем вы хотите их использовать. Макросы - это про метапрограммирование и, можно даже сказать отчасти про reflective программирование. Я, конечно, не разработчик паттернов и стандартов, но использовать макрос (объявленный macro_rules!) как замену функции, как по мне, плохая идея. Во-первых, потому, что функция принимает переменные конкретного типа, а макрос, который принимает переменную, банально не знает её типа, соответственно, понять смысл операций можно только по названию и по самой сигнатуре макроса. А синтаксис макросов не то чтобы очень очевиден

Но, надеюсь, благодаря этой статье он станет более понятен для вас.

Что за зверь такой, macro_rules!?

Давайте начнём с самого простого. macro_rules! - наш путь к написанию макросов, встроенный прямо в стандартную библиотеку. Давайте начнём с простого примера - макрос, который делает то же, что и unwrap. Это, конечно, невероятно глупая и плохая идея, но для примера подойдёт.

Обратите внимание, что макрос объявлен до вызова.

Проверить:

macro_rules! uncover{

($var:ident) => {

match $var{

Some(t) => t,

None => panic!("None value")

}

}

}

fn main(){

let x = Some(2i32);

let unwrapped = uncover!(x);

println!("{}", unwrapped);

}

Ну-с давайте разбираться. Что же мы сделали? Перво-наперво, мы объявили макрос uncover:

macro_rules! uncover

Который принимает переменную $var типа ident. Вообще говоря, ident это не только переменная, но и название функции. Полный список типов, которые можно передать как аргумент в макрос можно поглядеть тут.

В каком-то смысле, вы передаёте в макрос не переменную, а код. Все, что знает макрос про $var - это название того, что мы передали в uncover (в нашем случае, х).

Далее идёт код, который вполне себе похож на нативный Rust код, однако есть момент, который бросается в глаза: мы используем переменную со знаком доллара. Итак, что же сделает этот макрос при вызове? Он вставит всю сигнатуру на то место, где вы его вызываете. То есть по сути в рантайме код будет выглядеть не так:

let unwrapped = uncover!(x);

А так:

let unwrapped = match x{

Some(t) => t,

None => panic!(None value)

};

Использовать макросы как обычные функции - плохая идея. Они делают не то же самое, что функции (хотя чем-то похоже на inline-фуннции). И хоть код

let x = 2i32;

let unwrapped = uncover!(x);

не скомпилируется, лучше использовать старые-добрые функции. Всё-таки, они более явные и отлично выполняют цель, с которой были созданы.

Summary

Итак, зачем же тебе, простой Иван город Тверь, писать макросы? Да не знаю, сам подумай. Может, есть повторяющееся место в коде, под которое идеально подойдёт макрос? Или ты написал нереальную реализацию списка со скоростью работы O(1) и хочешь инициализировать его вот так: list![1,2,3] ? Ну или тебе просто нравится заниматься метапрограммированием? Правда, последнее трудно вяжется с macro_rules! Всё-таки, в языке есть более мощное метапрограммирование, тёмная магия proc_macro, syn, qoute и TokenTree, но о ней как-нибудь в другой раз.

Вот, собственно, и всё. Писать макросы с помощью macro_rules не так-то сложно, главное разобраться в базовых правилах. Может, это сбережёт ваши нервы и/или деньги. Конечно, я не затрагивал в этой статье самое интересное, это наиболее простое из того, что есть. Цель статьи - показать, что макросы - это несложно.

Пишите, если хотите статью про proc_macro и syn, там действительно есть на что посмотреть.

Подробнее..

Перевод Rust теперь и на платформе Android

10.04.2021 14:17:23 | Автор: admin

Корректность кода на платформе Android является наиважнейшим аспектом в контексте безопасности, стабильности и качества каждого релиза Android. По-прежнему сложнее всего вытравливаются ошибки, связанные с безопасностью памяти и попадающиеся в коде на С и C++. Google вкладывает огромные усилия и ресурсы в обнаружение, устранение багов такого рода, а также в уменьшение вреда от них, старается, чтобы багов в релизы Android проникало как можно меньше. Тем не менее, несмотря на все эти меры, ошибки, связанные с безопасностью памяти, остаются основным источником проблем со стабильностью. На их долю неизменно приходится ~70% наиболее серьезных уязвимостей Android.

Наряду с текущимиипланируемымимероприятиями по улучшению выявления багов, связанных с памятью, Google также наращивает усилия по их предотвращению. Языки, обеспечивающие безопасность памяти наиболее эффективные и выгодные средства для решения этой задачи. Теперь в рамках проекта Android Open Source Project (AOSP) наряду с языками Java и Kotlin, отличающимися безопасностью памяти, поддерживается и язык Rust, предназначенный для разработки операционной системы как таковой.

Системное программирование

Управляемые языки, в частности, Java и Kotlin, лучше всего подходят для разработки приложений под Android. Эти языки проектировались в расчете на удобство использования, портируемость и безопасность. Среда исполнения Android (ART)управляет памятью так, как указал разработчик. В операционной системе Android широко используется Java, что фактически защищает большие участки платформы Android от багов, связанных с памятью. К сожалению, на низких уровнях ОС Android Java и Kotlin бессильны.

На низких уровнях ОС нужны языки для системного программирования, такие как C, C++ и Rust. При проектировании этих языков приоритет отдавался контролируемости и предсказуемости. Они обеспечивают доступ к низкоуровневым ресурсам системы и железу. Они обходятся минимальными ресурсами, прогнозировать их производительность также сравнительно просто.

При работе с C и C++ разработчик отвечает за управление сроком жизни памяти. К сожалению, при такой работе легко допустить ошибки,особенно при работе со сложными и многопоточными базами кода.

Rust предоставляет гарантии по поводу безопасности памяти, так как использует сочетание проверок во время компиляции, чтобы обеспечить соблюдение времени жизни объектов и владение ими, а также проверки во время исполнения и таким образом гарантировать валидность обращений к памяти. Такая безопасность действительно обеспечивается, а производительность остается не меньшей, чем при работе с C и C++.

Пределы работы в песочнице

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

В Android это означает, что, если код написан на C/C++ и разбирает потенциально небезопасный ввод, то он должен содержаться в жестко ограниченной песочнице без привилегий. Тогда как следование правилу двух хорошо помогает снижать тяжесть и повышать доступность уязвимостей, связанных с безопасностью, оно сопряжено с некоторыми ограничениями. Работа в песочнице дорогое удовольствие; для ее обеспечения требуются новые процессы, которыесопряжены с дополнительными накладными расходами и провоцируют задержки, связанные с межпроцессной коммуникацией и дополнительным расходом памяти. Работа в песочнице не позволяет полностью исключить уязвимости в коде, а эффективность такой работы снижается при высокой плотности багов, позволяющей злоумышленникам сцеплять вместе множество уязвимостей сразу.

Язык, обеспечивающий безопасность памяти, такой, как Rust, помогает преодолеть эти ограничения двумя способами:

  1. Снижает плотность багов в нашем коде, тем самым повышая эффективность применяемой песочницы.

  2. Снижает потребность в использовании песочницы, поскольку предоставляет новые возможности, которые более безопасны в работе и одновременно менее требовательны к ресурсам.

Что же насчет всего имеющегося C++?

Разумеется, если мы введем новый язык программирования, это никак не поможет нам с исправлением уже имеющихся багов в имеющемся коде на C/C++.

Вышеприведенный анализ возраста багов, связанных с безопасностью памяти (отсчитывается с момента их появления) позволяет судить, почему команда Android делает акцент на новых разработках, а не на переписывании зрелого кода на C/C++. Большинство багов возникает в новом или недавно измененном коде, причем, возраст около 50% багов составляет менее года.

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

Ограничения находимости багов

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

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

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

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

Предотвращение прежде всего

В Rust модернизируется ряд языковых аспектов, что позволяет улучшить корректность кода:

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

  • Конкурентность данных предотвращает гонку данных. Учитывая, насколько легко при этом становится писать эффективный потокобезопасный код, Rust обрел слоганБезбоязненная Конкурентность.

  • Более выразительная система типов помогает предотвратить логические ошибки при программировании (напр., обертки нового типа, варианты перечислений с содержимым).

  • Ссылки и переменные по умолчанию являются неизменяемыми что помогает разработчику следовать безопасному принципу наименьших привилегий. Программист помечает ссылку или переменную как изменяемые, только если в самом деле намерен сделать их таковыми. Притом, что в C++ есть const, эта возможность обычно используется нечасто и несогласованно. Напротив, компилятор Rust помогает избегать случайных аннотаций об изменяемости, так как выдает предупреждения об изменяемых значениях, которые никогда не меняются.

  • Улучшенная обработка ошибок в стандартных библиотеках потенциально провальные вызовы обертываются в Result, и поэтому компилятор требует от пользователя проверять возможность провала даже для функций, не возвращающих необходимого значения. Это позволяет защититься от таких багов как уязвимостьRage Against the Cage, возникающая из-за необработанной ошибки. Обеспечивая легкое просачивание ошибок при помощи оператора ? и оптимизируя Result с расчетом на низкие издержки, Rust стимулирует пользователей писать все потенциально провальные функции в одном и том же стиле, благодаря чему все они получают ту же защиту.

  • Инициализация требует, чтобы все переменные инициализировались перед началом использования. Исторически сложилось, что неинициализированные уязвимости памяти были в Android причиной 3-5% уязвимостей, связанных с безопасностью. В Android 11, чтобы сгладить эту проблему, стала применятьсяавтоматическая инициализация памяти на C/C++. Однако, инициализация в ноль не всегда безопасна, особенно для таких штук, как возвращаемые значения, и в этой области может стать новым источником неправильной обработки ошибок. Rust требует, чтобы любая переменная перед использованием инициализировалась в полноценный член своего типа. Тем самым избегается проблема непреднамеренной инициализации небезопасного значения. Подобно Clang в C/C++, компилятор Rust знает о требовании инициализации и позволяет избежать потенциальных проблем производительности, связанных с двойной инициализацией.

  • Более безопасная обработка целых чисел Очистка во избежание включена в отладочных сборках Rust по умолчанию, что стимулирует программистов указывать wrapping_add, если действительно предполагается допустить переполнение при расчетах, или saturating_add если не предполагается. Очистка при переполнении в дальнейшем должна быть включена для всех сборок Android. Кроме того, при всех преобразованиях целочисленных типов применяются явные приведения: разработчик не может сделать случайного приведения в процессе вызова функции при присваивании значения переменной или при попытке выполнять арифметические операции с другими типами.

Что дальше

Введение нового языка на платформу Android серьезное предприятие. Существуют такие связки инструментов и зависимости, которые необходимо поддерживать, тестовая инфраструктура и оснащение, которые потребуется обновить. Также придется дополнительно обучить разработчиков. Это проект не на один год. Следите за обновлениями в блоге Google.

Подробнее..

Как не копировать код в Rust

12.04.2021 20:16:10 | Автор: admin

Первое правило хорошего тона в программировании (или одно из первых) гласит: "Не копируй код". Используй функции и наследование.

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

impl dyn Trait

Если вы посмотрите на реализацию итератора, то увидите, что у него есть один required метод (то есть тот, который нужно реализовать нам) и provided методы (те, которые реализованы за нас). Что нам это даёт? А то, что если мы напишем свою реализацию какой-либо коллекции, то чтобы реализовать для нее итератор нам нужно всего-навсего имплементировать next(). Все остальные фп-шные штуки вроде map(), filter(), fold() и т.д. будут реализованы автоматически.

Представим, что вы пишите иерархию классов для игры в жанре RPG. Так вот, вместо того, чтобы в каждом новом классе писать один и тот же код, например, получения урона, можно сделать единый интерфейс, который подставит нужный код там, где вы этого попросите:

enum Damage{    Physical(f32),    Magic(f32)}trait Character{  fn get_magic_resistance(&self) -> f32;  fn get_physical_resistance(&self) -> f32;  fn set_hp(&mut self, new_value: f32);  fn get_hp(&self) -> f32;  fn get_type(&self) -> CharacterType;  fn get_dmg(&self) -> Damage;}impl dyn Character {  fn make_hurt(&mut self, dmg: Damage) {    match dmg{      Damage::Physical(dmg) => self.set_hp(self.get_hp() - dmg / self.get_physical_resistance().exp()),      Damage::Magic(dmg) => self.set_hp(self.get_hp() - dmg / self.get_magic_resistance().exp())    }  }}#[derive(Debug, Clone, Copy)]enum CharacterType {    Mage,    Warrior,    Rogue}impl Default for CharacterType {    fn default() -> Self{        CharacterType::Warrior    }}#[derive(Default)]struct Player{    ty: CharacterType,    phys_resist: f32,    mag_resist: f32,    hp: f32,    dmg: f32}impl Player {    pub fn new(ty: CharacterType, hp: f32, dmg: f32) -> Self {        Self{ ty, hp, dmg, .. Default::default() }    }}impl Character for Player{    #[inline]    fn get_magic_resistance(&self) -> f32 {        self.mag_resist    }        #[inline]    fn get_physical_resistance(&self) -> f32{        self.phys_resist    }        #[inline]    fn set_hp(&mut self, new_value: f32){        self.hp = new_value;    }        #[inline]    fn get_hp(&self) -> f32 {        self.hp    }        #[inline]    fn get_type(&self) -> CharacterType {        self.ty    }        fn get_dmg(&self) -> Damage{        match self.ty {            CharacterType::Mage => Damage::Magic(self.dmg),            _ => Damage::Physical(self.dmg)        }    }}struct EnemyWarrior{    ty: CharacterType,    phys_resist: f32,    mag_resist: f32,    hp: f32,    dmg: f32}impl Default for EnemyWarrior {    fn default() -> Self {        Self{            ty: CharacterType::Warrior,            phys_resist: 0.,            mag_resist: 0.,            hp: 10.,            dmg: 1.        }    }}impl Character for EnemyWarrior{    #[inline]    fn get_magic_resistance(&self) -> f32 {        self.mag_resist    }        #[inline]    fn get_physical_resistance(&self) -> f32{        self.phys_resist    }        #[inline]    fn set_hp(&mut self, new_value: f32){        self.hp = new_value;    }        #[inline]    fn get_hp(&self) -> f32 {        self.hp    }        fn get_type(&self) -> CharacterType {        CharacterType::Warrior    }        fn get_dmg(&self) -> Damage{        match self.ty {            CharacterType::Mage => Damage::Magic(self.dmg),            _ => Damage::Physical(self.dmg)        }    }}fn main(){    let mut player = Player::new(CharacterType::Warrior, 10., 1.);    let mut enemy = EnemyWarrior::default();        <dyn Character>::make_hurt(&mut enemy, player.get_dmg());    println!("{}", enemy.get_hp());}

TL;DR сделали 2 структуры и реализовали для них трейт Character, в котором 6 required методов и 1 provided метод.

Но внимательный читатель совершенно справедливо спросит: "Так мы ведь всё равно скопировали код?" Верно, из-за того, что в трейтах нельзя объявлять поля, нам пришлось городить required методы для получения нужных данных. Сейчас-то, конечно, их всего 6, но потом у нас могут добавиться передвижения, анимации, левел-апы и проч. Конечно, целый один метод у нас один на все имплементации, но код-то мы все равно копируем?

Deref<Target=_>

Тут нам на помощь приходит трейт Deref. Честно говоря, не знаю, насколько это хорошая практика использовать его с такой целью, но, например, в image есть такое: ImageBuffer реализует Deref для [P::Subpixel] и, соответственно, имеет все методы массива, которые есть в стандартной библиотеке. Давайте перепишем наш пример под Deref и посмотрим, сколько места нам удалось сэкономить.

use std::ops::Deref;use std::ops::DerefMut;enum Damage{    Physical(f32),    Magic(f32)}#[derive(Default)]struct Character{    ty: CharacterType,    phys_resist: f32,    mag_resist: f32,    hp: f32,    dmg: f32}impl Character {    #[inline]    fn get_magic_resistance(&self) -> f32 {        self.mag_resist    }        #[inline]    fn get_physical_resistance(&self) -> f32{        self.phys_resist    }        #[inline]    fn set_hp(&mut self, new_value: f32){        self.hp = new_value;    }        #[inline]    fn get_hp(&self) -> f32 {        self.hp    }        #[inline]    fn get_type(&self) -> CharacterType {        self.ty    }        fn get_dmg(&self) -> Damage{        match self.ty {            CharacterType::Mage => Damage::Magic(self.dmg),            _ => Damage::Physical(self.dmg)        }    }  fn make_hurt(&mut self, dmg: Damage) {    match dmg{      Damage::Physical(dmg) => self.set_hp(self.get_hp() - dmg / self.get_physical_resistance().exp()),      Damage::Magic(dmg) => self.set_hp(self.get_hp() - dmg / self.get_magic_resistance().exp())    }  }}#[derive(Debug, Clone, Copy)]enum CharacterType {    Mage,    Warrior,    Rogue}impl Default for CharacterType {    fn default() -> Self{        CharacterType::Warrior    }}#[derive(Default)]struct Player(Character);impl Player {    pub fn new(ty: CharacterType, hp: f32, dmg: f32) -> Self {        Self(Character{ ty, hp, dmg, .. Default::default() })    }}impl Deref for Player {    type Target = Character;        #[inline]    fn deref(&self) -> &Self::Target{        &self.0    }}impl DerefMut for Player {    #[inline]    fn deref_mut(&mut self) -> &mut Self::Target{        &mut self.0    }}struct EnemyWarrior(Character);impl Default for EnemyWarrior {    fn default() -> Self {        Self(Character {            ty: CharacterType::Warrior,            phys_resist: 0.,            mag_resist: 0.,            hp: 10.,            dmg: 1.        })    }}impl Deref for EnemyWarrior {    type Target = Character;        #[inline]    fn deref(&self) -> &Self::Target{        &self.0    }}impl DerefMut for EnemyWarrior {    #[inline]    fn deref_mut(&mut self) -> &mut Self::Target{        &mut self.0    }}fn main(){    let mut player = Player::new(CharacterType::Warrior, 10., 1.);    let mut enemy = EnemyWarrior::default();        enemy.make_hurt(player.get_dmg());    println!("{}", enemy.get_hp());}

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

Но у такого подхода тоже есть минус. Если нам нужны другие данные, помимо "родительских", тогда нам придётся создавать дополнительные структуры, чтобы хранить уже свои, независимые от "родительских", данные. Тогда нам придется писать код вроде:

struct Child (Parent, ChildInner);impl Child {  pub fn get_some_field_from_inner(&self) -> &ChildInner::Field {    &self.1.field  }}

И, согласитесь, сигнатура этого метода не очень удобочитаемая. А если у нас будет несколько внутренних структур, то остаётся только пожелать здоровья людям, которые будут это ревьюить и/или поддерживать.

Заключение

В заключение хочу сказать, что в расте не помешало бы явное наследование структур. Всё-таки, как ни крути, в других языках оно позволяет писать с одной стороны хорошо читаемый, с другой стороны лаконичный код (если, конечно, это не множественное наследование). Подходы в Rust, конечно, позволяют экономить какое-то количество места, но хотелось бы чего-то более явного. Да и интерфейсы Deref, DerefMut вообще не предназначены для того, для чего мы их использовали в данной статье. Они, как следует из названия, нужны чтобы разыменовывать умные указатели. А если вы объединяете несколько структур данных, то вам придется использовать синтаксис self.1, который далеко не очевидный. В общем, как и всегда, есть и плюсы и минусы.

Вот, собственно, и все, что я хотел сказать на эту тему. Может, я что-то упустил? Напишите в комментариях, если вас тоже волнует эта тема, хочется знать, что не мне одному не хватает наследования в Rust.

Подробнее..

Как мы выбираем языки программирования в Typeable

26.04.2021 18:04:48 | Автор: admin

Неоднократно меня спрашивали, почему я предпочитаю использовать такие языки программирования как Haskell и Rust, т.к. они не являются самыми широко используемыми и популярными инструментами. Этот пост написан с целью демистифицировать то, что происходит у меня в голове, когда я думаю о выборе технологии.

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

Решения можно упрощенно разделить на несколько категорий: архитектурные, методические и инструментальные. Архитектурные говорят о том, как мы структурируем проект. Методами определяется то, как мы организуем процесс работы, убеждаемся в качестве и корректности реализации. Инструменты же определяют, с чем команде разработки нужно работать, чтобы получить результат. Для полного цикла разработки сегодня используется большое количество инструментов: нужно формализовать требования, процесс разработки, нужно написать программный код и протестировать его, собрать релиз и т. д. Несмотря на такое обилие задач, самое большое значение может сыграть выбор языка программирования, ведь он определяет совокупность следующих параметров:

  1. Baseline по скорости работы ПО.
  2. Особенности распространения и эксплуатации программы, например требование интерпретатора или возможность статической линковки.
  3. Экосистема библиотек и компонентов, которые можно переиспользовать. Отмечу что важно не только количество библиотек, но и качество релевантных для вас.
  4. Возможности параллельного/конкурентного/асинхронного исполнения программ, что может быть важным для многих систем.
  5. Сложность обучения людей выбранной технологии, что значительно влияет и на сообщество языка, и на перепрофилирование разработчиков.
  6. Выразительность языка.

Кроме того, выбор языка программирования может значительно повлиять ещё и на методические вопросы, например, инструменты экосистемы языка могут определять, как и в каком объеме пишутся юнит-тесты. Хорошая инфраструктура для property тестов может дать толчок в этом направлении, а отсутствие хорошей инфраструктуры для юнит-тестов, может осложнить их написание и поддержку.

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

Исходя из этого, следует отдавать себе отчет в том, что правильный выбор языка программирования для проекта и команды может иметь далеко идущие последствия. Памятуя про шахматную аналогию, держим в уме, что каждый маленький плюс пойдет в копилку языка и может сыграть значительную роль в большой разработке. Стоит также оговориться, что речь идёт о выборе средства разработки для ситуаций, где нет жестких ограничений по выбору технологий в связи, например, с большой экосистемой, уже на чем-то написанной. В Typeable мы руководствуемся следующими соображений для языков общего назначения:

  1. Статическая типизация должна поддерживаться языком программирования. Это позволяет сократить длительность для каждой итерации цикла изменения и валидации кода для разработчика. Также это позволяет существенно снизить количество багов, как со стороны функциональных требований, так и безопасности ПО.
  2. Алгебраические типы данных очень сложно переоценить влияние этой фичи, после того, как начинаешь ей пользоваться. Очень доступная и абсолютно необходимая при моделировании инвариантов вещь. Те же типы-суммы настолько незаменимы, что выбрать язык, в котором их нужно моделировать через другие конструкции, это ставить себе преграды уже на первом шаге.
  3. Гибкая возможности для поддержки и исполнения многопоточных программ. Языки с GIL (Global Interpreter Lock) сразу же не удовлетворяют этому требованию. Хочется иметь возможность хорошо утилизировать возможности железа и иметь достаточно высокоуровневые абстракции для работы.
  4. Достаточная экосистема библиотек, оцениваем субъективно их качество в том числе. Не считаем необходимым все подключать в виде библиотек, но наиболее базовые вещи, вроде биндингов к популярным базам данных, должны быть в наличии.
  5. Светлые головы в коммьюнити разработчиков на этом языке программирования. Профиль разработчика, которого бы мы хотели видеть своим сотрудником это заинтересованный в CS и разработке человек. В противопоставлении этому можно поставить статус легких в освоении технологий, которые привлекают людей в IT ради легкой наживы, что сильно размывает рынок спецов.
  6. В нашем распоряжении должны быть языки программирования, которые позволяют реализовывать ПО с жесткими требованиями ко времени обработки и потребления памяти.

В результате всего вышесказанного в нашем ящичке с инструментами есть достаточно блоков, которые позволяют нам занять уверенную позицию во многих проектах. Возвращаясь к шахматной аналогии, это наши принципы, которые позволяют вести позиционную игру. Позиционная игра игра направленная на создание долгосрочной позиции, открывающей игроку возможности и минимизирующей слабости. Она противопоставляется игре атакующей, острой, где есть большая доля риска, а атакующий игрок пытается эту игру завершить до того, как его противник сможет занять хорошую оборонительную позицию. Острая разработка это олимпиадное программирование, MVP для маркетинговых экспериментов, многие задачи в data science, да и зачастую создание ПО, сопровождающее Computer Science публикации. Их объединяет то, что долгой поддержки они зачастую не требуют, им только нужно отработать в определенный момент времени. Позиционная же игра это игра вдолгую, где ключевыми показателями являются поддерживаемость и обновляемость. Именно этим мы и занимаемся, и нам нужен хороший фундамент, для того чтобы быть уверенными в долгосрочной работе ПО, которое мы пишем и обновляем. Такие проекты тоже могут начинаться с MVP, но они делаются с совсем другими предпосылками.

Почему же список соображений для выбора технологий именно такой? Причин тут несколько. Во-первых, желательно исключить вопросы модности и трендовости технологий, для того чтобы увеличить предсказуемость на долгом промежутке времени. Компилятор с долгой историей и активным сообществом это пусть и консервативный, но надежный выбор, в противопоставление новым сверкающим вещам, появляющимся из года в года. Наверняка часть из них перейдет из последней категории в первую, но мы об этом узнаем позже, вероятно, через годы. Вместо трендов мы пытаемся использовать фундаментальный Computer Science и большое количество исследований по этой теме, которые нашли применение в используемых нами языках программирования. Например: теория типов это смежная математике и CS дисциплина, которая занимается фундаментальными вопросами формализации требований. Это ровно то, что нам нужно для написания ПО. К тому же это совокупный опыт других людей, занимающихся точными науками, и как-то глупо, на мой взгляд, этим опытом пренебрегать. Лучше взять за основу такую дисциплину, чем не взять ничего, либо же взять субъективное мнение, основанное на жизненном опыте одного отдельно взятого человека.

Во-вторых, мы ищем реализацию наибольшего количества взятых нами принципов в языках программирования и компиляторах. По этой причине вдобавок к нашему любимому Haskell, в нашем ящичке инструментов стал появляться Rust. С real-time требованиями и жесткими ограничениями на использование памяти нам нужно что-то достаточно низкоуровневое. Строгость типизации в C все же оставляет желать лучшего, поэтому если есть возможность использовать Rust для такой задачи, мы предпочтем это сделать.

Третья причина: мы делаем ПО в первую очередь для наших клиентов, и мы бы хотели, чтобы они были защищены от наших предрассудков. Поэтому при выборе инструмента риск не может превышать некий уровень, который должен быть оговорен с клиентом. Но даже с такими условиями у нас появились довольно маргинальные технологии вроде GHCJS, т.к. при совокупном разборе сильных и слабых сторон картинка все равно была привлекательной для нас и наших клиентов. Про то, как мы пришли к этому решению, мы уже писали: Elm vs Reflex.

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

Перевод Rust 1.52.0 улучшения Clippy и стабилизация API

07.05.2021 14:19:42 | Автор: admin

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


Если вы установили предыдущую версию Rust средствами rustup, то для обновления до версии 1.52.0 вам достаточно выполнить следующую команду:


rustup update stable

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


Что было стабилизировано в 1.52.0


Самое значительное изменение этого выпуска не касается самого языка или стандартной библиотеки. Это улучшения в Clippy.


Ранее запуск cargo clippy после cargo check не запускал Clippy: кэширование в Cargo не видело разницы между ними. В версии 1.52 это поведение было исправлено, а значит, теперь пользователи будут получать то поведение, которое ожидают, независимо от порядка запуска этих команд.


Стабилизированные API


Следующие методы были стабилизированы:



Следующие ранее стабилизированные API стали const:



Другие изменения


Синтаксис, пакетный менеджер Cargo и анализатор Clippy также претерпели некоторые изменения.


Участники 1.52.0


Множество людей собрались вместе, чтобы создать Rust 1.52.0. Мы не смогли бы сделать это без всех вас. Спасибо!


От переводчиков


С любыми вопросами по языку Rust вам смогут помочь в русскоязычном Телеграм-чате или же в аналогичном чате для новичковых вопросов. Если у вас есть вопросы по переводам или хотите помогать с ними, то обращайтесь в чат переводчиков.
Также можете поддержать нас на OpenCollective.


Данную статью совместными усилиями перевели Belanchuk, TelegaOvoshey, blandger, nlinker и funkill.

Подробнее..

Перевод Как самый недооценённый контрибьютор спасал язык Rust от смерти

08.05.2021 10:22:37 | Автор: admin

Дэйв Херман, самый недооценённый контрибьютор Rust

Автор оригинала Брайан Андерсон. Он тоже имеет отношение к работе над языком Rust. Понятно, что в своей статье он будет хвалить этот язык, но важнее другое: он раскрывает внутреннюю кухню проекта. Андерсон рассказывает про первые несколько лет работы над Rust в стенах компании Mozilla. Он хочет восстановить справедливость, вспомнив выдающегося, но скромного ментора и инженера, который, во многом, решил судьбу проекта.

Я считаю, что язык Rust сейчас явно на подъёме. Вспоминаю, как много нужно было сделать правильно, чтобы достичь успеха. Команда неоднократно принимала судьбоносные решения, в которых не была полностью уверена. Каждое неверное решение могло привести проект к провалу. Иногда мы просто не знали правильный ответ. Но в каждой такой ситуации как будто происходило маленькое чудо, и проект двигался дальше. Конечно же, эти чудеса происходили не на пустом месте: нас выручала интуиция нескольких очень опытных профессионалов, которые горели проектом и действительно хотели создать что-то крутое.

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

Mozilla Research


К 2009 году Mozilla получила крупную сумму денег от прибыльной сделки с Google (он стал поисковиком по умолчанию в браузере Firefox). И, насколько я понял, именно в этот момент руководство Mozilla решило вложить деньги в новые проекты.

Так появилось подразделение Mozilla Research. Его задачей стала разработка экспериментальных проектов совместно с академическим ИТ-сообществом.

Наиболее известными среди таких проектов стали язык программирования Rust и новый браузерный движок Servo.

К руководству подразделением среди прочих был привлечён и Дэйв Херман. О нём я и буду рассказывать до конца статьи.

Кто такой Дэйв Херман?


Дэйв Херман теоретик языков программирования и, как я его называю, макрофил (тот, кто очень любит макросы). Он был одним из представителей Mozilla в комиссии ECMAScript. Он и Грейдон Хоар, главный разработчик Rust, в то время совместно работали над стандартом ECMAScript 4. Оба горели идеей создания нового языка программирования.

Несмотря на то, что Хоар считается создателем Rust, Херман серьёзно повлиял на многие ключевые решения. Ниже я опишу всё, что помню об этом.

Что конкретно Дэйв Херман сделал для Rust?


Хотя Rust был анонсирован в июне 2010 года, работа над ним внутри Mozilla фактически началась в конце 2009 года. Единственная публичная запись о том этапе разработки на данный момент находится в репозитории rust-prehistory.

За нескольких месяцев до того, как Rust был представлен публике на Mozilla Summit 2010, команда спешно допиливала его. Видно, что вклад Дэйва в эту работу сложно недооценивать:

~/rust-prehistory $ git shortlog -sn

1156 Graydon Hoare

163 Andreas Gal

104 Dave Herman

59 graydon@pobox.com

55 Patrick Walton

37 Graydon Hoare ext:(%22)

13 Roy Frostig

9 graydon@mozilla.com

6 Brendan Eich

5 Michael Bebenita

1 Brian Campbell

Стратегия Дэйва


После этого он почти перестал писать код и в течение нескольких лет работал с командой в другой роли как более опытный наставник, идейный вдохновитель и аналитик.

В то время большинство из тех, кто работал над Rust, трудились в одном офисе (за исключением, прежде всего, Грейдона, который был на удалёнке) и регулярно собирались за столом в маленьком конференц-зале штаб-квартиры Mozilla в Маунтин-Вью. Команда состояла из штатных сотрудников, постоянно меняющейся толпы стажёров и самого Дэйва Хермана.

Я предполагаю, что он считал себя наставником, мягко подталкивающим команду к продуктивному взаимодействию, основываясь на собственном опыте работы с ECMAScript и на своих собственных представлениях о разработке языков программирования. Но, вместе с тем, он никогда не продавливал решения за счёт своего авторитета и по сей день не пытается претендовать на что-либо в Rust.

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

Команда обсуждала разные вопросы. Сегодня некоторые из них кажутся странными или ненужными, а ответы на них очевидными, тривиальными: Какое ключевое слово использовать, чтобы вернуть результат из функции? Но некоторые вопросы действительно заставляют задуматься всерьёз: Как безопасно хранить указатель на поле структуры?.

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

Мировоззрение


Можно сказать, что мировоззрение Дэйва сформировало мировоззрение всей команды и сам язык Rust. И поэтому чаще всего Дэйв был доволен решениями команды.

Дэйв имел право голоса во всех дискуссиях. Но особенно его интересовали несколько тем:

  1. Педагогическая составляющая

    Помимо того, что Дэйв инженер, он ещё и преподаватель, ментор. Он считал важным, чтобы суть принятых технологических решений можно было легко объяснить другим специалистам и сообществу в целом. Это требование хорошо помогало, когда к команде присоединялся новый сотрудник или стажёр.

  2. Прозрачность процессов и развитие сообщества

    Rust с самого начала развивался как open source проект. Я думаю, на это во многом повлиял опыт работы Дэйва в комиссии по стандартизации ECMAScript. Он был уверен, что наиболее успешные языки разрабатывает большая группа людей и компаний, которые находят компромисс между собственными мотивами и интересами.

Поэтому процесс разработки Rust нужно было сделать настолько открытым для участия любых заинтересованных сторон, насколько это было разумно в то время. Так, большая часть процесса работы над Rust на ранних этапах была задокументирована в опубликованных протоколах собраний. Уверен, это заслуга Дэйва: многие из этих протоколов были записаны им лично.

Наверное, развитие сообщества Rust самое важное достижение Дэйва в этом проекте. В том числе это повлияло на его жизнеспособность.

  1. Макросы

    Как я писал выше Дэйв фанат макросов. У него огромный опыт разработки на языке Racket (мультипарадигменный язык программирования общего назначения, принадлежащий семейству Lisp/Scheme).

Под руководством Дэйва стажёры добавили в Rust декларативные макросы (macro_rules!). Я помню, что он и Пол Стэнсифер (который на тот момент был стажёром) провели много часов, обсуждая, как аккуратно реализовать систему макросов, перейдя из семейства Lisp в семейство C-подобных языков, к которым относится Rust.

  • Важно также, что Дэйв сильно повлиял на процесс трансформации Rust из языка операторов в язык выражений.

Подбор ценных специалистов и спасение проекта от смерти


Дэйв привёл в компанию Нико, который разработал систему владения (ownership) Rust. Именно Дэйв взял на работу Иегуду Каца, который создал распознаватель функциональности Cargo.

Помимо этого, Дэйв выполнял ещё одну важную роль: когда казалось, что Rust находился под угрозой закрытия, он отстаивал проект перед руководством Mozilla.

Поначалу мы удивились, что Mozilla выделила на этот проект так много денег. Но, пока мы работали над Rust в стенах компании, нам часто казалось, что проект могут закрыть в любой момент. Стало особенно страшно, когда генеральный директор и бывший главный инженер Mozilla Брендан Эйх (к тому же, активный участник команды Rust), покинул компанию. Это стало одной из причин, по которой нужно было скорее создавать крупное сообщество вокруг языка. Мы понимали: время тикает и в любой момент может произойти непоправимое.

Однако Дэйв всегда верил в Rust. Он делал всё, что мог, чтобы спасти проект. Он развивал сообщество, неоднократно просил руководство дать нам время и пытался усилить команду. На самом деле, я не знаю всего, с чем ему пришлось столкнуться. Но главное мы продолжали работать над Rust, а команда не уменьшалась и даже время от времени росла.

Тем не менее, в проекте всегда не хватало разработчиков. Помню, как я злился: как мы могли конкурировать с Google и Apple, имея такой маленький штат инженеров? Частично, решение этой проблемы сводилось к тому, чтобы создать заинтересованное и разнообразное сообщество контрибьюторов. Но это медленный и непрогнозируемый процесс. Дэйв предложил более оптимальное решение: взять стажёров; много стажёров.

В итоге стажёров у нас стало больше, чем штатных сотрудников. Их всех привёл Дэйв. Rust создавался с активным участием студентов, и многие из них стажировались в Mozilla. Впоследствии это стало частью стратегии развития всего подразделения Mozilla Research.

И после этого вы скажете, что Дэйв Херман мало сделал для Rust?

Что было дальше?


Активное участие Дэйва в разработке Rust закончилось примерно в 2014-2015 году. Скорее всего, большинство участников сообщества Rust (находясь вне компании Mozilla) так и не узнали о его существовании.

Дэйв не был главным разработчиком Rust. После того, как язык Rust представили широкой аудитории, он внёс всего шесть коммитов в публичный репозиторий. Он отправлял свои изменения в список рассылки всего четыре раза.

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

В начале февраля 2021 года Google, Microsoft, AWS, Huawei, Mozilla запустили некоммерческую организацию Rust Foundation. Она будет отвечать за развитие экосистемы и поддержку разработчиков языка Rust, а также за спонсирование проекта.



Облачные серверы от Маклауд подходят для разработки на Rust.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Перевод Rust 1.53.0 IntoIterator для массивов, quotquot в шаблонах, Unicode-идентификаторы, поддержка имени HEAD-ветки в Cargo

18.06.2021 18:20:53 | Автор: admin

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


Если вы установили предыдущую версию Rust средствами rustup, то для обновления до версии 1.53.0 вам достаточно выполнить следующую команду:


rustup update stable

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


Что было стабилизировано в 1.53.0


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


IntoIterator для массивов


Это первый выпуск Rust, в котором массивы реализуют типаж IntoIterator. Теперь вы можете итерироваться в массиве по значению:


for i in [1, 2, 3] {    ..}

Раньше это было возможно только по ссылке, с помощью &[1, 2, 3] или [1, 2, 3].iter().


Аналогично вы теперь можете передать массив в методы, ожидающие T: IntoIterator:


let set = BTreeSet::from_iter([1, 2, 3]);

for (a, b) in some_iterator.chain([1]).zip([1, 2, 3]) {    ..}

Это не было реализовано ранее из-за проблем с совместимостью. IntoIterator всегда реализуется для ссылок на массивы и в предыдущих выпусках array.into_iter() компилировался, преобразовываясь в (&array).into_iter().


Начиная с этого выпуска, массивы реализуют IntoIterator с небольшими оговорками для устранения несовместимости кода. Компилятор, как и прежде, преобразовывает array.into_iter() в (&array).into_iter(), как если бы реализации типажа ещё не было. Это касается только синтаксиса вызова метода .into_iter() и не затрагивает, например, for e in [1, 2, 3], iter.zip([1, 2, 3]) или IntoIterator::into_iter([1, 2, 3]), которые прекрасно компилируются.


Так как особый случай .into_iter() необходим только для предотвращения поломки существующего кода, он будет удалён в новой редакции Rust 2021, которая выйдет позже в этом году. Если хотите узнать больше следите за анонсами редакции.


"Или" в шаблонах


Синтаксис шаблонов был расширен поддержкой |, вложенного в шаблон где угодно. Это позволяет писать Some(1 | 2) вместо Some(1) | Some(2).


match result {     Ok(Some(1 | 2)) => { .. }     Err(MyError { kind: FileNotFound | PermissionDenied, .. }) => { .. }     _ => { .. }}

Unicode-идентификаторы


Теперь идентификаторы могут содержать не-ASCII символы. Можно использовать все действительные идентификаторы символов Unicode, определённые в UAX #31. Туда включены символы из многих разных языков и письменностей но не эмодзи.


Например:


const BLHAJ: &str = "";struct  {    : String,}let  = 1;

Компилятор предупредит о потенциальной путанице с участием разных символов. Например, при использовании идентификаторов, которые выглядят очень похоже, мы получим предупреждение:


warning: identifier pair considered confusable between `` and `s`

Поддержка имени HEAD-ветки в Cargo


Cargo больше не предполагает, что HEAD-ветка в git-репозитории называется master. А следовательно, вам не надо указывать branch = "main" для зависимостей из git-репозиториев, в которых ветка по умолчанию main.


Инкрементальная компиляция до сих пор отключена по умолчанию


Как ранее говорилось в анонсе 1.52.1, инкрементальная компиляция была отключена для стабильных выпусков Rust. Функциональность остаётся доступной в каналах beta и nightly. Метод включения инкрементальной компиляции в 1.53.0 не изменился с 1.52.1.


Стабилизированные API


Следующие методы и реализации типажей были стабилизированы:



Другие изменения


Синтаксис, пакетный менеджер Cargo и анализатор Clippy также претерпели некоторые изменения.


Участники 1.53.0


Множество людей собрались вместе, чтобы создать Rust 1.53.0. Мы не смогли бы сделать это без всех вас. Спасибо!




От переводчиков


С любыми вопросами по языку Rust вам смогут помочь в русскоязычном Телеграм-чате или же в аналогичном чате для новичковых вопросов. Если у вас есть вопросы по переводам или хотите помогать с ними, то обращайтесь в чат переводчиков.
Также можете поддержать нас на OpenCollective.


Данную статью совместными усилиями перевели TelegaOvoshey, blandger, Belanchuk и funkill.

Подробнее..

Улучшаем Кузнечик на Rust

03.05.2021 20:08:12 | Автор: admin

Начало

Порой, простое и красивое решение лучше правильного и выдержанного. Возможно, именно так бы и было, если в жизни всё происходило как в учебниках. На практике же, нужно пройти долгий путь, чтобы создать задуманное.

Отступая от лирики, в данной статье я хочу рассказать заинтересованному читателю про шифрование по ГОСТ, а именно алгоритм Кузнечик, и про то, что стоит обратить внимание на новые и перспективные средства язык Rust.

Хватит отступлений, предлагаю начать!

Что будет в данной статье

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

Для тех, кто уже устал читать, даю ссылочку на код, возможно кому-то пригодится.

Rust или не Rust?

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

Я считаю, для данной задачи Rust удовлетворяет требованиям производительности и использования памяти, а также что, он более перспективный и удобный в использовании, чем тот же C++. Это лишь моё субъективное мнение и никого не прошу придерживаться его. К тому же, язык программирования это всего лишь инструмент со своими особенностями, преимуществами и недостатками. Если вы считаете, что это не так рад буду услышать вашу позицию по данному вопросу.

Реализация

Перед тем, как вдаваться в подробности кода было бы не плохо продемонстрировать HelloWorld. Для начала подключим библиотеку kuznechik:

Cargo.toml:

[dependencies]kuznechik = "0.2.0"

Теперь зашифруем и расшифруем строку "Hello World!":

main.rs:

fn hello_world() {    // Инициализация    let gamma = vec![0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xce, 0xf0, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf0, 0x01, 0x12,                     0x23, 0x34, 0x45, 0x56, 0x67, 0x78, 0x89, 0x90, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19];    let kuz = Kuznechik::new("Кузнечик на Rust").unwrap();    let mut alg = AlgOfb::new(&kuz);    alg.gamma = gamma.clone();        let data = String::from("Hello World!").into_bytes();    // Шифрование    let enc_data = alg.encrypt(data.clone());    println!("Encrypted data:\n{:?}", &enc_data);    // Расшифрование    alg.gamma = gamma;    let dec_data = alg.decrypt(enc_data);    println!(        "Decrypted data:\n{}",        String::from_utf8(dec_data.clone()).unwrap()    );    assert_eq!(dec_data, data);}

В коде, представленном выше для создания объекта Kuznechik используется пароль в виде строки. Функция Kuznechik::new() принимает строку и хеширует её по алгоритму Sha3-256 для получения мастер ключа. Также вы можете напрямую задать мастер ключ, для этого можно воспользоваться функцией Kuznechik::new_with_master_key(). Данная функция принимает массив длиной 256 бит (тип [u8; 32]).

Функции alg.encrypt() и alg.decrypt() принимают на вход и возвращают вектор байтов Vec<u8>. Данные функции принимают во владение входные вектора. В некоторых режимах длина выходного вектора больше входного, связано это с процедурой дополнения.

Обзор библиотеки

Архитектура

Архитектура библиотеки устроена так, что неизменяемые данные ключи, хранятся в структуре Kuznechik, а изменяемые и присущи конкретному режиму шифрования данные в структурах, начинающихся с префикса Alg- (в данном примере - AlgOfb). Это касается и функционального кода. Всё это позволяет создать один экземпляр Kuznechik (единожды развернуть ключи) и использовать разные режимы.

Режимы шифрования

Алгоритм Кузнечик может работать в шести режимах (типы AlgEcb, AlgCtr, AlgOfb, AlgCbc, AlgCfb, AlgMac). В приведённом выше примере представлен режим гаммирования с обратной связью по выходу (OFB). В данном режиме для работы алгоритма требуется гамма, значение которой задаётся через переменную alg.gamma. Необходимо учесть, что данная переменная изменяется в процессе шифрования и для успешного расшифрования требуется установить начальное значение гаммы.

Базовый алгоритм

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

Базовый алгоритм шифрования выглядит намного проще чем его описание в стандарте:

pub fn encrypt_block(data: &mut Block128, keys: &[Block128; 10]) {    for i in 0..9 {        tfm_lsx(data, &keys[i]);    }    tfm_x(data, &keys[9]);}

Здесь производится девять итераций LSX-преобразования и затем ещё одну итерацию X-преобразования. Напомню длина блока 128 бит или 16 байт. Тип Block128 именно такого размера, элементы которого имеют тип u8.

Далее LSX-преобразование. Оно состоит из поочерёдного вызова X, S, и L преобразований.

fn tfm_lsx(data: &mut Block128, key: &Block128) {    tfm_x(data, key);    tfm_s(data);    tfm_l(data);}

В свою очередь X-преобразование делает сложение по модулю 2 (проще говоря - XOR) блока данных с ключом:

fn tfm_x(data: &mut Block128, key: &Block128) {    for i in 0..16 {        data[i] ^= key[i];    }}

В S-преобразовании в данные вносится нелинейность для чего к данным применяется подстановка (пи).

fn tfm_s(data: &mut Block128) {    for i in 0..16 {        data[i] = K_PI[data[i] as usize];    }}

L-преобразование производит 16 раз R-преобразование на данными.

fn tfm_l(data: &mut Block128) {    for _ in 0..16 {        tfm_r(data);    }}

И наконец R-преобразование:

fn tfm_r(data: &mut Block128) {    let temp = trf_linear(data);    data.rotate_right(1);    data[0] = temp;}

Данное преобразование лучше всего показать на рисунке. F линейное преобразование, которое соотносит 16 байтам однобайтовый результат, согласно выражению ниже.

Линейное преобразование:

Чтобы не вычислять значение линейного преобразования на каждой итерации L-преобразования, можно вычислить, своего рода, таблицу умножения. Таблица содержит 7 строк и 256 столбцов, что соответствует произведениям в поле Галуа коэффициентов на значения входных данных (произведения входных данных на коэффициент равный 1 нет смысла держать в таблице, т.к. результат равен входным данным).

Вычисление линейного преобразования по такой таблице умножения занимает O(1) времени и требует O(1) памяти, а точнее 7 * 256 = 1792 байта, что не так много для современного компьютера.

Собственно реализация:

fn trf_linear(data: &Block128) -> u8 {  // indexes:  0,  1,   2,   3,   4,   5,   6    // values:  16, 32, 133, 148, 192, 194, 251    let mut res = 0u8;    res ^= MULT_TABLE[3][data[0] as usize];    res ^= MULT_TABLE[1][data[1] as usize];    res ^= MULT_TABLE[2][data[2] as usize];    res ^= MULT_TABLE[0][data[3] as usize];    res ^= MULT_TABLE[5][data[4] as usize];    res ^= MULT_TABLE[4][data[5] as usize];    res ^= data[6];    res ^= MULT_TABLE[6][data[7] as usize];    res ^= data[8];    res ^= MULT_TABLE[4][data[9] as usize];    res ^= MULT_TABLE[5][data[10] as usize];    res ^= MULT_TABLE[0][data[11] as usize];    res ^= MULT_TABLE[2][data[12] as usize];    res ^= MULT_TABLE[1][data[13] as usize];    res ^= MULT_TABLE[3][data[14] as usize];    res ^= data[15];    res}

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

На этом всё, показывать обратные преобразования и режимы работы здесь будет уже лишним, и так уже кода получилось многовато, если что вы знаете где искать. Да, благодарю вас если вы дочитали до этого момента, всё же статья уже затянулась.

Про велосипед

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

  • Лицензирование ПО.

  • Качество ПО (безопасность, производительность, требования к памяти и тд.).

  • Интерфейс взаимодействия.

  • Зависимости.

  • Ну и конечно же опыт, который получаешь в процессе разработки.

Продолжение следует

Разместить все свои мысли в одной статье, на мой взгляд, было бы неправильно. Так можно быстро утомиться и потерять интерес к материалу. К тому же мне было бы приятно услышать ваше мнение и рассмотреть общие с вами темы.

На этом пока всё. Надеюсь, что данная статья поможет кому-нибудь в понимании алгоритма Кузнечик и языка Rust. Спасибо за внимание.

Отдельное спасибо Chris F за фотографии кузнечика.

Подробнее..

Перевод Инструмент для отслеживания DNS-запросов dnspeep

07.05.2021 18:23:01 | Автор: admin

Недавно я создала небольшой инструмент под названием dnspeep, который позволяет понять, какие DNS-запросы отправляет ваш компьютер и какие ответы он получает. Всего мой код занял 250 строк на Rust. В этой статье я расскажу о коде, объясню, для чего он нужен, почему в нём возникла необходимость, а также расскажу о некоторых проблемах, с которыми я столкнулась при его написании. И, конечно, вы сами сможете попробовать код в действии.


Что нужно для начала работы с кодом

Чтобы было удобнее работать с кодом, я подготовила несколько бинарных файлов.

Команды для Linux (x86):

wget https://github.com/jvns/dnspeep/releases/download/v0.1.0/dnspeep-linux.tar.gztar -xf dnspeep-linux.tar.gzsudo ./dnspeep

Команды для Mac:

wget https://github.com/jvns/dnspeep/releases/download/v0.1.0/dnspeep-macos.tar.gztar -xf dnspeep-macos.tar.gzsudo ./dnspeep

Коду необходим доступ ко всем отправляемым компьютером пакетам DNS, поэтому его необходимо запускать от имени root. По этой же причине утилиту tcpdump также нужно запускать от имени root: код использует libpcap ту же библиотеку, что и tcpdump. Если вам по какой-либо причине не захочется загружать бинарные файлы и запускать их от имени root, вы можете воспользоваться моим исходным кодом и создать на его основе собственный.

Что получается в результате

Каждая строка представляет собой DNS-запрос и соответствующий запросу ответ.

$ sudo dnspeepquery   name                 server IP      responseA       firefox.com          192.168.1.1    A: 44.235.246.155, A: 44.236.72.93, A: 44.236.48.31AAAA    firefox.com          192.168.1.1    NOERRORA       bolt.dropbox.com     192.168.1.1    CNAME: bolt.v.dropbox.com, A: 162.125.19.131

Эти запросы отражают мои визиты на сайт neopets.com в браузере, а запрос bolt.dropbox.com возник потому, что у меня запущен агент Dropbox, и, как я полагаю, время от времени он заходит на свой сайт для синхронизации.

Зачем создавать ещё один инструмент DNS?

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

Ваш браузер (и другие компьютерные программы) постоянно отправляет DNS-запросы. Если знать, какие запросы отправляет компьютер и какие ответы он получает, можно получить более реальную картину "жизни" компьютера.

Написанный мною код я также использую как инструмент отладки. На вопросы типа: "А не связана ли моя проблема с DNS?" ответить иногда бывает довольно сложно. Пользователи, не зная всей информации, зачастую, чтобы найти проблему, используют метод проб и ошибок или просто строят догадки, хотя, казалось бы, стоит просто проанализировать ответы на получаемые компьютером DNS-запросы, и проблема будет решена.

Можно увидеть, какое программное обеспечение "тайно" выходит в Интернет

Мне нравится в этом инструменте, что он даёт представление о том, какие программы моего компьютера пользуются Интернетом! Например, я обнаружила, что какая-то утилита на моём компьютере время от времени по непонятной причине отправляет запросы на ping.manjaro.org, вероятно, чтобы проверить, подключён ли мой компьютер к Интернету.

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

Если вы раньше не работали с tcpdump, поначалу может быть непонятно, что делает эта утилита

Рассказывая людям об отправляемых с компьютера DNS-запросах, я почти всегда хочу добавить: "Всю информацию можно получить через tcpdump!" Что делает утилита tcpdump? Она осуществляет разбор пакетов DNS! Например, вот как выглядит DNS-запрос incoming.telemetry.mozilla.org.:

11:36:38.973512 wlp3s0 Out IP 192.168.1.181.42281 > 192.168.1.1.53: 56271+ A? incoming.telemetry.mozilla.org. (48)11:36:38.996060 wlp3s0 In  IP 192.168.1.1.53 > 192.168.1.181.42281: 56271 3/0/0 CNAME telemetry-incoming.r53-2.services.mozilla.com., CNAME prod.data-ingestion.prod.dataops.mozgcp.net., A 35.244.247.133 (180)

Сначала эта информация может показаться "тёмным лесом", но её при некоторых навыках можно научиться читать. Давайте для примера разберём такой запрос:

192.168.1.181.42281 > 192.168.1.1.53: 56271+ A? incoming.telemetry.mozilla.org. (48)
  • A? означает DNS-запрос типа A;

  • incoming.telemetry.mozilla.org. это имя объекта, к которому осуществляется запрос;

  • 56271 это идентификатор DNS-запроса;

  • 192.168.1.181.42281 исходный IP/порт;

  • 192.168.1.1.53 IP/порт назначения;

  • (48) длина DNS-пакета.

Ответ выглядит следующим образом:

56271 3/0/0 CNAME telemetry-incoming.r53-2.services.mozilla.com., CNAME prod.data-ingestion.prod.dataops.mozgcp.net., A 35.244.247.133 (180)
  • 3/0/0 количество записей в ответе: 3 ответа, 0 полномочий, 0 дополнительно. Исходя из моего опыта, tcpdump выводит только количество ответов на запрос.

  • CNAME telemetry-incoming.r53-2.services.mozilla.com, CNAME prod.data-ingestion.prod.dataops.mozgcp.net. и A 35.244.247.133 это те самые три ответа

  • 56271 идентификатор ответов, соответствующий идентификатору запроса. По этому идентификатору можно понять, что это ответ на запрос из предыдущей строки.

С таким форматом, конечно, работать довольно сложно, ведь человеку нужно просто посмотреть на трафик DNS, к чему все эти нагромождения? И вот ему приходится вручную сопоставлять запросы и ответы, к тому же не всегда находящиеся на соседних строках. Здесь в дело вступают компьютерные возможности!

Я решила написать небольшую программку dnspeep, которая будет сама выполнять такое сопоставление, а также удалять определённую (на мой взгляд, лишнюю) информацию.

Проблемы, с которыми я столкнулась при написании кода

При написании кода я столкнулась с рядом проблем.

  • Мне пришлось несколько изменить библиотеку pcap, чтобы заставить её правильно работать с Tokio на Mac OS вот эти изменения. Это была одна из тех ошибок, на поиск которой ушло много часов, а всё исправление уложилось в одну строку.

  • Разные дистрибутивы Linux, по всей видимости, используют разные версии libpcap.so, поэтому мне не удалось воспользоваться непосредственно бинарным файлом, динамически компонующим libpcap (у других пользователей возникала такая же проблема, например здесь). Поэтому компилировать библиотеки libpcap в инструмент на Linux мне пришлось статически. Я до сих пор не понимаю, как такое правильно организовать на Rust, но я добилась, чего хотела, и всё заработало я скопировала файл libpcap.a в каталог target/release/deps, а затем просто запустила cargo build.

  • Используемая мною библиотека dns_parser поддерживает не все типы DNS-запросов, а только некоторые самые распространённые. Возможно, для разбора DNS-пакетов можно было использовать другую библиотеку, но я не нашла подходящей.

  • Поскольку интерфейс библиотеки pcap выдает набор "голых" байтов (в том числе для данных Ethernet-фреймов), мне пришлось написать код, который определял, сколько байтов нужно отсечь от начала строки, чтобы получить IP-заголовок пакета. Но я уверена, что в моей задаче ещё остались "подводные камни".

Кстати, вы даже не представляете, каких сложностей мне стоило подобрать название для своей утилиты ведь инструментов для работы с DNS великое множество, и у каждого своё название (dnsspy! dnssnoop! dnssniff! dnswatch!) Сначала я хотела включить в название слово spy (шпион) или его синонимы, а затем остановилась на показавшемся мне забавным названии, которое о чудо! ещё никто не занял для собственного DNS-инструмента.

У моей утилиты есть один недостаток: она не сообщает, какой именно процесс отправил DNS-запрос, но выход есть используйте инструмент dnssnoop. Этот инструмент работает с данными eBPF. Судя по описанию, он должен работать нормально, но я его ещё не пробовала.

В моём коде наверняка ещё много ошибок

Мне удалось его протестировать только на Linux и Mac, и я уже знаю как минимум об одной ошибке (из-за того, что поддерживаются не все DNS-запросы). Если найдёте ошибку, пожалуйста, сообщите мне!

Сами по себе ошибки не опасны интерфейс libpcap доступен только для чтения, самое худшее, что может случиться, он получит какие-то непонятные данные и выдаст ошибку или аварийно завершит работу.

Мне нравится составлять небольшие учебные материалы

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

  • Простой способ составления DNS-запросов;

  • Рассказывается, что происходит внутри компьютера при отправке DNS-запроса;

  • Ссылка на dnspeep.

Ранее в своих постах я просто объясняла, как работают существующие инструменты (например dig или tcpdump), а если и писала собственные инструменты, то довольно редко. Но сейчас для меня очевидно, что выходные данные этих инструментов довольно туманны, их сложно понять, поэтому мне захотелось создать более дружественные варианты просмотра такой информации, чтобы не только гуру tcpdump, но любые другие пользователи могли понять, какие DNS-запросы отправляет их компьютер.

А если вы хотите не только понимать, что происходит с запросами и ответами DNS на вашем компьютере, но и писать собственные протоколы для своих приложений, обратите внимание на профессии C++ разработчик или Java-разработчик, позволяющие с нуля освоить эти языки или прокачать своё владение ими. Приходите опытные менторы и эксперты своего дела с удовольствием поделяться с вами своими знаниями.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Небольшой язык программирования и его разработка

17.05.2021 00:12:32 | Автор: admin

Как всё началось

Сидел я на третьем курсе колледжа и ничего не делал, получая пятерки. Материал я усваивал быстро (спасибо форумам и хабру), но чего-то мне не хватало и тогда я взялся за изучение операционных систем, надеясь что-то сделать к диплому. Время шло, практика и кончился третий курс.

Переходя в следующий курс я начал активно изучать всё что касалось ОСи, но толком никуда не продвинулся. Тогда и родилась у меня идея создать свой ЯП.

Времени было мало, а делать было что-то нужно и я в свободное от удалёнки (с серой ЗП) что-то писал.

Язык я решил назвать The Gorge.

Часть первая. Как работает язык и где его найти

Было принято решение разместить язык на платформе гитхаб и создать новый профиль.
На тот момент я имел в распоряжении старый подаренный мне акк, но в последствии всё-таки создал свой и сейчас его можно найти так: (просто допишите сайт)/pcPowerJG/natural-network.

В папке src в файле lib.rs мы можем увидеть чудо, язык написан почти полностью на раст (почему почти? к сожалению в далёкие времена 2019 года раст не давал открыть файл на моей любимой манжаре и пришлось открывать его через Си).

Ну так вот, первое что мне было необходимо сделать - создать словарь используемых ключевых слов.

words.push("object".to_string());//1 // используется для создания объекта, который хранит значения в памятиwords.push("if".to_string());//2// оператор условия, нужен для сравнения ДВУХ параметровwords.push("exit_()".to_string());//3//выход из приложенияwords.push("func".to_string());     //4//инициализация функции words.push("print".to_string());//5 // вывод на консольwords.push("remove".to_string());//6 //удалениеwords.push("array".to_string());//7 // создание массиваwords.push("struct".to_string()); //8 // создание структурыwords.push("end".to_string());//9//end operationwords.push("end_func".to_string()); // 10 // конец функцииwords.push("return".to_string()); //  11words.push("!eq".to_string());//  12words.push(">".to_string());  //  13words.push("<".to_string());  //  14words.push("loop".to_string());// 15words.push("end_loop".to_string());// 16words.push("_".to_string()); // 17 // просто в качестве НЕ ключевого словаwords.push("break".to_string()); // 18words.push("true".to_string()); // 19words.push("false".to_string()); // 20

Как мы видим у слов есть определённая нумерация (и не с нуля. это важно).

Следующая главная функция это функция старт.

pub fn start_(&mut self, text: String) -> u8

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

Так как мы пишем интерпретатор, нам необходимо как-то это обрабатывать, для этого необходимо создавать карту действий, но мы ограничимся простым массивом.

Так же создадим переменные для временного хранения информации (тело цикла к примеру или математическое выражение, имя функции и т.д.).

Привожу код.

let mut temp_values: String = String::new();//ВРЕМЕННЕ ПЕРЕМЕННЕlet mut temp_name: String = String::new();        //...let mut temp_buffer: String = String::new();//...let mut func_text: String = String::new();let mut last_op: [usize; 3] = [0; 3]; // храним три последних действия// ----------------------------------------------let mut if_count: usize = 0;let mut if_result: bool = true; // ответ на условиеlet mut struct_flag: bool = false; // это структура или условиеlet mut function_inactive_flag: bool = false; // если функция не активнаlet mut loop_flag: bool = false; // попали на циклlet mut index_loop: usize = 0; // количество циклов (для цикла в цикле)

Так же смотря код можно заметить много флагов, они нужны как-раз таки для нормального функционирования.

Всего наш код делиться на три блока

if ch == ' ' || ch == '\t' {  //...................} else if ch == '\n' {  //...................} else if ch == '=' {  //...................}} else {  temp_values.push(ch);}

В первом мы выполняем действия при разделении кода (то есть сразу смотрим что за ключевое слово или переменная и записываем в карту действий). Второй выполняем после символа окончания строки, тут мы выполняем действия. И третий выполняется при присваивании.
Блок else нужен только для записи переменной.

Дальше ныряем по коду, предлагаю посмотреть каждый блок отдельно.

if function_inactive_flag {  // ...}if loop_flag {  // ...}match temp_values.trim() {  // ...}

Мы видим в блоке ещё три блока. Первый блок связан с функциями, в нём происходит запись их в переменную (мы храним имя функции как переменную).

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

Всё остальное же идёт согласно законам логики построения программы.

К примеру: a = b + c
Преобразуется в: last_op[0] = 1 last_op[1] = 17
И выполниться: в функции math_work .

Математика

Функция преобразования переменных в значения:

fn math_work(&self, text: String) -> String {  let text: String = Words::trim(text.clone());  let mut result_string: String = String::new();  let mut temp_string: String = String::new();  for ch in text.chars() {    match ch {      '+' | '-' | '/' | '*' | '(' | ')' | '&' | '|' | '!' | '=' | '<' | '>' => {        if Words::is_digit(temp_string.clone()) {          result_string += temp_string.clone().as_str();        } else {          result_string += self.search_var(temp_string).0.clone().as_str();        }        result_string.push(ch.clone());        temp_string = String::new();      },      _ => {        temp_string.push(ch.clone());      },    }  }   let (value, type_, _temp) = self.search_var(temp_string.clone());  if _temp {    result_string += value.as_str();  } else {    result_string += temp_string.clone().as_str();  } result_string}

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

Передаём всё в следующую функцию:

fn eval(str_: Vec<char>) -> f32 {  let mut i: usize = 0;  Words::expr(str_, &mut i)}
Вся математическая магия
fn eval(str_: Vec<char>) -> f32 {  let mut i: usize = 0;  Words::expr(str_, &mut i)}fn plus_one(u: &mut usize) {  *u += 1;}fn number(ch_: Vec<char>, idx: &mut usize) -> f32 {  let mut result: f32 = 0.0;  //float result = 0.0;  let mut div: f32 = 10.0;  let mut sign: f32 = 1.0;  if ch_[*idx] == '-'{    sign = -1.0;    *idx += 1;  }  while *idx < ch_.len() &&  match ch_[*idx] {    '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { true },    _ => { false }  }  {    result = result * 10.0 + (f32::from_str(&ch_[*idx].to_string()).expect("не удалось форматировать строку"));    *idx += 1;  }  if *idx < ch_.len() && (ch_[*idx] == '.'){    *idx += 1;            while *idx < ch_.len() &&    match ch_[*idx] {      '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { true },      _ => { false }    }     {      result = result + (f32::from_str(&ch_[*idx].to_string()).expect("не удалось форматировать строку")) / div;      div *= 10.0;      *idx += 1;    }  }  sign * result}fn expr(ch_: Vec<char>, idx: &mut usize) -> f32 {  let mut result: f32 = Words::term(ch_.clone(), idx);      while *idx < ch_.len() && (ch_[*idx] == '+' || ch_[*idx] == '-') {    match ch_[*idx] {      '+' => {        *idx += 1;        result += Words::term(ch_.clone(), idx);      },      '-' => {        *idx += 1;            result -= Words::term(ch_.clone(), idx);      },      _ => {},    }   } result}fn term(ch_: Vec<char>, idx: &mut usize) -> f32 {  let mut result: f32 = Words::factor(ch_.clone(), idx);  let mut div: f32 = 0.0;  while *idx < ch_.len() && (ch_[*idx] == '*' || ch_[*idx] == '/') {    match ch_[*idx] {      '*' => {        *idx += 1;        result *= Words::factor(ch_.clone(), idx);      },      '/' => {        *idx += 1;            div = Words::factor(ch_.clone(), idx);            if (div != 0.0) {          result /= div;        } else {          panic!("Division by zero!\n");                            }      },      _ => {},    }  } result}fn factor(ch_: Vec<char>, idx: &mut usize) -> f32 {  let mut result: f32 = 0.0;  let mut sign: f32 = 1.0;  if (ch_[*idx] == '-') {    sign = -1.0;    *idx += 1;  }  if (ch_[*idx] == '(') {    *idx += 1;    result = Words::expr(ch_.clone(), idx);    if (ch_[*idx] != ')') {      panic!("Brackets unbalanced!\n");    }    *idx += 1;  } else { result = Words::number(ch_, idx); }  sign * result}

Переменные

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

Все переменные занимают место сразу в двух массивах, массив имён и типов:

object_buffer: Vec<(String, usize)>

Массив значений:

value_buffer: Vec<String>

Для добавления новой переменной используется функция add_vars:

fn add_vars(&mut self, vars_name: String, mut vars_value: String, vars_type: usize) {  //object_buffer: Vec<(String, usize)>  //value_buffer: Vec<String>  if vars_value.clone().split('\"').collect::<Vec<&str>>().len() > 1 {    vars_value = vars_value.split('\"').collect::<Vec<&str>>()[1].to_string();  } else {    vars_value = vars_value.clone().trim().to_string();  }  self.object_buffer.push((vars_name, vars_type));  self.value_buffer.push(vars_value);}

В ней всего одна проверка, есть ли кавычки (что в кавычках мы считаем текстом и не отрезаем пробелы).

Для удаления переменной:

fn remove_vars(&mut self, vars_name: String) {  for i in 0..self.object_buffer.len() {    if self.object_buffer[i].0.clone() == vars_name {      self.object_buffer.remove(i);      self.value_buffer.remove(i);      return;    }  }}

Запись значения и поиск:

fn set_value(&mut self, vars_name: String, mut vars_value: String) {  for i in 0..self.object_buffer.len() {    if self.object_buffer[i].0 == vars_name {      if vars_value.clone().split('\"').collect::<Vec<&str>>().len() > 1 {        vars_value = vars_value.split('\"').collect::<Vec<&str>>()[1].to_string();      } else {        vars_value = vars_value.clone().trim().to_string();      }      self.value_buffer[i] = vars_value.clone();      return;    }  }}pub fn search_var(&self, vars_name: String) -> (String, usize, bool) {  for i in 0..self.object_buffer.len() {    if self.object_buffer[i].0 == vars_name {      let value: String = self.value_buffer[i].clone();      let type_: usize = self.object_buffer[i].1.clone();      return (value, type_, true);     }  }  (String::new(), 0, false)}

Так же планируется импортировать из старой версии языка поддержку внешних библиотек.

import("/lib.so")extern_func("lib.so", func_name)  extern_func("lib.so", func_name, arg1, arg2)close_import("lib.so")

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

Что нужно исправить и добавить?

  1. Исправить функцию обработки условий (не заходите туда, я серьезно);

  2. Зачем нам всё хранить в тексте? Исправить на байты;

  3. Импортировать поддержу сишных библиотек из старой версии языка

Спасибо за внимание.

Подробнее..

Векторные языки SQL интерпретатор в 100 строк

10.06.2021 16:07:13 | Автор: admin

В предыдущей статье я описал векторные языки и их ключевые отличия от обычных языков. На коротких примерах я постарался показать, как эти особенности позволяют реализовывать алгоритмы необычным образом, кратко и с высоким уровнем абстракции. В силу своей векторной природы такие языки идеально присоблены для обработки больших данных, и в качестве доказательства в этой статье я полностью реализую на векторном языке простой SQL интерпретатор. А чтобы продемонстрировать, что векторный подход можно использовать на любом языке, я реализую тот же самый интерпретатор на Rust. Преимущества векторного подхода столь велики, что даже интерпретатор в интерпретаторе сможет обработать select с группировкой из таблицы в 100 миллионов строк за 20 с небольшим секунд.

Общий план.

Конечная цель - реализовать интепретатор, способный выполнять выражения типа:

select * from (select sym,size,count(*),avg(price) into r  from bt where price>10.0 and sym='fb'  group by sym,size)  as t1 join ref on t1.sym=ref.sym, t1.size = ref.size

Т.е. он должен поддерживать основные функции типа сложения и сравнения, позволять where и group by выражения, а также - inner join по нескольким колонкам.

В качестве векторного языка я возьму Q, так как его специализация - базы данных.

Интерпретатор будет состоять из лексера, парсера и собственно интерпретатора. Для экономии места я буду приводить только ключевые места, а весь код можно найти здесь. Так же для краткости я реализую лишь часть функциональности, но так, чтобы все важное было на месте: join, where, group by, 3 типа данных, агрегирующие функции и т.п.

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

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

Наконец, сам интерпретатор. На Q он имеет весьма скромные размеры - строк 25. На Rust, конечно, гораздо больше, но написать его проще, чем может показаться. Нужно просто по шагам повторять все операции Q, адаптируя их к специфике Rust.

Это моя первая программа на Rust и сразу хочу сказать, что слухи о его сложности сильно преувеличены. Если писать в функциональном стиле (read only), то проблем нет никаких. После того, как Rust несколько раз забраковал мои идеи, я понял, чего он хочет и уже не сталкивался с необходимостью все переделывать из-за контроллера ссылок, а явные времена жизни понадобились только один раз и по делу. Зато взамен мы получаем программу, которую можно распараллелить по щелчку пальцев. Что мы и сделаем, чтобы добиться просто феноменальной производительности для столь примитивной программы.

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

Лексер

Векторные языки идеальны для написания лексеров. Пусть у нас есть функция fsa, которая принимает на вход текущее состояние лексера и входной символ и возвращает новое состояние:

fsa[state;char] -> state

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

Т.е. есть следующие этапы:

  • Кодирование. Входные символы отображаются в группы (my.var -> aa.aaa, 12.01 -> 00.00, "str 1" -> "sss 1" и т.д.).

  • Трансформация. Закодированные символы пропускаются через fsa (aa.aaa -> aAAAAA, 00.00 -> 0IFFF, "sss 1" -> "SSSSSR).

  • Разбиваем начальную строку на части по начальным состояниям (a, 0, " и т.д.). Для удобства все не начальные состояния обозначены большими буквами.

Все три этапа - это векторные операции, поэтому на Q эта идея реализуется одной строкой (все состояния закодированы так, что начальные меньше limit):

(where fsa\[start;input]<limit)cut input

Это в сущности весь лексер. Правда еще необходимо определить fsa. В подавляющем большинстве случаев в качестве fsa можно взять матрицу - таблицу переходов конечного автомата. Простой автомат можно задать и руками, но можно реализовать небольшой DSL. Отображение в группы можно организовать через небольшой массив (ограничимся ASCII символами для простоты):

cgrp: ("\t \r\n";"0..9";"a..zA..Z"),"\\\"=<>.'";c2grp: 128#0; // массив [0;128]// Q позволяет присваивать значения по индексу любой формы.// В данном случае массиву массивов. В Rust необходимы два явных цикла:// cgrp.iter().enumerate().for_each(|(i,&s)| s.iter()//   .for_each(|&s| c2grp[s as usize] = i + 1));c2grp[`int$cgrp]: 1+til count cgrp;

Для краткости я не привожу все цифры и буквы. Нас интересуют пробельные символы, цифры, буквы, а также несколько специальных символов. Мы закодируем эти группы числами 1, 2 и т.д., все остальные символы поместим в группу 0. Чтобы закодировать входную строку, достаточно взять индекс в массиве c2grp:

c2grp `int$string

Автомат задается правилами (текущее состояние(я);группа(ы) символов) -> новое состояние. Для обозначения групп и начальных состояний токенов удобно использовать первые символы соответствующих групп (для группы 0..9 - 0, например). Для обозначения промежуточных состояний - большие буквы. Например, правило для имен можно записать так:

aA А a0.

т.е. если автомат находится в состояниях "a" (начало имени) или "A" (внутри имени), и на вход поступают символы из групп [a,0,.], то автомат остается в состоянии "A". В начальное состояние "a" автомат попадет автоматически, когда встретит букву (это правило действует по умолчанию). После этого, если дальше он встретит букву, цифру или точку, то перейдет во внутреннее состояние "A" и будет там оставаться до тех пор, пока не встретит какой-то другой символ. Я запишу все правила без лишних комментариев (Rust):

let rules: [&[u8];21] =  [b"aA A a0.",                         // имена   b"0I I 0",b"0I F .",b"F F 0",        // int/float   b"= E =",b"> E =",b"< E =",b"< E >", // <>, >= и т.п.   b"\" S *",b"S S *",b"\"S R \"",      // "str"   b"' U *",b"U U *",b"'U V '",         // 'str'   b"\tW W \t"];                        // пробельные символы

Числа и строки заданы в упрощенном формате. "*" в качестве входного символа имеет специальное значение - все возможные символы. Большие буквы - это состояния внутри токенов. Такая договоренность дает нам возможность легко определить начало токена - это все состояния, которые не большие буквы.

Матрица fsa из этих правил генерируется элементарно. Схематично это выглядит так:

fsa[*;y] = y (по умолчанию для всех состояний)"aA A a0." -> "aA","A","a0."; fsa[enc["aA"];enc["a0."]] = enc["A"]...

Необходимо закодировать символы с помощью вектора states:

states: distinct " ",(first each cgrp),raze fsa[;1];limit: 2+count cgrp;enc:states?; // в Q encode - это поиск индекса элемента в векторе

Вперед поместим все начальные состояния (пробел для учета группы 0), чтобы можно было легко определить limit.

Код генерации fsa я опускаю - он следует схеме выше.

Матрица переходов у нас есть, осталось определить саму функцию lex. В парсере нам понадобится знать тип токена, поэтому вычислим и его тоже. На Rust лексер выглядит так:

let s2n = move |v| ["ID","NUM","STR","STR","WS","OTHER"][find1(&stn,&v)];move |s| {    if s.len()==0 {return Vec::<Token>::new()};    let mut sti = 0usize;    let st: Vec<usize> = s.as_bytes().iter().map(|b| { // st:fsa\[0;c2grp x]        sti = fsa[sti][c2grp[std::cmp::min(*b as usize,127)]];        sti}).collect();    let mut ix: Vec<usize> = st.iter().enumerate() // ix:where st<sta        .filter_map(|(i,v)| if *v<sta {Some(i)} else {None}).collect();    ix.push(st.len());    (0..ix.len()-1).into_iter()        .filter_map(|i|            match s2n(st[ix[i]]) {                 "WS" => None,                  kind => Some(Token{ str:&s[ix[i]..ix[i+1]], kind})             }).collect()

На Q получится значительно более кратко:

s2n:(states?"a0\"'\t")!("ID";"NUM";"STR";"STR";"WS");lex:{  i:where (st:fsa\[0;c2grp x])<limit;  {x[;where not "WS"~/: x 0]} (s2n st i;i cut x)};

Если мы запустим лексер, то получим:

lex "10 + a0" -> (("NUM";"";"ID");("10";"+";"a0"))

Интерпретатор

Интерпретатор можно разделить на две части - выполнение выражений и выполнение select. Первая часть тривиальна на Q, но требует большого количества кода на Rust. Я приведу основные структуры данных, чтобы было понятно, как в целом работает интерпретатор. В основе лежит enum Val:

type RVal=Arc<Val>;enum Val {       I(i64),    D(f64),    II(Vec<i64>),    DD(Vec<f64>),    S(Arc<String>),    SS(Vec<Arc<String>>),    TBL(Dict<RVal>),    ERR(String),}

Есть три типа данных - строки, целые и нецелые, две формы их представления - атомарная и вектор. Также есть таблицы и ошибки. Dict - это пара Vec<String> и Vec<T> одинаковой длины. В случае таблицы T = Vec<RVal>, где каждый Val - это II, DD или SS. Rust позволяет в легкую распаралелливать программу, но нужно, чтобы типы данных позволяли передавать свои значения между потоками. Для этого я обернул все разделяемые значения в асинхронный счетчик ссылок Arc. Считается, что атомарные операции более медленные, однако в программе, которая работает с большими данными, это не имеет большого значения.

Интерпретатор работает с выражениями:

enum Expr {    Empty,    F1(fn (RVal) -> RRVal, Box<Expr>), // f(x)    F2(fn (RVal,RVal) -> RRVal, Box<Expr>, Box<Expr>), // f(x,y)    ELst(Vec<Expr>),    ID(String),  // variable/column    Val(Val),    // simple value - 10, "str"    Set(String,Box<Expr>), // 'set var expr' - assignment    Sel(Sel), // select    Tbl(Vec<String>,Vec<Expr>), // [c1 expr1, c2 expr2] - create table }

ELst и Empty используются только парсером. Expr (ссылки на себя) необходимо хранить в куче (Box). Выполняются выражения функцией eval в некотором контексте, где заданы переменные (Set), а также могут быть определены колонки таблицы:

struct ECtx {    ctx: HashMap<String,Arc<Val>>,   // variables}struct SCtx {    tbl: Arc<Table>,                // within select    idx: Option<Vec<usize>>,        // idx into tbl    grp: Arc<Vec<String>>,          // group by cols}

eval сравнительно проста (self = ECtx):

type RRVal=Result<Arc<Val>,String>;fn top_eval(&mut self, e: Expr) -> RRVal {    match e {        Expr::Set(id,e) => {            let v = self.eval(*e, None)?;            self.ctx.insert(id,v.clone()); Ok(v)},        Expr::Sel(s) => self.do_sel(s),        _ => self.eval(e, None)    }}fn eval(&self, e: Expr, sctx:Option<&SCtx>) -> RRVal {    match e {        Expr::ID(id) => self.resolve_name(sctx,&id),        Expr::Val(v) => Ok(v.into()),        Expr::F1(f,e) => Ok(f(self.eval(*e,sctx)?)?),        Expr::F2(f,e1,e2) => Ok(f(self.eval(*e1,sctx)?,self.eval(*e2,sctx)?)?),        Expr::Tbl(n,e) => { self.eval_table(None,n,e) }        e => Err(format!("unexpected expr {:?}",e))    }}

Set и Sel нужен модифицируемый контекст, а его нельзя будет передать просто так в другой поток. Поэтому eval разбит на две части. Задача resolve_name - найти переменную или колонку и при необходимости применить where индекс. eval_table - собрать таблицу из частей и проверить, что с ней все в порядке (колонки одной длины и т.п.). Функции F1 (max, count ...) и F2 (+, >=, ...) сводятся к огромным match блокам, где для каждого типа прописывается нужная операция. Макросы позволяют уменьшить количество кода. Например, для арифметических операций часть match выглядит так:

(Val::D(i1),Val::I(i2)) => Ok(Val::D($op(*i1,*i2 as f64)).into()),(Val::D(i1),Val::D(i2)) => Ok(Val::D($op(*i1,*i2)).into()),(Val::I(i1),Val::II(i2)) => Ok(Val::II(i2.par_iter()    .map(|v| $op(*i1,*v)).collect()).into()),

Это может показаться неоптимальным, но когда вы обрабатываете таблицу на 100 миллионов строк, это не имеет ни малейшего значения. Видите слово par_iter выше? Это все, что нужно сделать в Rust, чтобы операция стала параллельной.

Выполнение select гораздо интереснее и сложнее. Разберем его на Q, потому что код на Rust многословно повторяет код на Q, который и сам по себе непростой.

Select состоит из подвыражений (join, where, group, select, distinct, into), каждое из которых выполняется отдельно. Самое сложное из них - join. В его основе лежит функция rename, задача которой присвоить колонкам уникальные имена, чтобы не возникло конфликта при join:

// если x это name -> найти, select -> выполнитьsget:{[x] $[10=type x;get x;sel1 x]};// в грамматике таблица определяется как '(ID|sel) ("as" ID)?'// так что x это список из 2 элементов: (ID из as или имя таблицы;ID/select)// y - уникальный префиксrename:{[x;y]  t:sget x 1; // получить таблицу: names!vals  k:(k!v),(n,/:k)!v:(y,n:x[0],"."),/:k:key t; // k - оригинальные имена,        // v - уникальные, n - с префиксом (table.name)  (k;v!value t)};

Все эти манипуляции сводятся к построению двух словарей - отображения из настоящих имен колонок и расширенных (table.name) в уникальные и из уникальных имен в сами колонки таблицы. Уникальные имена позволяют иметь в одной join таблице колонки с одинаковыми именами из разных таблиц и обращаться к ним в выражениях через нотацию с точкой.

В основе join следующая функция:

// x - текущая таблица в формате rename// y - следующая таблица в этом формате// z - join выражение, список (колонка в x;и в y)// условие join: x[z[i;0]]==y[z[i;1]] для всех ijoin_core:{[x;y;z]  // m - отображение имен в уникальные для новой таблицы x+y  // имена из x имеют приоритет  // c - переименовываем join колонки в уникальные имена  c:(m:y[0],x 0)z;  // после join z[;0] и z[;1] колонки будут одинаковыми  // поэтому колонки из y перенаправим на x  m[z[;1]]:c[;0];  // x[1]c[;0] - просто join колонки из таблицы x (подтаблица)  // y[1]c[;1] - симметрично из y  // sij[xval;yval] -> (idx1;idx2) найти индексы join в обеих таблицах  // sidx[(i1;i2);x;y без join колонок] -  //  собрать новую таблицу из x и y и индексов  (m;sidx[sij[x[1]c[;0];y[1]c[;1]];x 1;c[;1]_ y 1])}// sidx просто применяет индексы ко всем колонкам и объединяет y и z// y z - это словари, но поскольку традиционно векторные функции имеют// максимально широкую область определения, не нужно обращаться явно к value sidx:{(y@\:x 0),z@\:x 1};

Вся работа выполняется в функции sij, все остальное - это манипуляции именами, колонками и индексами. Если бы мы захотели добавить другой тип индекса, достаточно было бы написать еще одну sij. Конечно, функция выглядит непросто, но учитывая, что она покрывает 80% select, ей можно это простить.

Функция sij сводится к поиску строк таблицы x в таблице y. В Rust для этих целей можно использовать HashMap с быстрой hash функцией FNV - поместить в Map одну таблицу и потом искать в ней строки второй. В Q, судя по времени выполнения, скорее всего используется что-то подобное. В целом в Q у нас есть два варианта - использовать векторные примитивы или воспользоваться встроенными возможностями связанными с таблицами. В первом варианте все по-честному:

// x и y - списки колонокsij:{j:where count[y 0]>i:$[1=count x;y[0]?x 0;flip[y]?flip x]; (j;i j)};// или на псевдокоде// i=find_idx[tblY;tblX]; j=i where not null i; return (j,i[j])

используем функцию поиска значения в векторе (?) и транспозиции матрицы (flip). Этот вариант не такой медленный как может показаться - всего в 2.5 раза медленнее, чем оптимизированный поиск сразу по таблице (который выглядит ровно также - x?y, только x и y - таблицы, а не списки векторов). Это показывает в очередной раз силу векторных примитивов.

Наконец сам join - это просто цикл свертки по всем таблицам (fold):

// "/" это fold, rename' это map(rename)sjoin:{[v] join_core/[rename[v 0;"@"];rename'[v 1;string til count v 1];v 2]};

Остальные части select гораздо проще. where:

swhere:{[t;w] i:til count value[t 1]0;  // все строки по умолчанию  $[count w;where 0<>seval[t;i;();w];i]}; // выбрать те, которые не 0// seval такой же как eval в Rust, т.е. его сигнатура:// seval[table,index;group by cols;expr], ECtx - это сам Q

Основная функция select:

sel2:{[p] // p ~ словарь с элементами select (`j, `s, `g  и т.п.)  i:swhere[tbl:sjoin p`j;p`w]; // сходу делаем join и where  if[0=count p`s; // в случае select * надо найти подходящие имена колонкам    rmap:v[vi]!key[tbl 0] vi:v?distinct v:value tbl 0;    p[`s]:{nfix[x]!x} rmap key tbl 1];  if[count p`g; // group by    // из group колонок нужен только первый элемент, нужно знать их имена    gn:nfix {$[10=type x;x;""]} each p`g;    // sgrp вернет список индексов (idx1;idx2;..) для каждой группы    // затем нужно выполнить seval[tbl;idxN;gn;exprM] для всех idx+expr    // т.е. двойной цикл, который в Q скрыт за двумя "each"    g:sgrp[tbl;i;p`g]];    :key[p`s]!flip {x[z] each y}[seval[tbl;;gn];value p`s] each g;  // если group нет, то все элементарно - просто seval для всех select выражений  (),/:seval[tbl;i;()] each p`s };

Функция sgrp в основе group by - это просто векторный примитив group, возвращающий словарь, где ключи - уникальные значения, а значения - их индексы во входном значении:

sgrp:{[t;i;g] i value group flip seval[t;i;()] each g};

Я опускаю distinct и into части, поскольку они малоинтересны. В целом - это весь select на Q. В краткой записи он занимает всего 25 строк. Можно ли ждать хоть какой-то производительности от столь скромной программы? Да, потому что она написана на векторном языке!

Производительность

Напомню, что этот игрушечный интерпретатор может выполнять выражения типа

select * from (select sym,size,count(*),avg(price) into r  from bt where price>10.0 and sym='fb'  group by sym,size)  as t1 join ref on t1.sym=ref.sym, t1.size = ref.size

и при этом справляться с таблицами в сотни миллионов строк. В частности таблица bt в выражении выше сгенерирована выражением:

// в интерпретаторе на Rust// s = ("apple";"msft";"ibm";"bp";"gazp";"google";"fb";"abc")// i/f - i64/f64 интервалы [0-100)set bt [sym rand('s',100000000), size rand('i', 100000000),    price rand('f', 100000000)]

Т.е. содержит 100 миллионов строк. Поначалу базовый select с group by (получается 800 групп по ~125000 элементов)

select sym,size,count(*),avg(price) into r from bt group by sym,size

работал 44 секунды в программе на Rust, что совсем неплохо и само по себе. Однако после некоторых оптимизаций, а главное распаралелливания ключевых операций, мне удалось добиться скорости порядка 6 секунд. На мой взгляд весьма хороший результат для подобной таблицы.

Самое главное, программа на Rust, несмотря на свой внушительный вид, - это почти 1 в 1 программа на Q. Поэтому больших интеллектуальных усилий и даже отладки она не потребовала. Также благодаря векторности изначального языка ее ускорение путем распараллеливания не потребовало почти никаких усилий - если все операции изначально над массивами, то все что нужно - это вставить там и тут par_iter вместо iter.

Интерпретатор на Q не столь быстр, но вышеуказанный select всего на 50% медленнее аналогичного запроса на самом Q. Это значит, что по сути Q работает на больших данных почти также быстро, как и программа на компилируемом языке.

Хочу также отметить то, насколько великолепным языком проявил себя Rust. За все время разработки и отладки я не получил ни одного segfault и даже panic увидел всего несколько раз, и почти все это были простые ошибки выхода за пределы массива. Также поражает, насколько легко и безопасно в нем можно распараллелить задачу.

Парсер

Я отложил парсер на конец, поскольку он довольно объемен и не имеет прямого отношения к теме статьи. Тем не менее, может вам будет интересно ознакомиться с тем, как легко можно написать весьма серьезный парсер в функциональном стиле на Rust.

Это рекурсивный нисходящий парсер без заглядывания вперед. Из-за этого он не может предсказать следующий шаг и вынужден обходить все варианты. Такой парсер, конечно, медленный и не годится для серьезных задач, но для SQL выражений больше и не надо. Какая разница, парсится выражение 1 микросекунду или 10, если сам запрос займет минимум сотни микросекунд.

Такие парсеры часто пишут руками, и выглядят они примерно так:

parse_expr1(..) {   if(success(parse_expr2(..)) {    if (success(parse_str("+") || success(parse_str("-")) {      if(success(parse_expr1(..)) {         return <expr operation expr>      }      return Fail    }    return <expr>  }  return Fail;}

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

type ParseFn = Box<dyn Fn(&PCtx,&[Token],usize) -> Option<(Expr,usize)>>;type PPFn = fn(Vec<Expr>) -> Expr;

ParseFn будет захватывать правила грамматики, поэтому она должна быть замыканием (closure) и лежать в куче. PCtx содержит другие ParseFn для рекурсивных вызовов и PPFn для постобработки дерева. Если парсинг не удался, она возвращает None, иначе Some с выражением и новым индексом в массив токенов. PPFn обрабатывает узел дерева, поэтому принимает безликий список выражений и превращает его в нужное выражение.

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

("expr", "expr1 ('or' expr {lst})? {f2}"),("expr1","'not' expr1 {f1} | expr2 ('and' expr1 {lst})? {f2}"),("expr2","expr3 (('='|'<>'|'<='|'>='|'>'|'<') expr2 {lst})? {f2}"),("expr3","expr4 (('+'|'-') expr3 {lst})? {f2}"),("expr4","vexpr (('*'|'/') expr4 {lst})? {f2}"),("vexpr","'(' expr ')' {2} | '-' expr {f1} | call | ID | STR | NUM |  '[' (telst (',' telst)* {conc}) ']' {tblv}"),("call", "('sum'|'avg'|'count'|'min'|'max') '(' expr ')' {call} |  'count' '(' '*' ')' {cnt} | 'rand' '(' STR ',' NUM ')' {rand}"),

Тут видны ключевые части - имя правила, само правило и PP функции в фигурных скобках. Каждая продукция правила должна заканчиваться на PP функцию, поскольку правило возвращает Expr, а не Vec<Expr>. PP функция по умолчанию возвращает последний элемент вектора, поэтому кое-где PP функций нет. ID, NUM и т.п. должны обрабатываться ParseFn функцией с соответствующим именем.

Генерируется наш парсер с помощью следующей функции:

let parse = |str| {    let t = l(str);  // add ({}) depth map    let mut lvl = 0;    pp_or(&t.into_iter().map(|v| {      match v.str.as_bytes()[0] {        b'(' | b'{' => lvl+=1,        b')' | b'}' => lvl-=1,        _ => ()};      (v,std::cmp::max(0,lvl))}).collect::<Vec<(Token,i32)>>()    , 0)};

l - это лексер, я переиспользую для этого лексер SQL. Нужно добавить карту глубины вложенности скобок, чтобы было удобно выделять BNF подвыражения. Поскольку это парсер для внутренних потребностей, то нет необходимости проверять правильность скобок, беспокоиться о глубине рекурсии и т.п.

Далее наше правило поступает в парсер BNF. Нужно реализовать следующие компоненты:

  • or правило - A | B

  • and правило - A B C

  • const правило - "(", "select".

  • token правило - NUM, STR.

  • subrule правило - expr1, call.

  • optional правило - A?

  • 0+ правило - A*

  • 1+ правило - A+

  • PP правило - {ppfn}

Это работа, требующая тщательности, но проделать ее нужно один раз. Например, or правило:

fn pp_or(t: &[(Token,i32)], lvl:i32) -> ParseFn {    if t.len() == 0 {return Box::new(|_,_,i| Some((Expr::Empty,i)))};    let mut r: Vec<ParseFn> = t      .split(|(v,i)| *i == lvl && v.str.as_bytes()[0] == b'|' )      .map(|v| pp_and(v,lvl)).collect();    if 1 == r.len() {        r.pop().unwrap()    } else {        Box::new(move |ctx,toks,idx|          r.iter().find_map(|f| f(ctx,toks,idx)))    }}

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

pp_and работает аналогично, только все ее подфункции должны вернуть Some. Также в случае успеха она должна вызвать нужную PPFn для обработки результата.

fn pp_and(t: &[(Token,i32)], lvl:i32) -> ParseFn {    if t.len() == 0 {return Box::new(|_,_,i| Some((Expr::Empty,i)))};    let (rules,usr) = pp_val(Vec::<ParseFn>::new(),t,lvl);    Box::new(move |ctx,toks,i| {        let mut j = i;        let mut v = Vec::<Expr>::with_capacity(rules.len());        for r0 in &rules {        if let Some((v0,j0)) = r0(ctx,toks,j) {            j = j0; v.push(v0)            } else {return None} };        Some((ctx.ppfns[&usr](v),j))    })}

pp_val рекурсивно обрабатывает круглые скобки и все базовые выражения. Вот некоторые примеры из нее:

// Token - if ok call rules[Token]move |ctx,tok,i| if i<tok.len() && tok[i].kind == s   {ctx.rules[&s](ctx,tok,i)} else {None}// Subrulemove |ctx,tok,i| ctx.rules[&s](ctx,tok,i))}// rule?move |ctx,tok,i| Some(rule(ctx,tok,i).unwrap_or((Expr::Empty,i)))// rule+move |ctx,tok,i| {    let (e,i) = plst(&rule,ctx,tok,i);    if 0<e.len() {Some((Expr::ELst(e),i))} else {None}}// где plstlet mut j = i; let mut v:Vec<Expr> = Vec::new();while let Some((e,i)) = rule(ctx,tok,j) {j=i; v.push(e)};(v,j)

Это весь код, необходимый для создания парсера. Чтобы его сгенерировать, нужно вызвать parse и положить правило в map:

let mut map = HashMap::new();map.insert("expr".to_string(), parse("expr1 ('or' expr {lst})? {f2}"));...  

Также необходимо определить PP функции. В большинстве случаев они сравнительно просты:

let mut pfn: HashMap<String,PPFn> = HashMap::new();// default rulepfn.insert("default".to_string(),|mut e| e.pop().unwrap());// set name expr выражениеpfn.insert("set".to_string(),|mut e| Expr::Set(e.swap_remove(1).as_id(),  e.pop().unwrap().into()) );

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

Наконец, положим правила в специальную структуру и определим для нее функцию parse:

PCtx { rules:map, ppfns:pfn}...impl PCtx {    fn parse(&self, t:&[Token]) -> Expr {        if let Some((e,i)) = self.rules["top"](&self,t,0) {            if i == t.len() {e}              else {Val::ERR("parse error".into()).into()}        } else {Val::ERR("parse error".into()).into()}    }}

Все, парсер готов. Он не быстр, но зато очень прост. Плюс он полностью динамический - можно менять правила во время выполнения программы, например, отключить какие-то возможности.

Подробнее..

BGPexplorer машина времени для IPMPLS сетей

10.06.2021 18:12:02 | Автор: admin
Предисловие

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

Современные сети, основанные на маршрутизации IP-пакетов, а точнее сервисы, которые они предоставляют, по факту управляются протоколом BGP. Этот протокол был спроектирован в конце 80-хх на трех салфетках. Да, с тех пор в этот протокол добавили массу возможностей, в том числе обмен маршрутной информацией VPN, правилами фильтрации трафика и всяким прочим полезным, но основа там осталась все той же, описанной на трех салфетках. И в этом есть свой плюс, потому что этот протокол в своей сути очень прост.

Но я хотел поговорить не о его простоте, а о "махании кулаками после драки", с которой частенько приходится сталкивать любой службе эксплуатации сети, или NOC - network operation centre (а может быть и center).

Поступает жалоба "а у меня вот сайт не открывается", или "вот час тому назад плохо работал сайт такой-то, а 5 минут тому назад он починился". Инженер идет на маршрутизатор и смотрит что маршрут к адресу рекомого сайта изменился 5 минут тому назад. И что с этим можно сделать? Чаще всего мало чего. Маршрутизатор знает, что вот такой маршрут "прилетел" 5 минут тому назад, а вот что было раньше? Может быть ничего существенного и не произошло, у маршрута обновились какие-нибудь информационные параметры, а пакеты как летели, так и летят в том же направлении. А может быть наоборот - маршрут изменился существенно и пакеты летят совсем в другом направлении. И узнать историю без дополнительного инструментария (или машины времени) нет возможности. И опять же - хорошо бы узнать, что же именно менялось. Да, есть глобальный инструмент bgplay. Но он рассчитан на использование в Интернете и запоминает изменения при перестроении маршрута между разными провайдерами. А вот изменения внутри сети одного провайдера он не ловит, да и не должен, так как не все то что происходит внутри автономной системы, должно быть видно снаружи.

И вот захотелось мне заиметь такой инструмент. Поиски готового ни к чему не привели (ни на что тут не претендую, может плохо искал). Соответственно, если нет готового решения - почему бы и не попробовать сделать самому? Все вроде понятно, собираем маршрутную информацию и храним историю изменений.

А вот дальше я подзавис. Самая лучшая библиотека, даже я бы сказал целый фреймворк, для обработки BGP - это пакет exabgp. Всем хорош и удобен. Один, на мой взгляд, существенный минус - он на python. Не то чтобы я был питононенавистником, отнюдь. Но каждый инструмент хорош, когда применяется по назначению. А python имеет всем известные особенности (интерпретатор, GIL), которые препятствуют эффективной обработке данных, если алгоритмы реализовывать именно на нем. И в данном случае мне хотелось бы иметь реализацию инструмента на компилируемом (как минимум) языке, с управляемыми политиками блокировок. А также иметь возможность выделить обработку протокола BGP в виде библиотеки и желательно чтобы применяемый язык мог позволить библиотеке жить без своего неотделимого и неотъемлемого рантайма (привет, golang!). Для чего? Ну, например, для того чтобы в перспективе применять эту библиотеку для других bgp-шных приложений. Ну и для скорости. Далеко не для всех видов запросов для поиска маршрутной информации возможно построить эффективный индекс.

Решил я попробовать в качестве основного языка по этой теме Rust и в общем все что хотел получилось. Работу с протоколом BGP выделил в библиотеку. В отличие от exabgp реализовывать логику работы BGP FSM в библиотеке я не стал, по той простой причине что в Rust для сети используется не одно API, std там строго синхронное, а в реальных задачах лучше иметь возможность применять по желанию и потребности асинхронные библиотеки. Библиотеку разумеется назвал zettabgp, а приложение bgpexplorer.

Принцип работы прост. Bgpexplorer может как выступать в роли bgp-спикера, то есть роутер (лучше всего route reflector) устанавливает с сервером bgp-соседство, и отдает в сторону сервера всю маршрутную информацию. Приложение накапливает в своей RIB (Routing Information Database) как актуальную маршрутную информацию, так и историческую. Для просмотра и поисковых запросов доступен веб-интерфейс. Сейчас все хранится в оперативной памяти и ее нужно достаточно много - в зависимости разумеется от того, какого объема таблицу маршрутизации нужно отслеживать.


Если интересно попробовать - то это просто. Нужна сеть, которую нужно мониторить и комп (сервер) с достаточно большим объемом ОЗУ чтобы маршрутная информация туда поместилась.

На сервере нужно взять исходники с гитхаба, предполагается что уже есть git и rust.

$ git clone https://github.com/wladwm/bgpexplorer...$ cd bgpexplorer$ cargo build...

Приложение собрано. Теперь на роутере настроим соседство с сервером.

Если это Cisco, то:

! Номер автономки тут 65535 разумеется для примера, как и адресаrouter bgp 65535 ! указываем адрес сервера с той же автономкой, чтобы сессия была IBGP neighbor 10.1.1.1 remote-as 65535 ! указываем с какого адреса соединяться neighbor 10.1.1.1 update-source Loopback0 ! будем ждать попыток соединения с сервера neighbor 10.1.1.1 transport connection-mode passive address-family ipv4 ! для паблик инета активируем  neighbor 10.1.1.1 activate ! отправляем на сервер вообще все что есть  neighbor 10.1.1.1 route-reflector-client ! Активируем если нужно ipv4 labeled-unicast  neighbor 10.1.1.1 send-label address-family vpnv4 ! включаем VPNv4  neighbor 10.1.1.1 activate  neighbor 10.1.1.1 send-community extended

Возвращаемся на сервер и подготовим конфигурационный файл для приложения

$ cat > bgpexplorer.ini <<EOF[main]httplisten=0.0.0.0:8080httproot=contribsession=s0whoisjsonconfig=whois.json[s0]mode=bmpactivebgppeer=10.0.0.1peeras=65535EOF

В секции main указываем:

  • httplisten на каком адресе и порту будет работать протокол http

  • httproot где лежит корень файлового сервиса. Там лежит index.html и все такое прочее.

  • Whoisjsonconfig указывает на файл конфигурации сервиса whois

  • Session это имя секции, описывающей режим работы bgp, в данном примере s0

В секции сессии (s0 в данном примере) указано:

  • bgppeer адрес роутера BGP для активного режима

  • Peeras номер автономной системы

  • protolisten адрес:порт, где ожидать входящего соединения по протоколу BGP или BMP

  • Mode варианты работы протокола

В mode можно указать:

  • bgppassive ждать входящего подключения от роутера

  • bgpactive инициировать подключение к роутеру

  • bmpactive инициировать подключение к роутеру по протоколу BMP

  • bmppassive ждать входящего подключения от роутера по протоколу BMP

После написания ini-файла можно запустить bgpexplorer, например, командой

cargo run

После чего можно открыть браузер и направить его на адрес сервера с указанием порта.

На самом верху будет выбор RIB для просмотра - IPv4, IPv6, VPN разные всякие и т.п.

Далее строка для фильтра, а уже после нее - пейджинг и собственно маршрутная информация.

Неактивные маршруты будут отображены серым курсивом. У маршрутов, у которых есть история изменений будет кнопка разворачивания истории.

В фильтре можно задавать кроме собственно подсетей фильтрацию по community, aspath, nexthop, route-target, route-distinguisher.

При наведении курсора на ASn, адреса роутеров будет выполняться запрос к whois или DNS, и полученная информация будет отображаться в попапе. Иногда это долго, но бывает полезно.


Буду рад, если этот инструмент пригодится еще кому-нибудь. Найденные проблемы, пожелания, идеи, конструктивная критика приветствуется.

Подробнее..

Rust сохраняем безразмерные типы в статической памяти

11.06.2021 14:22:51 | Автор: admin

Не так давно в качестве хобби я решил погрузиться в изучение embedded разработки на Rust и через какое-то время мне захотелось сделать себе логгер, который бы просто писал логи через UART, но при этом не знал какая конкретно реализация используется. Вот тут я быстро осознал, именно в этом конкретном случае я не могу полагаться на статический полиморфизм и мономорфизацию, ведь компилятор не знает сколько нужно памяти выделять под конкретную реализацию. Фактически это означает, что нам нужно как-то уметь сохранять в памяти типы, размер которых неизвестен на этапе компиляции. Такой способностью обладает типBox, но он использует для этого динамическое выделение памяти из кучи. В итоге возникла идея написать свой аналог данного типа, но хранящий объект в предоставленном пользователем буфере, а не в глобальной куче.

А почему бы просто не взять какой-нибудьlinked_list_allocatorот Фила, дать ему пару килобайт памяти и воспользоваться обычнымBoxтипом, или даже взять какой-нибудь простейший bump аллокатор, ведь мы хотим использовать его лишь для того, чтобы создать несколько глобальных объектов, но есть множество сценариев, когда куча не используется принципиально? Это и дополнительная зависимость от целогоallocкрейта и дополнительные риски, что использование кучи выйдет за рамки строго детерминированных сценариев, что будет приводить к трудноуловимым ошибкам.

С другой стороны, мы можем просто принимать&'static dyn Traitи таким образом переложить заботу о том, как получить такую ссылку, на конечного пользователя, но чтобы обеспечить потом доступ к этой ссылке, нам необходимо использовать примитивы синхронизации или же воспользоваться unsafe кодом, с другой стороны, конечный пользователь тоже должен воспользоваться ими, чтобы создать такую ссылку. В конечном итоге у нас получается или двойная работа или unsafe в публичном API, что довольно плохо. Да и в целом, Box обладает гораздо более широкой областью применения, например, его можно использовать для организации очереди задач в очередном futures executor.

Что же такое безразмерные типы?

Хочу немного напомнить о том, что такое типы динамического размера (DST) и чем они отличаются от обычных. Большинство типов имеют фиксированный размер, известный на этапе компиляции, такие типы компилятор помечает трейтомSized, но есть типы, размер которых в памяти неизвестен на этапе компиляции, поэтому компилятор не может размещать их в статической памяти или на стэке по значению, такие типы еще называют безразмерными.

Тут очень важно понимать, что для каждого конкретного типа компиляторточнознает внутреннее устройство его безразмерного аналога, проблема в том, что один и тот же безразмерныйdyn Displayможет быть получен из самых разнообразных конкретных типов, чем и обеспечивается динамический полиморфизм. И именно поэтому можно приводить к безразмерным типам лишь ссылки и указатели, уж размер указателя компилятору всегда известен.

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

#[repr(C)]pub(crate) union PtrRepr<T: ?Sized> {    pub(crate) const_ptr: *const T,    pub(crate) mut_ptr: *mut T,    pub(crate) components: PtrComponents<T>,}#[repr(C)]pub(crate) struct PtrComponents<T: ?Sized> {    pub(crate) data_address: *const (),    pub(crate) metadata: <T as Pointee>::Metadata,}

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

pub struct DynMetadata<Dyn: ?Sized> {    vtable_ptr: &'static VTable,    phantom: crate::marker::PhantomData<Dyn>,}/// The common prefix of all vtables. It is followed by function pointers for trait methods.////// Private implementation detail of DynMetadata::size_of etc.#[repr(C)]struct VTable {    drop_in_place: fn(*mut ()),    size_of: usize,    align_of: usize,}

Таким образом, в текущей реализации, размер&dyn Displayна x86_64 составляет 16 байт, а когда мы пишем такой вот код:

let a: u64 = 42;let dyn_a: &dyn Display = &a;

Компилятор генерирует объектVTableи сохраняет его где-то в статической памяти, а обычную ссылку заменяет на широкую, содержащую кроме адреса еще и указатель на таблицу виртуальных функций. Ссылка на таблицу виртуальных функций статическая и не зависит от места расположения значения, таким образом, для того, чтобы создать желаемыйBox<dyn Display>из искомого значенияa, нам необходимо извлечь метаданные из ссылки наdyn_aи все это вместе скопировать в заранее приготовленный для этого буфер. Чтобы все это сделать, нам необходимо использовать nightly features:unsizeиptr_metadata.

Для получения&dyn Tиз&Valueиспользуется специальный маркерный трейтUnsize, который выражает отношение междуSizedтипом и его безразмерным альтер-эго. То есть,TэтоUnsize<dyn Trait>в том случае, еслиTреализуетTrait.

А чтобы работать с метаданными указателя используется функцияcore::ptr::metadataи типажPointee, который связывает тип указателя и тип его метаданных, в случае с безразмерными типами метаданные имеют типDynMetadata<T>, гдеTэто искомый безразмерный тип.

#[inline]fn meta_offset_layout<T, Value>(value: &Value) -> (DynMetadata<T>, Layout, usize)where    T: ?Sized + Pointee<Metadata = DynMetadata<T>>,    Value: Unsize<T> + ?Sized,{    // Get dynamic metadata for the given value.    let meta = ptr::metadata(value as &T);    // Compute memory layout to store the value + its metadata.    let meta_layout = Layout::for_value(&meta);    let value_layout = Layout::for_value(value);    let (layout, offset) = meta_layout.extend(value_layout).unwrap();    (meta, layout, offset)}

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

Обратите внимание, что мы беремLayoutот ссылки на метаданные, а неDynMetadata<Dyn>::layout, последний описывает размещениеVTable, но нас интересует размещение самогоDynMetadata, будьте внимательны!

Пишем свой Box

Вот, теперь у нас есть все необходимое, чтобы написать нашBox, его код довольно простой:

Конструктор, который копирует данные и метаданные в предоставленный буфер, используя довольно удобное API указателя.

impl<T, M> Box<T, M>where    T: ?Sized + Pointee<Metadata = DynMetadata<T>>,    M: AsRef<[u8]> + AsMut<[u8]>,{    pub fn new_in_buf<Value>(mut mem: M, value: Value) -> Self    where        Value: Unsize<T>,    {        let (meta, layout, offset) = meta_offset_layout(&value);        // Check that the provided buffer has sufficient capacity to store the given value.        assert!(layout.size() > 0);        assert!(layout.size() <= mem.as_ref().len());        unsafe {            let ptr = NonNull::new(mem.as_mut().as_mut_ptr()).unwrap();            // Store dynamic metadata at the beginning of the given memory buffer.            ptr.cast::<DynMetadata<T>>().as_ptr().write(meta);            // Store the value in the remainder of the memory buffer.            ptr.cast::<u8>()                .as_ptr()                .add(offset)                .cast::<Value>()                .write(value);            Self {                mem,                phantom: PhantomData,            }        }    }}

А вот и код, который собирает байты назад в&dyn Trait:

    #[inline]    fn meta(&self) -> DynMetadata<T> {        unsafe { *self.mem.as_ref().as_ptr().cast() }    }    #[inline]    fn layout_meta(&self) -> (Layout, usize, DynMetadata<T>) {        let meta = self.meta();        let (layout, offset) = Layout::for_value(&meta).extend(meta.layout()).unwrap();        (layout, offset, meta)    }    #[inline]    fn value_ptr(&self) -> *const T {        let (_, offset, meta) = self.layout_meta();        unsafe {            let ptr = self.mem.as_ref().as_ptr().add(offset).cast::<()>();            ptr::from_raw_parts(ptr, meta)        }    }    #[inline]    fn value_mut_ptr(&mut self) -> *mut T {        let (_, offset, meta) = self.layout_meta();        unsafe {            let ptr = self.mem.as_mut().as_mut_ptr().add(offset).cast::<()>();            ptr::from_raw_parts_mut(ptr, meta)        }    }

Дальше смело можно добавить реализациюDerefиDerefMut, в конце концов, в данном случае это как раз тот самый случай, для которых эти самые типы и были созданы.

impl<T, M> Deref for Box<T, M>where    T: ?Sized + Pointee<Metadata = DynMetadata<T>>,    M: AsRef<[u8]> + AsMut<[u8]>,{    type Target = T;    #[inline]    fn deref(&self) -> &T {        self.as_ref()    }}impl<T, M> DerefMut for Box<T, M>where    T: ?Sized + Pointee<Metadata = DynMetadata<T>>,    M: AsRef<[u8]> + AsMut<[u8]>,{    #[inline]    fn deref_mut(&mut self) -> &mut T {        self.as_mut()    }}
running 8 teststest tests::test_box_dyn_fn ... oktest tests::test_box_nested_dyn_fn ... oktest tests::test_box_in_provided_memory ... oktest tests::test_box_trait_object ... oktest tests::test_box_move ... oktest tests::test_drop ... oktest tests::test_layout_of_dyn ... oktest tests::test_box_insufficient_memory ... ok

Miri

Казалось бы, все замечательно, можно использовать библиотеку в боевом коде... Но, постойте, мы же написали unsafe код, как мы вообще можем быть уверены в том, что нигде не нарушили никакие инварианты? К счастью, существует такой проект, как Miri, который интерпретирует промежуточное представление MIR, генерируемое компилятором rustc, используя специальную виртуальную машину. Таким образом, можно находить очень многие ошибки в unsafe коде, подробнее об этом можно почитать в этойстатье. Давайте попробуем запустить наши тесты используя Miri.

cargo miri test   Compiling static-box v0.0.1 (/home/aleksey/Projects/opensource/static-box)    Finished test [unoptimized + debuginfo] target(s) in 0.40s     Running unittests (target/x86_64-unknown-linux-gnu/debug/deps/static_box-e2c02215f3157959)running 8 teststest tests::test_box_dyn_fn ... error: Undefined Behavior: accessing memory with alignment 1, but alignment 8 is required   --> /home/aleksey/.rustup/toolchains/nightly-2021-04-25-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:886:9    |886 |         copy_nonoverlapping(&src as *const T, dst, 1);    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ accessing memory with alignment 1, but alignment 8 is required    |    = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior    = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information

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

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

        // Construct a box to move the specified memory into the necessary location.        // SAFETY: This code relies on the fact that this method will be inlined.        let mut new_box = Self {            align_offset: 0,            mem,            phantom: PhantomData,        };        let raw_ptr = new_box.mem.as_mut().as_mut_ptr();        // Compute the offset that needs to be applied to the pointer in order to make        // it aligned correctly.        new_box.align_offset = raw_ptr.align_offset(layout.align());

Вот собственно и все, после этого Miri больше не показывает ошибок выравнивания.

cargo miri test   Compiling static-box v0.1.0 (/home/aleksey/Projects/opensource/static-box)    Finished test [unoptimized + debuginfo] target(s) in 0.30s     Running unittests (target/x86_64-unknown-linux-gnu/debug/deps/static_box-ce23f69c165cf930)running 11 teststest tests::test_box_dyn_fn ... oktest tests::test_box_in_provided_memory ... oktest tests::test_box_in_static_mem ... oktest tests::test_box_in_unaligned_memory ... oktest tests::test_box_insufficient_memory ... oktest tests::test_box_move ... oktest tests::test_box_nested_dyn_fn ... oktest tests::test_box_trait_object ... oktest tests::test_drop ... oktest tests::test_layout_of_dyn_split_at_mut ... oktest tests::test_layout_of_dyn_vec ... oktest result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out   Doc-tests static-boxrunning 2 teststest src/lib.rs - (line 24) ... oktest src/lib.rs - (line 48) ... oktest result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s

Хочу еще сказать несколько слов относительно типаLayout, в нем содержится два поляsize, которое содержит размер памяти в байтах, необходимый для размещения объекта, иalign- это число (причем всегда степень двойки), которому должен быть кратен указатель на объект данного типа. И таким образом, чтобы починить выравнивание, мы просто вычисляем сколько нам нужно прибавить к адресу начала буфера, чтобы получить адрес кратныйalign. Дополнительно довольно доступно написано про выравнивание уу Фила.

Заключение

Ура, теперь мы можем писать вот такой вот код!

use static_box::Box;struct Uart1Rx {    // Implementation details...}impl SerialWrite for Uart1Rx {    fn write(&mut self, _byte: u8) {        // Implementation details    }}let rx = Uart1Rx { /* ... */ };SOME_GLOBAL_WRITER.init_once(move || Box::<dyn SerialWrite, [u8; 32]>::new(rx));// A bit of code later.SOME_GLOBAL_WRITER.lock().unwrap().write_str("Hello world!");

Итак, мы при помощи unsafe и некоторого количества nightly фич смогли написать тип, позволяющий размещать полиморфные объекты на стеке или в статической памяти без использования кучи, что может быть полезным во многих случаях. Хотя, конечно, каждый раз при получении ссылки на объект приходится дополнительно вычислять адрес метаданных и значения, но мы не можем просто так взять и сохранить эти адреса как поля структуры, в этом случае она станет самоссылающиеся, что довольно неприятно в Rust контексте, это не работает с семантикой перемещения. В целом, если воспользоваться pin API, и сделать нашBox неперемещаемым, то можно будет позволить себе эту оптимизацию, а заодно и обеспечить возможность работать с любыми Future типами.

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

А еще важно иногда выходить за рамки stable Rust, чтобы быть в курсе, куда же язык дальше развивается и тем самым расширять свой кругозор.

Ссылка на крейт

Подробнее..

Перевод Rust в ядре Linux

13.06.2021 16:20:24 | Автор: admin


Вболее ранней публикации компания Google объявила, что в Android теперь поддерживается язык программирования Rust, применяемый в разработке этой ОС как таковой. В связи с этим авторы данной публикации также решили оценить, насколько язык Rust востребован в разработке ядра Linux. В этом посте на нескольких простых примерах рассмотрены технические аспекты этой работы.

На протяжении почти полувека C оставался основным языком для разработки ядер, так как C обеспечивает такую степень управляемости и такую предсказуемую производительность, какие и требуются в столь критичном компоненте. Плотность багов, связанных с безопасностью памяти, в ядре Linux обычно весьма низка, поскольку код очень качественный, ревью кода соответствует строгим стандартам, а также в нем тщательно реализуются предохранительные механизмы. Тем не менее,баги, связанные с безопасностью памяти, все равно регулярно возникают. В Android уязвимости ядра обычно считаются серьезным изъяном, так как иногда позволяют обходить модель безопасности в силу того, что ядро работает в привилегированном режиме.

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

Поддержка Rust


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

Мы присоединились к организацииRust для Linux, где сообщество уже много сделало и продолжает делать для добавления поддержки Rust в систему сборки ядра Linux. Также нам нужно проектировать системы таким образом, чтобы фрагменты кода, написанные на двух языках, могли взаимодействовать друг с другом: нас особенно интересуют безопасные абстракции без издержек, которые позволяют коду Rust использовать функционал ядра, написанный на C, а также возможность реализовывать функционал на идиоматическом Rust, который можно гладко вызывать из тех частей ядра, что написаны на C.

Поскольку Rust новый язык в ядре, у нас также есть возможность обязывать разработчиков придерживаться наилучших практик в области составления документации и соблюдения единообразия. Например, у нас есть конкретные машинно-проверяемые требования, касающиеся использования небезопасного кода: для каждой небезопасной функции разработчик должен документировать те требования, которым должна удовлетворять вызывающая сторона таким образом, гарантируется безопасность использования. Кроме того, при каждом вызове небезопасных функций разработчик обязан документировать те требования, которые должны удовлетворяться вызывающей стороной в качестве гарантии того, что использование будет безопасным. Кроме того, для каждого вызова к небезопасным функциям (или использования небезопасных конструкций, например, при разыменовании сырого указателя) разработчик должен документировать обоснование, почему делать так безопасно.

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

Пример драйвера


Рассмотрим реализацию семафорного символьного устройства. У каждого устройства есть актуальное значение; при записи nбайт значение устройства увеличивается на n; при каждом считывании это значение понижается на 1, пока значение не достигнет 0, в случае чего это устройство блокируется, пока такая операция декремента не сможет быть выполнена на нем без ухода ниже 0.

Допустим,semaphore это файл, представляющий наше устройство. Мы можем взаимодействовать с ним из оболочки следующим образом:

> cat semaphore

Когдаsemaphore это устройство, которое только что инициализировано, команда, показанная выше, блокируется, поскольку текущее значение устройства равно 0. Оно будет разблокировано, если мы запустим следующую команду из другой оболочки, так как она увеличит значение на 1, тем самым позволив исходной операции считывания завершиться:

> echo -n a > semaphore

Мы также сможем увеличить счетчик более чем на 1, если запишем больше данных, например:

> echo -n abc > semaphore

увеличивает счетчик на 3, поэтому следующие 3 считывания не приведут к блокированию.

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

Теперь покажем, как такой драйвер был быреализован на Rust, сравнив этот вариант с реализацией на C. Правда, отметим, что разработка этой темы в Google только начинается, и в будущем все может измениться. Мы хотели бы подчеркнуть, как Rust может пригодиться разработчику в каждом из аспектов. Например, во время компиляции он позволяет нам устранить или сильно снизить вероятность того, что в код вкрадутся целые классы багов, притом, что код останется гиьким и будет работать с минимальными издержками.

Символьные устройства


Разработчику необходимо сделать следующее, чтобы реализовать на Rust драйвер для нового символьного устройства:

  1. Реализовать типажFileOperations: все связанные с ним функции опциональны, поэтому разработчику требуется реализовать лишь те, что релевантны для данного сценария. Они соотносятся с полями в структуре Cstruct file_operations.
  2. Реализовать типажFileOpenerэто типобезопасный эквивалент применяемого в C поляopenиз структурыstruct file_operations.
  3. Зарегистрировать для ядра новый тип устройства: так ядру будет рассказано, какие функции нужно будет вызывать в ответ на операции над файлами нового типа.

Далее показано сравнение двух первых этапов нашего первого примера на Rust и C:



Символьные устройства в Rust отличаются целым рядом достоинств по части безопасности:

  • Пофайловое управление состоянием жизненного цикла:FileOpener::openвозвращает объект, чьим временем жизни с того момента владеет вызывающая сторона. Может быть возвращен любой объект, реализующий типаж PointerWrapper, и мы предоставляем реализации дляBoxиArc, так что разработчики, реализующие идиоматические указатели Rust, выделяемые из кучи или предоставляемые путем подсчета ссылок, обеспечены всем необходимым.

    Все ассоциированные функции вFileOperationsполучают неизменяемые ссылки на self(подробнее об этом ниже), кроме функцииrelease, которая вызывается последней и получает в ответ обычный объект (а впридачу и владение им). Тогда реализация releaseможет отложить деструкцию объекта, передав владение им куда-нибудь еще, либо сразу разрушить его. При работе с объектом с применением подсчета ссылок деструкция означает декремент количества ссылок (фактическое разрушение объекта происходит, когда количество ссылок падает до нуля).

    То есть, здесь мы опираемся на принятую в Rust систему владения, взаимодействуя с кодом на C. Мы обрабатываем написанную на C часть кода, которым владеет объект Rust, разрешая ему вызывать функции, реализованные на Rust, а затем, в конце концов, возвращаем владение обратно. Таким образом, коль скоро код на C, время жизни файловых объектов Rust также обрабатывается гладко, и компилятор обязывает поддерживать правильное управление временем жизни объекта на стороне Rust. Например, open не может возвращать в стек указатели, выделенные в стеке, или объекты, выделенные в куче, ioctl/read/writeне может высвобождать (или изменять без синхронизации) содержимое объекта, сохраненное вfilp->private_data, т.д.


    Неизменяемые ссылки: все ассоциированные функции, вызываемые между openиreleaseполучают неизменяемые ссылки наself, так как они могут конкурентно вызываться сразу множеством потоков, а действующие в Rust правила псевдонимов не допускают, чтобы в любой момент времени на объект указывало более одной изменяемой ссылки.

    Если разработчику требуется изменить некоторое состояние (а это в порядке вещей), то это можно сделать при помощивнутренней изменяемости : изменяемое состояние можно обернуть в MutexилиSpinLock(илиatomics) и безопасно через них изменить.

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


    Состояние для каждого устройства отдельно: когда экземпляры файлов должны совместно использовать состояние конкретного устройства, что при работе с драйверами бывает очень часто, в Rust это можно делать полностью безопасно. Когда устройство зарегистрировано, может быть предоставлен типизированный объект, для которого затем выдается неизменяемая ссылка, когда вызываетсяFileOperation::open. В нашем примере разделяемый объект обертывается вArc, поэтому объекты могут безопасно клонировать и удерживать ссылки на такие объекты.

    Причина, по которойFileOperation
    выделен в собственный типаж (в отличие, например, отopen, входящего в состав типажаFileOperations) так можно разрешить выполнять регистрацию конкретной реализации файла разными способами.

    Таким образом исключается, что разработчик может получить неверные данные, попытавшись извлечь разделяемое состояние. Например, когда в C зарегистрировано miscdevice, указатель на него доступен вfilp->private_data; когда зарегистрированоcdev, указатель на него доступен в inode->i_cdev. Обычно эти структуры встраиваются в объемлющую структуру, которая содержит разделяемое состояние, поэтому разработчики обычно прибегают к макросуcontainer_of, чтобы восстановить разделяемое состояние. Rust инкапсулирует все это и потенциально проблемные приведения указателей в безопасную абстракцию.


    Статическая типизация: мы пользуемся тем, что в Rust поддерживаются дженерики, поэтому реализуем все вышеупомянутые функции и типы в виде статических типов. Поэтому у разработчика просто нет возможности преобразовать нетипизированную переменную или поле в неправильный тип. В коде на C в вышеприведенной таблице есть приведения от, в сущности, нетипизированного указателя (void*) к желаемому типу в начале каждой функции: вероятно, в свеженаписанном виде это работает нормально, но также может приводить к багам по мере развития кода и изменения допущений. Rust отловит все такие ошибки еще во время компиляции.


    Операции над файлами: как упоминалось выше, разработчику требуется реализовать типажFileOperations, чтобы настраивать поведение устройства на свое усмотрение. Это делается при помощи блока, начинающегося с impl FileOperations for Device, гдеDevice это тип, реализующий поведение файла (в нашем случае это FileState). Оказавшись внутри блока, инструменты уловят, что здесь может быть определено лишь ограниченное количество функций, поэтому смогут автоматически вставить прототипы. (лично я используюneovimи LSP-серверrust-analyzer.)

    При использовании этого типажа в Rust, та часть ядра, что написана на C, все равно требует экземпляр struct file_operations. Контейнер ядра автоматически генерирует такой экземпляр из реализации типажа (а опционально также макросdeclare_file_operations): хотя, в нем и есть код, чтобы сгенерировать корректную структуру, здесь все равно всеconst, поэтому интерпретируется во время компиляции, и во время выполнения не дает никаких издержек.

    Обработка Ioctl

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



    Команды Ioctl стандартизированы таким образом, что, имея команду, мы знаем, предоставляется ли под нее пользовательский буфер, как ее предполагается использовать (чтение, запись, и то, и другое, ничего из этого) и ее размер. В Rust предоставляется диспетчер(для доступа к которому требуется вызватьcmd.dispatch), который на основе этой информации автоматически создает помощников для доступа к памяти и передает их вызывающей стороне.

    Но от драйвера нетребуетсяэтим пользоваться. Если, например, он не использует стандартную кодировку ioctl, то Rust подходит к этому гибко: просто вызывает cmd.rawдля извлечения сырых аргументов и использует их для обработки ioctl (потенциально с небезопасным кодом, что требуется обосновать).

    Но, если в реализации драйвера действительно используется стандартный диспетчер, то ситуация значительно улучшается, поскольку ему вообще не придется реализовывать какой-либо небезопасный код, и:

    • Указатель на пользовательскую память никогда не является нативным, поэтому пользователь не может случайно разыменовать его.
    • Типы, позволяющие драйверу считывать из пользовательского пространства, допускают лишь однократное считывание, поэтому мы не рискуем получить баги TOCTOU (время проверки до времени использования). Ведь когда драйверу требуется обратиться к данным дважды, он должен скопировать их в память ядра, где злоумышленник не в силах ее изменить. Если исключить небезопасные блоки, то допустить баги такого класса в Rust попросту невозможно.
    • Не будет случайного переполнения пользовательского буфера: считывание или запись ни в коем случае не выйдут за пределы пользовательского буфера, которые задаются автоматически на основе размера, записанного в команде ioctl. В вышеприведенном примере реализацияIOCTL_GET_READ_COUNTобладает доступом лишь к экземпляру UserSlicePtrWriter, что ограничивает количество доступных для записи байт величиной sizeof(u64), как закодировано в команде ioctl.
    • Не смешиваются операции считывания и записи: в ioctl мы никогда не записываем буферы, предназначенные для считывания, и наоборот. Это контролируется га уровне обработчиков чтения и записи, которые получают только экземплярыUserSlicePtrWriterиUserSlicePtrReaderсоответственно.

    Все вышеперечисленное потенциально также можно сделать и в C, но разработчику ничего не стоит (скорее всего, ненамеренно) нарушить контракт и спровоцировать небезопасность; для таких случаев Rust требует блокиunsafe, которые следует использовать лишь изредка и проверяться особенно пристально. А вот что еще предлагает Rust:

    • Типы, применяемые для чтения и записи пользовательской памяти, не реализуют типажиSendиSync, и поэтому они (и указатели на них) небезопасны при использовании в другом контексте. В Rust, если бы разработчик попытался написать код, который передавал бы один из этих объектов в другой поток (где использовать их было бы небезопасно, поскольку контекст менеджера памяти там мог быть неподходящим), то получил бы ошибку компиляции.
    • ВызываяIoctlCommand::dispatch, логично предположить, что нам потребуется динамическая диспетчеризация, чтобы добраться до фактической реализации обработчика (и это повлечет дополнительные издержки, которых не было бы в C), но это не так. Благодаря использованию дженериков, компилятор сделает функцию мономорфной, что обеспечит нам статические вызовы функции. Функцию можно будет даже оформить внутристрочно, если оптимизатор сочтет это целесообразным.

    Блокировка и условные переменные

    Разработчикам разрешено использовать мьютексы и спинлоки для обеспечения внутренней изменяемости. В нашем примере мьютекс используется для защиты изменяемых данных; в приведенной ниже таблице показаны структуры данных, используемые в C и Rust соответственно, а также показано, как реализовать ожидание и дождаться, пока счет станет ненулевым, и мы сможем выполнить требуемое считывание:



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

    А вот какими достоинствами обладает соответствующая реализация на Rust:

    • ПолеSemaphore::innerдоступно только во время удержания блокировки, при помощи ограничителя, возвращаемого функциейlock. Поэтому разработчики не могут случайно прочитать или записать защищенные данные, предварительно их не заблокировав. В примере на C, приведенном выше,countиmax_seenвsemaphore_stateзащищены мьютексом, но программа не обязывает держать блокировку во время доступа к ним. there is no enforcement that the lock is held while they're accessed.
    • Получение ресурса есть инициализация (RAII): блокировка снимается автоматически, когда ограничитель (innerв данном случае) выходит из области видимости. Таким образом, все блокировки всегда снимаются: если разработчику нужно, чтобы блокировка оставалась на месте, он может продлевать существование ограничителя, например, возвращая его; верно и обратное: если необходимо снять блокировку ранее, чем ограничитель выйдет из области видимости, это можно сделать явно, вызвав функцию drop.
    • Разработчики могут использовать любую блокировку, использующую типажLock, в состав которого, кстати, входят MutexиSpinLock, и, в отличие от реализации на C, это не повлечет никаких дополнительных издержек во время выполнения. Другие конструкции для синхронизации, в том числе, условные переменные, также работают прозрачно и без дополнительных издержек времени выполнения.
    • Rust реализует условные переменные при помощи очередей ожидания, предусмотренных в ядре. Благодаря этому разработчики могут пользоваться атомарным снятием блокировки и погружать поток в сон, не задумываясь о том, как это отразится на низкоуровневом планировщике функций ядра. В вышеприведенном примере на C вsemaphore_consumeвидим смесь семафорной логики и тонкого планирования в стиле Linux: например, код получится неправильным, еслиmutex_unlockбудет вызван доprepare_to_wait, поскольку таким образом можно забыть об операции пробуждения.
    • Никакого несинхронизированного доступа: как упоминалось выше, переменные, совместно используемые несколькими потоками или процессорами, должны предоставляться только для чтения, и внутренняя изменяемость пригодится для тех случаев, когда изменяемость действительно нужна. Кроме вышеприведенного примера с блокировками, есть еще пример с ioctl из предыдущего раздела, где также демонстрируется, как использовать атомарную переменную. В Rust от разработчиков также требуется указывать, как память должна синхронизироватьсяпри атомарных обращениях. В той части примера, что написана на C, нам довелось использовать atomic64_t, но компилятор не предупредит разработчика о том, что это нужно сделать.

    Обработка ошибок и поток выполнения

    В следующих примерах показано, как в нашем драйвере реализованы операцииopen,read иwrite:







    Здесь видны и еще некоторые достоинства Rust:

    • Оператор?operator: используется реализациями openиreadв Rust для неявного выполнения обработки ошибок; разработчик может сосредоточиться на семафорной логике, и код, который у него получится, будет весьма компактным и удобочитаемым. В версиях на C необходимая обработка ошибок немного замусоривает код, из-за чего читать его сложнее.
    • Обязательная инициализация: Rust требует, чтобы все поля структуры были инициализированы при ее создании, чтобы разработчик не мог где-нибудь нечаянно забыть об инициализации поля. В C такая возможность не предоставляется. В нашем примере с open, показанном выше, разработчик версии на C мог легко забыть вызвать kref_get(пусть даже все поля и были инициализированы); в Rust же пользователь обязан вызватьclone(повышающую счет ссылок на единицу), в противном случае он получит ошибку компиляции.
    • Область видимости при RAII: при реализации записи в Rust используется блок выражений, контролирующий, когда innerвыходит из области видимости и, следовательно, когда снимается блокировка.
    • Поведение при целочисленном переполнении: Rust стимулирует разработчиков всегда продумывать, как должны обрабатываться переполнения. В нашем примере с write, мы хотим обеспечить насыщение, чтобы не пришлось плюсовать к нашему семафору нулевое значение. В C приходится вручную проверять, нет ли переполнений, компилятор не оказывает дополнительной поддержки при такой операции.




    Облачные серверы от Маклауд быстрые и безопасные.

    Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Extendr вызываем rust из R (и наоборот)

14.06.2021 20:18:14 | Автор: admin

Зачем нужен Rust в R?

Первый вопрос, который должен возникнуть у читателя -- а зачем вообще использовать Rust вместе с R? Ответ довольно прост: Rust -- новый системный язык программирования, спроектированный специально для написания безопасного и легко распараллеливаемого кода. Rust довольно сложен в освоении (в сравнении с другими языками), но при этом предоставляет отличные инструменты для разработки. Rust имеет довольно неплохую ООП систему и очень много заимствует из функциональных языков программирования. Несмотря на дополнительную сложность из-за функциональных/ООП компонентов, Rust позиционируется как zero-cost abstraction язык, так же как и C++.

Из-за своей популярности Rust привлекает разработчиков, которые портируют старые библиотеки и разрабатывают новые крейты. Большинство из этих крейтов можно напрямую использовать в пакетах для R, упрощая жизнь разработчикам на R.

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

Что нужно, чтобы R код мог вызвать Rust-библиотеку?

На самом деле -- не так уж много. R-пакеты могут содержать директорию src/, в которой находится исходный код на одном из компилируемых языков. С помощью src/Makevars или src/Makevars.win файлов (вариация make) можно контролировать процесс сборки, например, вызвав на одном из шагов cargo (см. пример здесь):

cargo build --release --manifest-path=rustlib/Cargo.toml

При этом Rust -библиотека должна собираться как crate-type = ["staticlib"]. Кроме непосредственной компиляции Rust-кода, нужно предоставить C-обертки к экспортируемым функциям, а так же добавить несколько магических вызовов специальных R-функций, которые объясняют R, какие именно функции и какого типа экспортируются из данной библиотеки (например, вот так).

Основная проблема -- C-обертки и преобразование типов из R SEXP (фактически, специальный указатель) во что-то, совместимое с Rust, учитывая при этом специфику управления памятью в R (все эти ваши PROTECT, UNPORTECT, и т. д.). Как результат -- легко создать примитивный прототип без функционала, практически невозможно написать достаточно большой проект.

Интегрируем R и Rust: три простых шага

Шаг первый: баиндинги для заголовочных файлов R

Взаимодействие с R происходит через специализированный API, доступный обычно ввиде C/ C++ заголовочных файлов (см. $R_HOME\include\). Разумеется, вызывать эти методы можно практически из любого языка, но это неудобно -- загловочные файлы невозможно подключить напрямую к Rust. К счастью, у этой проблемы уже давно есть решение: rust-bindgen (rust-lang/rust-bindgen). bindgen позволяет автоматически генерировать Rust-обертки из заголовочных файлов, и делает это довольно эффективно.

Так появился крейт libR-sys, который предоставляет баиндинги ко всем необходимым внутренним R функциям. Генерация баиндингов -- вещь нетривиальная, bindgen зависит от clangи сложен в конфигурировании, поэтому мы предоставляем pre-computed (заранее сгененрированные) баиндинги для большинства платформ, поддерживающих R. Список включает в себя linux-x64 (созданный с помощью Ubuntu-20.04), win-x86/x64 (с помощью msys2, x86 может иметь проблемы в каких-то пограничных случаях), macOS включая 11 версию (по возможности), x64 и экспериментально arm64 (честно я не знаю, есть ли arm64 сборка R под macOS). Для каждой из упомянутых платформ/архитектур мы стараемся предоставить три версии баиндингов: oldrel, release, и devel, что соответствует "прошлой", "текущей" (сейчас это 4.1.0) и "находящейся в разработке" версиям R.

В качестве альтернативы баиндинги можно сгенерировать непосредственно в системе где компилируется R пакет, при условии что все необходимые зависимости присутствуют (особенная головная боль на Windows). Гипотетически, можно собрать R для неподдерживаемой платформы и прямо на месте сгененрирвоать баиндинги (если Rust поддерживает такую платформу).

Исхоный код проекта досутпен здесь. Крейт находится в стадии поддержки, т. к. основная разработка завершена. Сейчас решается вопрос об автоматизации деплоймента новых баиндингов когда происходит релиз новой версии R.

Шаг второй: автоматизируем преобразование типов и экспорт функций

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

Прежде чем продолжить, я хочу сделать небольшое отступление. Вся идея проекта extendr и, в особенности, имплементация большей части Rust-крейтов, принадлежит Энди Томасону (@andy-thomason). Без его вклада, на мой субъективный взгляд, extendr в том виде, в котором он существует сейчас, был бы невозможен.

Вернемся обратно к коду. Как избавиться от боилерплейта? Легко, надо всего лишь распарсить исходный код Rust. Например, используя syn и подобные крейты. Моей экспертизы недостаточно, чтобы детально описать процесс парсинга и кодогенерации, но для конечного пользователя экспорт Rust функции становится невероятно простым. Во-первых, нужно пометить функции с помощью аттрибута #[extendr]:

#[extendr]fn add_i32(x : i32, y : i32) -> i32 { x + y }#[extendr]fn add_vec(x : &[i32], y : &[i32]) -> Vec<i32> {     x.iter().zip(y.iter()).map(|v| v.0 + v.1).collect()}

Во-вторых, нужно явно объявить экспортируемые функции:

extendr_module! {  mod extendrtest;  fn add_i32;  fn add_vec;}

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

К сожалению, остается одно небольшое ограничение при интеграции Rust-кода в проект. Дело в том, что если в папке src/ отсутствуют файлы-исходники, то стандартная процедура компиляции R попросут игнорирует все остальное и библиотека не компилируется. Чтобы обойти это, в src/ добавляется единственный файл entrypoint.c, примерно следующего содержания:

void R_init_extendrtest_extendr(void *dll);void R_init_extendrtest(void *dll) {  R_init_extendrtest_extendr(dll);}

Здесь R_init_extendrtest_extendr генерируется автоматически с помощью Rust-крейта, а R_init_extendrtest -- непосредственно вызывается из R. Мы пока что не нашли способа избавиться от этого ограничения.

Некоторых изменений требуют и Makevars-файлы. Вот пример из одного из тестовых проектов:

LIBDIR = ./rust/target/releaseSTATLIB = $(LIBDIR)/libextendrtest.aPKG_LIBS = -L$(LIBDIR) -lextendrtestall: C_clean$(SHLIB): $(STATLIB)$(STATLIB):cargo build --lib --release --manifest-path=./rust/Cargo.tomlC_clean:rm -Rf $(SHLIB) $(STATLIB) $(OBJECTS)clean:rm -Rf $(SHLIB) $(STATLIB) $(OBJECTS) rust/target

Фактически, мы отдельно компилируем Rust-крейт, а потом создаем совместимую с R библиотеку используя Rust-библиотеку и результат компиляции entrypoint.c.

Аналогично выглядит и версия для Windows, с той лишь разницей что на Windows мы поддерживаем иx86, и x64, из-за чего приходится динамически выбирать правильный путь к STATLIB.

extendr выполняет не только кодогенерацию на стороне Rust, он еще генерирует обертки на стороне R. Если предположить, что приведенный выше Rust код является частью пакета {extendrtest}, то становятся досутпны следующие функции:

extendrtest::add_i32(4L, 11L)# [1] 15extendrtest::add_vec(1:10, 10:1)#  [1] 11 11 11 11 11 11 11 11 11 11

Да, насктолько просто.

Шаг третий: user-friendliness

В своей работе мы вдохновлялись такими проектами как {cpp11} - header-only пакет для интеграции C++11 кода. Так появился на свет {rextendr}, R - пакет без Rust-зависимости, который решает три основные задачи:

  • Создание шаблона пакета, использующего extendr, наподобие {usethis};

  • Компиляция и исполнение Rust - кода на лету, прямо в R-сессии. Именно это демонстрирует Анимация Для Привлечения Внимания;

  • Предоставление специальных knitr-модулей (engines), а именно {extendr} и {extendrsrc}, которые позволяют включать фрагменты Rust-кода (и результаты его выполнения) в ваш Rmarkdown прямо рядом с R-кодом, обеспечивая их взаимодействие.

Сейчас {rextendr} отправился на проверку в CRAN, и мы ждем результатов. Я думаю, самое время продемонстроровать несколько примеров именно с использованием {rextendr}. Сразу оговорюсь, для упрощения и воспроизводимости, я буду использовать{reprex}.

Самый простой пример это, конечно же,

rextendr::rust_function("fn hello_r() -> &'static str { \"Hello R!\" }")#> i build directory: 'C:\Users\...\AppData\Local\Temp\Rtmp259cVM\file10186cb44264'#> v Writing 'C:/Users/.../AppData/Local/Temp/Rtmp259cVM/file10186cb44264/target/extendr_wrappers.R'.hello_r()#> [1] "Hello R!"

Пример со сложением я уже показывал, но что будет, если явно передать NA?

rextendr::rust_function("fn add_i32(x : i32, y : i32) -> i32 { x + y }")#> i build directory: 'C:\Users\...\AppData\Local\Temp\Rtmp2P2cnQ\file2f7c65e8269a'#> v Writing 'C:/Users/.../AppData/Local/Temp/Rtmp2P2cnQ/file2f7c65e8269a/target/extendr_wrappers.R'.add_i32(42L, NA)#> Error in add_i32(42L, NA): unable to convert R object to primitive

Сообщения об ошибках еще не очень информативны, мы работаем над этим, но тем не менее мы получаем базовую валидацию просто за счет системы типов -- NA не совместим с i32. Однако, можно написать вот так

rextendr::rust_function("fn add_i32_opt(x : Option<i32>, y : Option<i32>) -> Option<i32> {    match (x, y) {        (Some(a), Some(b)) => Some(a + b),        _ => None    }}")#> i build directory: 'C:\Users\...\AppData\Local\Temp\Rtmpyg3uPw\file6587a897d2a'#> v Writing 'C:/Users/.../AppData/Local/Temp/Rtmpyg3uPw/file6587a897d2a/target/extendr_wrappers.R'.add_i32_opt(NA, 42L)#> [1] NAadd_i32_opt(42L, 100L)#> [1] 142

Хотите еще больше магии? Макрос R! выполняет внутри R-код, возвращая результат если операция была успешной. Как насчет

x <- 42L y <- 100Lrextendr::rust_eval("R!(x)? * 2 + R!(y)? * 3")#> i build directory: 'C:\Users\...\AppData\Local\Temp\RtmpKeC23J\file32ec53677fc9'#> v Writing 'C:/Users/.../AppData/Local/Temp/RtmpKeC23J/file32ec53677fc9/target/extendr_wrappers.R'.#> [1] 384

Можно попробовать смешать переменные из R и Rust.

library(tibble)x <- 10:1 # Эта переменная на стороне Rrextendr::rust_eval("call!(\"tibble\", x = R!(x), y = 1..=10)")#> i build directory: 'C:\Users\...\AppData\Local\Temp\RtmpcDWhlk\file45802f52dc5'#> v Writing 'C:/Users/.../AppData/Local/Temp/RtmpcDWhlk/file45802f52dc5/target/extendr_wrappers.R'.#> # A tibble: 10 x 2#>        x     y#>    <int> <int>#>  1    10     1#>  2     9     2#>  3     8     3#>  4     7     4#>  5     6     5#>  6     5     6#>  7     4     7#>  8     3     8#>  9     2     9#> 10     1    10

Эти макросы полезны, но до сих пор нестабильны. Они лишь демонстрируют потенциальные возможности для взаимодействия R и Rust.

Для безопасной печати в Rout существует отдельный макрос : rprintln!.

x <- 42Lrextendr::rust_eval("rprintln!(\"Hello from Rust! x = {}\", R!(x)?.as_integer().unwrap());")#> i build directory: 'C:\Users\...\AppData\Local\Temp\RtmpWQh3w0\file48e024f161ce'#> v Writing 'C:/Users/.../AppData/Local/Temp/RtmpWQh3w0/file48e024f161ce/target/extendr_wrappers.R'.#> Hello from Rust! x = 42

Пишем свой extendr-пакет

В этом разделе я просто приведу пример генерации пакета с использование {rextendr} и других стандартных инструментов:

pkg <- file.path(tempfile(), "myextendr")dir.create(pkg, recursive = TRUE)usethis::create_package(pkg)usethis::proj_activate(pkg)rextendr::use_extendr()rextendr::document()rextendr::document()hello_world()
Как это выглядит
pkg <- file.path(tempfile(), "myextendr")dir.create(pkg, recursive = TRUE)usethis::create_package(pkg)#> v Setting active project to 'C:/Users/.../AppData/Local/Temp/RtmpAVW4HZ/file122c180d1953/myextendr'#> v Creating 'R/'#> v Writing 'DESCRIPTION'#> Package: myextendr#> Title: What the Package Does (One Line, Title Case)#> Version: 0.0.0.9000#> Authors@R (parsed):#>     * First Last <first.last@example.com> [aut, cre] (YOUR-ORCID-ID)#> Description: What the package does (one paragraph).#> License: `use_mit_license()`, `use_gpl3_license()` or friends to pick a#>     license#> Encoding: UTF-8#> LazyData: true#> Roxygen: list(markdown = TRUE)#> RoxygenNote: 7.1.1#> v Writing 'NAMESPACE'#> v Setting active project to '<no active project>'usethis::proj_activate(pkg)#> v Setting active project to 'C:/Users/.../AppData/Local/Temp/RtmpAVW4HZ/file122c180d1953/myextendr'#> v Changing working directory to 'C:/Users/.../AppData/Local/Temp/RtmpAVW4HZ/file122c180d1953/myextendr/'rextendr::use_extendr()#> v Creating 'src/rust/src'.#> v Writing 'src/entrypoint.c'#> v Writing 'src/Makevars'#> v Writing 'src/Makevars.win'#> v Writing 'src/.gitignore'#> v Writing 'src/rust/Cargo.toml'.#> v Writing 'src/rust/src/lib.rs'#> v Writing 'R/extendr-wrappers.R'#> v Finished configuring extendr for package myextendr.#> * Please update the system requirement in 'DESCRIPTION' file.#> * Please run `rextendr::document()` for changes to take effect.rextendr::document()#> i Generating extendr wrapper functions for package: myextendr.#> ! No library found at 'src/myextendr.dll', recompilation is required.#> Re-compiling myextendr#>   -  installing *source* package 'myextendr' ...#>      ** using staged installation#>      ** libs#>      rm -Rf myextendr.dll ./rust/target/x86_64-pc-windows-gnu/release/libmyextendr.a entrypoint.o#>      "C:/rtools40/mingw64/bin/"gcc  -I"C:/PROGRA~1/R/R-41~1.0/include" -DNDEBUG          -O2 -Wall  -std=gnu99 -mfpmath=sse -msse2 -mstackrealign  -UNDEBUG -Wall -pedantic -g -O0 -c entrypoint.c -o entrypoint.o#>      cargo build --target=x86_64-pc-windows-gnu --lib --release --manifest-path=./rust/Cargo.toml#>              Updating crates.io index#>             Compiling winapi-build v0.1.1#>       Compiling winapi v0.3.9#>       Compiling winapi v0.2.8#>       Compiling proc-macro2 v1.0.27#>       Compiling unicode-xid v0.2.2#>       Compiling syn v1.0.73#>       Compiling extendr-engine v0.2.0#>       Compiling lazy_static v1.4.0#>             Compiling kernel32-sys v0.2.2#>             Compiling quote v1.0.9#>             Compiling extendr-macros v0.2.0#>             Compiling libR-sys v0.2.1#>             Compiling extendr-api v0.2.0#>             Compiling myextendr v0.1.0 (C:\Users\...\AppData\Local\Temp\RtmpAVW4HZ\file122c180d1953\myextendr\src\rust)#>              Finished release [optimized] target(s) in 33.09s#>      C:/rtools40/mingw64/bin/gcc -shared -s -static-libgcc -o myextendr.dll tmp.def entrypoint.o -L./rust/target/x86_64-pc-windows-gnu/release -lmyextendr -lws2_32 -ladvapi32 -luserenv -LC:/PROGRA~1/R/R-41~1.0/bin/x64 -lR#>      installing to C:/Users/.../AppData/Local/Temp/RtmpAVW4HZ/devtools_install_122c37bd1965/00LOCK-myextendr/00new/myextendr/libs/x64#>   -  DONE (myextendr)#> v Writing 'R/extendr-wrappers.R'.#> i Updating myextendr documentation#> i Loading myextendr#> Writing NAMESPACE#> Writing NAMESPACE#> Writing hello_world.Rdrextendr::document()#> i Generating extendr wrapper functions for package: myextendr.#> i 'R/extendr-wrappers.R' is up-to-date. Skip generating wrapper functions.#> i Updating myextendr documentation#> i Loading myextendr#> Writing NAMESPACE#> Writing NAMESPACEhello_world()#> [1] "Hello world!"

hello_world() написана на Rust и автоматически экспортируется в R. Обратите внимание, что hello_world.Rd был создан при вызове rextendr::document() (аналог devtools::document()). Дело в том, что rextendr-парсер воспринимает /// комментарии как R комментарии. Rust функция выглядит вот так

/// Return string `"Hello world!"` to R./// @export#[extendr]fn hello_world() -> &'static str {    "Hello world!"}

Что автоматически генерирует R обертку

#' Return string `"Hello world!"` to R.#' @exporthello_world <- function() .Call(wrap__hello_world)

и, как результат, обновляет документацию и NAMESPACE с помощью {roxygen2}.

Если этого мало

Здесь я хотел бы коротко описать последнюю важную фичу extendr. Крейт позволяет экспортировать не просто функции, а целые типы. Легким движением руки можно пробросить кастомный тип из Rust в R , а инстансы этого типа -- передавать в обе стороны как ссылки. Это позволяет заполучить ООП в R в традиционном (object-first) стиле, модицифируя in-place объекты, созданные и доступные из Rust:

Мутабельный объект
rextendr::rust_source(code = "struct Counter {    n: i32,}#[extendr]impl Counter {    fn new() -> Self {        Self { n: 0 }    }        fn increment(&mut self) {        self.n += 1;    }        fn get_n(&self) -> i32 {        self.n    }}")#> i build directory: 'C:\Users\...\AppData\Local\Temp\RtmpWOu1pt\file5318783e2176'#> v Writing 'C:/Users/.../AppData/Local/Temp/RtmpWOu1pt/file5318783e2176/target/extendr_wrappers.R'.cntr <- Counter$new()cntr$get_n()#> [1] 0cntr$increment()cntr$increment()cntr$get_n()#> [1] 2

Вместо заключения

Статья получилась гораздо длиннее и сумбурней, чем я ожидал. Тем не менее, я не успел описать все возможности extendr. Этот проект амбициозный и еще далек от завершения, но я считаю, что давно пришло время добавить поддержку Rust в R, а главное сделать взаимодействие этих языков удобным. Мы осторожно надеемся, что в конечном итоге сможем добавить официальную поддержку Rust, на равне с C / C++. К сожалению, сейчас ее отсутствие накладывает на нас некоторые ограничения.

Отдельным вызовом было заставить эту систему работать на Windows. Мы столкнулись со множеством проблем, но на данный момент нам удалось справиться практически со всеми трудностями. Для запуска на Windows extendr требует стандартный Rust - тулчейн, stable-x86_64-pc-windows-msvc, с дополнительными целями (targets) x86_64-pc-windows-gnu и i686-pc-windows-gnu, а также Rtools40v2 (последняя версия на момент написания, отличается от Rtools40).

Скудную документацию можно найти здесь и в репозиториях проекта extendr.

Спасибо что дочитали до конца!

Подробнее..
Категории: Rust , R , Package , Interop , Crate

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru