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

C

Перевод Почему я всё ещё люблю C, но при этом терпеть не могу C?

19.06.2021 18:15:23 | Автор: admin
Мне на удивление часто приходится говорить о том, почему мне всё ещё нравится язык C, и о том, почему я плохо отношусь к C++. Поэтому я решил, что мне стоит об этом написать, а не снова и снова повторять одно и то же.



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

Почему C это не самый лучший язык программирования?


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

В синтаксисе C имеются неоднозначные конструкции (например, символ * может играть роль обычного оператора умножения, может применяться в виде унарного оператора разыменования, или может использоваться при объявлении указателей; а радости работы с typedef достойны отдельной статьи).

Этот язык не является безопасным. Например в C-программах довольно часто встречаются ошибки, связанные с обращением к элементам массивов, которые находятся за пределами границ массивов. В языке нет проверок на ошибки такого рода, производимых во время выполнения программы. А вот, например, в Borland Pascal, не говоря уже о более современных языках, такие проверки есть (это плюс, даже если подобные возможности можно, пользуясь параметрами компиляции, отключить ради повышения производительности). А использование в языке указателей ещё сильнее усложняет задачу программиста по поддержанию кода в хорошем состоянии. И, кроме того, C имеет и некоторые другие неприятные особенности, вроде возможности вызова функции без объявления прототипа, в результате чего функции легко можно передать аргумент неправильного типа.

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

Почему я, несмотря на все недостатки C, пользуясь именно этим языком?


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

Например, если нужно получить значение элемента массива, имея два смещения, одно из которых может быть отрицательным числом, то при программировании на C можно воспользоваться такой конструкцией: arr[off1 + off2]. А при использовании Rust это уже будет arr[((off1 as isize) + off2) as usize]. C-циклы часто короче, чем идиоматичные механизмы Rust, использование которых предусматривает комбинирование итераторов (конечно, обычными циклами можно пользоваться и в Rust, но это не приветствуется линтером, который всегда рекомендует заменять их итераторами). И, аналогично, мощными инструментами являются memset() и memmove().

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

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

При чём тут C++?


Если говорить о C++, то хочу сразу сказать, что я не отношусь к тем, кто ненавидит этот язык. Если вы этим языком пользуетесь и он вам нравится я ничего против этого не имею. Я не могу отрицать того, что C++, в сравнении с C, даёт нам два следующих преимущества. Во-первых это улучшение структуры программ (это поддержка пространств имён и классов; в Simula, в конце концов, есть хоть что-то хорошее). Во-вторых это концепция RAII (если описать это в двух словах, то речь идёт о наличии конструкторов для инициализации объектов при их создании и о наличии деструкторов для очистки ресурсов после уничтожения объектов; а если глубже разработать эту идею, то можно прийти к понятию времени жизни объекта из Rust). Но, в то же время, у C++ есть несколько особенностей, из-за которых мне этот язык очень и очень не нравится.

Это, прежде всего склонность языка постоянно вбирать в себя что-то новое. Если в каком-то другом языке появится какая-нибудь возможность, которая станет популярной, она окажется и в C++. В результате стандарт C++ пересматривают каждые пару лет, и при этом всякий раз в него добавляют новые возможности. Это привело к тому, что C++ представляет собой монструозный язык, которого никто не знает на 100%, язык, в котором одни возможности часто дублируют другие. И, кроме того, тут нет стандартного способа указания того, возможности из какой редакции C++ планируется использовать в коде. В Rust это поддерживается на уровне единицы компиляции. В IIRC C++ для той же цели предлагалась концепция эпох, но эта затея не удалась. И вот одно интересное наблюдение. Время от времени мне попадаются новости о том, что кто-то в одиночку (и за приемлемое время) написал рабочий компилятор C. Но я ни разу не встречал похожих новостей о компиляторе C++.

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

И, наконец, я мог бы вообще не обращать внимания на C++, если бы этот язык не был бы связан с C и не оказывал бы на C плохое влияние. Я не говорю о ситуации, когда, говоря о C и C++, их объединяют, упоминая как C/C++, и считая, что тот, кто знает C, знает и C++. Я имею в виду связь между языками, которая оказывает влияние и на стандарты, и на компиляторы. С одной стороны C++ основан на C, что придало C++, так сказать, хорошее начальное ускорение. А с другой стороны сейчас C++, вероятно, лучше смотрелся бы без большей части своего C-наследия. Конечно, от него пытаются избавиться, называя соответствующие конструкции устаревшими, но C-фундамент C++ никуда пока не делся. Да и будет ли популярным, скажем, некий С++24, вышедший в виде самостоятельного языка, основанного на C++21 и лишённого большей части устаревших механизмов? Не думаю.

Воздействие компиляторов C++ на C


Вышеописанная связь C и C++ приводит к тому, что к C относятся как к C++, лишённому некоторых возможностей. Печально известным примером такого восприятия C является C-компилятор Microsoft, разработчики которого не позаботились о поддержке возможностей C99 до выхода версии компилятора 2015 года (и даже тогда разработчики придерживались стратегии bug-for-bug compatibility, когда в новой реализации чего-либо воспроизводят старые известные ошибки; делалось это для того, чтобы пользователи компилятора не были бы шокированы, внезапно обнаружив, что вариадические макросы вдруг там заработали). Но тот же подход можно видеть и в стандартах, и в других компиляторах. Эти проблемы связаны друг с другом.

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

Я, конечно, говорю, о неопределённом поведении, и о том, как оно рассматривается компиляторами. Эта тема стала популярным пугалом (код полагается на способ представления чисел с дополнением до двух; в результате в нём имеется неопределённое поведение и компилятор может выбросить оптимизировать целый блок кода!).

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

  • Поведение, определяемое архитектурой системы (то есть то, что зависит от архитектуры процессора). Сюда, в основном, входят особенности выполнения арифметических операций. Например, если я знаю, что целевая машина использует для представления чисел дополнение до двух (нет, это не CDC 6600), то почему компилятор (который, как представляется, тоже знает об особенностях целевой архитектуры) должен считать, что система будет вести себя иначе? Ведь тогда он сможет лучше выполнять некоторые теоретически возможные оптимизации. То же применимо к операциям побитового сдвига. Если я знаю о том, что в архитектуре x86 старшие биты, выходящие за пределы числа, отбрасываются, а на ARM отрицательный сдвиг влево это сдвиг вправо, почему я не могу воспользоваться этими знаниями, разрабатывая программу для конкретной архитектуры? В конце концов, то, что на разных платформах целые числа имеют разные размеры в байтах, считается вполне приемлемым. Компилятор, обнаружив нечто, рассчитанное на конкретную платформу, может просто выдать предупреждение о том, что код нельзя будет перенести на другую платформу, и позволить мне писать код так, как я его писал.
  • Неочевидные приёмы работы с указателями и каламбуры типизации. Такое ощущение, что плохое отношение к подобным вещам возникло лишь ради потенциальной возможности оптимизации кода компиляторами. Я согласен с тем, что использование memcpy() на перекрывающихся областях памяти может, в зависимости от реализации (в современных x86-реализациях копирование начинается с конца области) и от относительного расположения адресов, работать неправильно. Разумно будет воздержаться от подобного. А вот другие ограничения того же плана кажутся мне менее оправданными. Например запрещение одновременной работы с одной и той же областью памяти с использованием двух указателей разных типов. Я не могу представить себе проблему, возникновение которой может предотвратить наличие подобного ограничения (это не может быть проблема, связанная с выравниванием). Возможно, сделано это для того чтобы не мешать компилятору оптимизировать код. Кульминацией всего этого является невозможность преобразования, например, целых чисел в числа с плавающей запятой с использованием объединений. Об этом уже рассуждал Линус Торвальдс, поэтому я повторяться не буду. С моей точки зрения это делается либо для улучшения возможностей компиляторов по оптимизации кода, либо из-за того, что этого требует C++ для обеспечения работы системы отслеживания типов данных (чтобы нельзя было поместить экземпляр некоего класса в объединение, а потом извлечь его как экземпляр совсем другого класса; это тоже может как-то повлиять на оптимизации).
  • Поведение кода, определяемое реализацией (то есть поведение, которое не в точности отражает то, что предписано стандартом). Мой любимый пример подобной ситуации это вызов функции: в зависимости от соглашения о вызове функций и от реализации компилятора аргументы функций могут вычисляться в совершенно произвольном порядке. Поэтому результат вызова foo(*ptr++, *ptr++, *ptr++) неопределён и на подобную конструкцию не стоит полагаться даже в том случае, если программисту известна целевая архитектура, на которой будет выполняться код. Если аргументы передаются в регистрах (как в архитектуре AMD64) компилятор может вычислить значение для любого регистра, который покажется ему подходящим.
  • Полностью неопределённое поведение кода. Это тоже такой случай, когда сложно спорить со стандартом. Пожалуй, самый заметный пример такого поведения кода связан с нарушением правила, в соответствии с которым значение переменной в одном выражении можно менять лишь один раз. Вот как это выглядит в одном знаменитом примере: i++ + i++. А вот пример, который выглядит ещё страшнее: *ptr++ = *ptr++ + *ptr++е.

C++ это язык более высокого уровня, чем C. В то время, как в C++ имеется большинство возможностей C, использовать эти возможности не рекомендуется. Например, вместо прямого преобразования типов надо применять reinterpret_cast<>, а вместо указателей надо применять ссылки. От С++-программистов не ждут того, что они будут понимать низкоуровневый код так же хорошо, как C-программисты (это, конечно, лишь средняя температура по больнице, в реальности всё зависит от конкретного программиста). И всё же, из-за того, что существует очень много C++-программистов, и из-за того, что C и C++ часто воспринимают как C/C++, C-компиляторы часто расширяют в расчёте на поддержку ими C++ и переписывают на C++ для того чтобы упростить реализацию сложных конструкций. Это произошло с GCC, и того, кто начнёт отлаживать с помощью GDB С++-код, находящийся в .c-файлах, ждёт много интересного. Грустно то, что для того чтобы скомпилировать код C-компилятора нужен C++-компилятор, но, чему нельзя не радоваться, всё ещё существуют чистые C-компиляторы, вроде LCC, PCC и TCC.

Итоги


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

Но, по крайней мере, нельзя заменить C90 на какой-нибудь C90 Special Edition и сделать вид, что C90 никогда не существовало.

Как вы относитесь к языку C?


Подробнее..

Опыт написания асинхронного поллинга сетевых устройств

03.06.2021 14:23:54 | Автор: admin

Основная идея асинхронного поллинга

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

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

Немного математики

Математика будет инженерно-прикидочная, точность 20%

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

То есть нужно снимать 10 млн метрик в минуту или 150 тысяч в секунду. Округлю до 100 тысяч метрик в секунду.

Опрос одной метрики занимает около 5мс: 4мс отвечает устройство, + 1мс на обработку.

Это значит, что один синхронный поток может обрабатывать 200 метрик в секунду или нужно 500 синхронных потоков. Вполне реалистично.

(Все, конец, больше писать нечего)

На самом деле это только начало. Фишка в не идеальности мира, а именно в потерях ответов на запросы.Потери происходят из-за потерь траффика или недоступности устройств.

В моем случае таймаут ожидания составлял одну секунду и делалось 2 попытки, значит на недоступном устройстве можно потерять 2000мс на ожидание

Посчитаем еще раз

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

Итак, 3% метрик опрашиваются со скоростью 2000мс.

В таком случае один поток cможет опрашивать всего 15,5 метрик в секунду.

Но введем оптимизацию и в случае, если у устройства 5 раз подряд не удается снять метрики, остальные 95 метрик не будем снимать в текущем цикле опроса. Получится, что один поток может обрабатывать 125 метрик в секунду или нам нужно будет 800 потоков. На грани, но можно.

Учтем специфику задачи у нас мониторинг и нам интересно чтобы он быстро работал _особенно_ когда все идет нет так. Плюс есть технические аргументы 800 потоков генерируют высокую конкуренцию за общие ресурсы и в целом, работают плохо в системе, которая по дизайну была рассчитана на десятки потоков.

А что, если недоступно не 3%, а 20% или все 100% устройств?

Ок, посчитаем еще один раз

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

Хорошо бы, чтобы мониторинг мог локализовывать и такие аварии, когда и 45%, и 99% сети недоступно.

Тогда немного другая задача: 100% устройств не отвечают. Сколько нужно потоков, чтобы увидеть, что они заработали в течении 30 секунд? - Около 7 тысяч.

Такую параллельность разумно обеспечить асинхронностью

Входные данные

Система написана на С, парралельность обеспечена форками, для синхронизации используются мьютексы, активно используется Shared Memory для хранения объектов. Реализация SNMP на основе Net-SNMP

Задача минимальными усилиями сделать так, чтобы поллинг был асинхронным

Реализация1 : все что работает пусть работает, лишнего не трогай

Кажется, нужно просто добавить парралелизма?

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

Работал он примерно так:

Первая реализация парралельного поллингаПервая реализация парралельного поллинга

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

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

Ожидание таймаутов огромно по сравнению с нормальными ответамиОжидание таймаутов огромно по сравнению с нормальными ответами

Несмотря на простую реализацию, прирост скорости оказался примерно 100- кратным.

С установкой на продуктив появились особенности устройства, обладающие большим количеством элементов, стали опрашиваться нестабильно.

Но схема хорошо работала на большом количестве одинаковых небольших устройств.

Поэтому появилась вторая реализация:

Реализация2 : Синхронно-асинхронный режим работы

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

Стало лучше. Но проблемы с хостами с большим количеством метрик остались: все еще иногда фиксировались не ответы или потери элементов.

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

В итоге, вторая реализация стала работать качественней, но потеряла в скорости. Стало ясно, что малой кровью не обойтись.

Реализация3 : Настоящая асинхронная

Это первая версия, которая могла считаться истинно асинхронной. Для ее реализации пришлось ввести полноценную машину состояний для каждой операции, благо в случае SNMP это протокол UDP и состояний немного.

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

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

Реализация3.5 : еще лучше, еще быстрее

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

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

Сначала я сделал так, чтобы один хост гарантированно попадал в один и тот же поллер. Потери пропали, хотя иногда хосты с 3-4 тысячами метрик могли ожидать 2-4 минуты пока устройство отвечало на все запросы и такие метрики фиксировались как опаздывающие.

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

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

Реализация4.0 : до основанья, а затем

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

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

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

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

И осталось еще одно ограничение - количество исходящих портов. Так как серверу нужна еще прочая сетевая активность помимо SNMP, общее количество соединений ограничил в 48 тысяч.

Даже это - большая цифра. Поллер с таймаутом в 2 секунды при 100% неответов сможет проверить 15 хостов за 30 секунд в одном соединении или примерно 720 тысяч! хостов за 30 секунд в 48 тысяч соединений.

Эти цифры больше, чем устраивают, но, если нужно больше всегда можно решить с помощью конфигурации с несколькими IP адресами или RAW socket.

Сейчас такой поллинг в тестах упирается в соединения на отметке в 120-140 тысяч метрик в секунду.

На проде снимает около 30-40 тысяч метрик в секунду, работает в один поток, использует при этом 50% одного ядра.

Вывод:

От Реализации1 до Реализации4 прошло почти 2 года. Каждая реализация занимала по 2-3 человеко-недели.

Стоило оно того?

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

Думаю, что очень даже стоило.

Все, что снималось на 15 вспомогательных серверах, получилось снимать на одном и тот курит.

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

Мониторьте в радость.

Подробнее..

Перевод Как использовать Python для проверки протокола Signal

08.06.2021 18:13:14 | Автор: admin

Galois работает над повышением удобства SAW, инструмента для верификации программ наCиJava, исходный код кторого открыт. Основным способом взаимодействия пользователей сSAW является его спецификация иязык программирования сценариев. Чтобы сделать SAW как можно более доступным, вкачестве языка программирования SAW теперь можно использовать Python! Для демонстрации этой новой возможности в Galoisсоздали пример, выполнив проверку части реализации протокола Signal наязыкеС.В частности, как спецификация SAW определяются условия, при которых сообщение протокола Signal будет успешно аутентифицировано. К старту курса о Fullstack-разработке на Python мы перевели материал об этом примере.


SAW-клиент Python

Управление SAW может осуществляться средствами Python через библиотеку saw-client вPyPI. Реализация спомощью Python непредставляет сложности управление SAW осуществляется через JSON-RPC API, как показано впредыдущей статье. Библиотека saw-client постоянно развивалась, итеперь вней реализован высокоуровневый интерфейс, отвечающий зареализацию функций RPC.

Помимо Python, вSAW также используется альтернативный язык программирования сценариев, называемый SAWScript. Хотя наSAWScript возможно писать теже проверки, что иPython, этотязыкнелишён недостатков. SAWScript специализированный язык, поэтому ондовольно сложен для понимания теми, кто впервые берётся заизучениеSAW. Кроме того, вSAWScript практически отсутствует возможность подключения внешних библиотек. Если вызахотите написать наSAWScript то, чего нет встандартной библиотеке, вам придётся реализовать нужную функцию самостоятельно.

Сдругой стороны, Python широко используемый язык, изначально хорошо знакомый гораздо большему числу людей. УPython также имеется богатый набор библиотек ивспомогательных программ, доступных вкаталоге PyPI. Даже если Python невходит вчисло ваших любимых языков программирования, мывсё равно советуем попробовать saw-client. Если под рукой неокажется ничего другого, код, написанный вsaw-client, может послужить источником вдохновения для реализации аналогичного клиента надругом языке.

Базовая спецификация вsaw-client

Давайте рассмотрим, как saw-client можно использовать для создания спецификаций реального кода наязыкеC. Для примера возьмём libsignal-protocol-c. Эта библиотека представляет собой реализованный наязыке Cпротокол Signal, криптографический протокол, используемый для шифрования мгновенных сообщений, голосовых ивидеозвонков. Этот протокол применяется вприложении Signal Messenger, получившем название попротоколу, нотакже поддерживается вдругих приложениях, таких как WhatsApp, Facebook Messenger иSkype.

Общее описание возможностей SAW сиспользованием библиотеки libsignal-protocol-c приведено вразделе "Планы".

Для начала рассмотрим базовую структуру данных, используемую библиотекой libsignal-protocol-c, аименно signal_buffer:

struct signal_buffer {    size_t len;    uint8_t data[];};

signal_buffer представляет собой байтовый массив (массив данных) сдлинойlen. При отправке сообщения спомощью libsignal-protocol-c основным компонентом сообщения является signal_buffer.

Чтобы быть уверенным, что libsignal-protocol-c работает так, как заявлено, нужно убедиться, что содержимое signal_buffer сообщения соответствует ожидаемому. Библиотека проверяет соответствие двух буферов signal_buffer спомощью функции signal_constant_memcmp:

int signal_constant_memcmp(const void *s1, const void *s2, size_t n){    size_t i;    const unsigned char *c1 = (const unsigned char *) s1;    const unsigned char *c2 = (const unsigned char *) s2;    unsigned char result = 0;    for (i = 0; i < n; i++) {        result |= c1[i] ^ c2[i];    }    return result;}

Интуитивно понятно, что утилита signal_constant_memcmp должна проверить, одинаковоли содержимое двух байтовых массивов signal_buffer. Если они одинаковы, функция вернёт значение0. Если содержимое несовпадает, возвращается значение, указывающее набайты, вкоторых массивы отличаются.

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

from saw_client.llvm import *class ConstantMemcmpEqualSpec(Contract):    def specification(self) -> None:        _1        self.execute_func(_2)        _3

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

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

  • Аргументы для передачи впроверяемую функцию (_2).

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

Учитывая требования кспецификации, проверим, как утилита signal_constant_memcmp работает в пределах спецификации SAW:

class ConstantMemcmpEqualSpec(Contract):    n: int    def __init__(self, n: int):        super().__init__()        self.n = n    def specification(self) -> None:        s1  = self.fresh_var(array_ty(self.n, i8), "s1")        s1p = self.alloc(array_ty(self.n, i8), points_to = s1)        s2  = self.fresh_var(array_ty(self.n, i8), "s2")        s2p = self.alloc(array_ty(self.n, i8), points_to = s2)        self.precondition(cryptol(f"{s1.name()} == {s2.name()}"))        self.execute_func(s1p, s2p, cryptol(f"{self.n} : [64]"))        self.returns(cryptol("0 : [32]"))

Предварительными условиями являются наличие двух байтовых массивов (s1p иs2p), содержимое которых s1 иs2одинаково. Вчастности, одинаковость содержимого гарантирует вызов self.precondition(...). Аргумент self.precondition(...) записывается наCryptol, предметно-ориентированном языке программирования (DSL), используемом вкриптографии. Приведённое выражение наCryptol довольно простое, так как выполняет только проверку равенства, нониже мыувидим более сложные примеры наCryptol.

Аргументами функции являются два байтовых массива суказанием ихдлин (self.n), преобразуемых вначале ввыражение Cryptol, чтобы SAW мог получить оних представление. Порстусловие, снова ввиде выражения наCryptol, заключается втом, что функция возвращает значение 0.

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

mod = llvm_load_module("libsignal-protocol-c.bc") # An LLVM bitcode filearray_len = 42 # Pick whichever length you want to checkllvm_verify(mod, "signal_constant_memcmp", ConstantMemcmpEqualSpec(array_len))

Если проверка пройдёт нормально, можно запустить этот код наPython иувидеть следующий результат:

Verified: lemma_ConstantMemcmpEqualSpec (defined at signal_protocol.py:122)

Ура! Инструмент SAW проверил правильность работы утилиты signal_constant_memcmp. Важно отметить, что нам ненужно было даже упоминать обитовых манипуляциях внутри функции SAW выполнил ихавтоматически. Отметим, однако, что команда ConstantMemcmpEqualSpec определяет происходящее только втом случае, если байтовые массивы равны друг другу. Еслибы мыхотели охарактеризовать происходящее вслучае неравенства байтовых массивов, потребоваласьбы несколько более сложная спецификация.

Также следует отметить, что вприведённом выше коде встречаются повторения, так как мыдважды вызываем функцию self.fresh_var(), азатем self.alloc(). Ксчастью, Python избавляет оттаких проблем:

def ptr_to_fresh(spec: Contract, ty: LLVMType,                 name: str) -> Tuple[FreshVar, SetupVal]:    var = spec.fresh_var(ty, name)    ptr = spec.alloc(ty, points_to = var)    return (var, ptr)class ConstantMemcmpEqualSpec(Contract):    ...    def specification(self) -> None:        (s1, s1p) = ptr_to_fresh(self, array_ty(self.n, i8), "s1")        (s2, s2p) = ptr_to_fresh(self, array_ty(self.n, i8), "s2")        ...

Верификация кода сиспользованием HMAC

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

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

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

hmac_init : {n} [n][8] -> HMACContexthmac_init = undefinedhmac_update : {n} [n][8] -> HMACContext -> HMACContexthmac_update = undefinedhmac_final : HMACContext -> [SIGNAL_MESSAGE_MAC_LENGTH][8]hmac_final = undefined

Это будут неинтерпретируемые функции, используемые для создания кода, связанного сHMAC, вбиблиотеке libsignal-protocol-c. Основная идея заключается втом, что, получив навходе криптографический ключ, hmac_init создаст HMACContext. HMACContext будет многократно обновляться через hmac_update, используя данные первого аргумента. Затем hmac_final преобразует HMACContext вsignal_buffer достаточной длины для хранения MAC.

Определение HMACContext зависит оттого, какая криптографическая хэш-функция используется всочетании сHMAC. Параметры библиотеки libsignal-protocol-c настроены для используемых еюхеш-функций, поэтому можно свободно подключать библиотеки OpenSSL, Common Crypto или другую подходящую библиотеку.

Поскольку эти функции считаются неинтерпретируемыми, SAW небудет ихоценивать вовремя верификации. Другими словами, то, как реализованы эти функции, неимеет значения; undefined выбрано для удобства, ноподойдёт илюбая другая реализация.

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

class SignalHmacSha256InitSpec(Contract):    key_len: int    def specification(self) -> None:        hmac_context_ptr = self.alloc(...)        (key_data, key)  = ptr_to_fresh(self, array_ty(self.key_len, i8),                                        "key_data")            self.execute_func(..., hmac_context_ptr, key,                          cryptol(f"{self.key_len} : [64]"))        init = f"hmac_init`{{ {self.key_len} }} {key_data.name()}"        dummy_hmac_context = self.alloc(..., points_to = cryptol(init))        self.points_to(hmac_context_ptr, dummy_hmac_context)        self.returns(cryptol("0 : [32]"))key_len = 32init_spec = llvm_assume(mod, "signal_hmac_sha256_init",                        SignalHmacSha256InitSpec(key_len))

Нестарайтесь понять каждую строчку кода. Просто знайте, что самой важной его частью является последняя строка, вкоторой вместо llvm_verify используется llvm_assume. Функция llvm_assume позволяет SAW использовать спецификацию, фактически немоделируя её посути SAW трактует еёкак аксиому. Это позволяет привязать поведение signal_hmac_sha256_init кнеинтерпретируемой функции hmac_init впостусловиях спецификации.

Аналогичным образом llvm_assume также можно использовать для создания спецификаций, включающих hmac_update иhmac_final. После этого можно проверить очень важную функцию, связанную сMAC: signal_message_verify_mac. Фактически данная функция принимает сообщение вкачестве аргумента, вычисляет MAC для данных внутри сообщения ипроверяет, совпадаетли онсMAC вконце сообщения. Если значения совпадают, можно суверенностью утверждать, что при отправке получателю сообщение неменялось.

Объяснение всех тонкостей работы signal_message_verify_mac занялобы довольно много времени, поэтому вэтой заметке мыкоснёмся лишь главного вопроса: как должно выглядеть содержимое сообщения? Данные внутри сообщения могут быть произвольными, однако MAC вконце должен иметь вполне определённую форму. Эту форму можно определить спомощью функции Python:

def mk_hmac(serialized_len: int, serialized_data: FreshVar,        receiver_identity_key_data : FreshVar,        sender_identity_key_data: FreshVar,        mac_key_len: int, mac_key_data: FreshVar) -> SetupVal:    sender_identity_buf = f"""        [{DJB_TYPE}] # {sender_identity_key_data.name()}            : [{DJB_KEY_LEN} + 1][8]        """    receiver_identity_buf = f"""        [{DJB_TYPE}] # {receiver_identity_key_data.name()}            : [{DJB_KEY_LEN} + 1][8]        """    hmac = f"""        hmac_final         (hmac_update`{{ {serialized_len} }} {serialized_data.name()}          (hmac_update`{{ {DJB_KEY_LEN}+1 }} ({receiver_identity_buf})           (hmac_update`{{ {DJB_KEY_LEN}+1 }} ({sender_identity_buf})            (hmac_init`{{ {mac_key_len} }} {mac_key_data.name()}))))        """    return cryptol(hmac)

Довольно сложно, неправдали? Ноещё раз нестарайтесь понять каждую строчку кода. Тут важно понять, что сначала вызывается hmac_init, затем выполняются несколько вызовов hmac_update, после чего осуществляется вызов hmac_finalcall. Это весьма близко интуитивным допущениям, сделанным ранее для HMAC, поэтому, если SAW убедится втом, что MAC выглядит как данное выражение Cryptol, можно быть уверенным, что онработает так, как ожидалось.

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

lass SignalMessageVerifyMacSpec(Contract):    serialized_len: int    def specification(self) -> None:        ...        mac_index = 8 + self.serialized_len - SIGNAL_MESSAGE_MAC_LENGTH        ser_len   = f"{self.serialized_len} : [64]"        self.points_to(serialized[0], cryptol(ser_len))        self.points_to(serialized[8], serialized_message_data)        self.points_to(serialized[mac_index], mk_hmac(...))        self.execute_func(...)        self.returns(cryptol("1 : [32]"))

Здесь serialized указывает наsignal_buffer для всего сообщения. Для описания памяти, содержащейся вразличных частях буфера, можно использовать нотацию слайса Python (например, serialized[0]). Первая часть содержит self.serialized_len, общую длину сообщения. Через восемь байтразмещается serialized_message_data данные сообщения. Всамом конце буфера содержится MAC, вычисленный спомощью mk_hmac(...).

Проверяем всё напрактике вызываем llvm_verify согласно этой спецификации. Вэтот раз нужно передать несколько дополнительных аргументов. Нужно явно указать, какие допущения мысделали ранее спомощью llvm_assume посредством аргумента lemmas. Также нужно указать инструменту решения SMT, какие функции должны рассматриваться как неинтерпретируемые. Это делается спомощью аргумента script:

uninterps = ["hmac_init", "hmac_update", "hmac_final"]llvm_verify(mod, "signal_message_verify_mac",  SignalMessageVerifyMacSpec(...),            lemmas=[init_spec, update_spec1, update_spec2, final_spec],            script=ProofScript([z3(uninterps)]))

В результате мы видим долгожданную зелёную галочку:

Планы

Спомощью saw-client мысмогли получить ряд интересных данных окоде вlibsignal-protocol-c. Мысмогли продемонстрировать, что signal_message_verify_mac, функция, проверяющая целостность сообщения, отправленного попротоколу Signal, работает правильно, если последняя часть сообщения содержит верный код аутентификации сообщения (MAC). Кроме того, мыопределили, каким должно быть содержимое MAC относительно абстрактной спецификации криптографических хэш-функций.

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

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

Вперспективе мыпопытаемся сделать Python полноправным языком написания кода для SAW, иданная работа первый шаг вэтом направлении. Весь код, представленный вэтой заметке, можно найти здесь. Рекомендуем испытать вработе инструмент saw-client. Любые ваши пожелания икомментарии отправляйте в трекер проблем ивопросов SAW.

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

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

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

Recovery mode Задача о рюкзаке (Knapsack problem) простыми словами

05.06.2021 00:21:43 | Автор: admin

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

Хотел бы отметить книгу Grokking Algorithms автора Aditya Bhargava. У нее прям максимально простым языком расписаны основы алгоритмов. Так что если, вы как и я в универе думали что алгоритмы вам никогда не пригодятся, потому что FAANG это не для вас. То я вас и разочарую и обрадую, попасть туда может каждый, если конечно приложит достаточно усилий, ну а разочарую тем что вам конечно придется поднапрячься и осилить алгоритмы и чем раньше вы начнете это делать, тем лучше.

На хабре, уже есть одна статья на эту тему: Алгоритм решения задачи о рюкзаке ( версия 2, исправленная) / Хабр (habr.com) . Но, да простит меня автор, на мой взгляд она совершенно непонятная.

И так, перейду к делу. Сначала расскажу обо всем на пальцах, а потом мы рассмотрим решение на нашем любимом C#.

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

Вещи которые есть в магазинеВещи которые есть в магазине

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

Есть несколько способов решения. Один из них это перебор всех вариантов.

Для простоты, предмета будет только три, поскольку количество различных комбинаций в которых их можно унести растет очень быстро и даже для 3 предметов уже будет равно 8. Оно вычисляется по формуле 2n где n - количество предметов, то есть если предмета будет 4, то количество возможных комбинаций достигнет уже 16 и так далее. Такой вариант нас не устроит поскольку решая эту задачу онлайн на каком-нибудь Codility ваше решение зарубят с Timeout Exceeded. Нужно что-то получше.

Мы будем решать эту задачу методом динамического программирования

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

Заполним небольшую табличку:

1

2

3

4

Ожерелье / 4000 / 4 ед

Кольцо / 2500 / 1 ед

Подвеска / 2000 / 3 ед

С левой стороны у нас будут все вещи. При этом порядок в котором они расположены, в данной задаче нас не интересует. Количество колонок равно количеству потенциальных рюкзаков размером от 1, минимально возможного целого положительного числа, до размера нашего рюкзака, с шагом 1. Таким образом мы пытаемся решить ряд более мелких задач, для рюкзаков размером 1, 2, 3 и 4. Всегда ведь проще начинать с малого?)

1

2

3

4

Ожерелье / 4000 / 4 ед

0

0

0

4000

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

Рюкзак размером 1: Ожерелье не влезет в рюкзак, значит стоимость того что мы можем унести равна 0.

Рюкзак размером 2: Ожерелье не влезет в рюкзак, значит стоимость того что мы можем унести равна 0.

Рюкзак размером 3: Ожерелье не влезет в рюкзак, значит стоимость того что мы можем унести равна 0.

Рюкзак размером 4: Здесь все становится немного интересней, вес ожерелья 4 и размер рюкзака 4. Ура, наконец то успех, мы не зря ворвались в магазин, кладем ожерелье в большой рюкзак и убегаем.

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

1

2

3

4

Ожерелье / 4000 / 4 ед

0

0

0

4000

Кольцо / 2500 / 1 ед

2500

2500

2500

4000

Добавилось кольцо. Делаем подсчеты для второго ряда:

Рюкзак размером 1: У нас добавилось кольцо и его размер как раз равен размеру даже самого маленького рюкзака. Но у нас ведь еще есть и ожерелье, мы уже проверили, оно не влезет. Кладем только кольцо.

Рюкзак размером 2: То же что и для размера 1. Кладем только кольцо.

Рюкзак размером 3: То же что и для размера 1. Кладем только кольцо.

Рюкзак размером 4: Здесь у нас есть выбор, либо положить только одно маленькое кольцо, либо взять ожерелье, которое тяжелее, но и стоит дороже. Забываем про кольцо, и возвращаемся за ожерельем!

Так, наконец добавим третий ряд

1

2

3

4

Ожерелье / 4000 / 4 ед

0

0

0

4000

Кольцо / 2500 / 1 ед

2500

2500

2500

4000

Подвеска / 2000 / 3 ед

2500

2500

2500

4500

Рюкзак размером 1: Кольцо дороже подвески, да и подвеска не влезет, она по всем параметрам проигрывает вещам выше, берем кольцо размером 1.

Рюкзак размером 2: Тоже самое что и для размера 1.

Рюкзак размером 3: Несмотря на то что мы можем здесь взять подвеску, кольцо выигрывает ее по параметрам, снова кладем ее.

Рюкзак размером 4: А вот тут у нас, весь рюкзак, и все возможные вещи. И мы видим что кольцо и подвеска вместе стоят на 500 долларов дороже ожерелья и при этом все так же влезут в рюкзак. Значит мы возьмем и кольцо и подвеску стоимостью 4500 и весом как раз 4 единицы.

В правом нижнем углу у нас верный результат.

Ну и где же здесь пере использование спросите вы? Да, мы им не пользовались, потому что таблица была сравнительно простая. Но, если присмотреться то можно заметить закономерность!

Представим что для количества вещей у нас счетчик i, а для рюкзаков j.

На примере последней ячейки рассмотрим формулу в действии

Зеленым выделена первая опция, красным вторая. Как видим стоимость в красном круге перевешивает стоимость в зеленомЗеленым выделена первая опция, красным вторая. Как видим стоимость в красном круге перевешивает стоимость в зеленом

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

И собственно алгоритм задачи на языке C#:

public static int[] weights = { 4, 1, 3 };public static int[] values = { 4000, 2500, 2000 };public static int CountMax(int[] weights, int[] values, int maxCapacity){    //строим массив и закладываем место на ячейки пустышки     //выходящие из левого верхнего угла    int[,] arr = new int[weights.Length + 1, maxCapacity + 1];    //проходим по всем вещам    for (int i = 0; i <= weights.Length; i++)    {        //проходим по всем рюкзакам        for (int j = 0; j <= maxCapacity; j++)        {            //попадаем в ячейку пустышку            if (i == 0 || j == 0)            {                arr[i, j] = 0;            }            else            {                   //если вес текущей вещи больше размера рюкзака                //казалось бы откуда значение возьмется для первой вещи                 //при таком условии. А оно возьмется из ряда пустышки                if (weights[i - 1] > j)                {                    arr[i, j] = arr[i - 1, j];                }                else                {                    //здесь по формуле. Значение над текущей ячейкой                    var prev = arr[i - 1, j];                    //Значение по вертикали: ряд вверх                    //и по горизонтали: вес рюкзака - вес текущей вещи                    var byFormula = values[i - 1] + arr[i - 1, j - weights[i - 1]];                    arr[i, j] = Math.Max(prev, byFormula);                }            }        }    }    // возвращаем правую нижнюю ячейку    return arr[weights.Length, maxCapacity];}

Всем побед и удачных собесов!

Подробнее..
Категории: Алгоритмы , C , Net , Algorithms , Knapsack problem

MEX (Minimum EXcluded) Алгоритм поиска минимального отсутствующего числа

13.06.2021 20:13:49 | Автор: admin
Добрый день. Сегодня хочется поговорить о том, как найти MEX (минимальное отсутствующие число во множестве).


Мы разберем три алгоритма и посмотрим на их производительность.

Добро пожаловать под cat

Предисловие


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


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

И покопавшись в сети, оказалось, что нет общепринятого алгоритма нахождения MEX

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

static void Main(string[] args)        {            //MEX = 2            int[] values = new[] { 0, 12, 4, 7, 1 };                        //MEX = 5            //int[] values = new[] { 0, 1, 2, 3, 4 };                        //MEX = 24            //int[] values = new[] { 11, 10, 9, 8, 15, 14, 13, 12, 3, 2, 0, 7, 6, 5, 27, 26, 25, 4, 31, 30, 28, 19, 18, 17, 16, 23, 22, 21, 20, 43, 1, 40, 47, 46, 45, 44, 35, 33, 32, 39, 38, 37, 36, 58, 57, 56, 63, 62, 60, 51, 49, 48, 55, 53, 52, 75, 73, 72, 79, 77, 67, 66, 65, 71, 70, 68, 90, 89, 88, 95, 94, 93, 92, 83, 82, 81, 80, 87, 86, 84, 107, 106, 104 };                        //MEX = 1000            //int[] values = new int[1000];            //for (int i = 0; i < values.Length; i++) values[i] = i;                        //Импровизированный счетчик итераций            int total = 0;            int mex = GetMEX(values, ref total);            Console.WriteLine($"mex: {mex}, total: {total}");            Console.ReadKey();        }

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

1) Решение в лоб


Как нам найти минимальное отсутствующие число? Самый простой вариант сделать счетчик и перебирать массив до тех пор, пока не найдем число равное счетчику.

  static int GetMEX(int[] values, ref int total)        {            for (int mex = 0; mex < values.Length; mex++)            {                bool notFound = true;                for (int i = 0; i < values.Length; i++)                {                    total++;                    if (values[i] == mex)                    {                        notFound = false;                        break;                    }                }                if (notFound)                {                    return mex;                }            }            return values.Length;        }    }


Максимально базовый случай. Сложность алгоритма составляет O(n*cell(n/2)) Т.к. для случая { 0, 1, 2, 3, 4 } нам нужно будет перебрать все числа т.к. совершить 15 операций. А для полностью заполного ряда из 100 числе 5050 операций Так себе быстродейственность.

2) Просеивание


Второй по сложности вариант в реализации укладывается в O(n) Ну или почти O(n), математики хитрят и не учитывают подготовку данных Ибо, как минимум, нам нужно знать максимальное число во множестве.
С точки зрения математики выглядит так.
Берется битовый массив S длинной m (где m максимально число в изначально массиве (V) + 1) заполненный 0. И в один проход исходному множеству (V) в массиве (S) ставятся 1. После этого в один проход находим первое пустое значение.
static int GetMEX(int[] values, ref int total)        {            //Не учитываем в сложности             var max = values.Max() + 1;            bool[] sieve = new bool[max];            for (int i = 0; i < values.Length; i++)            {                total++;                sieve[values[i]] = true;            }            for (int i = 0; i < sieve.Length; i++)            {                total++;                if (!sieve[i])                {                    return i;                }            }            return values.Length;        }

Т.к. математики хитры люди. То они говорят, что алгоритм O(n) ведь проход по массиву исходному массиву всего один
Т.к. математики хитры люди. И говорят, что алгоритм O(n) ведь проход по массиву исходному массиву всего один
Вот сидят и радуются, что такой крутой алгоритм придумали, но правда такова.
Первое нужно найти максимально число в исходном массиве O1(n)
Второе нужно пройтись по исходному массиву и отметить это значение в массиве S O2(n)
Третье нужно пройтись массиве S и найти там первую попавшеюся свободную ячейку O3(n)
Итого, т.к. все операции в целом не сложные можно упростить все расчеты до O(n*3)
Но это явно лучше решения в лоб Давайте проверим на наших тестовых данных:
1) Для случая { 0, 12, 4, 7, 1 }: В лоб: 11 итераций, просеивание: 13 итераций
2) Для случая { 0, 1, 2, 3, 4 }: В лоб: 15 итераций, просеивание: 15 итераций
3) Для случая { 11,}: В лоб: 441 итерация, просеивание: 191 итерация
4) Для случая { 0,,999}: В лоб: 500500 итераций, просеивание: 3000 итераций

Дело в том, что если отсутствующие значение является небольшим числом, то в таком случае решение в лоб оказывается быстрее, т.к. не требует тройного прохода по массиву. Но в целом, на больших размерностях явно проигрывает просеиванью, что собственно неудивительно.
С точки зрения математика алгоритм готов, и он великолепен, но вот с точки зрения программиста он ужасен из-за объема оперативной памяти, израсходованной впустую, да и финальный проход для поиска первого пустого значения явно хочется ускорить.
Давайте сделаем это, и оптимизируем код.
static int GetMEX(int[] values, ref int total)        {            total = values.Length;            var max = values.Max() + 1;            var size = sizeof(ulong) * 8;            ulong[] sieve = new ulong[(max / size) + 1];            ulong one = 1;            for (int i = 0; i < values.Length; i++)            {                total++;                sieve[values[i] / size] |= (one << (values[i] % size));            }            var maxInblock = ulong.MaxValue;            for (int i = 0; i < sieve.Length; i++)            {                total++;                if (sieve[i] != maxInblock)                {                    for (int j = 0; j < size; j++)                    {                        total++;                        if ((sieve[i] & (one << j)) == 0)                        {                            return i * size + j;                        }                    }                }            }            return values.Length;        }

Что мы тут сделали. Во-первых, в 64 раза уменьшили количество оперативной памяти, которая необходима.
var size = sizeof(ulong) * 8;ulong[] sieve = new ulong[(max / size) + 1];
Во-вторых, оптимизировали фальную проверку: мы проверяем сразу блок на вхождение первых 64 значений: if (sieve[i] != maxInblock) и как только убедились в том, что значение блока не равно бинарным 11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111, только тогда ищем уже вхождение на уровне блока: ((sieve[i] & (one << j)) == 0
В итоге алгоритм просеивание нам дает следующие результат:
1) Для случая { 0, 12, 4, 7, 1 }: просеивание: 13 итераций, просеивание с оптимизацией: 13 итераций
2) Для случая { 0, 1, 2, 3, 4 }: В лоб: 15 итераций, просеивание с оптимизацией: 16 итераций
3) Для случая { 11,}: В лоб: 191 итерация, просеивание с оптимизацией: 191 итерации
4) Для случая { 0,,999}: В лоб: 3000 итераций, просеивание с оптимизацией: 2056 итераций

Так, что в итоге в теории по скорости?
O(n*3) мы превратили в O(n*2) + O(n / 64) в целом, чуть увеличили скорость, да еще объём оперативной памяти уменьшили аж в 64 раза. Что хорошо)

3) Сортировка


Как не сложно догадаться, самый простой способ найти отсутствующий элемент во множестве это иметь отсортированное множество.
Самый быстрый алгоритм сортировки это quicksort (быстрая сортировка), которая имеет сложность в O1(n log(n)). И итого мы получим теоретическую сложность для поиска MEX в O1(n log(n)) + O2(n)
static int GetMEX(int[] values, ref int total)        {            total = values.Length * (int)Math.Log(values.Length);            values = values.OrderBy(x => x).ToArray();            for (int i = 0; i < values.Length - 1; i++)            {                total++;                if (values[i] + 1 != values[i + 1])                {                    return values[i] + 1;                }            }            return values.Length;        }

Шикарно. Ничего лишнего)
Проверим количество итераций
1) Для случая { 0, 12, 4, 7, 1 }: просеивание с оптимизацией: 13, сортировка: ~7 итераций
2) Для случая { 0, 1, 2, 3, 4 }: просеивание с оптимизацией: 16 итераций, сортировка: ~9 итераций
3) Для случая { 11,}: просеивание с оптимизацией: 191 итерации, сортировка: ~356 итераций
4) Для случая { 0,,999}: просеивание с оптимизацией: 2056 итераций, сортировка: ~6999 итераций

Здесь указаны средние значения, и они не совсем справедливы. Но в целом: сортировка не требует дополнительной памяти и явно позволяет упростить последний шаг в переборе.
Примечание: values.OrderBy(x => x).ToArray() да я знаю, что тут выделилась память, но если делать по уму, то можно изменить массив, а не копировать его


Вот у меня и возникла идея оптимизировать quicksort для поиска MEX. Данный вариант алгоритма я не находил в интернете, ни с точки зрения математики, и уж тем более с точки зрения программирования. То код будем писать с 0 по дороге придумывая как он будет выглядеть :D

Но, для начала, давайте вспомним как вообще работает quicksort. Я бы ссылку дал, но нормально пояснения quicksort на пальцах фактически нету, создается ощущение, что авторы пособий сами разбираются в алгоритме пока его рассказывают про него
Так вот, что такое quicksort:
У нас есть неупорядоченный массив { 0, 12, 4, 7, 1 }
Нам потребуется случайное число, но лучше взять любое из массива, это называется опорное число (T).
И два указателя: L1 смотрит на первый элемент массива, L2 смотрит на последний элемент массива.
0, 12, 4, 7, 1
L1 = 0, L2 = 1, T = 1 (T взял тупа последние)

Первый этап итерации:
Пока работам только с указателем L1
Сдвигаем его по массиву вправо пока не найдем число больше чем наше опорное.
В нашем случае L1 равен 8

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

Третей этап итерации:
Меняем числа в указателях L1 и L2 местами, указатели не двигаем.
И переходим к первому этапу итерации.
Эти этапы мы повторяем до тех пор, пока указатели L1 и L2 не будет равны, не значения по ним, а именно указатели. Т.е. они должны указывать на один элемент.

После того как указатели сойдутся на каком-то элементе, обе части массива будут всё еще не отсортированы, но уже точно, с одной стороны объеденных указателей (L1 и L2) будут элементы, которые меньше T, а со второй больше T. Именно этот факт нам и позволяет разбить массив на две независимые группы, которые мы сортируем можно сортировать в разных потоках в дальнейших итерациях.
Статья на wiki, если и у меня непонятно написанно

Напишем Quicksort
static void Quicksort(int[] values, int l1, int l2, int t, ref int total)        {            var index = QuicksortSub(values, l1, l2, t, ref total);            if (l1 < index)            {                Quicksort(values, l1, index - 1, values[index - 1], ref total);            }            if (index < l2)            {                Quicksort(values, index, l2, values[l2], ref total);            }        }        static int QuicksortSub(int[] values, int l1, int l2, int t, ref int total)        {            for (; l1 < l2; l1++)            {                total++;                if (t < values[l1])                {                    total--;                    for (; l1 <= l2; l2--)                    {                        total++;                        if (l1 == l2)                        {                            return l2;                        }                        if (values[l2] <= t)                        {                            values[l1] = values[l1] ^ values[l2];                            values[l2] = values[l1] ^ values[l2];                            values[l1] = values[l1] ^ values[l2];                            break;                        }                    }                }            }            return l2;        }


Проверим реальное количество итераций:
1) Для случая { 0, 12, 4, 7, 1 }: просеивание с оптимизацией: 13, сортировка: 11 итераций
2) Для случая { 0, 1, 2, 3, 4 }: просеивание с оптимизацией: 16 итераций, сортировка: 14 итераций
3) Для случая { 11,}: просеивание с оптимизацией: 191 итерации, сортировка: 1520 итераций
4) Для случая { 0,,999}: просеивание с оптимизацией: 2056 итераций, сортировка: 500499 итераций

Попробуем поразмышлять вот над чем. В массиве { 0, 4, 1, 2, 3 } нет недостающих элементов, а его длина равно 5. Т.е. получается, массив в котором нет отсутствующих элементов равен длине массива 1. Т.е. m = { 0, 4, 1, 2, 3 }, Length(m) == Max(m) + 1. И самое главное в этом моменте, что это условие справедливо, если значения в массиве переставлены местами. И важно то, что это условие можно распространить на части массива. А именно вот так:
{ 0, 4, 1, 2, 3, 12, 10, 11, 14 } зная, что в левой части массива все числа меньше некого опорного числа, например 5, а в правой всё что больше, то нет смысла искать минимальное число слева.
Т.е. если мы точно знаем, что в одной из частей нет элементов больше определённого значения, то само это отсутствующие число нужно искать во второй части массива. В целом так работает алгоритм бинарного поиска.
В итоге у меня родилась мысль упростить quicksort для поиска MEX объединив его с бинарным поиском. Сразу скажу нам не нужно будет полностью отсортировывать весь массив только те части, в которых мы будем осуществлять поиск.
В итоге получаем код
static int GetMEX(int[] values, ref int total)        {            return QuicksortMEX(values, 0, values.Length - 1, values[values.Length - 1], ref total);        }        static int QuicksortMEX(int[] values, int l1, int l2, int t, ref int total)        {            if (l1 == l2)            {                return l1;            }            int max = -1;            var index = QuicksortMEXSub(values, l1, l2, t, ref max, ref total);            if (index < max + 1)            {                return QuicksortMEX(values, l1, index - 1, values[index - 1], ref total);            }            if (index == values.Length - 1)            {                return index + 1;            }            return QuicksortMEX(values, index, l2, values[l2], ref total);        }        static int QuicksortMEXSub(int[] values, int l1, int l2, int t, ref int max, ref int total)        {            for (; l1 < l2; l1++)            {                total++;                if (values[l1] < t && max < values[l1])                {                    max = values[l1];                }                if (t < values[l1])                {                    total--;                    for (; l1 <= l2; l2--)                    {                        total++;                        if (values[l2] == t && max < values[l2])                        {                            max = values[l2];                        }                        if (l1 == l2)                        {                            return l2;                        }                        if (values[l2] <= t)                        {                            values[l1] = values[l1] ^ values[l2];                            values[l2] = values[l1] ^ values[l2];                            values[l1] = values[l1] ^ values[l2];                            break;                        }                    }                }            }            return l2;        }

Проверим количество итераций
1) Для случая { 0, 12, 4, 7, 1 }: просеивание с оптимизацией: 13, сортировка MEX: 8 итераций
2) Для случая { 0, 1, 2, 3, 4 }: просеивание с оптимизацией: 16 итераций, сортировка MEX: 4 итераций
3) Для случая { 11,}: просеивание с оптимизацией: 191 итерации, сортировка MEX: 1353 итераций
4) Для случая { 0,,999}: просеивание с оптимизацией: 2056 итераций, сортировка MEX: 999 итераций

Итого


Мы получили разны варианты поиска MEX. Какой из них лучше решать вам.
В целом. Мне больше всех нравится просеивание, и вот по каким причинам:
У него очень предсказуемое время выполнения. Более того, этот алгоритм можно легко использовать в многопоточном режиме. Т.е. разделить массив на части и каждую часть пробегать в отдельном потоке:
for (int i = minIndexThread; i < maxIndexThread; i++)sieve[values[i] / size] |= (one << (values[i] % size));

Единственное, нужен lock при записи sieve[values[i] / size]. И еще алгоритм идеален при выгрузки данных из базы данных. Можно грузить пачками по 1000 штук например, в каждом потоке и всё равно он будет работать.
Но если у нас строгая нехватка памяти, то сортировка MEX явно выглядит лучше.

П.с.
Я начал рассказ с конкурса на OZON в котором я пробовал участвовал, сделав предварительный вариант алгоритма просеиванья, приз за него я так и не получил, OZON счел его неудовлетворительным По каким именно причин он так и не сознался До и кода победителя я не видел. Может у кого-то есть идеи как можно решить задачу поиска MEX лучше?
Подробнее..

Корни разные нужны, корни разные важны

14.06.2021 16:14:45 | Автор: admin

Вместо вступления

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

Интересен алгоритм sqrxi32 от @Sdima1357 Пример 1, далее для краткости именуемый как _i32. Алгоритм _i32 безусловно выполняет главное условие задачи округление до ближайшего целого на всём множестве значений аргумента [ 0 .. 0xFFFFFFFF ], при этом показывает высокую производительность.

Пример 1: Вычисление квадратного корня из целого с округлением до ближайшего целого.

uint16_t sqrxi32( uint32_t y ){if ( y == 1 )return 1;uint32_t xh = y > 0x10000ul ? 0x10000ul : y;uint32_t xl = 0;uint32_t xc;for ( int k = 0; k < 16; k++ ){xc = ( xh + xl ) >> 1ul;if ( xc * xc - xc >= y ){xh = xc;}else{xl = xc;}}return ( xh + xl ) >> 1ul;}

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

О чём этот текст

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

Исходный код содержит содержит решение одной задачи разными алгоритмами.

Анализ результатов наблюдений за рамками настоящей публикации.

Условия и допуски

Для сокращение текста принимаем:

  • аппаратных платформ для тестов 3 платформы;

  • вариантов оптимизации сборки 3 значения

Для сборки двоичного кода применяем:

  • Одну единицу компиляции теста (файл main.c)

  • Компиляцию в моно-поточный исполняемый файл

  • Единую сборочную среду: CubeIDE (она же Eclipce CDT)

  • Стандартные настройки профиля сборки RELEASE в среде CubeIDE

  • Единый диалект компилятора: ISO C11 + gnu extensions (-std=gnu11)

  • Применительно к микроконтроллерам:

    • CubeMX default settings, +48MHz, +USART1, +HAL;

    • Runtime lib: Reduced C ( --spec=nano.specs );

    • Use float with printf from new lib nano ( -u _printf_float );

Таблица 1: Варианты сборки исполняемого кода

Таблица 2: Общие характеристики аппаратных платформ

Таблица 3: Технические характеристики аппаратных платформ

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

Для оценки FPU платформы M4 в тестовый набор добавлена функция sqrt_fps, решающая вычислительную задачу с применением коротких действительных (float), именуемая далее _fps (Float Point Short) Пример 2.

Пример 2: Квадратный корень из целого с точностью float

uint16_t sqrt_fps( uint32_t number ){if ( number < 2 )return (uint16_t) number;float f_rslt = sqrtf( number );uint32_t rslt = (uint32_t) f_rslt;if ( !( f_rslt - (float) rslt < .5 ) )rslt++;return (uint16_t) rslt;}

Функция _fps работает без ошибок с аргументом менее 22-х бит, что соответствует десятичному порядку 1+E5 Иллюстрация 1.

Иллюстрация 1: Ошибки функции "_fps" на порядках 1+E6+

Для всех наблюдаемых алгоритмов ограничиваем диапазон аргумента множеством значений
[0 .. 1+E5].

Таблица 4: Список наблюдаемых алгоритмов

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

Относительная производительность платформ

Ожидаемо, производительность платформы x86 выше производительности платформы ARM Cortex безотносительно характера оптимизации сборки. Последнее демонстрирует левая часть графика Иллюстрация 2.

Иллюстрация 2: Относительная производительность аппаратных платформ

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

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

График каждой платформы, в свою очередь, представлен тремя столбцами, демонстрирующими зависимость производительности от варианта оптимизации сборки: -O0, -Os, -O3.

Правая часть графика (Иллюстрация 2) показывает относительный прирост производительности у каждой аппаратной платформы в зависимости от варианта оптимизации сборки: -O0, -Os, -O3.

Производительность 100% демонстрирует двоичный код, собранный без оптимизации ( -O0 ). Это базовая производительность платформы.

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

Наблюдаем наибольший прирост производительности от оптимизации на этапе сборки наплатформе M4.

Платформа x86

На графике (Иллюстрация 3) по оси Y отображается число цикличных вызовов наблюдаемых функций за одну миллисекунду. На оси X наблюдаемые функции (Таблица 4).

Чем выше на графике столбики, тем лучше производительность.

Цветом на оси X обозначен способ оптимизации на этапе сборки. Соответствие цвета и характера оптимизации отражает легенда.

Иллюстрация 3: Производительность алгоритмов на платформе x86

Платформа x86 максимально раскрывает преимущества алгоритмов с плавающей точкой перед целочисленными.

Заслуживает отдельного внимания часть графика в оранжевом контуре.

Производительность кода без оптимизации (O0) лучше на 39% для алгоритма _fpu (Os) и на 16% для алгоритма _fps (O3). Другими словами, любая оптимизация на этапе сборки снижает производительность платформы x86 на действительных числах.

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

Платформа M4

Платформа M4 демонстрирует предсказуемый результат (Иллюстрация 4).

Иллюстрация 4: Производительность алгоритмов на платформе M4

Модуль с плавающей точкой M4 даёт ожидаемый прирост производительности для алгоритма _fps, основанного на коротком действительном float.

Последнее подтверждается результатом сравнения производительности алгоритмов при отключенном модуле FPU на платформе M4 Иллюстрация 5.

Наблюдая графики помним, что точность вычислений алгоритма _fps гарантируется в диапазоном 1+E5 (см. Иллюстрация 1) без относительно того, включен ли модуль FPU на M4 или нет.

Иллюстрация 5: Производительность алгоритмов на платформе M4 без FPU

Платформа M0

Результаты платформы M0 похожи на результаты платформы M4безFPU (Иллюстрация 5), только медленнее Иллюстрация 6.

Заметим, тактовая частота при тестировании устанавливалась одинаковой и для M4, и для M0 48 MHz. Однако, производительность M0 хуже в два с лишним раза, чем M4, в условиях равенства прочих характеристик.

Иллюстрация 6: Производительность алгоритмов на платформе M0

Алгоритм _fps на платформе M0 ожидаемо опережает в два раза алгоритм _fpu.

Целочисленные алгоритмы опережают алгоритмы с плавающей точкой.

По странному стечению обстоятельств в заключительном графике (Иллюстрация 6) снова есть место для оранжевого контура.

При сборке без оптимизации (O0) алгоритм _evn быстрее алгоритма _i32. И алгоритм _evn медленнее, чем _i32, если сборка проводится с оптимизацией.

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

Вместо заключения

Производительность программы зависит от многих причин.

Оптимизация на этапе сборки способна ухудшать производительность, замедлять программу, что демонстрируют экспериментальные данные в оранжевых контурах выше (Иллюстрация 3 и Иллюстрация 6).

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

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

Приложение 1. Порядок тестирования платформы x86

  1. Создать в среде CubeIDE (Eclipse CDT) проект C штатным способом

  2. Написать текст программы Пример 3

  3. Добавить в проект файл sqrt_cmp.h Пример 6

  4. Осуществить сборку и запуск программы:

    1. штатными средствами IDE;

    2. или из командной строки Пример 4

  5. Меняя вид оптимизации ( -O0, -O3, -Os ) наблюдать результат.

Пример 3: Исходный текст программы x86 main.c

#include sqrt_cmp.hint main( void ){main_Of_SqrtComp();return 0;}

Пример 4 Запуск теста на платформе x86 из терминала

gcc main.c -o main -I. -Wall -lm -std=gnu11 -O3 && ./main

Запуск теста из терминала платформы x86 предполагает, что файлы main.c и sqrt_cmp.h располагаются в одном каталоге, и этот каталог выбран рабочим (pwd).

Иллюстрация 7: Запуск теста из терминала x86

Приложение 2. Порядок тестирования платформы STM32

  1. Создать в среде CubeIDE проект STM32 штатным способом (CubeMX)

  2. Добавить файл sqrt_cmp.h в проект STM32 Пример 6

  3. Включить sqrt_cmp.h в файл main.c Пример 5

  4. Осуществить сборку и запуск программы штатными средствами IDE

  5. Меняя вид оптимизации ( -O0, -O3, -Os ) наблюдать результат

Пример 5: Исходный текст для STM32 (с пропусками < ... >) main.c

<  >/* Private includes ----------------------------------------------------------*//* USER CODE BEGIN Includes */#include "sqrt_cmp.h"/* USER CODE END Includes */<  >/**  * @brief  The application entry point.  * @retval int  */int main(void){<  >  /* Infinite loop */  /* USER CODE BEGIN WHILE */  main_Of_SqrtComp();  while (1)  {    /* USER CODE END WHILE */    /* USER CODE BEGIN 3 */  }  /* USER CODE END 3 */

Приложение 3. Порядок тестирования других алгоритмов и платформ

Сборка теста для других платформ проводится по аналогии.

Для отличных от упомянутых выше аппаратных платформ (Таблица 3), вероятно, потребуется косметическая модификация файла sqrt_cmp.h.

Пример 6: Содержание файла sqrt_cmp.h

/****************************************************************************** * File: sqrt_cmp.h Created on 5 авг. 2020 г. * CC0 1.0 Universal (CC0 1.0) * Creative Commons Public Domain Dedication * No Copyright * * TAB Size .EQ 4 ********************************************************************************/#ifndef __SQRT_CMP_H#define __SQRT_CMP_H#include<math.h>#include<stdio.h>#include<stdint.h>#ifdef __cplusplusextern "C" {#endif/****************************************************************************** * Interface of the entry point for all sqrt tests ******************************************************************************/void main_Of_SqrtComp();/****************************************************************************** * test case selection: TEST_SET * select one of the test suite via a comment. ******************************************************************************/#define TEST_SETTEST_ALL//#define TEST_SETTEST_ROUNDING//#define TEST_SETTEST_PERFORMANCE/****************************************************************************** * Interfaces of test functions. * See implementation of them at the end of this file. ******************************************************************************/typedef uint16_t (*sqrt_func)( uint32_t number );uint16_t sqrt_fpu( uint32_t number );// floating point function from articleuint16_t sqrt_evn( uint32_t number );// integer function from articleuint16_t sqrxi32( uint32_t y );// integer function from comment byuint16_t sqrt_fps( uint32_t number );// optimized floating point function for Cortex M4// <-- insert interface of your function here/****************************************************************************** * Set to variable named as 'round_test_func' below * to the alias of one of the functions above. * The NULL will select last function in comp_list[] ******************************************************************************/sqrt_func round_test_func = sqrt_fps;// specific instance for the rounding test//sqrt_func round_test_func = sqrxi32;// specific instance for the rounding test//sqrt_func round_test_func = sqrt_evn;// specific instance for the rounding test//sqrt_func round_test_func = NULL;// last function in comp_list[]/****************************************************************************** * The array of test functions for competing routines is called comp_list[]. * Adding a new function to the test: - copy the implementation of the new function to the end of this file; - declare the function interface at the beginning of this file; - add the alias and declaration of the new function to end of array named comp_list[]. ******************************************************************************/// @formatter:offtypedef struct{sqrt_funcfsqrt;char *alias;} SCompFunc;SCompFunc comp_list[] =// competition list{{ sqrt_fpu, "_fpu" },{ sqrt_fps, "_fps" },{ sqrt_evn, "_evn" },{ sqrxi32,  "_i32" }// <-- insert your function name & alias here};/* @formatter:on *//****************************************************************************** * Platform-independent definitions ******************************************************************************/#define PUT_FORMAT_MSG(f_, ...) { \sprintf( (char *)s_buf, (char *)f_, ##__VA_ARGS__ ); \PUT_MSG( (char *)s_buf ); }#define MS_PER_SEC1000#define US_PER_SEC( MS_PER_SEC * MS_PER_SEC )#define ARRAY_SIZE(a) (sizeof a / sizeof *a)// size of static array at runtime#define SIRV(f) if ( f ) ;// suppress Ignore Return Value warning/****************************************************************************** * Platform-specific defines ******************************************************************************/#if defined( USE_HAL_DRIVER )// STM32 ARM Cortex platform#include<string.h>#include "main.h"//*****************************************************************************// Platform-specific defines for the helper functions#define SCALE_RATE1// must be .GE than 1#define X_CLOCKHAL_GetTick()#define X_DELAY( ms )HAL_Delay( ms )//*****************************************************************************// Platform-specific defines for the terminal output#define USART_HANDLEhuart1// set valid USART handler alias here defined by the config of MCU#define USART_TIMEOUT150// max timeout for HAL_UART_Transmitextern UART_HandleTypeDef USART_HANDLE;extern HAL_StatusTypeDef HAL_UART_Transmit ( UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout );#define PUT_MSG( msg ) \HAL_UART_Transmit( &USART_HANDLE, (uint8_t *)msg, strlen( (char *)msg ), USART_TIMEOUT )#define CPU_CLOCK_MHz( SystemCoreClock / US_PER_SEC )// CPU CLK in MHz#if defined( STM32F0 )#defineCPU_ID ( "STM32 ARM Cortex M0" )#elif defined ( STM32F3 )#defineCPU_ID ( "STM32 ARM Cortex M4" )#else#defineCPU_ID ( "Maybe STM32 ARM Cortex" )#endif#define PUT_SYS_INFOPUT_FORMAT_MSG( " %s @ "fdU()" MHz\n", CPU_ID, CPU_CLOCK_MHz )#else// #if defined( USE_HAL_DRIVER)#include <time.h>#include <stdlib.h>//*****************************************************************************// Platform-specific defines for the helper functions#define SCALE_RATE100// must be .GE than 1#define X_CLOCK(uint32_t) x_clock()#define X_DELAY( ms )x_delay( ms )uint32_t x_clock(){uint64_t result = (uint64_t) clock();result *= MS_PER_SEC;result /= CLOCKS_PER_SEC;return (uint32_t) result;}void x_delay( uint32_t ms ){uint64_t tm = x_clock();while ( ( x_clock() - tm ) < ms );}//*****************************************************************************// Platform-specific defines for the terminal output#define PUT_MSG( msg ) \printf( "%s", (char *)msg ), fflush ( stdout );#if defined( __unix__ )// anybody other platform for gcc#define PUT_SYS_INFOSIRV( system( "cat /proc/cpuinfo | grep 'model name' | head -1 | sed s/'model name\t:'/''/" ) )#else#define PUT_SYS_INFOPUT_MSG( "Undefined System & CPU" )#endif// #if defined( __unix__ )  // anybody other platform for gcc#endif// #if defined( USE_HAL_DRIVER)#if  ( __WORDSIZE == 64 )#define fdI(s)"%" #s "d"#define fdU(s)"%" #s "u"#define fdX(s)"%" #s "x"#else// let's say __WORDSIZE == 32#define fdI(s)"%" #s "ld"#define fdU(s)"%" #s "lu"#define fdX(s)"%" #s "lx"#endif// #if ( __WORDSIZE == 64 )#if defined ( DEBUG ) || defined ( _DEBUG ) // chk build mode of CubeIDE#defineBUILD_MODE"DEBUG"#else // Maybe Release#defineBUILD_MODE"RELEASE"#endif// #if defined ( DEBUG ) || defined ( _DEBUG )/****************************************************************************** * the helper data with testing ranges ******************************************************************************/// @formatter:offtypedef struct{uint32_tstart;uint32_tstop;uint32_trepeat;} STestRange;STestRangetest_rngs[] ={{ 0, 1000, 100 * SCALE_RATE },{ 0, 10000, 10 * SCALE_RATE },{ 0, 100000, 1 * SCALE_RATE }};uint32_t test_results[ARRAY_SIZE( test_rngs )][ARRAY_SIZE( comp_list ) + 1];#define MSG_BUFF_SIZE512uint8_t s_buf[MSG_BUFF_SIZE];// buffer for a terminal output/* @formatter:on *//****************************************************************************** * Test sets definitions. Do not change it. ******************************************************************************/#define TEST_ROUNDING1#define TEST_PERFORMANCE2#define TEST_ALL( TEST_ROUNDING | TEST_PERFORMANCE )#ifndef TEST_SET#defineTEST_SETTEST_ALL#endif#define HI_ROUND_TEST_RANGE_END0x007FFFFFUL#define HI_ROUND_TEST_RANGE_START( HI_ROUND_TEST_RANGE_END >> 4 )/****************************************************************************** * Interface of helper functions ******************************************************************************/void main_Header();void testRounding();void testPerformance();/****************************************************************************** * Implementation of the entry point for all sqrt tests ******************************************************************************/void main_Of_SqrtComp(){X_DELAY( MS_PER_SEC / 2 );// suppress the output of a previous instance// while the new instance is loading into the MCUuint32_t start_time = X_CLOCK;main_Header();// checking normal and extended ranges for roundingif ( TEST_SET & TEST_ROUNDING )testRounding();// checking normal ranges on execution timeif ( TEST_SET & TEST_PERFORMANCE )testPerformance();uint32_t test_time = X_CLOCK - start_time;uint32_t test_m = ( test_time / MS_PER_SEC ) / 60;uint32_t test_s = ( test_time / MS_PER_SEC ) % 60;uint32_t test_ms = test_time % MS_PER_SEC;PUT_FORMAT_MSG( "\ndone, spent time: "fdU()" m, "fdU()"."fdU()" s\n", test_m, test_s, test_ms );}/****************************************************************************** * Implementation of the helper functions ******************************************************************************/void main_Header(){PUT_MSG( "\n\n**********************************************************\n" );PUT_SYS_INFO;PUT_FORMAT_MSG( "*********** %s, built at %s\n", BUILD_MODE, __TIME__ );}void testPerformance(){uint32_t i_func, i_rpt, i_rng;uint32_t number, first, second, diff;uint64_t temp;PUT_MSG( "----------+ Performance test" );for ( i_rng = 0; i_rng < ARRAY_SIZE( test_rngs ); i_rng++ ){PUT_MSG( "\n" );PUT_FORMAT_MSG( "test range:["fdU()".."fdU()"], repeat="fdU()"\n", test_rngs[i_rng].start, test_rngs[i_rng].stop,test_rngs[i_rng].repeat );test_results[i_rng][0] = test_rngs[i_rng].stop;for ( i_func = 0; i_func < ARRAY_SIZE( comp_list ); i_func++ ){PUT_FORMAT_MSG( "%s ... ", comp_list[i_func].alias );first = X_CLOCK;for ( i_rpt = 0; i_rpt < test_rngs[i_rng].repeat; i_rpt++ )for ( number = test_rngs[i_rng].start; number < test_rngs[i_rng].stop; number++ )comp_list[i_func].fsqrt( number );second = X_CLOCK;diff = second - first;temp = ( test_rngs[i_rng].stop - test_rngs[i_rng].start ) * test_rngs[i_rng].repeat;test_results[i_rng][i_func + 1] = (uint32_t) ( temp / diff );if ( i_func < ARRAY_SIZE( comp_list ) - 1 )PUT_MSG( ", " );}}// small reportPUT_FORMAT_MSG( "\n----------+ Report: sqrt`s calls per ms\n%10s", "range" );for ( i_func = 0; i_func < ARRAY_SIZE( comp_list ); i_func++ )PUT_FORMAT_MSG( "%10s", comp_list[i_func].alias );for ( i_rng = 0; i_rng < ARRAY_SIZE( test_rngs ); i_rng++ ){PUT_MSG( "\n" );for ( i_func = 0; i_func < ARRAY_SIZE( comp_list ) + 1; i_func++ )PUT_FORMAT_MSG( fdU( 10 ), test_results[i_rng][i_func] );}PUT_FORMAT_MSG( "\n----------+\n%10s", "average" );for ( i_func = 0; i_func < ARRAY_SIZE( comp_list ); i_func++ ){temp = 0;for ( i_rng = 0; i_rng < ARRAY_SIZE( test_rngs ); i_rng++ )temp += test_results[i_rng][i_func + 1];temp /= ARRAY_SIZE( test_rngs );PUT_FORMAT_MSG( fdU( 10 ), (uint32_t)temp );}}void testRoundingFunction( uint32_t start, uint32_t finish, sqrt_func psqrt, char *fname );void testRounding(){uint16_t i_rng;uint16_t f_rng;PUT_MSG( "----------+ Rounding test\n" );// checking the existence for the test functionfor ( f_rng = 0; f_rng < ARRAY_SIZE( comp_list ); f_rng++ )if ( comp_list[f_rng].fsqrt == round_test_func )break;if ( !( f_rng < ARRAY_SIZE( comp_list ) ) ){f_rng = ARRAY_SIZE( comp_list ) - 1;PUT_FORMAT_MSG( "Value of 'round_test_func' not found.\n" );}PUT_FORMAT_MSG( "Function '%s' is tested for rounding.\n", comp_list[f_rng].alias );// checking standard rangesfor ( i_rng = 0; i_rng < ARRAY_SIZE( test_rngs ); i_rng++ )testRoundingFunction( test_rngs[i_rng].start, test_rngs[i_rng].stop, comp_list[f_rng].fsqrt, comp_list[f_rng].alias );// checking extended rangetestRoundingFunction( HI_ROUND_TEST_RANGE_START, HI_ROUND_TEST_RANGE_END, comp_list[f_rng].fsqrt, comp_list[f_rng].alias );}void turn_the_fan( uint32_t ms );void testRoundingFunction( uint32_t start, uint32_t finish, sqrt_func psqrt, char *fname ){uint32_t rf, ri;uint32_t n, c = 0;PUT_FORMAT_MSG( "test range:["fdU( 10 )".."fdU( 10 )"] ... ", start, finish );for ( n = start; n < finish; n++ ){rf = sqrt_fpu( n );ri = ( *psqrt )( n );if ( rf != ri ){if ( c++ > 3 ){PUT_FORMAT_MSG( "\b\n(!)too many mistakes in '%s', ", fname );break;}else{double d = sqrt( (double) n );PUT_FORMAT_MSG( "\b\n%s("fdU( 10 )")="fdU()" != "fdU(), fname, n, ri, rf );PUT_FORMAT_MSG( " (real value is %.6lf)", d );}}turn_the_fan( MS_PER_SEC );}if ( !c ){PUT_FORMAT_MSG( "\b done.\n" );}else{PUT_FORMAT_MSG( "test failed.\n" );}}void turn_the_fan( uint32_t ms ){static char ca[] = "|/-\\";static uint32_t cs = ARRAY_SIZE(ca) - 1;static uint32_t cn = 0;static uint32_t at = 0;uint32_t ct = X_CLOCK;if ( ct - at > ms ){at = ct;PUT_FORMAT_MSG( "\b%c", ca[cn++ % cs] );}}/****************************************************************************** * Implementation of the sqrt functions ******************************************************************************/// floating point arg & result with doubleuint16_t sqrt_fpu( uint32_t number ){if ( number < 2 )return (uint16_t) number;double f_rslt = sqrt( number );uint32_t rslt = (uint32_t) f_rslt;if ( !( f_rslt - (double) rslt < .5 ) )rslt++;return (uint16_t) rslt;}// floating point arg & result with floatuint16_t sqrt_fps( uint32_t number ){if ( number < 2 )return (uint16_t) number;float f_rslt = sqrtf( number );uint32_t rslt = (uint32_t) f_rslt;if ( !( f_rslt - (float) rslt < .5 ) )rslt++;return (uint16_t) rslt;}// unsigned integer arg & result// @formatter:offuint16_t sqrt_evn ( uint32_t number ){if ( number < 2 )return ( uint16_t ) number;uint32_t temp;uint32_t div;uint32_t rslt;if ( number & 0xFFFF0000L )if ( number & 0xFF000000L )if ( number & 0xF0000000L )if ( number & 0xE0000000L )div = 43771;elsediv = 22250;elseif ( number & 0x0C000000L )div = 11310;elsediv = 5749;elseif ( number & 0x00F00000L )if ( number & 0x00C00000L )div = 2923;elsediv = 1486;elseif ( number & 0x000C0000L )div = 755;elsediv = 384;elseif ( number & 0xFF00L )if ( number & 0xF000L )if ( number & 0xC000L )div = 195;elsediv = 99;elseif ( number & 0x0C00L )div = 50;elsediv = 25;elseif ( number & 0xF0L )if ( number & 0x80L )div = 13;elsediv = 7;elsediv = 3;rslt = number;while ( 1 ){temp = number / div;temp += div;div = temp >> 1;div += temp & 1;if ( rslt > div )rslt = div;else{if ( number / rslt == rslt - 1 && number % rslt == 0 )rslt--;return ( uint16_t ) rslt;}}}/* @formatter:on */// unsigned integer arg & resultuint16_t sqrxi32( uint32_t y ){if ( y == 1 )return 1;uint32_t xh = y > 0x10000ul ? 0x10000ul : y;uint32_t xl = 0;uint32_t xc;for ( int k = 0; k < 16; k++ ){xc = ( xh + xl ) >> 1ul;if ( xc * xc - xc >= y ){xh = xc;}else{xl = xc;}}return ( xh + xl ) >> 1ul;}// <-- insert implementation of your function sqrt here#ifdef __cplusplus}#endif#endif // __SQRT_CMP_H
Подробнее..

Разработка стековой виртуальной машины и компилятора под неё (часть II)

04.06.2021 20:14:20 | Автор: admin

В первой части Разработка стековой виртуальной машины и компилятора под неё (часть I) сделал свою элементарную стековую виртуальную машину, которая умеет работать со стеком, делать арифметику с целыми числами со знаком, условные переходы и вызовы функций с возвратом. Но так как целью было создать не только виртуальную машину, но и компилятор C подобного языка, пришло время сделать первые шаги в сторону компиляции. Опыта никакого. Буду действовать по разумению.

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

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

constexpr char* BLANKS = "\x20\n\t";constexpr char* DELIMETERS = ",;{}[]()=><+-*/&|~^!.";enum class TokenType {NONE = 0, UNKNOWN, IDENTIFIER,CONST_CHAR, CONST_INTEGER, CONST_REAL, CONST_STRING,COMMA, MEMBER_ACCESS, EOS, OP_BRACES, CL_BRACES, OP_BRACKETS, CL_BRACKETS, OP_PARENTHESES, CL_PARENTHESES,BYTE, SHORT, INT, LONG, CHAR, FLOAT, DOUBLE, STRING, IF, ELSE, WHILE, RETURN,ASSIGN, EQUAL, NOT_EQUAL, GREATER, GR_EQUAL, LESS, LS_EQUAL,PLUS, MINUS, MULTIPLY, DIVIDE, AND, OR, XOR, NOT, SHL, SHR,LOGIC_AND, LOGIC_OR, LOGIC_NOT};typedef struct {TokenType type;          char* text;              WORD length;             WORD row;                WORD col;                } Token;

Далее напишем класс для разбора, ключевым методом которого станет parseToTokens(char*). Тут алгоритм простой: идем до разделителя (BLANKS и DELIMETERS), определяем начало и конец токена, классифицируем его и добавляем в вектор (список) токенов. Особые случаи разбора - это отличать целые числа от вещественных, вещественные числа (например, "315.0") отличать от применения оператора доступа к членам структуры/объекта ("obj10.field1"), а также отличать ключевые слова от других идентификаторов.

void VMParser::parseToTokens(const char* sourceCode) {TokenType isNumber = TokenType::UNKNOWN;bool insideString = false;                                         // inside string flagbool isReal = false;                                               // is real number flagsize_t length;                                                     // token length variablechar nextChar;                                                     // next char variablebool blank, delimeter;                                             // blank & delimeter char flagstokens->clear();                                                   // clear tokens vectorrowCounter = 1;                                                    // reset current row counterrowPointer = (char*)sourceCode;                                    // set current row pointer to beginningchar* cursor = (char*)sourceCode;                                  // set cursor to source beginning char* start = cursor;                                              // start new token from cursorchar value = *cursor;                                              // read first char from cursorwhile (value != NULL) {                                            // while not end of stringblank = isBlank(value);                                          // is blank char found?delimeter = isDelimeter(value);                                  // is delimeter found?length = cursor - start;                                         // measure token length    // Diffirentiate real numbers from member access operator '.'isNumber = identifyNumber(start, length - 1);                    // Try to get integer part of real numberisReal = (value=='.' && isNumber==TokenType::CONST_INTEGER);     // Is current token is real numberif ((blank || delimeter) && !insideString && !isReal) {          // if there is token separator                   if (length > 0) pushToken(start, length);                      // if length > 0 push token to vectorif (value == '\n') {                                           // if '\n' found rowCounter++;                                                // increment row counterrowPointer = cursor + 1;                                     // set row beginning pointer}nextChar = *(cursor + 1);                                      // get next char after cursorif (!blank && isDelimeter(nextChar)) {                         // if next char is also delimeterif (pushToken(cursor, 2) == TokenType::UNKNOWN)              // try to push double char delimeter tokenpushToken(cursor, 1);                                      // if not pushed - its single char delimeterelse cursor++;                                               // if double delimeter, increment cursor} else pushToken(cursor, 1);                                   // else push single char delimeterstart = cursor + 1;                                            // calculate next token start pointer}else if (value == '"') insideString = !insideString;             // if '"' char - flip insideString flag else if (insideString && value == '\n') {                        // if '\n' found inside string// TODO warn about parsing error}cursor++;                                                        // increment cursor pointervalue = *cursor;                                                 // read next char}length = cursor - start;                                           // if there is a last tokenif (length > 0) pushToken(start, length);                          // push last token to vector}

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

class VMParser {public:VMParser();~VMParser();void parseToTokens(const char* sourceCode);Token getToken(size_t index);size_t getTokenCount();  private:vector<Token>* tokens;WORD rowCounter;char* rowPointer;  bool isBlank(char value);bool isDelimeter(char value);TokenType pushToken(char* text, size_t length);TokenType getTokenType(char* text, size_t length);TokenType identifyNumber(char* text, size_t length);TokenType identifyKeyword(char* text, size_t length);};

Попробуем используя класс VMParser разобрать строку исходного кода C подобного языка (исходный код бессмысленный, просто набор случайных токенов для проверки разбора):

int main(){    printf ("Wow!");    float a = 365.0 * 10 - 10.0 / 2 + 3;while (1 != 2) {    abc.v1 = 'x';}if (a >= b) return a && b; else a || b; };

В итоге получаем в консоли следующий перечень классифицированных токенов:

Результат разбора исходного кода C подобного языкаРезультат разбора исходного кода C подобного языка

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

Для простоты сначала реализуем компилятор арифметических выражений с целыми числами (приоритет "*" и "/" над "+" и "-" учитывается), без скобок, унарных операций и других важных вещей, в том числе проверки синтаксических ошибок. Разбор выражений напишем вот так:

void VMCompiler::parseExpression(size_t startIndex, VMImage* destImage) {Token tkn;currentToken = startIndex;parseTerm(destImage);tkn = parser->getToken(currentToken);while (tkn.type==TokenType::PLUS || tkn.type==TokenType::MINUS) {  currentToken++;  parseTerm(destImage);  if (tkn.type==TokenType::PLUS) destImage->emit(OP_ADD); else destImage->emit(OP_SUB);  tkn = parser->getToken(currentToken);}}void VMCompiler::parseTerm(VMImage* destImage) {Token tkn;parseFactor(destImage);currentToken++;tkn = parser->getToken(currentToken);while (tkn.type == TokenType::MULTIPLY || tkn.type == TokenType::DIVIDE) {  currentToken++;  parseFactor(destImage);  if (tkn.type == TokenType::MULTIPLY) destImage->emit(OP_MUL); else destImage->emit(OP_DIV);  currentToken++;  tkn = parser->getToken(currentToken);}}void VMCompiler::parseFactor(VMImage* destImage) {Token tkn = parser->getToken(currentToken);char buffer[32];strncpy(buffer, tkn.text, tkn.length);buffer[tkn.length] = 0;destImage->emit(OP_CONST, atoi(buffer));}

Попробуем скормить этому компилятору выражение "3+5*6+2*3+15/5", запускаем компилятор с выводом скомпилированных команд и сразу запускаем виртуальную машину. Ожидаем, что результат вычисления должен остаться на вершине стека - 42.

Ура! Получилось! Первые шаги в сторону компилятора сделаны.

Подробнее..

Перевод Находим и устраняем уязвимости бинарных файлов в Linux с утилитой checksec и компилятором gcc

12.06.2021 16:15:59 | Автор: admin

Изображение: Internet Archive Book Images. Modified by Opensource.com. CC BY-SA 4.0

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

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

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

  • как использовать утилиту checksec для поиска уязвимостей;
  • как использовать компилятор gcc для устранения найденных уязвимостей.

Установка checksec


Для Fedora OS и других систем на базе RPM:

$ sudo dnf install checksec

Для систем на базе Debian используйте apt.

Быстрый старт с checksec


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

$ file /usr/bin/checksec/usr/bin/checksec: Bourne-Again shell script, ASCII text executable, with very long lines$ wc -l /usr/bin/checksec2111 /usr/bin/checksec

Давайте запустим checksec для утилиты просмотра содержимого каталогов (ls):

$ checksec --file=/usr/bin/ls<strong>RELRO      STACK CANARY   NX      PIE       RPATH   RUNPATH   Symbols    FORTIFY Fortified    Fortifiable  FILE</strong>Full RELRO   Canary found   NX enabled  PIE enabled   No RPATH  No RUNPATH  No Symbols    Yes  5    17       /usr/bin/ls

Выполнив команду в терминале, вы получите отчёт о том, какими полезными свойствами обладает этот бинарник, и какими не обладает.

Первая строка это шапка таблицы, в которой перечислены различные свойства безопасности RELRO, STACK CANARY, NX и так далее. Вторая строка показывает значения этих свойств для бинарного файла утилиты ls.

Hello, бинарник!


Я скомпилирую бинарный файл из простейшего кода на языке С:

#include <stdio.h>int main(){printf(Hello World\n);return 0;}

Обратите внимание, что пока я не передал компилятору ни одного флага, за исключением -o (он не относится к делу, а просто говорит, куда выводить результат компиляции):

$ gcc hello.c -o hello$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=014b8966ba43e3ae47fab5acae051e208ec9074c, for GNU/Linux 3.2.0, not stripped$ ./helloHello World

Теперь запущу утилиту checksec для моего бинарника. Некоторые свойства отличаются от свойств

ls (для него я запускал утилиту выше):$ checksec --file=./hello<strong>RELRO      STACK CANARY   NX      PIE       RPATH   RUNPATH   Symbols     FORTIFY Fortified    Fortifiable   FILE</strong>Partial RELRO  No canary found  NX enabled  No PIE     No RPATH  No RUNPATH  85) Symbols    No  0    0./hello

Checksec позволяет использовать различные форматы вывода, которые вы можете указать с помощью опции --output. Я выберу формат JSON и сделаю вывод более наглядным с помощью утилиты jq:

$ checksec --file=./hello --output=json | jq{./hello: {relro: partial,canary: no,nx: yes,pie: no,rpath: no,runpath: no,symbols: yes,fortify_source: no,fortified: 0,fortify-able: 0}}

Анализ (checksec) и устранение (gcc) уязвимостей


Созданный выше бинарный файл имеет несколько свойств, определяющих, скажем так, степень его уязвимости. Я сравню свойства этого файла со свойствами бинарника ls (тоже указаны выше) и объясню, как это сделать с помощью утилиты checksec.

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

1. Отладочные символы


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

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

Сhecksec показывает, что отладочные символы присутствуют в моём бинарнике, но их нет в файле ls.

$ checksec --file=/bin/ls --output=json | jq | grep symbolssymbols: no,$ checksec --file=./hello --output=json | jq | grep symbolssymbols: yes,


То же самое может показать запуск команды file. Символы не удалены (not stripped).

$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=014b8966ba43e3ae47fab5acae051e208ec9074c, for GNU/Linux 3.2.0, <strong>not stripped</strong>

Как работает checksec


Запустим эту команду с опцией --debug:

$ checksec --debug --file=./hello

Так как утилита checksec это один длинный скрипт, то для его изучения можно использовать функции Bash. Выведем команды, которые запускает скрипт для моего файла hello:

$ bash -x /usr/bin/checksec --file=./hello

Особое внимание обратите на echo_message вывод сообщения о том, содержит ли бинарник отладочные символы:

+ readelf -W --symbols ./hello+ grep -q '\.symtab'+ echo_message '\033[31m96) Symbols\t\033[m ' Symbols, ' symbols=yes' 'symbols:yes,'

Утилита checksec использует для чтения двоичного файла команду readelf со специальным флагом --symbols. Она выводит все отладочные символы, которые содержатся в бинарнике.

$ readelf -W --symbols ./hello

Из содержимого раздела .symtab можно узнать количество найденных символов:

$ readelf -W --symbols ./hello | grep -i symtab

Как удалить отладочные символы после компиляции


В этом нам поможет утилита strip.

$ gcc hello.c -o hello$$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=322037496cf6a2029dcdcf68649a4ebc63780138, for GNU/Linux 3.2.0, <strong>not stripped</strong>$$ strip hello$$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=322037496cf6a2029dcdcf68649a4ebc63780138, for GNU/Linux 3.2.0, <strong>stripped</strong>

Как удалить отладочные символы во время компиляции


При компиляции используйте флаг -s:

$ gcc -s hello.c -o hello$$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=247de82a8ad84e7d8f20751ce79ea9e0cf4bd263, for GNU/Linux 3.2.0, <strong>stripped</strong>

Убедиться, что символы удалены, можно и с помощью утилиты checksec:

$ checksec --file=./hello --output=json | jq | grep symbolssymbols: no,

2. Canary


Canary (осведомители) это секретные значения, которые хранятся в стеке между буфером и управляющими данными. Они используются для защиты от атаки переполнения буфера: если эти значения оказываются изменены, то стоит бить тревогу. Когда приложение запускается, для него создаётся свой стек. В данном случае это просто структура данных с операциями push и pop. Злоумышленник может подготовить вредоносные данные и записать их в стек. В этом случае буфер может быть переполнен, а стек повреждён. В дальнейшем это приведёт к сбою работы программы. Анализ значений canary позволяет быстро понять, что произошёл взлом и принять меры.

$ checksec --file=/bin/ls --output=json | jq | grep canarycanary: yes,$$ checksec --file=./hello --output=json | jq | grep canarycanary: no,$Чтобы проверить, включен ли механизм canary, скрипт checksec запускает следующую команду:$ readelf -W -s ./hello | grep -E '__stack_chk_fail|__intel_security_cookie'

Включаем canary


Для этого при компиляции используем флаг -stack-protector-all:

$ gcc -fstack-protector-all hello.c -o hello$ checksec --file=./hello --output=json | jq | grep canarycanary: yes,

Вот теперь сhecksec может с чистой совестью сообщить нам, что механизм canary включён:

$ readelf -W -s ./hello | grep -E '__stack_chk_fail|__intel_security_cookie'2: 0000000000000000   0 FUNC  GLOBAL DEFAULT UND __stack_chk_fail@GLIBC_2.4 (3)83: 0000000000000000   0 FUNC  GLOBAL DEFAULT UND __stack_chk_fail@@GLIBC_2.4$

3. PIE


Включённое свойство PIE позволяет произвольно размещать в памяти исполняемый код независимо от его абсолютного адреса:

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

$ checksec --file=/bin/ls --output=json | jq | grep piepie: yes,$ checksec --file=./hello --output=json | jq | grep piepie: no,

Часто свойство PIE включают только при компиляции библиотек. В выводе ниже hello помечен как LSB executable, а файл стандартной библиотеки libc (.so) как LSB shared object:

$ file hellohello: ELF 64-bit <strong>LSB executable</strong>, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=014b8966ba43e3ae47fab5acae051e208ec9074c, for GNU/Linux 3.2.0, not stripped$ file /lib64/libc-2.32.so/lib64/libc-2.32.so: ELF 64-bit <strong>LSB shared object</strong>, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=4a7fb374097fb927fb93d35ef98ba89262d0c4a4, for GNU/Linux 3.2.0, not stripped

Checksec получает эту информацию следующим образом:

$ readelf -W -h ./hello | grep EXECType:               EXEC (Executable file)

Если вы запустите эту же команду для библиотеки, то вместо EXEC увидите DYN:

$ readelf -W -h /lib64/libc-2.32.so | grep DYNType:               DYN (Shared object file)

Включаем PIE


При компиляции программы нужно указать следующие флаги:

$ gcc -pie -fpie hello.c -o hello

Чтобы убедиться, что свойство PIE включено, выполним такую команду:

$ checksec --file=./hello --output=json | jq | grep piepie: yes,$

Теперь у нашего бинарного файла (hello) тип сменится с EXEC на DYN:

$ file hellohello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bb039adf2530d97e02f534a94f0f668cd540f940, for GNU/Linux 3.2.0, not stripped$ readelf -W -h ./hello | grep DYNType:               DYN (Shared object file)

4. NX


Средства операционной системы и процессора позволяют гибко настраивать права доступа к страницам виртуальной памяти. Включив свойство NX (No Execute), мы можем запретить воспринимать данные в качестве инструкций процессора. Часто при атаках переполнения буфера злоумышленники помещают код в стек, а затем пытаются его выполнить. Однако, если запретить выполнение кода в этих сегментах памяти, можно предотвратить такие атаки. При обычной компиляции с использованием gcc это свойство включено по умолчанию:

$ checksec --file=/bin/ls --output=json | jq | grep nxnx: yes,$ checksec --file=./hello --output=json | jq | grep nxnx: yes,

Чтобы получить информацию о свойстве NX, checksec вновь использует команду readelf. В данном случае RW означает, что стек доступен для чтения и записи. Но так как в этой комбинации отсутствует символ E, на выполнение кода из этого стека стоит запрет:

$ readelf -W -l ./hello | grep GNU_STACKGNU_STACK   0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10

Отключение NX


Отключать свойство NX не рекомендуется, но сделать это можно так:

$ gcc -z execstack hello.c -o hello$ checksec --file=./hello --output=json | jq | grep nxnx: no,

После компиляции мы увидим, что права доступа к стеку изменились на RWE:

$ readelf -W -l ./hello | grep GNU_STACKGNU_STACK   0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RWE 0x10

5. RELRO


В динамически слинкованных бинарниках для вызова функций из библиотек используется специальная таблица GOT (Global Offset Table). К этой таблице обращаются бинарные файлы формата ELF (Executable Linkable Format). Когда защита RELRO (Relocation Read-Only) включена, таблица GOT становится доступной только для чтения. Это позволяет защититься от некоторых видов атак, изменяющих записи таблицы:

$ checksec --file=/bin/ls --output=json | jq | grep relrorelro: full,$ checksec --file=./hello --output=json | jq | grep relrorelro: partial,

В данном случае включено только одно из свойств RELRO, поэтому checksec выводит значение partial. Для отображения настроек сhecksec использует команду readelf.

$ readelf -W -l ./hello | grep GNU_RELROGNU_RELRO   0x002e10 0x0000000000403e10 0x0000000000403e10 0x0001f0 0x0001f0 R  0x1$ readelf -W -d ./hello | grep BIND_NOW

Включаем полную защиту (FULL RELRO)


Для этого при компиляции нужно использовать соответствующие флаги:

$ gcc -Wl,-z,relro,-z,now hello.c -o hello$ checksec --file=./hello --output=json | jq | grep relrorelro: full,

Всё, теперь наш бинарник получил почётное звание FULL RELRO:

$ readelf -W -l ./hello | grep GNU_RELROGNU_RELRO   0x002dd0 0x0000000000403dd0 0x0000000000403dd0 0x000230 0x000230 R  0x1$ readelf -W -d ./hello | grep BIND_NOW0x0000000000000018 (BIND_NOW) 

Другие возможности checksec


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

Проверка нескольких файлов


Нет необходимости для каждого файла запускать отдельную команду. Можно запустить одну команду сразу для нескольких бинарных файлов:

$ checksec --dir=/usr/bin

Проверка процессов


Утилита checksec также позволяет анализировать безопасность процессов. Следующая команда отображает свойства всех запущенных программ в вашей системе (для этого нужно использовать опцию --proc-all):

$ checksec --proc-all

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

$ checksec --proc=bash

Проверка ядра


Аналогично вы можете анализировать уязвимости в ядре вашей системы.

$ checksec --kernel

Предупреждён значит вооружён


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



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

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

Подробнее..

История портирования Reindexerа как покорить Эльбрус за 11 дней

15.06.2021 18:13:51 | Автор: admin

Всем привет! На связи Антон Баширов, разработчик из ИТ-кластера Ростелекома. Импортозамещение набирает обороты, а российский софт всё глубже проникает в нашу повседневную ИТ-шную сущность бытия. Процессоры Эльбрус и Байкал становятся более востребованными, комьюнити расширяется, но мысли о необходимости портировать весь наш любимый технологический стек на неизведанную архитектуру E2K звучат страшнее рассказов про горящий в пламени production-кластер.

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

Итак, гость в студии база данных Reindexer, разработка нашего ИТ-кластера.

Стоит сказать, почему выбор пал именно на Reindexer, а не другую БД. Во-первых, всеми любимый и известный Postgres уже есть в составе пакетов ОС Эльбрус. Переносить его нет необходимости. Во-вторых, наш гость уже прошел испытания на ARM, следовательно, пришло время ему покорить Эльбрус.

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

День 0. Знакомство с гостем

Несколько слов про нашего гостя:

Имя:NoSQL in-memory database Reindexer с функционалом полнотекстового поиска
Возраст:на момент испытаний версия БД была 3.1.2
Происхождение:Россия
Место рождения:пытливый ум разработчика Олега Герасимова@olegator99
Место жительства:https://github.com/Restream/reindexer
Место работы:Ростелеком
Строение:основная составляющая C++, имеются примеси из языка Ассемблера и немного CMake
Особенности:- Ассемблерные вставки;
- Много С++11 и C++14;
- Используются корутины.

Деятельность:- Хранение данных в памяти и на диске;
- Выполнение SQL запросов со скоростью 500K queries/sec для PK запросов;
- Выполнение запросов типа full-text search (почти как Elastic, только не тормозит);
- Работа в режиме server и embedded;
- Общение с крутыми парнями: Go, Java, Rust, .NET, Python, C/C++ и (да простит меня Хабр) PHP.

Заскучали? На этом нудная часть закончилась. В эфире рубрика Ээээксперименты!

День 1. А ты думал, в сказку попал?

Для начала попробуем нашего друга собрать из того, что есть.Идем на тестовый сервер Эльбрус 8C с ОС Эльбрус 6.0.1, клонируем туда репозиторий и запускаем CMake.

Хорошие новости, мы нашли компилятор! Новость действительно хорошая, ведь у Эльбруса свой компилятор LCC.

К счастью, создатели Эльбрус сделали LCC полностью совместимым с GCC и наши любимые нативные программки и сборщики смогут чувствовать себя хорошо без особых манипуляций. LCC уже сделал за вас нужные линки:gcc -> /opt/mcst/bin/lcc*.

Зачем Эльбрусу свой компилятор?

Дело в том, что компилятор у Эльбруса не такой, как все. Он выполняет много работы, связанной с анализом зависимостей и оптимизацией:

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

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

Но вернемся к нашему гостю и тому, что не понравилось CMake.

В скриптах сборки, в CMakeLists.txt выполняется определение операционной системы и архитектуры процессора, на которой собирается Reindexer. Нужно это для того, чтобы подключать правильные исходники ассемблера, ведь как говорится Write once, run anywhere.

Разумеется, на момент написания скриптов никто не знал про процессоры Эльбрус, поэтому наш скрипт и упал. Исправляем.

Программисты люди ленивые, поэтому:

Попытка 2:

А что так можно было? На самом деле да для того, чтобы завелся CMake, этого было достаточно. Теперь ударим в бубен и запустимmake -j8:

Для тех, кто уже успел поработать с кроссплатформенным софтом, не новость, что С/С++ код с помощью макросов разделяют на платформозависимые секции.

Поэтому, в некоторые места кода Reindexer понадобилось добавить парочку новых условий для__E2K__и__LCC__:

После колдовства с макросами нас ждал монстр пострашнее:

Вот что бывает, когда игнорируешь messages у CMake.

Из-за того, что CMake не нашел подходящих исходников, компилятор не смог найти реализации двух функций:jump_fcontextиmake_fcontext. Про назначение этих функций я расскажу чуть позже, а пока что давайте успокоим нашего гостя и подсунем ему пару пустышек:

Этих операций оказалось достаточно, чтобы Reindexer собрался.

Поздравляю, у вас двойня!

Вот они, наши два долгожданных файла:

# file reindexer_serverreindexer_server: ELF 64-bit LSB executable, MCST Elbrus, version 1 (GNU/Linux
# file reindexer_toolreindexer_tool: ELF 64-bit LSB executable, MCST Elbrus, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux.so.2, for GNU/Linux 2.6.33, with debug_info, not stripped

Это можно считать успехом. Мы смогли собрать наш первый Эльбрус-бинарник. Даже два бинарника: reindexer_server и reindexer_tool.

Подведем итоги. Чтобы собрать Reindexer, мы:

  • Поправили CMake-скрипты;

  • Добавили несколько условий в макросы препроцессора;

  • Заглушили ASM функции.

День 3. Наш Гость, попав на Эльбрус, начал подавать признаки жизни

Первый запуск прошел успешно - сервер поднялся, и даже открылся web-ui.

Я привык не останавливаться на достигнутом и решил немного поработать над Reindexer.

Результат меня порадовал - Гость устал и прилег отдохнуть:

Такое поведение наблюдалось только на Эльбрус. На i5 все работало штатно.

Давайте разбираться. Вооружившись отладчиком (gdb кстати под E2K работает прекрасно) и CLion, мне удалось докопаться до истины.

Пару запросов к Reindexer смогли воспроизвести ошибку:

Падает в деструкторе. Интересный момент - упало на free (в данном случае free реализуется через jemalloc). Видимо, здесь идет высвобождение памяти по некорректному указателю. Ошибку эту я исправлял в два этапа:

  1. work around - дело в том, что QueryEntry лежит в объекте ExpressionTree, в самом классе примечательного я не нашел, поэтому копнул в сторону родителя. Оказалось, что до вызова деструктора был вызван вот такой копирующий конструктор, в котором есть интересный MakeDeepCopy(), реализованный с помощью библиотечкиmpark-variant.

    Подробнее про expression tree рассказываюттут.

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

    Итог - оно заработало.

  2. TODO: Исправление тоже есть, но рассказ про него уже выходит за рамки данной статьи. Небольшой спойлер (код из mpark-variant с патчем под e2k):

inline constexpr DECLTYPE_AUTO visit(Visitor &&visitor, Vs &&... vs)#ifdef E2K //Fix for Elbrus    -> decltype(detail::visitation::variant::visit_value(lib::forward<Visitor>(visitor),                                                 lib::forward<Vs>(vs)...))    {     return detail::visitation::variant::visit_value(lib::forward<Visitor>(visitor),                                                 lib::forward<Vs>(vs)...);    }#else    DECLTYPE_AUTO_RETURN(        (detail::all(             lib::array<bool, sizeof...(Vs)>{{!vs.valueless_by_exception()...}})             ? (void)0             : throw_bad_variant_access()),        detail::visitation::variant::visit_value(lib::forward<Visitor>(visitor),                                                 lib::forward<Vs>(vs)...))#endif

День 5. Он ожил! Ну почти

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

Но все это время я упорно игнорировал один компонент.

Помните, как мы убрали завязку на ASM и функцииmake_fcontext и jump_fcontext?

Так вот, ASM исходники в Reindexer нужны для реализации C++ корутин, а эти функции - ключевые для корутин из библиотеки boost/context.

Кто такая эта ваша Корутина?

Корутина это функция, которая может быть приостановлена и возобновлена в любой момент. Для реализации достаточно трех основных компонентов:
- функция
- механизм для ее паузы (suspend)
- механизм для возобновления ее с той точки, в которой была сделана пауза (resume).

Реализацию корутин можно увидеть в таких языках и библиотеках, как:

  • Libcoro(корутины на C/С++)

  • Koishi(тоже корутины, тоже на С/С++)

  • Boost(и это тоже корутины, тоже на С/С++, корутин много не бывает!)

  • Node-fibers(корутины для NodeJS на базе libcoro)

  • Tarantool(fibers на базе libcoro)

  • Kotlin(свои корутины, не на C++)

  • C++20

  • Goroutine

Для наглядности вот скрин теста корутин из Koishi:

Последовательность следующая:

  1. Создали корутину, выделили стек, задали функцию;

  2. Возобновили корутину - в этот момент вызвалась наша функция;

  3. Приостановили корутину с помощьюkoishi_yield здесь мы вернули управление в test1 сразу после строчкиkoishi_resume;

  4. Если мы еще раз сделаемkoishi_resumeна нашей корутине мы вернемся на строчку функции cofunc1 сразу после первого вызоваkoishi_yield.

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

Начнем с анализа ситуации. Ожидаемо, что ASM Эльбруса бустовские корутины не поддерживают (пока что), а значит нам нужно реализовать их самим. Подробности и реализацию бустовских корутин можете посмотретьздесь, а сейчас наступило время скрещивать ужа с ежом.

Есть несколько вариантов реализовать корутины:

  • Вариант 1:написать ASM реализацию. Самый очевидный, но при этом самый сложный вариант. У нас есть в планах попробовать именно этот вариант, так как ASM корутины - самые быстрые.

  • Вариант 2: забить. Нет, это не наш путь.

  • Вариант 3: использовать библиотеку.

По счастливой случайности Koishi оказалось той самой библиотекой, поддерживающей реализацию корутин с помощью встроенных E2K функций:makecontext_e2k()иfreecontext_e2k().

Koishi, Koishi, что это вообще такое?

Если коротко это библиотека, предоставляющая удобный API по использованию корутин на C/C++ с несколькими вариантами реализации:

  • ucontext

  • ucontext_e2k (наша прелесть)

  • fcontext

  • win32fiber

  • ucontext_sjlj

  • emscripten

Там, где стандартная медицина бессильна, в дело вступает генная инженерия.

Для начала внедрим Koishi в организм Reindexer:

Заменим больной орган на здоровый:

Стараемся не повредить то, что уже работает:

Одним из backend-ов Koishi для корутин выступает fcontext (те же самые boost исходники, что в Reindexer). Последуем древней мудрости работает не трогай! и оставим как есть в случае, если у нас не E2K архитектура. Для Эльбруса мы будем использоватьucontext_e2k.c

И вот он, наш мутант с корутинами полностью здоров и функционален (и на amd64, и на E2K):

День 11. Проводим финальные испытания

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

Всего в Reindexer около 300 функциональных тестов, и мне не терпится запустить их все.

Тесты бежали, бежали и не добежали. Один из тестов вызвал Segmentation Fault.

Всему виной оказались вот эти строчки:

struct ConnectOpts {  /* тут тоже код, но он не такой интересный */  uint16_t options = 0;  int expectedClusterID = -1;};

Обычный код, что тут такого? скажете вы. Но не все так просто. Запоминаем, что здесь два поля, которые инициализируются при объявлении.

Попробуем воспроизвести на изолированном более простом примере.

ASM, настало твое время

Внимание на скриншот:

Ошибка проявляется именно в том случае, когда отсутствуют не проинициализированные поля. Другими словами, если все ваши поля инициализируются при объявлении вы счастливчик, поймавший Segmentation Fault.

Баг заключается в том, что скомпилированное создание структуры выглядит как странныйaddd. Именно эта строчка и вызывает segfault. Кстати, если вместоbool anyField = falseнаписать простоbool anyField, то код совершенно другой и ошибки нет.

Семь бед, один ответ обновитесь!

Разгадка тайны оказалась проста. На момент работы с Reindexer последней версией компилятора LCC была v.1.25.16, однако в состав пакетов ОС Эльбрус по умолчанию входит 1.25.14, на котором я и запускал тесты. Добрые люди из сообщества подсказали нам, что уже в 15 версии данный баг был исправлен. Я решил обновиться на 16-ую и посмотреть, что будет.

Вот что нам принес новый компилятор LCC v.1.25.16:

C++ код такой же, однако ASM совсем другой, и что самое главное он работает! Последовательно (заранее прошу прощения за мой ломаный asm-русский переводчик):

  1. gestp - выделяем стек и кладем его в %dr3

  2. setwd - задаем текущее окно в регистровом файле

  3. setbn - задаем подвижную базу регистров в текущем окне регистрового файла

  4. addd - кладем "дно" стека (стек размером 0x20) в %dr2

  5. addd - выделяем на стеке нашу структуру

  6. ldb - достаем из констант наш false и кладем его в %r5

  7. stb - кладем наш false из регистра %r5 в наше поле anyField1

  8. addd - подготовили локальный указатель на структуру для вызова метода

  9. addd - передаем указатель в базовый регистр для передачи при вызове anyMethod (в anyMethod мы достаем указатель из регистра %dr0)

  10. disp - подготавливаем регистр перехода на anyMethod

  11. call - вызываем anyMethod

Дальше все просто пересобрать, перезапустить, обрадоваться.

Тесты прошли успешно, и теперь у нас есть полностью рабочий и протестированный Reindexer на Эльбрусе.

На этом все ?

На самом деле нет. Сейчас мы смогли:

  • собрать Reindexer Server

  • запустить Reindexer Server

  • собрать Reindexer Tool

  • запустить Reindexer Tool

  • переписать кусочек Reindexer Tool

  • уронить Reindexer и поднять его

  • добиться 100% проходимости Reindexer тестов

Мы научились:

  • собирать C++ софт на Эльбрус

  • переписывать C++ софт под особенности и отличия Эльбрусов

  • разбираться в ASM E2K

  • превозмогать трудности

  • разбираться в C++ корутинах даже на Эльбрусе

Что в планах:

  • корутины на ASM E2K (может быть даже сравнить fcontext ASM на i5 и ucontext/ASM на E2K)

  • оптимизировать Reindexer под архитектуру Эльбрус

  • перенести еще несколько интересных приложений на Эльбрус

  • дополнить базу библиотек и пакетов Эльбрус

  • организовать E2K инфраструктуру и песочницу

Что же можно сказать про Эльбрус со стороны простого разработчика?

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

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

Подробнее..

Разработка стековой виртуальной машины и компилятора под неё (часть III)

20.06.2021 10:05:00 | Автор: admin

По ходу разработки генератора кода для виртуальной машины понял, что виртуальная машина не готова к полноценным вызовам функций, с передачей аргументов и хранением локальных переменных функций. Поэтому её необходимо доработать. А именно, нужно определиться с Соглашением о вызовах (calling convention). Есть много разных вариантов, но конечный выбор за разработчиком. Главное - это обеспечить целостность стека, после вызова.

Соглашение о вызовах (calling convention) - это правила по которым при вызове функции передаются аргументы в вызываемую функцию (стек/регистры, порядок), кто и как очищает стек после вызова (вызывающий/вызываемый) и как возвращается результат функции в точку вызова (стек/регистр). Ко всему прочему, вызываемые функции могут создавать локальные переменные, которые будут хранится в стеке, что тоже необходимо учитывать, особенно, чтобы работала рекурсия.

На сегодняшний день, наиболее знакомые мне Соглашения о вызове (calling convention), регулирующее правила передачи аргументов функции, очистки стека после вызова, а также логика хранения локальных переменных - это C declaration (cdecl, x86/64) и pascal. Попробую применить эти знания с небольшими модификациями, а именно без прямого доступа программы к регистрам виртуальной машины (она же всё таки стековая, а не регистровая). Итак, логика будет следующая:

Поясню что происходит. Функция main() вызывает функцию sum() и передаёт ей два аргумента - значение переменной i и константное число 10. Осуществляется передача аргументов путём добавления значений аргументов в стек слева направо (как в pascal). После чего осуществляется вызов функции инструкцией виртуальной машины call, которой указывается адрес вызова и количество передаваемых через стек аргументов - 2.

Дальше команда call должна сделать следующие вещи:
1) сохранить в стек адрес возврата после вызова IP (Instruction Pointer + 1)
2) сохранить в стек значение Frame Pointer (регистр виртуальной машины, которым мы показываем до куда очищать стек после вызова).
3) сохраняем в стек значение Locals Pointer (регистр указывающий на место в стеке где начинаются локальные переменные вызывающей функции).
4) выставить значение Frame Pointer на первый аргумент в стеке, чтобы мы знали докуда очищать стек после завершения выполнения функции.
5) выставить значение Locals Pointer на адрес в стеке сразу после сохранных значение IP, FP, LP.

В свою очередь команда ret должна выполнить действия в обратном порядке:
1) Восстановить из стека предыдущие значения IP, FP, LP.
2) Взять результат выполнения функции с вершины стека.
3) Выставить SP = FP (очистить стек в состояние до вызова).
4) Положить на вершину стека результат выполнения функции.

Так как делаем стековую, а не регистровую виртуальную машину, не хочу давать прямой доступ к регистрам, поэтому доработаем инструкцию CALL / RET, а также добавим четыре дополнительные инструкции LOAD (положить в стек значение локальной переменной с указанным индексом), STORE (взять верхнее значение в стеке и сохранить в локальной переменной с указанным индексом), ARG (добавить в стек значение аргумента функции с указанным индексом), а также DROP - инструкция выбросить из стека верхнее значение. Последняя инструкция DROP нужна для функций значение которых нам не нужно, так как мы не даём прямой доступ к регистрам.

case OP_CALL:a = memory[ip++];      // get call address and increment addressb = memory[ip++];      // get arguments count (argc)b = sp + b;            // calculate new frame pointermemory[--sp] = ip;     // push return address to the stackmemory[--sp] = fp;     // push old Frame pointer to stackmemory[--sp] = lp;     // push old Local variables pointer to stackfp = b;                // set Frame pointer to arguments pointerlp = sp - 1;           // set Local variables pointer after top of a stackip = a;                // jump to call addressbreak;case OP_RET:a = memory[sp++];      // read function return value on top of a stackb = lp;                // save Local variables pointersp = fp;               // set stack pointer to Frame pointer (drop locals)lp = memory[b + 1];    // restore old Local variables pointerfp = memory[b + 2];    // restore old Frame pointerip = memory[b + 3];    // set IP to return addressmemory[--sp] = a;      // save return value on top of a stackbreak;case OP_LOAD:a = memory[ip++];         // read local variable indexb = lp - a;               // calculate local variable addressmemory[--sp] = memory[b]; // push local variable to stackbreak;case OP_STORE:a = memory[ip++];         // read local variable indexb = lp - a;               // calculate local variable addressmemory[b] = memory[sp++]; // pop top of stack to local variablebreak;case OP_ARG:a = memory[ip++];         // read parameter indexb = fp - a - 1;           // calculate parameter addressmemory[--sp] = memory[b]; // push parameter to stackbreak;case OP_DROP:                   // pop and drop value from stacksp++;break;

Скомпилируем код представленный на иллюстрации выше чтобы протестировать как работают новые инструкции CALL, RET, LOAD, STORE, ARG (примечание: syscall 0x21 - это распечатка числа с вершины стека в консоль):

Запустим исполнение этого кода с распечаткой состояния виртуальной машины после выполнения каждой инструкции:

[   0]    iconst  5     IP=2 FP=65535 LP=65534 SP=65534 STACK=[5] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[5,5] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[5,4] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[5,4,4] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[4,4] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[4,4,4] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[4,4,4,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[4,4,4,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[4,4,4,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[4,4,4,10,14,65535,65534,10,4] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[4,4,4,10,14,65535,65534,10,4,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[4,4,4,10,14,65535,65534,10,14] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[4,4,4,10,14,65535,65534,10,14,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[4,4,4,10,14,65535,65534,10,4] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[4,4,4] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[4,4] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[4,4,0] -> TOP[  18]    icmpjg  [2]   IP=2 FP=65535 LP=65534 SP=65534 STACK=[4] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[4,4] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[4,3] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[4,3,3] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[3,3] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[3,3,3] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[3,3,3,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[3,3,3,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[3,3,3,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[3,3,3,10,14,65535,65534,10,3] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[3,3,3,10,14,65535,65534,10,3,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[3,3,3,10,14,65535,65534,10,13] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[3,3,3,10,14,65535,65534,10,13,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[3,3,3,10,14,65535,65534,10,3] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[3,3,3] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[3,3] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[3,3,0] -> TOP[  18]    icmpjg  [2]   IP=2 FP=65535 LP=65534 SP=65534 STACK=[3] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[3,3] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[3,2] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[3,2,2] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[2,2] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[2,2,2] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[2,2,2,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[2,2,2,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[2,2,2,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[2,2,2,10,14,65535,65534,10,2] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[2,2,2,10,14,65535,65534,10,2,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[2,2,2,10,14,65535,65534,10,12] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[2,2,2,10,14,65535,65534,10,12,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[2,2,2,10,14,65535,65534,10,2] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[2,2,2] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[2,2] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[2,2,0] -> TOP[  18]    icmpjg  [2]   IP=2 FP=65535 LP=65534 SP=65534 STACK=[2] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[2,2] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[2,1] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[2,1,1] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[1,1] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[1,1,1] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[1,1,1,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[1,1,1,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[1,1,1,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[1,1,1,10,14,65535,65534,10,1] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[1,1,1,10,14,65535,65534,10,1,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[1,1,1,10,14,65535,65534,10,11] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[1,1,1,10,14,65535,65534,10,11,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[1,1,1,10,14,65535,65534,10,1] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[1,1,1] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[1,1] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[1,1,0] -> TOP[  18]    icmpjg  [2]   IP=2 FP=65535 LP=65534 SP=65534 STACK=[1] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[1,1] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[1,0] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[1,0,0] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[0,0] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[0,0,0] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[0,0,0,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[0,0,0,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[0,0,0,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[0,0,0,10,14,65535,65534,10,0] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[0,0,0,10,14,65535,65534,10,0,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[0,0,0,10,14,65535,65534,10,10] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[0,0,0,10,14,65535,65534,10,10,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[0,0,0,10,14,65535,65534,10,0] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[0,0,0] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[0,0] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[0,0,0] -> TOP[  18]    icmpjg  [2]   IP=20 FP=65535 LP=65534 SP=65534 STACK=[0] -> TOP[  20]    ---- halt ----IP=21 FP=65535 LP=65534 SP=65534 STACK=[0] -> TOPEXECUTION TIME: 0.620997s

В консоли данная программа выдает следующее

Эти числа в консоли говорят, что вызовы функций, передача аргументов, аллокация локальных переменных, возврат значения и восстановление стека после вызова работают нормально. Значит можно полностью переходить и фокусироваться на разработке компилятора для этой виртуальной машины (AST и генерацию кода). Теперь в виртуальной машине есть всё необходимое.

Ура! Это вдохновляет!

Подробнее..

Лаконичная реализация конечных автоматов в Matlab, Octave, C

16.06.2021 18:22:08 | Автор: admin

Актуальность


Конечные автоматы (finite state machines, fsm) штука полезная. Особенно они могут быть востребованы в средах, где в принципе нет развитой многозадачности (например, в Octave, который является в значительной степени бесплатным аналогом Matlab) или в программах для микроконтроллеров, где не используется по каким-то причинам RTOS. До недавнего времени у меня не получалось лаконично описать конечный автомат, хотя и очень хотелось это сделать. Лаконично, т.е. без воды, без создания лишних классов, структур данных, и т.д. Сейчас это, кажется, получилось и я спешу поделиться своей находкой. Возможно, я изобрёл велосипед, но возможно также, что кому-нибудь такой велосипед окажется полезен.


Начальные сведения


Конечный автомат задаётся:
  • набором состояний
  • набором событий
  • таблицей переходов (т.е. в каком состоянии по какому событию что делается и в какое новое состояние осуществляется переход)


Цель, которая стояла передо мной


Есть императивный язык, я буду рассматривать Octave, но это может быть и Matlab и C, например. Этот язык поддерживает:
  • функции
  • указатели на функции
  • то, что обычно поддерживают императивные языки (циклы, условные операторы и т.д.)


Хочется, чтоб базовые понятия языка (функции, структуры данных, массивы или что-то ещё) каким-то элегантным образом соответствовали тому, что нужно при реализации FSM. Профит в том, что
  • код будет самодокументированным
  • Doxygen или другие утилиты для анализа кода и генерации документации по коду будут давать дополнительную пользу


Описание идеи


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


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


Поэтому здесь я буду использовать switch/case инструкцию.

Единственной структурой данных будет переменная, где будет храниться состояние автомата.
Сами состояния будут идентифицироваться хэндлерами функций (function handlers), которые будут обрабатывать поведение машины в этом состоянии. Например:

function [new_state  data] = state_idle(data)    if data.block_index == 10        new_state = @state_stop;    else        % do something        data.block_index = data.block_index + 1;        printf('block_index = %d\n', data.block_index);    endendfunction [new_state data] = state_stop(data)    % set break flag    data.stop= 1;endfsm_state = @state_idle;data = struct();data.block_index = 0;data.stop = 0;while (1)    [fsm_state data] = fsm_state(data)    if data.stop        break;    endend


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

Пример из жизни :)


В качестве примера я реализовал на Octave игру Life, Джона Конвея. Если сконфигурировать её в режиме 100 х 100, то она будет симулировать работу 10 000 конечных автоматов и при этом работает она достаточно эффективно. В простейшем варианте (без событий), код для игры выглядит следующим образом:

function [new_state] = state_alive(neighbours)    alive_count = sum(sum(cellfun(@(x)x == @state_alive, neighbours)));    alive_count -= 1;    if (alive_count == 2) || (alive_count == 3)        new_state = @state_alive;    else        new_state = @state_dead;    endendfunction [new_state] = state_dead(neighbours)    alive_count = sum(sum(cellfun(@(x)x == @state_alive, neighbours)));        if (alive_count == 3)        new_state = @state_alive;    else        new_state = @state_dead;    endend% main scriptaddpath('fsm_life')debug_on_error(1)size_x = 30;size_y = 30;init_alive_percentage = 30;% initialization selection:%init = 'random';%init = 'cycle';init = 'glider';field = cell(size_y, size_x);[field{:}] = deal(@state_dead);switch (init)case 'random'    init_alive_count = round((size_x * size_y) * init_alive_percentage / 100);    for n=(1:init_alive_count)        x = floor((size_x-0.0000001)*rand())+1;        y = floor((size_y-0.0000001)*rand())+1;        field{y,x} = @state_alive;    endcase 'cycle'    field{2,1} = @state_alive;    field{2,2} = @state_alive;    field{2,3} = @state_alive;case 'glider'    field{1,3} = @state_alive;    field{2,3} = @state_alive;    field{3,3} = @state_alive;    field{3,2} = @state_alive;    field{2,1} = @state_alive;endprintf("Initial distribution:\n");cellfun(@(x)x == @state_alive, field)% simulationfor step = (1:100)    field_new = cell(size(field));    for x=(1:size_x)        for y=(1:size_y)            x_min = max(x-1, 1);            x_max = min(x+1, size_x);            y_min = max(y-1, 1);            y_max = min(y+1, size_y);            neighbours = field(y_min:y_max, x_min:x_max);            field_new{y,x} = field{y,x}(neighbours);        end    end    field = field_new;    printf('Distribution after step %d\n', step );    cellfun(@(x)x == @state_alive, field)    figure(1); imagesc(cellfun(@(x)x == @state_alive, field));    pause(0.05);end


Если хочется больше самодокументируемости и явного определения событий, тогда к двум функциям, отвечающим за состояния, добавится ещё 3 функции, отвечающие за события:
function event = event_die(neighbours)    alive_count = sum(sum(cellfun(@(x)x == @state_alive, neighbours)));    alive_count -= 1;    if (alive_count == 2) || (alive_count == 3)        event = '';    else        event = 'die';    endendfunction event = event_spawn(neighbours)    alive_count = sum(sum(cellfun(@(x)x == @state_alive, neighbours)));    if (alive_count == 3)        event = 'spawn';    else        event = '';    endendfunction event = event_survive(neighbours)    alive_count = sum(sum(cellfun(@(x)x == @state_alive, neighbours)));    alive_count -= 1;    if (alive_count == 2) || (alive_count == 3)        event = 'survive';    else        event = '';    endendfunction [new_state] = state_alive(neighbours)    event = '';    event = [event, event_die(neighbours)];    event = [event, event_survive(neighbours)];    switch event    case 'die'        new_state = @state_dead;    case 'survive'        new_state = @state_alive;    otherwise        msg = sprintf('Unknown event: %s\n', event);        error(msg);    endendfunction [new_state] = state_dead(neighbours)        event = event_spawn(neighbours);        switch event    case 'spawn'        new_state = @state_alive;    case ''        new_state = @state_dead;    otherwise        msg = sprintf('Unknown event: %s\n', event);        error(msg);    endend


Основной скрипт в этом случае останется тем же самым.
Подробнее..

Перевод 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% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Как уменьшить размер приложения на C, которое независимо от среды?

05.06.2021 00:21:43 | Автор: admin

В этой статье поделюсь опытом, как уменьшить размер приложения, написанное на C# и независящее от сборки, в 2 4 раза.

Внимание: Сжатие содержимого программы доступно только для self-contained публикаций. А также все действия происходят в Visual Studio Preview 2019.

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

В .csproject добавьте следующие строки:

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

<PublishSingleFile>true</PublishSingleFile><SelfContained>true</SelfContained><RuntimeIdentifier>win-x64</RuntimeIdentifier><PublishTrimmed>true</PublishTrimmed><TrimMode>Link</TrimMode>

Более безопасный режим: удаляет только неиспользуемые сборки.

<PublishSingleFile>true</PublishSingleFile><SelfContained>true</SelfContained><RuntimeIdentifier>win-x64</RuntimeIdentifier><PublishTrimmed>true</PublishTrimmed><TrimMode>CopyUsed</TrimMode>

Затем нажмите ПКМ по проекту Publish Folder Финиш Show All Settings. Выставите следующие настройки:

  • Deployment Mode: Self-Contained

  • Target Runtime: win-x64 или свою версию. (Должна совпадать со строчкой RuntimeIdentifier)

Разверните File publish options и поставьте галочки под: Produce single file и Trim unused asseblies.

Нажмите кнопку Publish.


Всё то же самое, только командой

Опасный режим:

dotnet publish -c Release -r win10-x64 -p:PublishTrimmed=True -p:TrimMode=Link -p:PublishSingleFile=true --self-contained true

Более безопасный режим:

dotnet publish -c Release -r win10-x64 -p:PublishTrimmed=True -p:TrimMode=CopyUsed -p:PublishSingleFile=true --self-contained true

Более подробно о том, что происходит за настройками выше

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

Команда PublishTrimmed активирует обрезку сборки.

Команда TrimMode выбирает способ обрезки сборки. Здесь и происходит вся магия по сокращению размера итогового файла.

Всего имеется 2 режима: CopyUsed (Assembly-level trimming) и Link (Member-Level Trimming).

Assembly-level trimming Просто удаляет неиспользуемые сборки. То есть алгоритм просто проходится по всем файлам программы, составляет список сборок, а затем удаляет из итоговой сборки все файлы, которые не используется. Этот метод мне помог сократить размер программы с 300 МБ до 96 МБ. При ZIP архивации этот файл стал 30МБ.

Member-Level Trimming Экспериментальный режим. Алгоритм анализирует ваш код и удаляет все ненужные классы, методы и т.д. Из-за того, что алгоритм влезает в код, существует большой риск, что приложение перестанет работать корректно, поэтому требует после публикации обширных тестов всех функций приложения. В моём случае, этот режим сократил размер программ с 300МБ до 86МБ, но при этом приложение перестало запускать и подавать какие-либо признаки жизни. Отладки тоже не поддалось, к сожалению.

Более подробно можете почитать в этой статье

Подробнее..

Как я сделал Discord бота для игровой гильдии с помощью .NET Core

05.06.2021 18:10:05 | Автор: admin
Батрак предупреждает о том что к гильдии присоединился игрокБатрак предупреждает о том что к гильдии присоединился игрок

Вступление

Всем привет! Недавно я написал Discord бота для World of Warcraft гильдии. Он регулярно забирает данные об игроках с серверов игры и пишет сообщения в Discord о том что к гильдии присоединился новый игрок или о том что гильдию покинул старый игрок. Между собой мы прозвали этого бота Батрак.

В этой статье я решил поделиться опытом и рассказать как сделать такой проект. По сути мы будем реализовывать микросервис на .NET Core: напишем логику, проведем интеграцию с api сторонних сервисов, покроем тестами, упакуем в Docker и разместим в Heroku. Кроме этого я покажу как реализовать continuous integration с помощью Github Actions.

От вас не потребуется никаких знаний об игре. Я написал материал так чтобы можно было абстрагироваться от игры и сделал заглушку для данных об игроках. Но если у вас есть учетная запись в Battle.net, то вы сможете получать реальные данные.

Для понимания материала, от вас ожидается хотя бы минимальный опыт создания веб сервисов с помощью фреймворка ASP.NET и небольшой опыт работы с Docker.

План

На каждом шаге будем постепенно наращивать функционал.

  1. Создадим новый web api проект с одним контроллером /check. При обращении к этому адресу будем отправлять строку Hello! в Discord чат.

  2. Научимся получать данные о составе гильдии с помощью готовой библиотеки или заглушки.

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

  4. Напишем Dockerfile для нашего проекта и разместим проект на хостинге Heroku.

  5. Посмотрим на несколько способов сделать периодическое выполнение кода.

  6. Реализуем автоматическую сборку, запуск тестов и публикацию проекта после каждого коммита в master

Шаг 1. Отправляем сообщение в Discord

Нам потребуется создать новый ASP.NET Core Web API проект.

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

Добавим к проекту новый контроллер

[ApiController]public class GuildController : ControllerBase{    [HttpGet("/check")]    public async Task<IActionResult> Check(CancellationToken ct)    {        return Ok();    }}

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

Получить его можно в пункте integrations в настройках любого текстового канала вашего Discord сервера.

Создание webhookСоздание webhook

Добавим webhook в appsettings.json нашего проекта. Позже мы унесем его в переменные окружения Heroku. Если вы не знакомы с тем как работать с конфигурацией в ASP Core проектах предварительно изучите эту тему.

{"DiscordWebhook":"https://discord.com/api/webhooks/****/***"}

Теперь создадим новый сервис DiscordBroker, который умеет отправлять сообщения в Discord. Создайте папку Services и поместите туда новый класс, эта папка нам еще пригодится.

По сути этот новый сервис делает post запрос по адресу из webhook и содержит сообщение в теле запроса.

public class DiscordBroker : IDiscordBroker{    private readonly string _webhook;    private readonly HttpClient _client;    public DiscordBroker(IHttpClientFactory clientFactory, IConfiguration configuration)    {        _client = clientFactory.CreateClient();        _webhook = configuration["DiscordWebhook"];    }    public async Task SendMessage(string message, CancellationToken ct)    {        var request = new HttpRequestMessage        {            Method = HttpMethod.Post,            RequestUri = new Uri(_webhook),            Content = new FormUrlEncodedContent(new[] {new KeyValuePair<string, string>("content", message)})        };        await _client.SendAsync(request, ct);    }}

Как видите, мы используем внедрение зависимостей. IConfiguration позволит нам достать webhook из конфигов, а IHttpClientFactory создать новый HttpClient.

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

Не забудьте что новый класс нужно будет зарегистрировать в Startup.

services.AddScoped<IDiscordBroker, DiscordBroker>();

А также нужно будет зарегистрировать HttpClient, для работы IHttpClientFactory.

services.AddHttpClient();

Теперь можно воспользоваться новым классом в контроллере.

private readonly IDiscordBroker _discordBroker;public GuildController(IDiscordBroker discordBroker){  _discordBroker = discordBroker;}[HttpGet("/check")]public async Task<IActionResult> Check(CancellationToken ct){  await _discordBroker.SendMessage("Hello", ct);  return Ok();}

Запустите проект, зайдите по адресу /check в браузере и убедитесь что в Discord пришло новое сообщение.

Шаг 2. Получаем данные из Battle.net

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

Получаем реальные данные

Вам понадобится зайти на https://develop.battle.net/ и получить там две персональных строки BattleNetId и BattleNetSecret. Они будут нужны нам чтобы авторизоваться в api перед отправкой запросов. Поместите их в appsettings.

Подключим к проекту библиотеку ArgentPonyWarcraftClient.

Создадим новый класс BattleNetApiClient в папке Services.

public class BattleNetApiClient{   private readonly string _guildName;   private readonly string _realmName;   private readonly IWarcraftClient _warcraftClient;   public BattleNetApiClient(IHttpClientFactory clientFactory, IConfiguration configuration)   {       _warcraftClient = new WarcraftClient(           configuration["BattleNetId"],           configuration["BattleNetSecret"],           Region.Europe,           Locale.ru_RU,           clientFactory.CreateClient()       );       _realmName = configuration["RealmName"];       _guildName = configuration["GuildName"];   }}

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

Кроме этого, нужно создать в appsettings проекта две новых записи RealmName и GuildName. RealmName это название игрового мира, а GuildName это название гильдии. Их будем использовать как параметры при запросе.

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

public async Task<WowCharacterToken[]> GetGuildMembers(){   var roster = await _warcraftClient.GetGuildRosterAsync(_realmName, _guildName, "profile-eu");   if (!roster.Success) throw new ApplicationException("get roster failed");   return roster.Value.Members.Select(x => new WowCharacterToken   {       WowId = x.Character.Id,       Name = x.Character.Name   }).ToArray();}
public class WowCharacterToken{  public int WowId { get; set; }  public string Name { get; set; }}

Класс WowCharacterToken следует поместить в папку Models.

Не забудьте подключить BattleNetApiClient в Startup.

services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();

Берем данные из заглушки

Для начала создадим модель WowCharacterToken и поместим ее в папку Models. Она представляет собой информацию об игроке.

public class WowCharacterToken{  public int WowId { get; set; }  public string Name { get; set; }}

Дальше сделаем вот такой класс

public class BattleNetApiClient{    private bool _firstTime = true;    public Task<WowCharacterToken[]> GetGuildMembers()    {        if (_firstTime)        {            _firstTime = false;            return Task.FromResult(new[]            {                new WowCharacterToken                {                    WowId = 1,                    Name = "Артас"                },                new WowCharacterToken                {                    WowId = 2,                    Name = "Сильвана"                }            });        }        return Task.FromResult(new[]        {            new WowCharacterToken            {                WowId = 1,                Name = "Артас"            },            new WowCharacterToken            {                WowId = 3,                Name = "Непобедимый"            }        });    }}

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

Сделайте интерфейс и подключите все что мы создали в Startup.

services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();

Выведем результаты в Discord

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

[ApiController]public class GuildController : ControllerBase{  private readonly IDiscordBroker _discordBroker;  private readonly IBattleNetApiClient _battleNetApiClient;  public GuildController(IDiscordBroker discordBroker, IBattleNetApiClient battleNetApiClient)  {     _discordBroker = discordBroker;     _battleNetApiClient = battleNetApiClient;  }  [HttpGet("/check")]  public async Task<IActionResult> Check(CancellationToken ct)  {     var members = await _battleNetApiClient.GetGuildMembers();     await _discordBroker.SendMessage($"Members count: {members.Length}", ct);     return Ok();  }}

Шаг 3. Находим новых и ушедших игроков

Нужно научиться определять какие игроки появились или пропали из списка при последующих запросах к api. Для этого мы можем закэшировать список в InMemory кэше (в оперативной памяти) или во внешнем хранилище.

Если закэшировать список в InMemory кэше, то мы потеряем его при перезапуске приложения. Поэтому позже мы подключим базу данных Redis как аддон в Heroku и будем кешировать туда.

А пока что подключим InMemory кэш в Startup.

services.AddMemoryCache(); 

Теперь в нашем распоряжении есть IDistributedCache, который можно подключить через конструктор. Я предпочел не использовать его напрямую , а написать для него обертку. Создайте класс GuildRepository и поместите его в новую папку Repositories.

public class GuildRepository : IGuildRepository{    private readonly IDistributedCache _cache;    private const string Key = "wowcharacters";    public GuildRepository(IDistributedCache cache)    {        _cache = cache;    }    public async Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)    {        var value = await _cache.GetAsync(Key, ct);        if (value == null) return Array.Empty<WowCharacterToken>();        return await Deserialize(value);    }    public async Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)    {        var value = await Serialize(characters);        await _cache.SetAsync(Key, value, ct);    }        private static async Task<byte[]> Serialize(WowCharacterToken[] tokens)    {        var binaryFormatter = new BinaryFormatter();        await using var memoryStream = new MemoryStream();        binaryFormatter.Serialize(memoryStream, tokens);        return memoryStream.ToArray();    }    private static async Task<WowCharacterToken[]> Deserialize(byte[] bytes)    {        await using var memoryStream = new MemoryStream();        var binaryFormatter = new BinaryFormatter();        memoryStream.Write(bytes, 0, bytes.Length);        memoryStream.Seek(0, SeekOrigin.Begin);        return (WowCharacterToken[]) binaryFormatter.Deserialize(memoryStream);    }}

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

public class GuildService{    private readonly IBattleNetApiClient _battleNetApiClient;    private readonly IGuildRepository _repository;    public GuildService(IBattleNetApiClient battleNetApiClient, IGuildRepository repository)    {        _battleNetApiClient = battleNetApiClient;        _repository = repository;    }    public async Task<Report> Check(CancellationToken ct)    {        var newCharacters = await _battleNetApiClient.GetGuildMembers();        var savedCharacters = await _repository.GetCharacters(ct);        await _repository.SaveCharacters(newCharacters, ct);        if (!savedCharacters.Any())            return new Report            {                JoinedMembers = Array.Empty<WowCharacterToken>(),                DepartedMembers = Array.Empty<WowCharacterToken>(),                TotalCount = newCharacters.Length            };        var joined = newCharacters.Where(x => savedCharacters.All(y => y.WowId != x.WowId)).ToArray();        var departed = savedCharacters.Where(x => newCharacters.All(y => y.Name != x.Name)).ToArray();        return new Report        {            JoinedMembers = joined,            DepartedMembers = departed,            TotalCount = newCharacters.Length        };    }}

В качестве возвращаемого результата используется модель Report. Ее нужно создать и поместить в папку Models.

public class Report{   public WowCharacterToken[] JoinedMembers { get; set; }   public WowCharacterToken[] DepartedMembers { get; set; }   public int TotalCount { get; set; }}

Применим GuildService в контроллере.

[HttpGet("/check")]public async Task<IActionResult> Check(CancellationToken ct){   var report = await _guildService.Check(ct);   return new JsonResult(report, new JsonSerializerOptions   {      Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.Cyrillic)   });}

Теперь отправим в Discord какие игроки присоединились или покинули гильдию.

if (joined.Any() || departed.Any()){   foreach (var c in joined)      await _discordBroker.SendMessage(         $":smile: **{c.Name}** присоединился к гильдии",         ct);   foreach (var c in departed)      await _discordBroker.SendMessage(         $":smile: **{c.Name}** покинул гильдию",         ct);}

Эту логику я добавил в GuildService в конец метода Check. Писать бизнес логику в контроллере не стоит, у него другое назначение. В самом начале мы делали там отправку сообщения в Discord потому что еще не существовало GuildService.

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

await _warcraftClient.GetCharacterProfileSummaryAsync(_realmName, name.ToLower(), Namespace);

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

Unit тесты

У нас появился класс GuildService с нетривиальной логикой, который будет изменяться и расширяться в будущем. Стоит написать на него тесты. Для этого нужно будет сделать заглушки для BattleNetApiClient, GuildRepository и DiscordBroker. Я специально просил создавать интерфейсы для этих классов чтобы можно было сделать их фейки.

Создайте новый проект для Unit тестов. Заведите в нем папку Fakes и сделайте три фейка.

public class DiscordBrokerFake : IDiscordBroker{   public List<string> SentMessages { get; } = new();   public Task SendMessage(string message, CancellationToken ct)   {      SentMessages.Add(message);      return Task.CompletedTask;   }}
public class GuildRepositoryFake : IGuildRepository{    public List<WowCharacterToken> Characters { get; } = new();    public Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)    {        return Task.FromResult(Characters.ToArray());    }    public Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)    {        Characters.Clear();        Characters.AddRange(characters);        return Task.CompletedTask;    }}
public class BattleNetApiClientFake : IBattleNetApiClient{   public List<WowCharacterToken> GuildMembers { get; } = new();   public List<WowCharacter> Characters { get; } = new();   public Task<WowCharacterToken[]> GetGuildMembers()   {      return Task.FromResult(GuildMembers.ToArray());   }}

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

Первый тест на GuildService будет выглядеть так:

[Test]public async Task SaveNewMembers_WhenCacheIsEmpty(){   var wowCharacterToken = new WowCharacterToken   {      WowId = 100,      Name = "Sam"   };      var battleNetApiClient = new BattleNetApiApiClientFake();   battleNetApiClient.GuildMembers.Add(wowCharacterToken);   var guildRepositoryFake = new GuildRepositoryFake();   var guildService = new GuildService(battleNetApiClient, null, guildRepositoryFake);   var changes = await guildService.Check(CancellationToken.None);   changes.JoinedMembers.Length.Should().Be(0);   changes.DepartedMembers.Length.Should().Be(0);   changes.TotalCount.Should().Be(1);   guildRepositoryFake.Characters.Should().BeEquivalentTo(wowCharacterToken);}

Как видно из названия, тест позволяет проверить что мы сохраним список игроков, если кэш пуст. Заметьте, в конце теста используется специальный набор методов Should, Be... Это методы из библиотеки FluentAssertions, которые помогают нам сделать Assertion более читабельным.

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

Главный функционал проекта готов. Теперь можно подумать о его публикации.

Шаг 4. Привет Docker и Heroku!

Мы будем размещать проект на платформе Heroku. Heroku не позволяет запускать .NET проекты из коробки, но она позволяет запускать Docker образы.

Чтобы упаковать проект в Docker нам понадобится создать в корне репозитория Dockerfile со следующим содержимым

FROM mcr.microsoft.com/dotnet/sdk:5.0 AS builderWORKDIR /sourcesCOPY *.sln .COPY ./src/peon.csproj ./src/COPY ./tests/tests.csproj ./tests/RUN dotnet restoreCOPY . .RUN dotnet publish --output /app/ --configuration ReleaseFROM mcr.microsoft.com/dotnet/core/aspnet:3.1WORKDIR /appCOPY --from=builder /app .CMD ["dotnet", "peon.dll"]

peon.dll это название моего Solution. Peon переводится как батрак.

О том как работать с Docker и Heroku можно прочитать здесь. Но я все же опишу последовательность действий.

Вам понадобится создать аккаунт в Heroku, установить Heroku CLI.

Создайте новый проект в heroku и свяжите его с вашим репозиторием.

heroku git:remote -a project_name

Теперь нам необходимо создать файл heroku.yml в папке с проектом. У него будет такое содержимое:

build:  docker:    web: Dockerfile

Дальше выполним небольшую череду команд:

# Залогинимся в heroku registryheroku container:login# Соберем и запушим образ в registryheroku container:push web# Зарелизим приложение из образаheroku container:release web

Можете открыть приложение в браузере с помощью команды:

heroku open

После того как мы разместили приложение в Heroku, нужно подключить базу данных Redis для кэша. Как вы помните InMemory кэш будет исчезать после перезапуска приложения.

Установите для нашего Heroku приложения бесплатный аддон RedisCloud.

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

Нам нужно получить эту переменную в коде приложения.

Установите библиотеку Microsoft.Extensions.Caching.StackExchangeRedis.

С помощью нее можно зарегистрировать Redis реализацию для IDistributedCache в Startup.

services.AddStackExchangeRedisCache(o =>{   o.InstanceName = "PeonCache";   var redisCloudUrl = Environment.GetEnvironmentVariable("REDISCLOUD_URL");   if (string.IsNullOrEmpty(redisCloudUrl))   {      throw new ApplicationException("redis connection string was not found");   }   var (endpoint, password) = RedisUtils.ParseConnectionString(redisCloudUrl);   o.ConfigurationOptions = new ConfigurationOptions   {      EndPoints = {endpoint},      Password = password   };});

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

public static class RedisUtils{   public static (string endpoint, string password) ParseConnectionString(string connectionString)   {      var bodyPart = connectionString.Split("://")[1];      var authPart = bodyPart.Split("@")[0];      var password = authPart.Split(":")[1];      var endpoint = bodyPart.Split("@")[1];      return (endpoint, password);   }}

На этот класс можно сделать простой Unit тест.

[Test]public void ParseConnectionString(){   const string example = "redis://user:password@url:port";   var (endpoint, password) = RedisUtils.ParseConnectionString(example);   endpoint.Should().Be("url:port");   password.Should().Be("password");}

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

Опубликуйте новую версию приложения.

Шаг 5. Реализуем циклическое выполнение

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

Есть несколько способов это реализовать:

Самый простой способ - это сделать задание на сайте https://cron-job.org. Этот сервис будет слать get запрос на /check вашего приложения каждые N минут.

Второй способ - это использовать Hosted Services. В этой статье подробно описано как создать повторяющееся задание в ASP.NET Core проекте. Учтите, бесплатный тариф в Heroku подразумевает что ваше приложение будет засыпать после того как к нему некоторое время не делали запросов. Hosted Service перестанет работать после того как приложение заснет. В этом варианте вам следует перейти на платный тариф. Кстати, так сейчас работает мой бот.

Третий способ - это подключить к проекту специальные Cron аддоны. Например Heroku Scheduler. Можете пойти этим путем и разобраться как создать cron job в Heroku.

Шаг 6. Автоматическая сборка, прогон тестов и публикация

Во-первых, зайдите в настройки приложения в Heroku.

Там есть пункт Deploy. Подключите там свой Github аккаунт и включите Automatic deploys после каждого коммита в master.

Поставьте галочку у пункта Wait for CI to pass before deploy. Нам нужно чтобы Heroku дожидался сборки и прогонки тестов. Если тесты покраснеют, то публикация не случится.

Сделаем сборку и прогонку тестов в Github Actions.

Зайдите в репозиторий и перейдите в пункт Actions. Теперь создайте новый workflow на основе шаблона .NET

В репозитории появится новый файл dotnet.yml. Он описывает процесс сборки.

Как видите по его содержимому, задание build будет запускаться после пуша в ветку master.

on:  push:    branches: [ master ]  pull_request:    branches: [ master ]

Содержимое самого задания нас полностью устраивает. Если вы вчитаетесь в то что там происходит, то увидите что там происходит запуск команд dotnet build и dotnet test.

    steps:    - uses: actions/checkout@v2    - name: Setup .NET      uses: actions/setup-dotnet@v1      with:        dotnet-version: 5.0.x    - name: Restore dependencies      run: dotnet restore    - name: Build      run: dotnet build --no-restore    - name: Test      run: dotnet test --no-build --verbosity normal

Менять в этом файле ничего не нужно, все уже будет работать из коробки.

Запуште что-нибудь в master и посмотрите что задание запускается. Кстати, оно уже должно было запуститься после создания нового workflow.

Отлично! Вот мы и сделали микросервис на .NET Core который собирается и публикуется в Heroku. У проекта есть множество точек для развития: можно было бы добавить логирование, прокачать тесты, повесить метрики и. т. д.

Надеюсь данная статья подкинула вам пару новых идей и тем для изучения. Спасибо за внимание. Удачи вам в ваших проектах!

Подробнее..
Категории: C , Net , Api , Docker , Dotnet , Discord , Bot , Бот , Heroku , Микросервис , Wow

Вызов кода Go из Dart с использованием cgo и Dart FFI на простом примере

09.06.2021 16:12:52 | Автор: admin

Ключевой мотивацией для написания данной статьи является факт сильного недостатка информации (особенно в русскоязычном сообществе) по использованию cgo и Dart FFI для вызова Go кода из языка Dart.

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

В случае если можно можно избежать экспорта go кода в Dart (например экспортировать готовую c библиотеку), то лучше воспользоваться такой возможностью и не использовать cgo. Однако, могут возникать случаи, когда перегонка go в dart кода является оптимальным решением (например вы уже знакомы с Go и Dart, и не хотите писать код на C, в таком случае есть смысл задуматься об использованием cgo и Dart FFI).

В данной статье на простом примере будет показано как можно вызвать код Go из языка Dart (например в приложениях на Flutter).

Что должно быть установлено:

  • Go

  • Dart

  • Текстовый редактор/IDE (я буду использовать VSCode, так как это самая популярная среда среди Dart и Go сообщества, так же будут установлены специальные плагины для поддержки языков Go и Flutter)

Шаг 1 - Создаем пустое консольное приложение на Dart

Вызываем Command Palette клавишей F1 и создаем новый проект на Dart, выбираем опцию Console Application (данный формат использован для примера, далее код на cgo можно будет использовать в том числе из Flutter проектов или других форматов приложений на Dart).

Назвать приложение можно в целом как угодно, я выбрал название cgo_dartffi_helloworld, исключительно для тестового примера. (Нам потребуется именно директория с проектом на Dart, так как мы будем добавлять ffi в pubspec.yaml файл).

Прожигаем кнопку создать и переходим в директорию со новоиспеченным проектом.

Шаг 2 - Добавляем ffi в yaml файл

Далее нам необходимо добавить ffi в yaml файл для возможности использования go кода из dart.

name: cgo_dartffi_helloworlddescription: A sample command-line application.version: 1.0.0environment:  sdk: '>=2.12.0 <3.0.0'dependencies:  path: ^1.8.0  ffi: ^0.1.3dev_dependencies:  pedantic: ^1.10.0  test: ^1.16.0

Шаг 3 - Создаем .go файл содержащий экспортируемую функцию

Далее необходимо создать файл на go, (в например в руте директории с проектом, например lib.go) который будет содержать функцию для экспорта в Dart. В данном примере эта функция - HelloFromGo().

// filename: lib.gopackage mainimport "C"//export HelloFromGofunc HelloFromGo() *C.char {message := "Hello to dart lang from go"return C.CString(message)}func main() {}

Стоит быть крайне аккуратными при написании кода cgo так как большая часть инструментов, включая сборщик мусора перестают работать. В cgo комментарии имеют значение (да, это странно), именно с помощью комментариев можно обозначить функцию которую необходимо экспортировать (используя слово export). Более подробно данные нюансы описаны на официальной странице cgo https://golang.org/cmd/cgo/, ну а мы вернемся к практической стороне вопроса.

Шаг 4 - Собираем динамическую библиотеку из go файла

Далее необходимо открыть терминал и запустить там следующую команду:

go build -buildmode=c-shared -o lib.a lib.go

Данная команда создаст файл lib.a (который и представляет из себя динамическую c библиотеку). Даже для такого небольшого файлика время сборки заставляет ужаснуться (аж целых несколько секунд, в отличии от моментальных сборок на go, еще один из плюсов go, который теряется при использовании cgo).

Шаг 5 - Проверяем наличие необходимых файлов

На данном этапе наша директория должна выглядеть примерно следующим образом:

Она должна содержать следующие файлы:

  • Измененный pubspec.yaml файл

  • lib.h, lib.a файлы созданные из файла lib.go

  • директорию bin с дефолтным файлом библиотеки dart (туда мы сейчас и отправимся)

Шаг 6 - Прописываем биндинги на cgo функцию в Dart коде

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

  • 6.1 - удаляем всё содержимое файла bin/cgo_dartffi_helloworld.dart и начинаем там писать с чистого листа

  • 6.2 - импортируем необходимые библиотеки (для нас это ffi и utf8 для передачи текста)

import 'dart:ffi' as ffi;import 'package:ffi/src/utf8.dart';
  • 6.3 - открываем динамическую библиотеку

final dylib = ffi.DynamicLibrary.open('lib.a');
  • 6.4 - привязываем нашу функции к функции в dart

typedef HelloFromGo = ffi.Pointer<Utf8> Function();typedef HelloFromGoFunc = ffi.Pointer<Utf8> Function();final HelloFromGo _finalFunction = dylib    .lookup<ffi.NativeFunction<HelloFromGoFunc>>('HelloFromGo')    .asFunction();
  • 6.5 - создаем метод который проверит вызов нашей функции (обратите внимание, метод .toDartString переводит стринг из формата C в формат Dart):

void main() {  print(_finalFunction().toDartString());}

Таким образом мы создали функцию на go, которая передает string в язык Dart.

Далее при написании своих функций следует учитывать, что форматы данных в языках Go, C и Dart могут отличаться (и зачастую так происходит), что приводит к необходимости использовать различные конвертации на стороне go/dart кода, более подробно можно ознакомиться по следующим ссылкам:

Полный код на Dart:

import 'dart:ffi' as ffi;import 'package:ffi/src/utf8.dart';final dylib = ffi.DynamicLibrary.open('lib.a');typedef HelloFromGo = ffi.Pointer<Utf8> Function();typedef HelloFromGoFunc = ffi.Pointer<Utf8> Function();final HelloFromGo _finalFunction = dylib    .lookup<ffi.NativeFunction<HelloFromGoFunc>>('HelloFromGo')    .asFunction();void main() {  print(_finalFunction().toDartString());}

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

typedef GetHash = Pointer<Utf8> Function(Pointer<Utf8> str);typedef GetHashFunc = Pointer<Utf8> Function(Pointer<Utf8> str);final GetHash _getHashGoFunction =    _lib.lookup<NativeFunction<GetHashFunc>>('GetHash').asFunction();

Главное помнить, что необходимо проверять форматы передаваемых данных.

Подробнее..
Категории: C , Go , Dart , Flutter , Golang , Cgo , Ffi

Linked Server MSSQL. Оптимизация производительности в 30 раз

13.06.2021 20:13:49 | Автор: admin

Исходные данные:

  1. Два SQL Server'а, которые находятся в прямой доступности между собой, на одном из которых настроен Linked Server.

  2. SQL запрос вида:

insert into LocalDatabaseName.dbo.TableName (column1, column2, ..., columnN)select column1, column2, ..., columnNfrom LinkedServerName.RemoteDatabaseName.dbo.TableName

Задача: максимально быстро скопировать записи с одного сервера на другой

Столкнулся с тем, что подобный запрос выполняется на 40k (40000) записей больше минуты. С ростом количества подобных запросов или количества записей, производительность сильно падает и оптимизировать запрос средствами SQL никак нельзя. С использованием приложения ImportExportDataSql мне удалось ускорить этот запрос до 2 секунд, не используя Linked Server.

Приложение ImportExportDataSql создавал для себя и постоянно его дорабатывал на протяжении нескольких лет. Основные требования при создании приложения - портативность, работа под всеми версиями Windows без установки сторонних библиотек (кроме NET Framework 3.5), простой интерфейс и высокая производительность.

ImportExportDataSql - универсальный конвертер данных, как альтернатива "bcp"

Главная форма ImportExportDataSqlГлавная форма ImportExportDataSql

При работе с данными очень часто требуется загружать файлы из разных файлов в БД (чаще всего CSV и Excel) и обратно (из БД в CSV). До этого пользовался утилитой bcp, но всегда не хватало графического интерфейса. Кроме этого у "bcp", есть недостатки, описанные в моей предыдущей статье.

В ImportExportDataSql кроме графического интерфейса, реализована возможность работы через командную строку. Пример командной строки:

Пример работы ImportExportDataSql из командной строки:
ImportExportDataSql.exe -ConnectionName="Имя соединения с БД" -TaskName="Имя Задачи 1" -TaskName="Имя задачи 2" [-Log="C:\FolderName\LogFileName.log"]

Параметры командной строки:

-ConnectionName - Имя соединения с БД, которое должно быть сохранено на форме "Соединение с БД" по кнопке "Сохранить настройку соединения с БД"

Сохранить настройку соединения с БДСохранить настройку соединения с БД

-TaskName - Имя задачи из пользовательского списка задач

-Log - имя лог файла. Необязательный параметр. По-умолчанию, используется лог файл в папке Logs\UserName\ImportExportDataSql.log

Список решаемых задач в ImportExportDataSql

  1. Сохранить из БД в файл - если файлы хранятся в БД и их нужно сохранить на диск

  2. Сохранить из БД в файл (утилитой bcp) - если файлы хранятся в БД и их нужно сохранить на диск с помощью утилиты bcp (создается bat файл)

  3. Сохранить из файла в БД - если нужно загрузить файлы с диска в таблицу БД с полем типа varbinary

  4. Сохранить из БД в скрипт SQL - сохраняет результат SELECT запроса в SQL файл

  5. Из БД в скрипт SQL (только INSERT)

  6. Из БД в скрипт SQL (только UPDATE)

  7. Статический скрипт SQL

  8. Сохранить из Excel в скрипт SQL

  9. Сохранить из БД в CSV

  10. Сохранить из CSV в SQL

  11. Сохранить из CSV в БД

  12. Сохранить конфигурацию БД в SQL - выгружает структуру БД в SQL файл

  13. Сохранить из БД в БД - сохраняет результат SELECT запроса на другой или текущий сервер

Все типы обработки, которые заканчиваются словом SQL могут объединяться в один файл, если имя файла одинаковое в нескольких задачах. Это очень удобно для копирования данных из одной БД в другую (например, при переносе данных с прода на тест или наоборот).

Сохранить из БД в БД

Сохранить из БД в БДСохранить из БД в БД

Использование данного способа позволило оптимизировать запрос (приведенный в начале статьи) копирования данных через Linked Server, сократив время выполнения с 1 минуты до 2 секунд. Алгоритм копирования данных из одной БД в другую выполнен стандартными классами языка C# из пространства имен System.Data.SqlClient: SqlConnection, SqlDataReader, SqlCommand и SqlBulkCopy.

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

  1. SQL запрос - выполняется на БД источнике, с которой нужно копировать информацию

  2. Настройки выгрузки в БД назначения:

    Имя соединения - выбирается из списка соединений, которые пользователь сохраняет на форме "Соединение с БД", отображаемая при запуске приложения. Точка (.) в параметре "Имя соединения" означает, что используется текущее соединение с БД.

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

    Номер последней обрабатываемой строки - служит для ограничения количества копируемых строк, и применяется для отладки. Например, если запрос возвращает 100 записей, а "Номер последней обрабатываемой строки" = 10, то будет скопировано только 10 первых строк из результата запроса.

    Количество строк в блоке - количество строк сохраняемых одной транзакцией

Способом "Сохранить из БД в БД" я также пользуюсь, когда необходимо скопировать результат запроса с большим количеством записей. Ограничение количества записей, в этом случае, дает преимущество, перед обычным запросом копирования записей (insert into ... select ...), так как снижается нагрузка на диск, не сильно растет журнал транзакций и не используется база tempdb (если "Количество строк в блоке" оптимальное).

Преимущества и применение ImportExportDataSql

Приложение ImportExportDataSql постоянно помогает мне в работе. С помощью него удобно переносить данные из одной БД в другую.

В коде встроено множество проверок, чтобы достаточно быстро можно было понимать на какой строке возникла ошибка при импорте CSV файла или Excel.

Можно загружать большие CSV файлы (больше 1Гб) и добавлять свои поля, которых нет в CSV. Отсекать ненужные поля из CSV, не загружая их в БД.

Скрипты при выгрузке в SQL формат дополнены различными проверками, чтобы при выполнении скрипта на другой базе все ошибки отображались в одной таблице, а не списком ошибок на панеле "Messages" в SQL Server Management Studio.

С помощью типа обработки "Сохранить конфигурацию БД в SQL" и командной строки я автоматизировал создание резервных копий джобов (jobs), репликаций и других объектов БД, чего нельзя сделать стандартными способами.

Заключение

Используя язык C# и класс SqlBulkCopy можно существенно сократить время выполнения запроса, в котором используется Linked Server.

Ссылки

Скачать ImportExportDataSql

Статья с подробным описанием ImportExportDataSql

Статья "Быстрое чтение CSV в C#", в которой рассказывается о недостатках "bcp"

Сообщество VK, для желающих пообщаться с автором

Подробнее..

Оптимизация .NET приложения как простые правки позволили ускорить PVS-Studio и уменьшить потребление памяти на 70

15.06.2021 18:13:51 | Автор: admin

Проблемы с производительностью, такие как аномально низкая скорость работы и высокое потребление памяти, могут быть обнаружены самыми разными способами. Такие недостатки приложения выявляются тестами, самими разработчиками или тестировщиками, а при менее удачном раскладе пользователями. Увы, но обнаружение аномалий лишь первый шаг. Далее проблему необходимо локализовать, ведь в противном случае решить её не получится. Тут возникает вопрос как найти в большом проекте причины, приводящие к излишнему потреблению памяти и замедлению работы? Есть ли они вообще? Быть может, дело и не в приложении вовсе? Эта статья посвящена истории о том, как разработчики C#-анализатора PVS-Studio столкнулись с подобной проблемой и смогли решить её.

Бесконечный анализ

Анализ крупных C#-проектов всегда занимает некоторое время. Это ожидаемо PVS-Studio погружается в исследование исходников достаточно глубоко и использует при этом различные технологии, такие как межпроцедурный анализ, анализ потока данных и т.д. Тем не менее анализ многих крупных проектов, найденных нами на github, производится не дольше нескольких часов.

Возьмём, к примеру, Roslyn. Его solution включает более 200 проектных файлов, и почти все из них проекты на C#. Нетрудно догадаться, что в каждом из проектов далеко не по одному файлу, а сами файлы состоят далеко не из пары строчек кода. PVS-Studio проводит полный анализ Roslyn примерно за 1,5-2 часа. Конечно, некоторые проекты наших пользователей требуют гораздо больше времени на анализ, но ситуации, когда анализ не проходит даже за сутки, исключительны.

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

Стоп, а как же тестирование?!

Наверняка у читателя возникает логичный вопрос почему же проблема не была выявлена на этапе тестирования? Как же так вышло, что она была обнаружена именно клиентом? Неужели C#-анализатор PVS-Studio не тестируется?

Тестируется и тщательно! Для нас тестирование является неотъемлемой частью процесса разработки. Корректность работы анализатора постоянно проверяется, ровно как проверяется и корректность работы отдельных его частей. Без преувеличения можно сказать, что unit-тесты диагностических правил и внутренних механизмов составляют примерно половину от общего объёма исходного кода проекта C#-анализатора. Кроме того, каждую ночь на сервере производится анализ большого набора проектов и проверка корректности формируемых анализатором отчётов. При этом автоматически определяется как скорость работы анализатора, так и объём потребляемой памяти. Более-менее существенные отклонения от нормы мгновенно обнаруживаются и исследуются.

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

Поиск причин

Дамп

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

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

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

От файла с дампом мало толку, если нет возможности его открыть. К счастью, пользователю этим заниматься уже не нужно :). Ну а мы решили изучить данные дампа при помощи Visual Studio. Делается это достаточно просто:

  1. Открываем проект с исходниками приложения в Visual Studio.

  2. В верхнем меню нажимаем File->Open->File (или Ctrl+O).

  3. Находим файл с дампом и открываем.

В результате появится окошко с кучей различной информации о процессе:

Нас в первую очередь интересовала возможность перехода в своеобразный режим отладки дампа. Для этого нужно нажать кнопку Debug With Managed Only.

Примечание. Если вас интересует более подробная информация по теме открытия дампов через Visual Studio для отладки, то отличным источником информации будет официальная документация.

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

  • отсутствует какая-либо возможность возобновления работы процесса, пошагового выполнения кода и т.п.;

  • в окне Quick Watch и Immediate Window невозможно использовать некоторые функции. К примеру, попытка вызова метода File.WriteAllText приводила к возникновению ошибки "Caracteres no vlidos en la ruta de acceso!". Дело в том, что дамп связан с окружением, на котором он был снят.

Отладка дампа позволила нам получить достаточно большое количество различных данных. Ниже представлена небольшая часть информации о состоянии процесса анализа в момент снятия дампа:

  • вычисленное количество файлов в проекте: 1 500;

  • приблизительное время анализа: 24 часа;

  • количество одновременно анализируемых в текущий момент файлов: 12;

  • количество уже проверенных файлов: 1060.

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

Тем не менее, понять причины замедления нам не удалось. Мы не обнаружили каких-либо аномалий или ошибок, а количество файлов в проекте не казалось чем-то из ряда вон выходящим. Анализ проекта похожего объёма обычно занимает у нас около 2 часов.

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

Наконец-то, воспроизведение проблемы

Используя данные из дампа, мы поняли, что анализ "завис" на конкретных файлах со сложной структурой кода. Мы попросили их у клиента, надеясь воспроизвести возникшую проблему. Однако при анализе отдельных файлов проблема не воспроизвелась.

Мы решили пойти дальше и создать собственный тестовый проект с большим количеством сложных конструкций. Было крайне важно воспроизвести проблему локально это позволило бы сильно упростить дальнейший поиск её решения.

Мы создали свой тестовый проект, стараясь повторить следующие характеристики проекта пользователя:

  • количество файлов;

  • средний размер файлов;

  • максимальный уровень вложенности и сложность используемых конструкций.

Скрестив пальцы, мы запустили его анализ и...

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

Казалось, что мы уже всё перепробовали. Казалось, что докопаться до правды не выйдет. А ведь мы бы и рады были заняться исследованием проблемы с замедлением! Мы бы и рады были одолеть её, наконец, порадовать клиента, порадоваться самим. Как ни крути, анализ проекта нашего пользователя не должен был зависать!

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

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

А отличие было в железе. Точнее говоря, в ОЗУ.

Казалось бы, при чём тут ОЗУ?

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

Возникает логичный вопрос к чему это всё? Мы же решали проблему замедления работы, а не высокого потребления памяти!

Дело в том, что высокое потребление памяти действительно может приводить к замедлению работы приложения. Это происходит в тех случаях, когда процессу не хватает памяти, установленной на устройстве. В таких случаях активируется особый механизм memory paging (другое название "swapping"). При его работе часть данных из оперативной памяти переносится во вторичное хранилище (диск). При необходимости система загружает данные с диска. Благодаря данному механизму приложения могут использовать оперативную память в большем объёме, чем доступно в системе. Увы, но у этого чуда есть своя цена.

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

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

Решаем проблему

dotMemory и диаграмма доминаторов

Мы использовали приложение dotMemory, разработанное компанией JetBrains. Это профилировщик памяти для .NET, который можно запускать как прямо из Visual Studio, так и в качестве отдельного инструмента. Среди всех возможностей dotMemory более всего нас интересовало профилирование процесса анализа.

Ниже представлено окно присоединения к процессу:

Сначала нужно запустить соответствующий процесс, затем выбрать его и начать профилирование с помощью кнопки "Run". Откроется новое окно:

В любой момент времени можно получить снимок состояния памяти. За время работы процесса можно сделать несколько таких снимков все они появятся на панели "Memory Snapshots":

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

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

Все указанные действия мы проделали с процессом анализа специально созданного тестового проекта. Диаграмма доминаторов для него выглядела следующим образом:

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

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

Анализ потока данных (Data-Flow Analysis) заключается в вычислении возможных значений переменных в различных точках компьютерной программы. Например, если ссылка разыменовывается и при этом известно, что в текущий момент она может быть равна null, то это потенциальная ошибка, и статический анализатор сообщит о ней. Подробнее об этой и других технологиях, использующихся в PVS-Studio, можно прочитать в статье.

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

Что же тогда делать? Неужели опять тупик?

А не такие уж они и разные

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

Мы решили повнимательнее взглянуть на значения в кеше. Оказалось, что PVS-Studio хранил большое количество абсолютно идентичных объектов. К примеру, для многих переменных анализатор не может вычислить значение, так как оно может быть любым (в пределах ограничений своего типа):

void MyFunction(int a, int b, int c ....){  // a = ?  // b = ?  // c = ?  ....}

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

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

А вот и нет! На самом деле, нужно совсем немного:

  • некоторое хранилище, в котором будут находиться уникальные значения переменных;

  • механизмы доступа к хранилищу добавление новых и получение существующих элементов;

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

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

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

Кроме того, можно вспомнить и такое понятие, как интернирование строк. По сути то же самое: если строки одинаковы по значению, то фактически они будут представлены одним и тем же объектом. В C# строковые литералы интернируются автоматически. Для прочих строк можно использовать методы string.Intern и string.IsInterned. Однако не всё так просто. Даже этим механизмом нужно пользоваться с умом. Если вам интересна данная тема, то предлагаю к прочтению статью "Подводные камни в бассейне строк, или ещё один повод подумать перед интернированием экземпляров класса String в C#".

Выигранная память

Мы внесли несколько мелких правок, реализовав паттерн Flyweight. Каковы были результаты?

Они были невероятны! Пиковое потребление оперативной памяти при проверке тестового проекта уменьшилось с 14,55 до 4,73 гигабайт. Столь простое и быстрое решение позволило уменьшить расход памяти примерно на 68%! Мы были шокированы и очень довольны результатом. Доволен был и клиент теперь ОЗУ его компьютера хватало, а значит, и анализ начал проходить за адекватное время.

Достигнутый результат действительно радовал, но...

Нужно больше оптимизаций!

Да, мы смогли уменьшить потребление памяти. Однако изначально мы же хотели ускорить анализ! Конечно, он действительно ускорился у клиента, как и на других машинах, где не хватало ОЗУ. Но ускорения на мощных компьютерах мы не добились только сократили потребление памяти. А раз уж мы столь глубоко погрузились в эту тему... Почему бы не продолжить?

dotTrace

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

Ответы на наши вопросы мог дать dotTrace хороший профилировщик производительности для .NET приложений, предоставляющий ряд интересных возможностей. Интерфейс этого приложения довольно сильно напоминает dotMemory:

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

Итак, используя dotTrace, мы запустили анализ одного большого проекта. Ниже показан пример окна, отображающего в реальном времени графики использования процессом памяти и CPU:

Чтобы начать "запись" данных о работе приложения, нужно нажать Start (по умолчанию процесс сбора данных начинается сразу). Подождав некоторое время, нажимаем "Get Snapshot And Wait". Перед нами отображается окно с собранными данными. Например, для простого консольного приложения это окно выглядит так:

Здесь нам доступно большое количество различной информации. В первую очередь интересно время работы отдельных методов. Также может быть полезно узнать время работы потоков. Доступна и возможность рассмотрения общего отчёта для этого нужно кликнуть в верхнем меню View->Snapshot Overview или использовать комбинацию Ctrl+Shift+O.

Уставший сборщик мусора

Что же мы смогли выяснить благодаря dotTrace? Ну, во-первых, мы в очередной раз убедились, что C#-анализатор не использует процессорные мощности даже наполовину. PVS-Studio C# многопоточное приложение, и, по идее, нагрузка на процессор должна быть ощутимой. Несмотря на это, при анализе загрузка процессора часто падала до 1315% общей мощности CPU. Очевидно, работаем неэффективно, но почему?

dotTrace показал нам, что большую часть времени анализа работает даже не само приложение, а сборщик мусора! Возникает логичный вопрос как же так?

Дело в том, что запуск сборки блокировал потоки анализатора. Сборка завершалась, анализатор немного поработал и снова запускается сборка мусора, а PVS-Studio "отдыхает".

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

Мы не виноваты, это всё их DisplayPart!

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

Возможно, мы могли бы вообще отказаться от использования этих объектов, если бы не один нюанс. В исходниках нашего C#-анализатора DisplayPart даже не упоминается! Как оказалось, этот тип играет определённую роль в используемом нами Roslyn API.

Roslyn (или .NET Compiler Platform) является основой C#-анализатора PVS-Studio. Он предоставляет нам готовые решения для ряда задач:

  • преобразование файла с исходным кодом в синтаксическое дерево;

  • удобный способ обхода синтаксического дерева;

  • получение различной (в том числе семантической) информации о конкретном узле дерева;

  • и т.д.

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

Оказалось, что объекты DisplayPart активно используются при создании строковых представлений так называемых символов. Если не погружаться в детали, то символ это объект, содержащий семантическую информацию о некоторой сущности в исходном коде. К примеру, символ метода позволяет получить данные о параметрах данного метода, классе-родителе, возвращаемом типе и т.д. Более подробно данная тема освещена в статье "Введение в Roslyn. Использование для разработки инструментов статического анализа". Очень рекомендую к прочтению всем, кто интересуется статическим анализом (вне зависимости от предпочитаемого языка программирования).

Строковые представления некоторых символов нам действительно приходилось получать, и делали мы это с помощью вызова метода ToString. Как оказалось, внутри отрабатывал сложный алгоритм, активно создающий объекты типа DisplayPart. Проблема состояла в том, что алгоритм отрабатывал каждый раз, когда нам было необходимо получить строковое представление (то есть довольно часто).

Как это обычно и бывает, локализация проблемы = 90% её решения. Раз уж вызовы ToString у символов создают столько проблем, то, может, и не стоит производить их?

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

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

Описанная правка, вопреки ожиданиям, практически не увеличила загрузку процессора (изменение составляло буквально несколько процентов). Тем не менее, PVS-Studio стал работать значительно быстрее: один из наших тестовых проектов ранее анализировался 2,5 часа, а после правок анализ проходил всего за 2. Ускорение работы на 20% действительно радовало.

Упакованный Enumerator

На втором месте по количеству выделяемой памяти были объекты типа List<T>.Enumerator, используемые при обходе соответствующих коллекций. Итератор списка является структурой, а значит, создаётся на стеке. Тем не менее, трассировка показывала, что такие объекты в больших количествах попадали в кучу! С этим нужно было разобраться.

Объект значимого типа может попасть в кучу в результате упаковки (boxing). Она выполняется при приведении объекта значимого типа к object или реализуемому интерфейсу. Итератор списка реализует интерфейс IEnumerator, и именно приведение к этому интерфейсу вело к попаданию итератора в кучу.

Для получения объекта Enumerator используется метод GetEnumerator. Общеизвестно, что это метод, определённый в интерфейсе IEnumerable. Взглянув на его сигнатуру, можно заметить, что возвращаемый тип данного метода IEnumerator. Получается, что вызов GetEnumerator у списка всегда приводит к упаковке?

А вот и нет! Метод GetEnumerator, определённый в классе List, возвращает структуру:

Так всё-таки будет упаковка производиться или нет? Ответ на этот вопрос зависит от типа ссылки, у которой вызывается GetEnumerator:

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

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

Примечание. Как правило, мы не вызываем GetEnumerator напрямую. Зато достаточно часто приходится использовать цикл foreach. Именно он "под капотом" получает итератор. Если в foreach передана ссылка типа List, то и итератор, используемый в foreach, будет лежать на стеке. Если же с помощью foreach производится обход абстрактного IEnumerable, то итератор будет сохранён в куче, а foreach будет работать со ссылкой типа IEnumerator. Описанное поведение актуально и для других коллекций, в которых присутствует GetEnumerator, возвращающий итератор значимого типа.

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

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

И ты, LINQ?!

Методы расширения, определённые в пространстве имён System.Linq, используются для работы с коллекциями повсеместно. Достаточно часто они действительно позволяют упростить код. Наверное, ни один более-менее серьёзный проект не обходится без использования всеми любимых методов Where, Select и т. д. C#-анализатор PVS-Studio не исключение.

Что ж, красота и удобство LINQ-методов дорого нам обошлись. Так дорого, что во многих местах мы отказались от их использования в пользу простого foreach. Как же так вышло?

Основная проблема снова состояла в создании огромного количества объектов, реализующих интерфейс IEnumerator. Такие объекты создаются на каждый вызов LINQ-метода. Взгляните на следующий код:

List<int> sourceList = ....var enumeration = sourceList.Where(item => item > 0)                            .Select(item => someArray[item])                            .Where(item => item > 0)                            .Take(5);

Сколько итераторов будет создано при его выполнении? Давайте посчитаем! Чтобы понять, как всё это работает, откроем исходники System.Linq. Они доступны на github по ссылке.

При вызове Where будет создан объект класса WhereListIterator особая версия Where-итератора, оптимизированная для работы с List (похожая оптимизация есть и для массивов). Данный итератор хранит внутри ссылку на список. При переборе коллекции WhereListIterator сохранит в себе итератор списка, после чего будет использовать его при работе. Так как WhereListIterator рассчитан именно на список, то приведение итератора к типу IEnumerator не производится. Однако сам *WhereListIterator *является классом, а значит, его экземпляры попадут в кучу. Следовательно, исходный итератор в любом случае будет храниться не на стеке.

Вызов Select приведёт к созданию объекта класса WhereSelectListIterator. Очевидно, и он будет храниться в куче.

Последующие вызовы Where и *Take *также приведут к созданию итераторов и выделению памяти под них.

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

Теперь взглянем на фрагмент, написанный с использованием foreach:

List<int> sourceList = ....List<int> result = new List<int>();foreach (var item in sourceList){  if (item > 0)  {    var arrayItem = someArray[item];    if (arrayItem > 0)    {      result.Add(arrayItem);      if (result.Count == 5)        break;    }  }}

Давайте попробуем проанализировать сравнить подходы с foreach и LINQ.

  • Преимущества варианта с LINQ-вызовами:

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

    • не требует создания коллекции для хранения результата;

    • вычисление значений будет произведено только при обращении к элементам;

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

  • Недостатки варианта с LINQ-вызовами:

    • память в куче выделяется гораздо чаще: в первом примере туда попадает 5 объектов, а во втором только 1 (список result);

    • повторные обходы последовательности приводят к повторному выполнению обхода с выполнением всех указанных функций. Случаи, когда такое поведение действительно полезно, довольно редки. Конечно, можно использовать методы типа ToList, однако это сводит преимущества варианта с LINQ-вызовами на нет (кроме первого).

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

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

*Замечание. *На самом деле существует простой способ реализовать отложенное выполнение и не плодить при этом лишние итераторы. Возможно, вы догадались, что я говорю о ключевом слове yield. С его помощью можно реализовывать генерацию последовательности элементов, задавать любые правила и условия добавления элементов в последовательность. Подробнее о возможностях yield в C# (а также о том, как эта штука работает внутри) можно найти в статье "Что такое yield и как он работает в C#?".

Изучив внимательно код анализатора, мы обнаружили множество мест, в которых оптимальнее использовать foreach вместо LINQ-методов. Это позволило существенно сократить количество необходимых операций выделения памяти в куче и сборки мусора.

Что же в итоге?

Успех!

Оптимизация работы PVS-Studio прошла успешно! Мы добились успехов в уменьшении потребляемой памяти, а также серьёзно увеличили скорость анализа (на некоторых проектах скорость работы увеличилась более чем на 20%, а пиковое потребление памяти сократилось практически на 70%!). А ведь всё начиналось с непонятной истории клиента о том, как он три дня не мог проверить свой проект! Тем не менее, на этом оптимизация работы анализатора не заканчивается, и мы продолжаем находить новые способы совершенствования PVS-Studio.

Изучение проблем заняло у нас куда больше времени, чем их решение. Но рассказанная история произошла очень давно. Сейчас, как правило, подобные вопросы решаются командой PVS-Studio куда быстрее. Главными помощниками в исследовании проблем выступают различные инструменты, такие как трассировщик и профилировщик. В этой статье я рассказывал о нашем опыте работы с dotMemory и dotPeek, однако это вовсе не означает, что эти приложения единственные в своём роде. Пожалуйста, напишите в комментариях, какими инструментами в таких случаях пользуетесь вы.

Это ещё не конец

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

Однако и это не тупик, а лишь ещё одно препятствие, которое мы должны преодолеть. Некоторое время назад до меня дошла "секретная информация" о планах реализации процесса анализа... в нескольких процессах! Это позволит обойти существующие ограничения, ведь сборка мусора в одном из процессов не будет влиять на анализ, выполняющийся в других. Подобный подход позволит эффективно использовать большое количество ядер в том числе и с использованием Incredibuild. Кстати, похожим образом уже работает C++ анализатор, для которого давным-давно доступна возможность распределённого анализа.

Откуда ещё берутся проблемы с производительностью

Кстати, стоит сказать, что проблемы с производительностью часто бывают связаны не с чрезмерным использованием LINQ-запросов или чем-то подобным, а с самыми обыкновенными ошибками в коде. Какие-нибудь "always true"-условия, заставляющие метод работать гораздо дольше, чем необходимо, опечатки и прочее всё это может негативно сказаться как на производительности, так и на корректности работы приложения в целом.

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

PVS-Studio один из таких анализаторов. Он использует серьёзные технологии вроде межпроцедурного анализа или анализа потока данных, что позволяет значительно повысить надёжность кода любого приложения. Кроме того, одним из наиболее приоритетных направлений работы компании является поддержка пользователей, решение их вопросов и возникающих проблем, а в некоторых случаях мы даже добавляем по просьбе клиента новый функционал :). Смело пишите нам по всем возникающим вопросам! А чтобы попробовать анализатор в деле вы можете перейти по ссылке. Удачного использования!

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Nikita Lipilin. .NET Application Optimization: Simple Edits Speeded Up PVS-Studio and Reduced Memory Consumption by 70%.

Подробнее..

Перевод Предупреждение для разработчиков о грядущих критических изменениях в движке

16.06.2021 14:10:53 | Автор: admin

Поддержка движка отстает, а исправление положения - задача не из легких

Разработчик программного обеспечения Unity Джош Питерсон рассказал нам о будущем поддержки .NET в широко используемом движке для разработки игр.

Согласно опросу, проведенному в конце прошлого года, использование C# в разработке игр является одной из основных причин популярности C#, но его реализация в Unity несколько беспорядочна.

Обработчик сценариев C# использует Mono, но разработчики также могут использовать .NET Framework при работе в Windows. Mono - это старая реализация .NET с открытым исходным кодом, созданная до того, как Microsoft выпустила .NET Core. Microsoft получила контроль над Mono вместе с Xamarin в 2016 году, и Mono теперь имеет много общего кода с .NET Core, но он все равно остается отдельным продуктом, в котором по-прежнему в некоторых сценариях используется рантайм.

Unity поддерживает собственный форк Mono, который, по словам Питерсона, примерно на два года отстает от upstream кода. Сейчас команда обновляет его до последней версии кода из upstream репозитория Mono - изменение, в котором он уверен на 95%, что оно попадет в следующий релиз, Unity 2021.2. Он добавил, что эта работа улучшит производительность и исправит ошибки, но сама по себе не привнесет никаких новых фич .NET - хотя она закладывает фундамент для фич, которые будут добавлены в будущем.

Тем не менее, Питерсон ожидает, что в Unity 2021.2 будет добавлена поддержка .NET Standard 2.1, но на этот раз с 75% уверенностью. Версии .NET Standard определяют набор API, которые должна поддерживать реализация .NET. Сложный аспект .NET Standard 2.1 заключается в том, что .NET Framework навсегда застрял на .NET Standard 2.0. Питерсон говорит: Хотя .NET Framework не поддерживает .NET Standard 2.1, библиотеки классов Mono его поддерживают, поэтому мы должны быть в состоянии выстроить хороший мост к экосистеме на основе .NET Core.

Это обновление не может произойти быстро, поэтому некоторые разработчики разочарованы таким медленным прогрессом. Предпринимаются ли какие-либо подвижки в направлении отказа от Mono в пользу полной интеграции .NET? Особенно сейчас, когда .NET становится настолько кроссплатформенным, - спросил пользователь в августе прошлого года. К числу востребованных фич относятся Span<T>, представленный в C# 7.2, и оператор диапазона, представленный в C# 8.0. Microsoft выпустила C# 8.0 в сентябре 2019 года, и внедрение полного набора фич в Unity заняло много времени. Пользователи также обеспокоены отставанием в производительности .NET в Unity.

Питерсон говорит, что поддержка C# 8.0 в 2021 году по-прежнему будет реализована на основе Mono. Он также выразил надежду, что C# 9.0, выпущенный Microsoft в ноябре 2020 года, также будет поддерживаться, но это зависит от добавления фич в Mono и IL2CPP (который преобразует код .NET в C++ для компиляции), в чем его уверенность снизилась до 50%, сказал он.

Что касается перехода на .NET Core, это вряд ли будет скоро. Питерсон сказал, что Unity, вероятно, откажется от .NET 5 в пользу .NET 6, который является предстоящим релизом с долгосрочной поддержкой. Даже тут он заметил, что похоже, что JIT рантайм здесь будет Mono, но он не уверен в этом и добавил, что нам может потребоваться перейти непосредственно к CoreCLR в целях поддержки .NET 6.

Одна из проблем заключается в том, что функция редактора Unity, называемая перезагрузкой домена (domain reloading), которая сбрасывает состояние сценария, зависит от функции (AppDomains), которой нет в .NET Core. Питерсон говорит, что это может быть реализовано другим способом, но это будет критическое изменение. Для разработчиков игр .NET 6 в любом случае станет критическим изменением, поскольку любые сборки, скомпилированные с использованием mscorlib.dll из экосистемы .NET Framework, не будут работать и должны быть перекомпилированы.

Сложность, связанная с .NET Standard, .NET Framework, .NET Core и Mono, является проблемой для разработчиков Unity и показывает, что унификация .NET, которую затеяла Microsoft, на самом деле является длительным процессом, а не тем, что может произойти в одночасье с выпуском .NET 5.0 в прошлом году.

Единственное, что меня волнует, это поддержка .NET 6. Самая большая проблема, с которой я столкнулся, заключалась в низкой производительности редактора и длительном времени итерации по мере увеличения размера проекта. В настоящее время я отказался от Unity, потому что его было слишком неудобно использовать, и перешел на Unreal, - сказал другой пользователь, добавив, что Mono скоро станет историей, и у него нет будущего.

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


В преддверии старта курса "Unity Game Developer. Professional" приглашаем всех желающих посетить бесплатный двухдневный интенсив в рамках которого мы разработаем все необходимые инструменты и архитектуру для диалоговой системы (чтобы наш персонаж мог общаться с неигровыми персонажами), реализуем инвентарь, добавим в игру торговцев и создадим систему квестов. Всего два занятия и практически готовая RPG у вас в кармане.

Подробнее..

Программируем на C 8.0. Атрибуты

18.06.2021 16:08:17 | Автор: admin
image Привет, Хабр! Обращаем ваше внимание на одну новинку (сдана в типографию), доступную уже сейчас для покупки в электронном виде.

Язык C# существует уже около двух десятилетий. Он неуклонно развивался и в плане возможностей, и в плане размера, но основные характеристики Microsoft всегда сохраняла без изменений. Каждая новая возможность должна идеально вписываться в состав предыдущих, улучшая язык, а не превращая его в несвязный набор различных функций.

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

Вашему внимаю предлагаю отрывок из книги.

Атрибуты


В .NET можно аннотировать компоненты, типы и их члены с помощью атрибутов. Назначение атрибута регулировать или изменять поведение платформы, инструмента, компилятора или CLR. Например, в главе 1 я демонстрировал класс, аннотированный атрибутом [TestClass]. Он сообщал инфраструктуре юнит-теста, что класс содержит ряд тестов, которые должны быть выполнены как часть набора тестов.

Атрибуты лишь содержат информацию, но ничего не делают сами по себе. Проведу аналогию с физическим миром. Если вы распечатываете этикетку, содержащую информацию о месте назначения и отслеживании, и прикрепляете ее к посылке, эта этикетка сама по себе не заставит посылку добраться до пункта назначения. Такая этикетка полезна только тогда, когда груз находится в обработке у транспортной компании. Когда компания заберет посылку, этикетка понадобится, чтобы определить, как и куда направить груз. Таким образом, этикетка важна, но ее единственной задачей является предоставление информации, которая требуется какой-либо системе. С атрибутами .NET все так же они работают только в том случае, если кто-то ожидает их найти. Некоторые атрибуты обрабатываются CLR или компилятором, но таких меньшинство. Большинство же используются платформами, библиотеками, инструментами (например, системами юнит-теста) или вашим собственным кодом.

Применение атрибутов

Во избежание необходимости вводить дополнительный набор понятий в систему типов .NET работает с ними как с экземплярами типов .NET. Для использования в качестве атрибута тип должен быть производным от класса System.Attribute, и это его единственная особенность. Чтобы применить атрибут, вы помещаете имя типа в квадратные скобки и, как правило, размещаете непосредственно перед целью атрибута. Листинг 14.1 показывает некоторые атрибуты из среды тестирования Microsoft. Один я применил к классу, чтобы указать, что он содержит тесты, которые я хотел бы запустить. Кроме этого, я применил атрибуты к отдельным методам, сообщая среде тестирования, какие из них представляют собой тесты, а какие содержат код инициализации, который должен выполняться перед каждым тестом.

Листинг 14.1. Атрибуты в классе юнит-теста

using Microsoft.VisualStudio.TestTools.UnitTesting;namespace ImageManagement.Tests{   [TestClass]   public class WhenPropertiesRetrieved   {      private ImageMetadataReader _reader;      [TestInitialize]      public void Initialize()      {         _reader = new ImageMetadataReader(TestFiles.GetImage());      }      [TestMethod]      public void ReportsCameraMaker()      {         Assert.AreEqual(_reader.CameraManufacturer, "Fabrikam");      }      [TestMethod]      public void ReportsCameraModel()      {          Assert.AreEqual(_reader.CameraModel, "Fabrikam F450D");      }   }}

Если вы заглянете в документацию по большинству атрибутов, вы обнаружите, что их настоящие имена оканчиваются на Attribute. Если нет класса с именем, указанным в скобках, компилятор C# попытается добавить Attribute, поэтому атрибут [TestClass] в листинге 14.1 ссылается на класс TestClassAttribute. Если хотите, вы можете записывать имя класса полностью, например [TestClassAttribute], но чаще используют более короткую форму.

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

Некоторые типы атрибутов способны принимать аргументы конструктора. Например, среда тестирования Microsoft содержит атрибут TestCategoryAttribute. При запуске можно выбрать выполнение только тех тестов, которые принадлежат определенной категории. Данный атрибут требует, чтобы вы передали имя категории в качестве аргумента конструктора, потому что нет смысла применять этот атрибут без указания имени. Синтаксис для указания аргументов конструктора атрибута вполне ожидаемый (листинг 14.2).

Листинг 14.2. Атрибут с аргументом конструктора

[TestCategory("Property Handling")][TestMethod]public void ReportsCameraMaker(){...

Вы также можете указать свойства или значения полей. Характеристиками некоторых атрибутов можно управлять только через свойства или поля, но не через аргументы конструктора. (Если атрибут имеет множество необязательных настроек, обычно проще представить их как свойства или поля вместо определения перегрузки конструктора для каждой возможной комбинации настроек.) Синтаксис заключается в одной или нескольких записях вида PropertyOrFieldName=Value после аргументов конструктора (или вместо них, если их нет). В листинге 14.3 показан другой атрибут, используемый в юнит-тестировании, ExpectedExceptionAttribute, позволяющий указать, что при выполнении теста вы ожидаете, что он выдаст конкретное исключение. Тип исключения является обязательным, поэтому мы передаем его в качестве аргумента конструктора, но данный атрибут позволяет также указать, должен ли исполнитель теста принимать исключения типа, производного от указанного. (По умолчанию он принимает только точное совпадение.) Это поведение управляется с помощью свойства AllowDerivedTypes.

Листинг 14.3. Указание необязательных настроек атрибута со свойствами

[ExpectedException(typeof(ArgumentException), AllowDerivedTypes = true)][TestMethod]public void ThrowsWhenNameMalformed(){...

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

Цели атрибутов

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

В большинстве случаев вы обозначаете цель, просто помещая атрибут непосредственно перед ней. Но это не сработает в случае сборок или модулей, потому что в вашем исходном коде нет ничего, что бы их представляло, все в вашем проекте идет в сборку, которую он производит. Модули, в свою очередь, тоже являются совокупностью (как правило, составляя сборку, как я описал в главе 12). Поэтому для них мы должны явно указать цель в начале атрибута. Вы часто будете видеть атрибуты уровня сборки, подобные показанным в листинге 14.4, в файле GlobalSuppressions.cs. Visual Studio иногда предлагает варианты для изменения вашего кода, и если вы решите подавить этот функционал, это можно сделать с помощью атрибутов уровня сборки.

Листинг 14.4. Атрибуты уровня сборки

[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(   "StyleCop.CSharp.NamingRules",   "SA1313:Parameter names should begin with lower-case letter",   Justification = "Triple underscore acceptable for unused lambda parameter",   Scope = "member",   Target = "~M:Idg.Examples.SomeMethod")]

Атрибуты уровня модуля следуют той же схеме, хотя и встречаются гораздо реже. Не в последнюю очередь это происходит потому, что многомодульные сборки встречаются довольно редко и не поддерживаются .NET Core. В листинге 14.5 показано, как настроить возможность отладки конкретного модуля в том случае, если вы хотите, чтобы один модуль в многомодульной сборке был легко отлаживаемым, а остальные JIT-компилируемыми с полной оптимизацией. (Это специально придуманный сценарий, с помощью которого я могу показать синтаксис. На практике вы вряд ли захотите это делать.) Я расскажу об атрибуте DebuggableAttribute позже, в подразделе JIT-компиляция на с. 743.

Листинг 14.5. Атрибут уровня модуля

using System.Diagnostics;[module: Debuggable(DebuggableAttribute.DebuggingModes.DisableOptimizations)]

Возвращаемые значения методов могут быть аннотированы, и это также требует квалификации, потому что атрибуты возвращаемого значения располагаются перед методом, там же, где и атрибуты, которые применяются к самому методу. (Атрибуты для параметров не нуждаются в квалификации, потому что они располагаются в круглых скобках вместе с аргументами.) В листинге 14.6 показан метод с атрибутами, применяемыми как к методу, так и к типу возвращаемого значения. (Атрибуты в этом примере являются частью служб взаимодействия, которые позволяют коду .NET вызывать внешний код, такой как API ОС. В этом примере импортируется функция библиотеки Win32, что позволяет использовать ее из C#. Существует несколько различных представлений для логических значений в неуправляемом коде, поэтому в данном случае я аннотировал возвращаемый тип с помощью атрибута MarshalAsAttribute, указав, какой именно тип следует ожидать CLR.)

Оформить предзаказ бумажной книги можно на нашем сайте
Подробнее..

Как WCF сам себе в ногу стреляет посредством TraceSource

21.06.2021 14:12:41 | Автор: admin

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

Предыстория

В дистрибутиве PVS-Studio есть одна утилита под названием CLMonitor.exe, или система мониторинга компиляции. Она предназначена для "бесшовной" интеграции статического анализа PVS-Studio для языков C и C++ в любую сборочную систему. Сборочная система должна использовать для сборки файлов один из компиляторов, поддерживаемых анализатором PVS-Studio. Например: gcc, clang, cl, и т.п.

Стандартный сценарий работы данной Windows утилиты очень простой, всего 3 шага:

  1. Выполняем 'CLMonitor.exe monitor';

  2. Выполняем сборку проекта;

  3. Выполняем 'CLMonitor.exe analyze'.

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

И вот в один прекрасный момент описанный сценарий не заработал, анализ просто-напросто не запустился. Ну и чтобы было поинтереснее, возникла эта проблема не у нас, а у пользователя, который обратился к нам в поддержку. У него стабильно после запуска анализа происходило десятиминутное ожидание ответа от сервера с дальнейшим выходом из программы по timeout'у. В чём причина непонятно. Проблема не воспроизводится. Да... беда. Пришлось запросить дампфайл для процесса нашей утилиты, чтобы посмотреть, что там происходит внутри.

Примечание. Проблема у пользователя возникла при использовании Windows утилиты CLMonitor.exe. Поэтому все дальнейшие примеры будут актуальны именно для Windows.

Как работает CLMonitor.exe

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

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

Зачем мы вообще отлавливаем процессы

Как вы поняли, история начинается с того, что нужно запустить сервер, который будет отлавливать все процессы. Делаем мы это не просто так. Вообще, более удобный способ проанализировать C++ проект это прямой запуск анализатора через утилиту командной строки PVS-Studio_Cmd. У неё, однако, есть существенное ограничение она может проверять только проекты для Visual Studio. Дело в том, что для анализа требуется вызывать компилятор, чтобы он препроцессировал проверяемые исходные файлы, ведь анализатор работает именно с препроцессированными файлами. А чтобы вызвать препроцессор, нужно знать:

  • какой конкретно компилятор вызывать;

  • какой файл препроцессировать;

  • параметры препроцессирования.

Утилита PVS-Studio_Cmd узнает все необходимое из проектного файла (*.vcxproj). Однако это работает только для "обычных" MSBuild проектов Visual Studio. Даже для тех же NMake проектов мы не можем получить необходимую анализатору информацию, потому что она не хранится в самом проектном файле. И это несмотря на то, что NMake также является .vcxproj. Сам проект при этом является как бы обёрткой для другой сборочной системы. Тут в игру и вступают всяческие ухищрения. Например, для анализа Unreal Engine проектов мы используем прямую интеграцию с *Unreal Build Tool * сборочной системой, используемой "под капотом". Подробнее здесь.

Поэтому, для того чтобы можно было использовать PVS-Studio независимо сборочной системы, даже самой экзотической, у нас и появилась утилита CLMonitor.exe. Она отслеживает все процессы во время сборки проекта и отлавливает вызовы компиляторов. А уже из вызовов компиляторов мы получаем всю необходимую информацию для дальнейшего препроцессирования и анализа. Теперь вы знаете, зачем нам нужно мониторить процессы.

Как клиент запускает анализ

Для обмена данными между сервером и клиентом мы используем программный фреймворк WCF (Windows Communication Foundation). Давайте далее кратко опишем, как мы с ним работаем.

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

static ErrorLevels PerformMonitoring(....) {  using (ServiceHost host = new ServiceHost(                       typeof(CLMonitoringContract),                          new Uri[]{new Uri(PipeCredentials.PipeRoot)}))   {    ....    host.AddServiceEndpoint(typeof(ICLMonitoringContract),                             pipe,                             PipeCredentials.PipeName);    host.Open();         ....  }}

Обратите тут внимание на две вещи: *CLMonitoringContract *и ICLMonitoringContract.

*ICLMonitoringContract * это сервисный контракт. *CLMonitoringContract * реализация сервисного контракта. Выглядит это так:

[ServiceContract(SessionMode = SessionMode.Required,                  CallbackContract = typeof(ICLMonitoringContractCallback))]interface ICLMonitoringContract{  [OperationContract]  void StopMonitoring(string dumpPath = null);} [ServiceBehavior(InstanceContextMode = InstanceContextMode.Single)]class CLMonitoringContract : ICLMonitoringContract{  public void StopMonitoring(string dumpPath = null)  {    ....    CLMonitoringServer.CompilerMonitor.StopMonitoring(dumpPath);  } }

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

public void FinishMonitor(){  CLMonitoringContractCallback сallback = new CLMonitoringContractCallback();  var pipeFactory = new DuplexChannelFactory<ICLMonitoringContract>(           сallback,            pipe,            new EndpointAddress(....));  ICLMonitoringContract pipeProxy = pipeFactory.CreateChannel();  ((IContextChannel)pipeProxy).OperationTimeout = new TimeSpan(24, 0, 0);  ((IContextChannel)pipeProxy).Faulted += CLMonitoringServer_Faulted;  pipeProxy.StopMonitoring(dumpPath);}

Когда клиент выполняет метод StopMonitoring, он на самом деле выполняется у сервера и вызывает его остановку. А клиент получает данные для запуска анализа.

Всё, теперь вы, хоть немного, имеете представление о внутренней работе утилиты CLMonitor.exe.

Просмотр дамп файла и осознание проблемы

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

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

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

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

Дамп 'клиента'

Так вот, когда мы открыли дамп файл клиента, перед глазами предстал следующий список потоков:

Нас интересует главный поток. Он висит на методе, отвечающем за запрос остановки сервера:

public void FinishMonitor(){  ....  ICLMonitoringContract pipeProxy = pipeFactory.CreateChannel();  ((IContextChannel)pipeProxy).OperationTimeout = new TimeSpan(24, 0, 0);  ((IContextChannel)pipeProxy).Faulted += CLMonitoringServer_Faulted;  pipeProxy.StopMonitoring(dumpPath);            // <=  ....}

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

Дамп 'сервера'

Открываем его и видим следующий список потоков:

Воу-воу, откуда так много TraceEvent'ов? Кстати, на скриншоте не уместилось, но всего их более 50. Ну давайте подумаем. Данный метод у нас используется, чтобы логировать различную информацию. Например, если отловленный процесс является компилятором, который не поддерживается, произошла ошибка считывания какого-либо параметра процесса и т.д. Посмотрев стеки данных потоков, мы выяснили, что все они ведут в один и тот же метод в нашем коде. А метод этот смотрит, является ли отловленный нашей утилитой процесс компилятором или это нечто иное и неинтересное, и, если мы отловили такой неинтересный процесс, мы это логируем.

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

Открываем исходники и действительно видим в методе TraceEvent оператор lock:

Мы предположили, что из-за постоянной синхронизации и логирования накапливается большое количество вызовов методов TraceEvent, ждущих освобождения TraceInternal.critSec. Хм, ну допустим. Однако это пока не объясняет, почему сервер не может ответить клиенту. Посмотрим еще раз в дамп файл сервера и заметим один одинокий поток, который висит на методе DiagnosticsConfiguration.Initialize:

В данный метод мы попадаем из метода NegotiateStream.AuthenticateAsServer, выполняющего проверку подлинности со стороны сервера в соединении клиент-сервер:

В нашем случае клиент-серверное взаимодействие происходит с помощью WCF. Плюс напоминаю, что клиент ждет ответ от сервера. По этому стеку очень похоже, что метод DiagnosticsConfiguration.Initialize был вызван при запросе от клиента и теперь висит и ждет. Хм... а давайте-ка зайдем в его исходный код:

И тут мы замечаем, что в данном методе имеется критическая секция, да еще и на ту же самую переменную. Посмотрев, что вообще такое этот critSec, увидим следующее:

Собственно, у нас уже есть достаточно информации, чтобы подвести итоги.

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

"Also one of the locks, TraceInternal.critSec, is only present if the TraceListener asks for it. Generally speaking such 'global' locks are not a good idea for a high performance logging system (indeed we don't recommend TraceSource for high performance logging at all, it is really there only for compatibility reasons)".

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

Итоги изучения дампов

Итак, что мы имеем:

  1. Клиент общается с сервером с помощью фреймворка WCF.

  2. Клиент не может получить ответа от сервера. После 10 минут ожидания он падает по тайм-ауту.

  3. На сервере висит множество потоков на методе TraceEvent и всего один - на Initialize.

  4. Оба метода зависят от одной и той же переменной в критической секции, притом это статическое поле.

  5. Потоки, в которых выполняется метод TraceEvent, бесконечно появляются и из-за lock не могут быстро сделать свое дело и исчезнуть. Тем самым они долго не отпускают объект в lock.

  6. Метод Initialize возникает при попытке клиента завершить работу сервера и висит бесконечно на lock.

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

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

Теперь остается только воспроизвести и починить проблему.

Воспроизведение проблемы

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

private void CrazyLogging(){  for (var i = 0; i < 30; i++)  {    var j = i;    new Thread(new ThreadStart(() =>    {      while (!Program.isStopMonitor)        Logger.TraceEvent(TraceEventType.Error, 0, j.ToString());    })).Start();  }}

За работу сервера у нас отвечает метод Trace, поэтому добавляем наше логирование в него. Например, вот сюда:

public void Trace(){  ListenersInitialization();  CrazyLogging();  ....}

Готово. Запускаем сервер (я буду это делать с помощью Visual Studio 2019), приостанавливаем секунд через 5 процесс и смотрим что у нас там с потоками:

Отлично! Теперь запускаем клиент (TestTraceSource.exe analyze), который должен установить связь с сервером и остановить его работу.

Запустив, мы увидим, что анализ не начинается. Поэтому опять останавливаем потоки в Visual Studio и видим ту же самую картину из дамп файла сервера. А именно появился поток, который висит на методе DiagnosticsConfiguration.Initialize. Проблема воспроизведена.

Как же её чинить? Ну для начала стоит сказать, что TraceSource это класс, который предоставляет набор методов и свойств, позволяющих приложениям делать трассировку выполнения кода и связывать сообщения трассировки с их источником. Используем мы его потому, что сервер может быть запущен не приаттаченным к консоли, и консольное логирование будет бессмысленно. В этом случае мы логировали всё в Event'ы операционной системы с помощью метода TraceSource.TraceEvent.

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

Код, воспроизводящий проблему

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

Чтобы запустить имитирование работы сервера, запустите .exe с флагом trace. Чтобы запустить клиент, воспользуйтесь флагом analyze.

**Примечание: **количество потоков в методе CrazyLogging следует подбирать индивидуально. Если проблема у вас не воспроизводится, то попробуйте поиграться с этим значением. Также можете запустить данный проект в Visual Studio в режиме отладки.

Точка входа в программу:

using System.Linq;namespace TestTraceSource{  class Program  {    public static bool isStopMonitor = false;    static void Main(string[] args)    {      if (!args.Any())        return;      if (args[0] == "trace")      {        Server server = new Server();        server.Trace();      }      if (args[0] == "analyze")      {        Client client = new Client();        client.FinishMonitor();      }    }    }}

Сервер:

using System;using System.Diagnostics;using System.ServiceModel;using System.Threading;namespace TestTraceSource{  class Server  {    private static TraceSource Logger;    public void Trace()    {      ListenersInitialization();      CrazyLogging();      using (ServiceHost host = new ServiceHost(                          typeof(TestTraceContract),                           new Uri[]{new Uri(PipeCredentials.PipeRoot)}))      {        host.AddServiceEndpoint(typeof(IContract),                                 new NetNamedPipeBinding(),                                 PipeCredentials.PipeName);        host.Open();        while (!Program.isStopMonitor)        {          // We catch all processes, process them, and so on        }        host.Close();      }      Console.WriteLine("Complited.");    }    private void ListenersInitialization()    {      Logger = new TraceSource("PVS-Studio CLMonitoring");      Logger.Switch.Level = SourceLevels.Verbose;      Logger.Listeners.Add(new ConsoleTraceListener());      String EventSourceName = "PVS-Studio CL Monitoring";      EventLog log = new EventLog();      log.Source = EventSourceName;      Logger.Listeners.Add(new EventLogTraceListener(log));    }    private void CrazyLogging()    {      for (var i = 0; i < 30; i++)      {        var j = i;        new Thread(new ThreadStart(() =>        {          var start = DateTime.Now;          while (!Program.isStopMonitor)            Logger.TraceEvent(TraceEventType.Error, 0, j.ToString());        })).Start();      }    }   }}

Клиент:

using System;using System.ServiceModel;namespace TestTraceSource{  class Client  {    public void FinishMonitor()    {      TestTraceContractCallback сallback = new TestTraceContractCallback();      var pipeFactory = new DuplexChannelFactory<IContract>(                                сallback,                                new NetNamedPipeBinding(),                                new EndpointAddress(PipeCredentials.PipeRoot                                                   + PipeCredentials.PipeName));      IContract pipeProxy = pipeFactory.CreateChannel();      pipeProxy.StopServer();      Console.WriteLine("Complited.");        }  }}

Прокси:

using System;using System.ServiceModel;namespace TestTraceSource{  class PipeCredentials  {    public const String PipeName = "PipeCLMonitoring";    public const String PipeRoot = "net.pipe://localhost/";    public const long MaxMessageSize = 500 * 1024 * 1024; //bytes  }  class TestTraceContractCallback : IContractCallback  {    public void JobComplete()    {      Console.WriteLine("Job Completed.");    }  }  [ServiceContract(SessionMode = SessionMode.Required,                    CallbackContract = typeof(IContractCallback))]  interface IContract  {    [OperationContract]    void StopServer();  }  interface IContractCallback  {    [OperationContract(IsOneWay = true)]    void JobComplete();  }  [ServiceBehavior(InstanceContextMode = InstanceContextMode.Single)]  class TestTraceContract : IContract  {    public void StopServer()    {      Program.isStopMonitor = true;    }  }}

Вывод

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

Спасибо за просмотр. Незаметно рекламирую свой Twitter.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Nikolay Mironov. How WCF Shoots Itself in the Foot With TraceSource.

Подробнее..

Категории

Последние комментарии

© 2006-2021, personeltest.ru