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

Cplusplus

Ускоряем нейросеть на уровне железа интервью с разработчиком компиляторов

25.03.2021 18:04:40 | Автор: admin

Обыденное представление о Deep Learning состоит в том, что для достижения успеха нужно хорошо знать математику и уметь программировать на Python. Но все становится немного сложнее, как только мы начинаем говорить о реализации нейросетевых решений в железе, где критична производительность. Мы пообщались с руководителем направления российского Исследовательского центра Samsung Вячеславом Гарбузовым, чтобы понять, как ускоряют работу нейросетей на аппаратном уровне, при чем тут компиляторы и какие знания требуются в этой редкой профессии. И самое интересное - какие вакансии в его подразделении открыты в настоящий момент.

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

Слава, привет! Расскажи о себе, чем занимается твоя команда сейчас.

Привет! Я руковожу управлением разработки ПО для систем на кристалле Исследовательского центра Samsung. Мы занимаемся разработкой SDK для ускорения исполнения моделей глубинного обучения (Deep Learning)на процессорах Exynos.

Кто твои непосредственные заказчики?

Наша работа связана с компонентным бизнесом и нашим заказчиком является Samsung Semiconductor. Мы ближе к земле.

Правильно ли я понимаю, чтомобильныйпроцессор Exynosв основном используется в телефонах Samsung и больше нигде?

Вовсе нет. Exynos используется в смартфонах других производителей. Кроме того, Exynos - это не только мобильные системы на кристалле (SoC). Есть микроконтроллеры, компоненты Интернета вещей. Крупные игроки на автомобильном рынке тоже заинтересованы в наших продуктах.

Расскажи про новый Exynos и AI-ускоритель в нем

Разработкой Exynos SoCи SDK к нему занимается подразделение Samsung System LSI (large-scale integration - высокоинтегрированные чипы). Узнать подробнее про новый Exynos 2100 можно извидеопрезентации. В разделе AI and Camera кратко рассказывается, что такое AI-ускоритель. Это железо для ускорения работы нейросетей. Обучение сети производится заранее, а исполнением (inference) как раз занимается это железо.

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

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

Для каких задач это всё используется?

Применения в телефоне в основном связаны с камерой: живой фокус, ночная съемка, Bixby Vision, обнаружение лиц, улучшающее картинку.

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

Сегментация людей и животных на фотоСегментация людей и животных на фото

Расскажи, как устроен этот AI-ускоритель.

Он состоит из двух частей:

  1. NPU (Neural Processing Unit - обработчик нейросетей). Фактически это ускоритель операций с тензорами. Он умеет быстро делать свертки (convolution), пулинги (pooling) - набор операций, популярных в глубинном обучении.

  2. DSP (digital signal processor - цифровой обработчик сигналов).Это процессор, специализированный под выполнение определенных задач. Его разрабатывают изначально под конкретные алгоритмы. Ребята проектируют этот DSP под распознавание лиц или под более широкий круг задач.

Это единый кластер в составе одной системы на кристалле. Для него мы и разрабатываемSDK. У нас две команды, одна работает над NPU, другая, соответственно, над DSP.

Какие компиляторные задачи у вас с NPU?

Компилятор для NPU - это та штука, которая превращает граф на выходе Deep Learning-фреймворка в последовательность процессорных команд. Отличие от обычного компилятора в том, что мы генерируем код не для CPU, а для нейросетевого ускорителя. Это другой процессор со своим языком. И чтобы вся система работала быстрее, мы оптимизируем ее на уровне компилятора.

В чем суть оптимизации? По большей части это memory allocation (оптимизация работы с памятью) и instruction scheduling (параллелизм на уровне инструкций). Наш процессор может несколько инструкций выполнять одновременно, например, считать ту же самую свертку и загружать данные для свертки. Мы должны сгенерировать код для этого процессора так, чтобы оптимизировать работу с памятью и максимизировать параллелизм.

А что с DSP? Какие задачи там?

Это уже более-менее похоже на традиционный процессор. Если свертку наш NPU умеет делать на уровне железа, то здесь мы должны эту свертку описать на языке C++ и исполнить на DSP. Зачем нужен отдельный сопроцессор, чтобы выполнять ту же самую свертку? Например, NPU занят в какой-то момент, и мы хотим параллельно решать другую задачу. Некоторые операции мы в принципе на NPU выполнить не можем.

У нас достаточно простой DSP, основанный на VLIW-архитектуре (very long instruction word очень длинная машинная команда). Особенность нашего DSP в том, что он аппаратно достаточно простой, и от компилятора требуется серьезная оптимизация.Мы делаем на базе LLVM компилятор для этого DSP.

Поговорим о других вещах. Где ты работал до Samsung?

Непосредственно до Samsung я работал в Topcon Positioning Systems и в Lynx Software Technologies. Занимался разработкой RTOS и инструментов.

Где и на кого ты учился?

Учился в МГУ на физика. Занимался ускорителями элементарных частиц, электронов в частности. Занимался автоматизацией физического эксперимента, системой управления для промышленного ускорителя.

Как помогает образование физика в твоей профессии?

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

Работая в твоем отделе, насколько важно хорошо разбираться в железе?

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

А в глубинном обучении?

Базовое представление надо иметь. Я полагаю, что современные выпускники вузов это всё уже знают на определенном уровне. Это всегда хорошо иметь в бэкграунде. Например, курс Нейронные сети и компьютерное зрение Samsung Research Russia на Stepik я добавил в закладки, но пока не прошел. И кстати, вчера в рамках этого курса былалекцияна YouTube про Embedded Inference как раз на эту тему - "Мобильные архитектуры нейросетей и фреймворки для их запуска".

Когда мы начинали этот проект в 2018 году, мне сказали: нужен компилятор для Deep Learning. Нам потребовалось найти людей, которые одновременно умеют и в Deep Learning, и в железо, и в компиляторы. И это сложно, потому что таких людей очень мало. Потом мы поняли, что требование знания Deep Learning не столь критично, всё-таки заказчики от нас просили только компилятор.

С выпускниками каких вузов тебе интересно работать?

Мне приятно работать с выпускниками МФТИ, особенно с теми, которые прошли через базовые кафедры ИСП РАН или Intel. У нас в отделе достаточно много ребят из Intel. По факультетам - ФУПМ, ФРКТ. Если говорить о других вузах, то это и МГУ - забавно, что много моих знакомых компиляторщиков заканчивали физфак. Также это ВШЭ, где есть МИЭМ, там учат проектировать железо, FPGA. А компиляторы можно условно рассматривать как часть железа в принципе.

В нашем Исследовательском центре мы проводили вечернюю школуSamsung Compiler Bootcamp, и , в основном, в ней учились студенты из Бауманки, МГУ и Вышки.

На тему FPGA - полезно ли это изучать?

Как бэкграунд - да, это правильно.

А вообще, много ли таких центров в Москве, где занимаются компиляторами?

Intel, JetBrains, Positive Technologies, Huawei. Из российских - МЦСТ, которые Эльбрус, они тоже компиляторы делают. Например, Роман Русяев, наш коллега из Исследовательского центра Samsung и разработчик компиляторов, как раз оттуда пришел (см. егостатьюна Хабре о Concept-Based Polymorphism), он часто выступает на конференциях и пишет статьи.Он активный участник C++ Community. Например, вот пара его выступлений где затрагивается тема оптимизации при помощи компилятора :"Исключения C++ через призму компиляторных оптимизаций","Настоящее и будущее copy elision".

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

О каких мировых трендах в компиляторов можно сейчас говорить?

Можно выделить такие тренды:

  1. Доминирование проекта LLVM

  2. Обобщение компилятора для различных предметных областей посредством универсального промежуточного представления (MLIR)

  3. Объединение различных инструментов для анализа и преобразования кода (компиляторов, анализаторов, performance estimators, линтеров и пр.) в рамках одного проекта

  4. Активные попытки использования высокой науки в промышленных компиляторах (formal verification, polyhedral optimizations, более подробно встатье)

Какие требования к соискателям, будущим разработчикам компиляторов, ты бы озвучил?

Обязательные требования: знание С/С++ на хорошем уровне. Понимание того, как устроены компиляторы, опыт их разработки. Понимание устройства операционной системы. Умение разбираться в больших объемах чужого кода. Навыки отладки embedded-устройств. Знание практик программной инженерии - непрерывная интеграция, ревизия кода, отслеживание задач. Владение скриптовыми языками - Bash или Python.

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

Работая в международной компании, как складывается коммуникация с иностранными коллегами? Как вы решаете вопросы взаимодействия с коллегами в пандемию?

Мы активно взаимодействуем с командами из других стран Корея, Китай, Индия, Израиль, США. До карантина они частенько приезжали к нам в гости, а мы к ним.

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

Какие книжки о компиляторах ты бы посоветовал?

Коллеги рекомендуют начинать с "Modern Compiler Implementation in ML", автор Andrew W. Appel.

Какие твои любимые книги о программировании вообще?

Керниган и Ричи Язык программирования С. Они классные. Еще Керниган и Пайк, Практика программирования. Там настолько все четко сделано.

Что скажешь об онлайн-курсах?

Если говорить о курсах по смежным темам, то по глубинному обучению это курс Samsung о нейронных сетях в компьютерном зрении, и известный курс Эндрю на (Andrew Ng). Полезенкурс по С++от Яндекса.

LLVM или GCC - что полезнее изучать?

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

Какие инструменты командной работы используете?

Используемgit, точнее корпоративный github. Важно сказать, что мы делаем Code Review, и это неотъемлемая часть работы наших инженеров. Здорово, что все друг другу помогают и делятся знаниями. Также мы делимся знаниями с помощью Confluence, у нас есть вики-портал с внутренней документацией по нашим разработкам. Есть Jira для отслеживания задач. И есть свой чат на основе Mattermost, то есть практически Slack - без него на удаленке мы бы вообще не выжили. Исповедуем ContinuousIntegration, а также автоматизируем все, что можно автоматизировать.

А что насчет методов Agile?

Мы не привязаны к какой-то конкретной методологии. Берем полезные практики, которые подходят нашему проекту, из разных методологий. Например, из скрама мы берем Daily Scrum - ежедневные собрания. У нас есть итеративное планирование. И так далее.

Не могу не спросить. А вот во время пандемии, когда все по видео общались, вы все равно Daily Scrum стоя проводили?

Ну нет, всё-таки все сидели.

Сколько у вас длится Daily Scrum?

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

Что еще интересного бывает?

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

----

А сейчас самое интересное: ВАКАНСИИ!

У нас открыты две вакансии, соответственно поNPUи поDSP. Если вас заинтересовало, откликайтесь на вакансию прямо на HeadHunter, и возможно, мы с вами встретимся на собеседовании.

Вопросы задавала: Татьяна Волкова, куратор трека по Интернету вещей социально-образовательной программы для вузов IT Академия Samsung

Отвечал: Вячеслав Гарбузов, руководитель направления, российский Исследовательский центр Samsung

Подробнее..

С безопасность для новичков

05.03.2021 18:07:04 | Автор: admin

Привет, хабровчане. Для будущих студентов курса "C++ Developer. Professional" Александр Колесников подготовил статью.

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


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

Сегодня язык программирования С++ существует в нескольких параллельных реальностях: C++98, C++11, C++14, C++17, C++20. Существует как минимум один источник, где можно немного разобраться со всем этим набором мультивселенных. Однако, когда дело дойдет до написания кода использования stackOverflow, вопрос а точно эта строка написана безопасно", будет мучать разработчика из релиза в релиз. Кстати, на момент написания статьи готовится новый стандарт С++23 =).

Откуда проблемы

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

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

  • неверное объявление типов данных;

  • неверное использование выражений;

  • неправильная обработка целочисленных данных;

  • неправильная работа с контейнерами;

  • неправильная работа со строками;

  • неправильная работа с памятью;

  • неверная обработка exception;

  • пренебрежение ограничениями OOP;

  • состояния гонки при обработке ресурсов мультипоточным приложением;

  • прочие проблемы, о которых мало, где сказано.

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

Разберем несколько примеров.

String Format

void check_password(const char *user) {  int ret;  //  static const char format[] = "%s wrong pass.\n";  size_t messageLength = strlen(user) + sizeof(msg_format);  char *data = (char *)malloc(messageLength); // <- так же не очень безопасный вариант  if (data == NULL) {    //Код для ошибки  }  ret = snprintf(data, messageLength, format, user);  if (ret < 0) {     //Код для ошибки  } else if (ret >= messageLength) {     //Последний шанс обработать некорректные данные  }  syslog(LOG_INFO, msg);  free(msg);}

В чем проблема? Данные, которые используются для создания строки, контролируются пользователем. Передача других спец символов (%n, %x) и использование строк больших размеров может вывести из строя приложение.

Integer overflow

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

...user->nameLength = getUserNameLength(&user->name) ;user->newDbCellLen = malloc(user->nameLength * sizeof(uint8_t))...

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

...int16_t checkLen(uint16_t firstNumber, uint16_t secondNumber){    uint16_t resultLength;    if (UINT_MAX - firstNumber < secondNumber)     {        //ошибка        return -1;    }    else    {        resultLength = firstNumber + secondNumber;    }    return resultLength;}

Преобразование типов

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

...unsigned int number = (unsigned int)ptr;number = (number & 0x7fffff) | (flag << 23);ptr = (char *)number;...

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

Выводы

Как видно из примеров в статье, С++ это язык, в котором нужно внимательно относиться к кажущимся мелочам, начиная от форматов данных и заканчивая типами переменных. Где брать примеры? Что делать с уязвимостями? К сожалению, универсального ответа нет, но можно постоянно работать и накапливать знания о языке и его особенностях. Начать можно здесь или здесь. Так же нужно использовать плагины и приложения, которые позволяют анализировать код на этапе сборки. Можно в этом случае ориентироваться на продукты вроде этого или этого.


Узнать подробнее о курсе "C++ Developer. Professional".

Смотреть открытый вебинар на тему Области видимости и невидимости.

Подробнее..

Проект arataga реальный пример использования SObjectizer и RESTinio для работы с большим количеством HTTP-соединений

18.01.2021 12:13:17 | Автор: admin

В последние 4.5 года я много рассказывал на Хабре про такие OpenSource проекты, как SObjectizer и RESTinio. Но вот об использовании SObjectizer и/или RESTinio в реальных проектах пока еще ни разу не удавалось поговорить (была лишь одна статья от стороннего автора).

Причина простая: мы не можем обсуждать те проекты, в которых мы сами применяли SObjectizer/RESTinio, ибо NDA. Равно как и не можем рассказывать о тех чужих проектах, о которых узнали в частном общении. Так что с наглядными примерами использования SObjectizer/RESTinio в реальной жизни всегда была напряженка.

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

К счастью или к несчастью, но далеко не самый удачный 2020-й год предоставил нам возможность показать как же выглядит реальный проект, в разработке которого SObjectizer и RESTinio активно используются. И в данной статье я попробую рассказать о том, как и для чего SObjectizer и RESTinio применяются в arataga, исходники которого можно найти на GitHub.

Тем, кто хочет больше узнать об arataga и причинах его появления на GitHub-е, рекомендую прочитать этот блог-пост. Здесь же в двух словах скажу лишь, что arataga -- это socks5+http/1.1 прокси-сервер, который затачивался под использование в следующих условиях (перечислены те из них, которые актуальны с точки зрения внутренней архитектуры arataga):

  • много точек входа, счет идет на тысячи (8 тысяч нужно было поддерживать сразу). У каждой точки входа уникальное сочетание IP+port;

  • подключения на одну точку входа могут идти с темпом в несколько десятков в секунду, параллельно на одной точке входа могут "висеть" сотни подключений;

  • одновременно могут существовать десятки тысяч подключений, общий темп появления новых подключений на всех точках входа может быть от 1500 в секунду;

  • аутентификация клиентов должна укладываться в единицы миллисекунд;

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

Далее в статье я сосредоточусь на архитектурных и технических моментах, не затрагивая тему "зачем это все было нужно?"

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

Тезисно о принятых проектных и архитектурных решениях

Многопроцессность или многопоточность?

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

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

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

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

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

Плюс к тому, у нас в руках был большой молоток, SObjectizer, с помощью которого разрабатывать многопоточные приложения гораздо проще, чем при ручной работе с std::thread, std::mutex и std::condition_variable и т.п. Если выразить основные сущности приложения в виде SObjectizer-овских агентов, распределить этих агентов должным образом по рабочим нитям и организовать их взаимодействие на базе асинхронных сообщений, то ужасы многопоточности можно спокойно обойти стороной. Что, собственно, в очередной раз и произошло.

Сколько рабочих потоков нужно?

Здесь все было понятно изначально, еще даже до начала разработки arataga, поскольку старый прокси-сервер, на замену которого arataga и разрабатывался, использовал модель thread-per-connection. Когда счет одновременно живущих соединений идет на десятки тысяч, то thread-per-connection не есть хорошо.

Поэтому об использовании в arataga схемы thread-per-connection речь не шла вообще. Сразу же был взят курс на применение чего-то похожего на thread-per-core.

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

Так, если на машине 8 ядер, то в arataga будет создано 6 рабочих потоков, которые будут отвечать за обслуживание соединений. Т.е. работой с трафиком будут заняты 6 ядер. Два оставшихся ядра будут доступны для других рабочих потоков внутри arataga. Плюс и самой ОС нужно что-то оставить, а то не очень приятно удаленно подключаться к работающему серверу по ssh, когда у него все ядра загружены под 100%

Что будет агентом, а что не будет?

Изначально в работе arataga выделялось несколько типов операций, которые должны были выполняться на разных рабочих контекстах:

  • разбор конфигурации. Обработка конфигурации для нескольких тысяч точек входа может занимать некоторое время (например, 5ms). Сюда входит и парсинг/валидация конфигурации, и сохранение ее локальной копии, и удаление устаревших точек входа, которых нет в новой конфигурации, и создание новых точек входа, добавленных в обновленной конфигурации;

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

  • обработка точек входа. Т.е. создание серверных сокетов на заданных IP+port, прием новых подключений с учетом ограничений на максимальное количество параллельных подключений и т.д.;

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

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

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

Но этот подход меня лично не вдохновлял по нескольким причинам.

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

Во-вторых, не очень понятно, в чем смысл использовать здесь агента. Взаимодействие с сетью предполагалось делать с использованием Asio и асинхронных операций. Т.е., если вызывается Asio-шный async_read_some, то когда-то Asio вызовет для этой операции completion_handler. И если мне нужно передать результат async_read_some агенту, то в completion_handler нужно будет отослать агенту сообщение, которое агент обработает. Что, вообще-то говоря, не бесплатно, т.к. сперва на каком-то io_context будет запланирован вызов completion_handler, а затем, когда до completion_handler дойдет очередь, будет запланирован вызов обработчика события у агента. И только затем когда-то будет вызван этот самый обработчик. Что наводит на мысль "а не слишком ли много приходится платить за концептуальную чистоту?" Тем более, что по прошлому опыту я знал, что когда операции чтения/записи сокета доставляются до агента в виде сообщений, то как-то кардинально это работу с сетью не упрощает. Может быть даже наоборот.

Добавим сюда еще и то, что регистрация/дерегистрация агентов в SObjectizer все-таки не самая дешевая операция (это связано с механизмом коопераций и гарантиями транзакционности операции регистрации). Конечно, если принимать новые подключения с темпом в 2000-3000 в секунду, то "тормоза" SObjectizer-а здесь не проявятся, SObjectizer может регистрировать/дерегистрировать кооперации с гораздо более высоким темпом. Но все-таки у частого создания/удаления агентов есть своя цена, и если ее можно не платить, то лучше ее не платить.

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

Для чего планировалось использовать RESTinio?

Изначально предполагалось, что посредством RESTinio будет реализован административный HTTP-вход. Причем не в виде SObjectizer-овского агента, а просто единственная отдельная нить, на которой будет работать RESTinio-сервер.

Библиотека RESTinio, действительно, была использована таким очевидным образом. А вот о паре менее очевидных способов применения RESTinio в arataga речь пойдет ниже.

Погрузимся в подробности

Некоторые детали использования SObjectizer

В этой части бегло пройдемся по некоторым моментам использования SObjectizer в коде arataga. Выбор чисто субъективный, мне показалось, что именно они наиболее показательны и/или не обычны.

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

Агент startup_manager и "глобальный таймер"

Работа arataga начинается с запуска агента startup_manager. Этот агент отвечает за последовательный запуск основных "компонентов" arataga: сперва стартует агент user_list_processor, затем агент config_processor, затем уже запускается отдельная нить с RESTinio-сервером для административного HTTP-входа.

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

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

В какой-то мере это преждевременная оптимизация.

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

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

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

Ну и зачем, спрашивается, это делать, если достаточно всего одного сообщения, которое будет отсылаться в отдельный mbox один раз в секунду? И на которое смогут подписаться столько получателей, сколько потребуется.

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

Понятие io_thread и ее реализация на базе asio_one_thread-диспетчера

Выше говорилось, что в реализации arataga было решено придерживаться модели, похожей на thread-per-core, при этом (nCPU-2) рабочих потока будет выделено под выполнение операций с сетью. Такие рабочие нити получили условное название io_thread (далее io_thread == "I/O нить" и поэтому "io_thread" будет иметь женский род).

Т.к. работа с сетью в arataga работа идет посредством Asio, то для следования идее thread-per-core было решено сделать так, чтобы на каждой io_thread работал свой собственный экземпляр asio::io_context.

Т.е. нам нужна была рабочая нить, которая создает экземпляр asio::io_context, а затем вызывает для него run(). После чего на этой нити нужно было бы еще как-то разместить и агентов, реализующих точки входа. И чтобы одна и та же нить обслуживала и операции Asio, и события SObjectizer-овских агентов.

В arataga для этих целей просто используются экземпляры asio_one_thread-диспетчера из so5extra. Каждый такой диспетчер -- это отдельный рабочий поток (т.е. io_thread).

Агент config_processor, о котором речь пойдет ниже, создает N экземпляров asio_one_thread-диспетчеров, а затем просто привязывает к этим экземплярам агентов, обслуживающих точки входа.

Агенты authentificator и dns_resolver, их дублирование на каждой io_thread

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

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

Мне показалось, что если уж мы пытаемся следовать модели thread-per-core, то логичным было бы сделать отдельную копию агентов dns_resolver и authentificator для каждой из io_thread. Поэтому эти агенты создаются сразу же вслед за очередным asio_one_thread-диспетчером и привязываются к только что созданному диспетчеру.

Распределение агентов по диспетчерам

В результате получилась следующая картинка:

У агентов config_processor и user_list_processor есть собственные рабочие потоки, которые реализуются посредством штатного диспетчера SObjectizer под названием one_thread.

Каждая io_thread представлена отдельным диспетчером asio_one_thread из so5extra. И на каждом таком диспетчере работает сразу несколько (десятков, сотен, тысяч) агентов acl_handler.

Агент config_processor

Агент config_processor отвечает за обработку конфигурации arataga, поддержание списка существующих точек входа, их распределение между io_threads, и за создание/удаление точек входа при изменении конфигурации.

При своем старте config_processor пытается прочитать локальную копию конфигурационного файла. Если это удалось, то config_processor принимает поднятую из конфига информацию за текущую конфигурацию и создает описанные в ней точки входа (регистрирует агентов acl_handler).

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

Когда от административного HTTP-входа поступает новая конфигурация, что после ее успешной обработки config_processor рассылает уведомления об изменении параметров arataga на отдельный multi-producer/multi-consumer mbox. Так изменения в конфигурации становятся доступны всем, кто в них заинтересован.

В работе config_processor с acl_handler есть важная особенность: сейчас в arataga агент acl_handler намертво привязывается к конкретному asio_one_thread и живет на этом asio_one_thread до тех пор, пока не будет удален.

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

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

Но если новые точки входа не создаются, то диспропорция останется и config_processor не будет перераспределять старые acl_handler между диспетчерами asio_one_thread. По двум причинам:

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

  • во-вторых, в каждом агенте acl_handler может существовать множество Asio-шных объектов, завязанных на конкретный экземпляр asio::io_context. Если перемещать содержимое acl_handler с одной io_thread на другую, то нужно будет и переконструировать эти Asio-шные объекты.

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

Агент user_list_processor

Агент user_list_processor отвечает за работу со списком пользователей.

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

Когда от административного HTTP-входа прилетает новый список пользователей, то user_list_processor пытается его обработать. И, если это получается успешно, то обновленный список рассылается на отдельный multi-producer/multi-consumer mbox на которые подписаны агенты authentificator. Что позволяет authentificator-ам получать обновленные списки пользователей сразу после того, как user_list_processor завершит их обработку.

Агент acl_handler

Агент acl_handler является, наверное, самым сложным и объемным агентом в arataga (hpp-файл, cpp-файл). Его задача -- это создать серверный сокет для точки входа, принимать и обслуживать подключения к этому серверному сокету.

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

Во-первых, acl_handler инициирует асинхронные I/O операции с помощью Asio, в частности, вызывает async_accept у Asio-шного asio::ip::tcp::acceptor. В async_accept передается completion-handler в виде лямбды, где напрямую вызываются методы агента. Такие вызовы безопасны потому, что агент привязан к asio_one_thread диспетчеру. Этот диспетчер специально создан для того, чтобы на одном рабочем контексте можно было работать и с агентами, и с Asio. Если бы acl_handler привязывался к какому-то другому диспетчеру (например, к штатному one_thread-диспетчеру), то этого делать было бы нельзя.

Во-вторых, логика у acl_handler достаточно тривиальная, но даже и здесь нашлось место для применения иерархического конечного автомата (которые в SObjectizer-е уже довольно давно поддерживаются):

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

В-третьих, агент acl_handler не занимается сам обслуживанием принятых подключений и обработкой протоколов socks5/http. Вместо этого для каждого соединения создается т.н. connection_handler. Это объект, в который отдается принятое подключение и который уже с этим подключением непосредственно работает. Т.е. читает из него данные, пытается определить протокол, пытается обслуживать подключение согласно того или иного протокола.

Т.е. когда в результате async_connect у acl_handler появляется новый asio::ip::tcp::socket, то создается объект, реализующий интерфейс connection_handler_t, и новый socket отдается этому только что созданному connection_handler-у. Больше про новое подключение acl_handler ничего не знает. Далее acl_handler лишь хранит у себя умный указатель на connection_handler и лишь время от времени дергает у connection_handler-а метод on_timer (тот самый "глобальный таймер" о котором речь шла выше). А вот connection_handler дальше живет своей собственной и весьма непростой жизнью.

Вообще говоря, как раз тот кусок arataga, который относится к connection_handler-ам, является самым мудреным в проекте. Вероятно, далеко не самым удачным. И, скорее всего, именно он мог бы стать первым кандидатом на рефакторинг по итогам опытной эксплуатации. Поскольку первая версия arataga создавалась в высоком темпе, в условиях сжатых сроков, то ничего лучше сходу придумать не удалось. Возможно, здесь следовало бы применить stackful-короутины. Но экспериментировать с ними не было времени. Так что, если история с arataga получит продолжение (что вряд ли, но вдруг), к этому куску arataga нужно будет сделать еще один, более вдумчивый подход. Пока что получилось то, что получилось :(

Использование retained_mbox для распространения конфигурации

Примечательным моментом использования SObjectizer-овских mbox-ов в arataga является применение retained_mbox из so5extra для распространения информации о текущей конфигурации и списках пользователей.

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

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

  • стартует агент user_list_processor. Он читает локальную копию списка пользователей и отсылает сообщение с этим списком в retained_mbox. На этом этапе подписчиков у retained_mbox еще нет, но нас это не волнует;

  • затем стартует агент config_processor, который читает локальную копию конфигурации и создает io_threads вместе с authentificator-ами;

  • каждый запущенный authentificator должен получить список пользователей, для чего authentificator-ы подписываются на соответствующее сообщение из retained_mbox-а;

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

Благодаря этому authentificator-у при старте не нужно ни у кого ничего явно запрашивать.

Правда, платить за это нужно тем, что authentificator вынужден делать подписку в so_evt_start, а не в предназначенном для этого so_define_agent.

Некоторые детали использования RESTinio

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

Первое применение RESTinio, самое очевидное: административный HTTP-вход

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

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

Упомянуть здесь можно разве лишь то, что для взаимодействия RESTinio- и SObjectizer-частей приложения были сделаны вспомогательные интерфейсы (раз, два). Когда startup_manager запускает RESTinio-сервер, то отдает серверу реализацию интерфейса requests_mailbox_t. RESTinio-сервер через этот интерфейс передает в SObjectizer-часть arataga входящие запросы. И каждый входящий запрос сопровождается реализацией интерфейса replier_t. Посредством этого интерфейса реальные обработчики запроса могут отвечать RESTinio-серверу.

Два эти интерфейса скрывают от RESTinio-части существование SObjectizer-части arataga и наоборот. Что мне показалось полезным. Хотя бы плане сокращения времени компиляции отдельных cpp-файлов.

Второе применение RESTinio, не очевидное, но вполне ожидаемое: работа с HTTP-заголовками при проксировании HTTP-соединений

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

Поэтому работу с проксируемыми HTTP-соединениями в arataga пришлось делать "с нуля". И одной из составляющей этой работы является разбирательство с HTTP-заголовками в запросах/ответах, а также с содержимым некоторых из них.

И вот когда у меня руки дошли до обработки HTTP-заголовков в проксируемых соединениях, то я внезапно (c) осознал, что в RESTinio уже есть инструментарий для этого. Чем и воспользовался, хотя изначально о таком даже и не думал.

Третье применение RESTinio, совсем не очевидное: синтаксический разбор конфигурации

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

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

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

acl.io.chunk_size 16kib # Вместо 16384timeout.authentification 1200ms # Вместо 1200timeout.connect_target 7s # Вместо 7000

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

Так что easy_parser из RESTinio, который изначально появился в RESTinio для упрощения разбора HTTP-заголовков, пригодился в arataga и для разбора конфигурации.

Любопытный, на мой взгляд, пример использования easy_parser-а из RESTinio можно найти здесь. Именно этот transfer_speed_p() используется для реализации команд конфигурации вроде:

bandlim.in 850kibbandlim.out 700kib

Заключение

Реальные проекты, подобные arataga, являются отличным полигоном, на котором разработчик библиотеки может увидеть насколько удобна его библиотека вне тепличных условий уютного манямирка. Как правило, при использовании библиотеки в новом проекте отлично проявляются просчеты в дизайне библиотеки и/или же обнажаются подводные камни, о которых ты пока что даже и не подозревал. Из чего затем вырастают вполне себе конкретные идеи о том, куда следует двигаться дальше.

С RESTinio это сработало на 100%. Опыт применения RESTinio в arataga дал пинка под за толчок в сторону разработки пусть приблизительного, но аналога middleware из ExpressJS. Первый результат уже доступен в 0.6.13. И, если пойти на слом API в ветке 0.7, то можно будет поддержать и цепочки асинхронных обработчиков.

Но еще более важным итогом использования RESTinio в arataga стало изменение лично моих взглядов на позиционирование RESTinio в мире аналогичных фреймворков для C++.

Так что для RESTinio испытание arataga оказалось исключительно полезным.

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

Я же надеюсь, что данная статья даст читателям, которые с любопытством посматривают на RESTinio и/или SObjectizer(+so5extra), но пока что сами эти проекты не пробовали, возможность взглянуть на реальный код, написанный с применением наших инструментов. И сделать собственные выводы о том, нравится ли увиденное или нет.

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

Подробнее..

Перевод Новый поток в C20 stdjthread

22.03.2021 18:15:22 | Автор: admin

Привет, Хабр! Перевод статьи подготовлен в рамках курса "C++ Developer. Professional"


Один из участников моего семинара в рамках CppCon 2018 спросил меня: Может ли std::thread быть прерван (interrupted)?. Мой ответ тогда был нет, но это уже не совсем так. С C++20 мы можем получить std::jthread (в итоге все таки получили прим. переводчика).

Позвольте мне развить тему, поднятую на CppCon 2018. Во время перерыва в моем семинаре, посвященному параллелизму, я побеседовал с Николаем (Йосуттисом). Он спросил меня, что я думаю о новом предложении P0660: Cooperatively Interruptible Joining Thread. На тот момент я ничего не знал об этом предложении. Следует отметить, что Николай является одним из авторов этого предложения (наряду с Хербом Саттером и Энтони Уильямсом). Сегодняшняя статья посвящена будущему параллелизма в C++. Ниже я привел общую картину параллелизма в текущем и грядущем C++.

Из названия документа Cooperatively Interruptible Joining Thread (совместно прерываемый присоединяемый поток) вы можете догадаться, что новый поток имеет две новые возможности: прерываемость (interruptible) и автоматическое присоединение (automatically joining, здесь и далее присоединение блокировка вызывающего потока до завершения выполнения, результат вызова метода join() прим. переводчика). Позвольте мне сначала рассказать вам об автоматическом присоединении.

Автоматическое присоединение

Это неинтуитивное поведение std::thread. Если std::thread все еще является joinable, то в его деструкторе вызывается std::terminate. Поток thr является joinable, если ни thr.join(), ни thr.detach() еще не были вызваны.

// threadJoinable.cpp#include <iostream>#include <thread>int main(){        std::cout << std::endl;    std::cout << std::boolalpha;        std::thread thr{[]{ std::cout << "Joinable std::thread" << std::endl; }};        std::cout << "thr.joinable(): " << thr.joinable() << std::endl;        std::cout << std::endl;    }

При выполнении программа терминируется.

Оба потока терминируются. На втором запуске поток th имеет достаточно времени, чтобы отобразить свое сообщение: Joinable std::thread.

В следующем примере я заменяю хедер <thread> на "jthread.hpp" и использую std::jthread из грядущего стандарта C++.

// jthreadJoinable.cpp#include <iostream>#include "jthread.hpp"int main(){        std::cout << std::endl;    std::cout << std::boolalpha;        std::jthread thr{[]{ std::cout << "Joinable std::thread" << std::endl; }};        std::cout << "thr.joinable(): " << thr.joinable() << std::endl;        std::cout << std::endl;    }

Теперь поток thr автоматически присоединяется в своем деструкторе, если он все еще является joinable, как, например, в этом примере.

Прерывание std::jthread

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

// interruptJthread.cpp#include "jthread.hpp"#include <chrono>#include <iostream>using namespace::std::literals;int main(){        std::cout << std::endl;        std::jthread nonInterruptable([]{                                   // (1)        int counter{0};        while (counter < 10){            std::this_thread::sleep_for(0.2s);            std::cerr << "nonInterruptable: " << counter << std::endl;             ++counter;        }    });        std::jthread interruptable([](std::interrupt_token itoken){         // (2)        int counter{0};        while (counter < 10){            std::this_thread::sleep_for(0.2s);            if (itoken.is_interrupted()) return;                        // (3)            std::cerr << "interruptable: " << counter << std::endl;             ++counter;        }    });        std::this_thread::sleep_for(1s);        std::cerr << std::endl;    std::cerr << "Main thread interrupts both jthreads" << std:: endl;    nonInterruptable.interrupt();    interruptable.interrupt();                                          // (4)        std::cout << std::endl;    }

Я запустил в main два потока, nonInterruptable, который нельзя прерывать, и interruptable, который можно (строки 1 и 2). В отличие от потока nonInterruptable, поток interruptable, получает std::interrupt_token и использует его в строке 3, чтобы проверить, был ли он прерван: itoken.is_interrupted(). В случае прерывания в лямбде срабатывает return и, следовательно, поток завершается. Вызов interruptable.interrupt() (строка 4) триггерит завершение потока. Аналогичный вызов nonInterruptable.interrupt() не сработает для потока nonInterruptable, который, как мы видим, продолжает свое выполнение.

Вот более подробная информация о токенах прерывания (interrupt tokens), присоединяющихся потоках и условных переменных.

Токены прерывания

Токен прерывания std::interrupt_token моделирует совместное владение (shared ownership) и может использоваться для сигнализирования о прерывании, если токен валиден. Он предоставляет три метода: valid, is_interrupted, и interrupt.

itoken.valid() true, если токен прерывания может быть использован для сигнализировании о прерывании

itoken.is_interrupted() true, если был инициализирован с true или был вызван метода interrupt()

itoken.interrupt() если !valid() или is_interrupted(), то вызов метода не возымеет эффекта. В противном случае, сигнализирует о прерывании посредством itoken.is_interrupted() == true. Возвращает значение is_interrupted()

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

std::jthread jthr([](std::interrupt_token itoken){    ...    std::interrupt_token interruptDisabled;     std::swap(itoken, interruptDisabled);     // (1)           ...    std::swap(itoken, interruptDisabled);     // (2)    ...}

std::interrupt_token interruptDisabled не валиден. Это означает, что поток не может принять прерывание между строками (1) и (2), но после строки (2) уже может.

Присоединение потоков

std::jhread представляет собой std::thread с дополнительным функционалом, реализующим сигнализирование о прерывании и автоматическое присоединение. Для поддержки этой функциональности у него есть std::interrupt_token.

Новые перегрузки Wait для условных переменных

Две вариации wait wait_for и wait_until из std::condition_variable получат новые перегрузки. Они принимают std::interrupt_token.

template <class Predicate>bool wait_until(unique_lock<mutex>& lock,                 Predicate pred,                 interrupt_token itoken);template <class Rep, class Period, class Predicate>bool wait_for(unique_lock<mutex>& lock,               const chrono::duration<Rep, Period>& rel_time,               Predicate pred,               interrupt_token itoken);template <class Clock, class Duration, class Predicate>bool wait_until(unique_lock<mutex>& lock,                 const chrono::time_point<Clock, Duration>& abs_time,                 Predicate pred,                 interrupt_token itoken);

Новые перегрузки требует предикат. Эти версии гарантированно получают уведомления, если поступает сигнал о прерывании для переданного им std::interrupt_token itoken. После вызовов wait вы можете проверить, не произошло ли прерывание.

cv.wait_until(lock, predicate, itoken);if (itoken.is_interrupted()){    // interrupt occurred}

Что дальше?

Как я и обещал в своей последней статье, следующая статья будет посвящена оставшимся правилам определения концептов (concepts).


Узнать подробнее о курсе "C++ Developer. Professional".

Смотреть запись демо-занятия по теме
Области видимости и невидимости: участники вместе с экспертом попробовали реализовать класс общего назначения и запустить несколько unit-тестов с использованием googletest.

Подробнее..

Развитие проекта arataga пара рефакторингов по результатам натурных испытаний

27.05.2021 10:19:54 | Автор: admin

OpenSource-проект arataga -- это работающий прототип производительного socks5+http/1.1 прокси-сервера. Реализован arataga на базе Asio, SObjectizer и RESTinio. Об arataga уже рассказывалось несколько месяцев назад именно как о хорошем примере того, как выглядит реальный код на SObjectizer-е. Ведь одно дело повествовать о сильных сторонах SObjectizer-а с иллюстрациями из игрушечных примеров. Совсем другое -- иметь возможность показать почти что продакшен-код.

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

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

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

Собственная реализация взаимодействия с DNS

Первым узким местом, которые выявили натурные испытания, стала процедура резолвинга доменных имен.

Отказ от Asio-шного async_resolve

В первой версии arataga ради экономии времени мы не стали делать реализацию взаимодействия с DNS посредством UDP. Вместо этого использовали средства для асинхронного резолвинга, которые доступны в Asio из коробки.

Как я понял, Asio для выполнения async_resolve использует дополнительную рабочую нить, на которой делает обычные синхронные обращения к ОС для преобразования доменного имени в набор IP-адресов. И, если одно такое обращение "притормозит", то будут приостановлены и все последующие обращения к async_resolve. Кроме того, если нам нужно выполнить резолвинг сразу нескольких имен, то параллельно мы этого сделать не можем. Резолвинг будет выполняться последовательно.

В итоге, когда потребовалось обслуживать множество параллельных преобразований доменных имен в IP-адреса Asio-шный async_resolve буквально "вставал колом". Некоторым клиентам результат операции async_resolve приходилось ждать по несколько десятков секунд (максимальные значения, насколько я помню, колебались в районе от 35 до 40 секунд, в зависимости от нагрузки).

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

Работа с DNS на уровне агентов: было и стало

Не думаю, что есть смысл рассказывать о том, как именно было реализовано общение с DNS-серверами посредством UDP-датаграмм, поскольку вся эта работа сосредоточена внутри одного агента a_namesever_interactor_t и никто деталей этой работы не видит.

Гораздо интереснее рассказывать о том, как резолвинг доменных имен представлен на уровне взаимодействия агентов в arataga.

Первоначальная схема была тривиальной: был некий интерфейсный почтовый ящик (mbox) и два сообщения -- resolve_request_t и resolve_reply_t. За этим почтовым ящиком скрывался единственный агент a_dns_resolver_t, который и отвечал за процедуру резолвинга доменных имен: держал кэш уже обработанных имен, организовывал список ждущих своей очереди доменных имен, дергал async_resolve и обрабатывал результаты резолвинга.

Важный момент, на который хотелось бы обратить внимание -- это то, что само существование dns_resolver было неизвестно другим частям arataga. Механизм резолвинга доменных имен был практически в буквальном смысле "черным ящиком". Просто есть некий mbox, в который нужно отсылать сообщения resolve_request. В каждом resolve_request передается mbox, на который затем прилетает resolve_reply. И это все, что требовалось знать.

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

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

Во-первых, это агент interactor::a_nameserver_interactor_t, который непосредственно общается с DNS-серверами по UDP. Агент nameserver_interactor отвечает за преобразование конкретного запроса на резолвинг в соответствующую UDP-датаграмму и обработку ответов от DNS-серверов.

Во-вторых, это агент lookup_conductor::a_conductor_t, который принимает входящие resolve_request, проверяет наличие результатов в кэше, выстраивает в очередь запросы, для которых нужен резолвинг, и отправляет команды агенту nameserver_interactor.

А вот с conductor все не так просто :)

И самое любопытное здесь то, что на самом деле conductor-ов два.

Первоначально планировалось использовать всего один экземпляр conductor. Который бы обрабатывал и IPv4, и IPv6. По аналогии с тем, как это делал dns_resolver из первых версий arataga: dns_resolver в случае успешного результата получал список IP-адресов, в котором присутствовали и IPv4, и IPv6 адреса. И когда приходил resolve_request_t для получения IPv4 адреса, то dns_resolver выбирал из списка IPv4 адрес. А когда приходил resolve_request для IPv6 адреса, то dns_resolver искал в списке IPv6 адрес (либо же конвертировал в IPv6 один из IPv4 адресов).

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

И при рефакторинге взаимодействия с DNS-серверами предполагалось, что таким же образом будет работать и nameserver_interactor.

Но в процессе тестирования nameserver_interactor с реальными DNS-серверами выяснилось, что работает либо запрос ресурсной записи типа A, либо запрос ресурсной записи типа AAAA. Но вот если отослать в одной UDP-датаграмме сразу два запроса (и для A, и для AAAA), то ответ придет только на один из них.

Посему имея на руках почти готовые реализации conductor и nameserver_interactor нужно было что-то оперативно решать. Понятно, что при обращении к nameserver_interactor придется явно указывать тип IP-адреса (IPv4 или IPv6). Но не понятно, как быть с кэшированием результатов и выстраиванием запросов в очереди.

Усложнять conductor и делать в нем отдельные кэши/очереди для разных типов IP-адресов? Вести общий кэш, в котором будут хранится адреса обоих типов и инициировать запрос к nameserver_interactor, если адресов нужного типа в кэше нет?

Тратить дополнительное время на рефакторинг conductor не хотелось, поэтому в модном и молодежном стиле "фигак-фигак и в продакшен" был применен метод грубой силы: вместо одного conductor-а запускается сразу два. Один обслуживает запросы для IPv4, второй -- запросы для IPv6. И оба они подписываются на сообщения resolve_request из интерфейсного mbox-а.

Вопрос был лишь в том, чтобы разрулить поток resolve_request между этими двумя агентами. Нужно был как-то сделать так, чтобы один агент получал только запросы для IPv4, а второй -- только запросы для IPv6.

Сделать это оказалось совсем не сложно. Были применены фильтры доставки для сообщений.

Фильтры доставки -- это предикаты, которые агент может "навесить" на сообщения из конкретного multi-consumer mbox-а. Когда сообщение отсылается в mbox, то mbox обращается к предикату с вопросом "можно ли доставлять конкретно этот экземпляр сообщения до твого владельца?" Если предикат говорит, что можно, то сообщение доставляется. Если нет, то игнорируется.

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

voida_conductor_t::so_define_agent(){// We want to receive only requests for our IP-version.so_set_delivery_filter(m_incoming_requests_mbox,[ip_ver = m_ip_version]( const resolve_request_t & req ) {return ip_ver == req.m_ip_version;} );so_subscribe( m_incoming_requests_mbox ).event( &a_conductor_t::on_resolve );

Каков результат этих изменений?

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

Таймеры для acl_handler-ов

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

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

Дело в том, что внутри arataga есть специальное сообщение one_second_timer, которое генерируется раз в секунду и которое используется acl_handler-ами для разных целей: и для контроля тайм-аутов текущих операций, и для пересчета лимитов по подключениям.

Дабы не тратить время при выпуске первой версии arataga была использована самая простая схема: каждый acl_handler просто подписывается на one_second_timer. Что означает, что когда это сообщение возникает, то оно доставляется всем подписчикам.

Т.е., если у нас создано 15K acl_handler-ов, то раз в секунду у них у всех запускается обработчик one_second_timer. Что и увеличивает потребление CPU.

Проблема в том, что далеко не у всех из этих 15K acl_handler-ов в настоящий момент есть необходимость реагировать на one_second_timer. У части acl_handler-ов не будет вообще подключений, которые нужно обслуживать. И доставлять one_second_timer до таких acl_handler-ов нет смысла.

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

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

Какие подходы нельзя назвать хорошими?

Фильтры доставки

Можно было бы попробовать задействовать фильтры доставки (о которых речь уже шла выше). Так, фильтр доставки при отсылке сообщения проверял бы наличие принятых подключений у acl_handler-а и, если подключений нет, то запрещал бы доставку сообщения.

Тут сразу несколько проблем.

Во-первых, отсылка сообщения one_second_timer идет с контекста специальной нити таймера, которой управляет сам SObjectizer. И на этой же нити в процессе отсылки сообщения запускаются фильтры доставки. Следовательно, если фильтр доставки хочет обратиться к каким-то потрохам acl_handler-а, то эти потроха должны быть как-то защищены с точки зрения thread-safety. Например, посредством mutex-а. Что отнюдь не бесплатно. И затрудняет реализацию самого acl_handler-а, т.к. внутри агента нужно заботиться о thread-safety, хотя агенты как раз и нужны, чтобы такими вещами не заниматься.

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

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

Подписка/отписка на/от one_second_timer

Если нужно доставлять one_second_timer только до агентов, которые обслуживают принятые подключения, то напрашивается простой и логичный подход: пусть acl_handler при принятии первого подключения создает подписку на one_second_timer, а при потере последнего подключения -- отписывается от one_second_timer.

Это нормальный подход. По крайней мере он идеологически правильный и не ведет к каким-либо проблемам с thread-safety.

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

А хотелось бы, чтобы работа с one_second_timer была еще дешевле.

Использованный подход

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

Введено понятие timer_provider. Это интерфейс объекта, который должен присутствовать на каждой io_thread в единственном числе. Его задачей является периодический вызов метода on_timer у привязанных к этой же io_thread объектов timer_consumer. Но не у всех timer_consumer, а только у тех, кто заявил о себе timer_provider-у. Т.е., когда у timer_consumer появляется потребность в обработке таймера, он вызывает у timer_provider-а метод activate_consumer, а когда такая потребность исчезает -- вызывает метод deactivate_consumer. Таким образом у timer_provider есть список активных timer_consumer-ов.

За интерфейсом timer_provider скрывается простой агент, который подписывается на one_second_timer. Получив это сообщение timer_provider просто бежит по своему списку активных timer_consumer-ов и вызывает у них on_timer.

timer_consumer-ами являются acl_handler-ы. Когда у acl_handler-а (он же timer_consumer) появляется первое принятое подключение, то acl_handler добавляет себя в список к timer_provider-у. А когда подключений не остается acl_handler вычеркивает себя из списка timer_provider-а.

Фокус в том, что все взаимодействующие в рамках одной io_thread timer_provider и timer_consumer-ы привязанны к одному и тому же диспетчеру. Что гарантирует, что они работают на одной и той же рабочей нити. А значит могут обращаться друг к другу не боясь приключений с thread-safety.

Это отличный пример того, как при работе с SObjectizer-ом отказаться от взаимодействия с агентами только посредством асинхронного взаимодействия и выполнять синхронные вызовы методов агентов, но при этом не иметь проблем с thread-safety. Ведь диспетчеры в SObjectizer-е и были введены как раз для того, чтобы программист мог управлять тем где и как работают его агенты. И, если у программиста есть возможность директивно привязать своих агентов к конкретной нити, то почему бы этим не пользоваться. По крайней мере в условиях, когда хочется выжимать максимум.

Защита от повисших указателей

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

Когда у acl_handler-а (т.е. у timer_consumer-а) вызывается so_evt_finish (это последнее событие для агента перед дерегистрацией), то acl_handler вычеркивает себя из списка timer_provider-а. Так что у timer_provider-а не может остаться повисших указателей на timer_consumer-ы (в теории, по крайней мере).

Но что с самим указателем на timer_provider? Как сделать так, чтобы он оставался валидным до тех пор, пока жив хотя бы один acl_handler?

Очень просто: кооперация с агентом timer_provider выступает в качестве родительской для всех коопераций с acl_handler-aми. В этом случае SObjectizer гарантирует, что родительская кооперация не будет уничтожена пока есть хотя бы одна живая дочерняя кооперация. Т.е. указатель на timer_provider внутри timer_consumer-ов будет оставаться валидным.

Каков результат этих изменений?

В зависимости от конфигурации, количества ACL внутри arataga и входящей нагрузки расход CPU на тестовых прогонах снизился от 1.5 до 4-х раз.

Заключение

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

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

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

Так что, если у кого-то есть сомнения о том, брать или не брать SObjectizer/RESTinio/json-dto в работу, то отбросьте их и попробуйте. В случае сложностей или проблем смело обращайтесь к нам, без помощи не оставим.

Подробнее..

Перевод Ваш ABI, скорее всего, неверен

07.06.2021 18:08:54 | Автор: admin

ABI, или двоичный интерфейс приложения (Application Binary Interface), определяет способ взаимодействия двоичных файлов друг с другом на конкретной платформе и включает соглашение о вызовах. Большинство ABI имеют один конструктивный недостаток, который снижает производительность.

Давайте начнем с рассмотрения ABI System V для процессоров линейки x86. ABI классифицирует аргументы функции по ряду различных категорий; мы будем рассматривать только две:

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

MEMORY: Этот класс состоит из типов, которые будет переданы в память и возвращены через стек.

Я не буду подробно описывать правила классификации аргументов; достаточно сказать, что в общем смысле:

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

  2. Если структура слишком большая, она имеет класс MEMORY и передается в стек.

  3. Если аргументов слишком много, те, которые не помещаются в регистры, будут переданы в стек.

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

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

Например:

void foo(int*);void bar(void);int x = 5;foo(&x);   // насколько нам известно, foo мог сохранить &x в глобальной переменнойx = 7;bar();       // через которую, bar мог изменить xreturn x;   // что означает, что это должно превратиться в фактическую загрузку; он не может быть обернут в константу     // (Этого не произошло бы, если бы x был передан по значению, но, как мы знаем, это не всегда приемлемо для больших структур.)

restrict во спасение! Если бы параметр foo был аннотирован с restrict, foo не смог бы использовать его псевдоним (C116.7.3.1p4,11). К сожалению, компиляторы обычно не в курсе об этом факте. Более того, поскольку принудительного применения типа restrict в C нет, в общем понимании на добросовестность атрибута рассчитывать нельзя, даже если он может быть правильным в тех случаях, когда ABI C используется для связи между языками с более строгой типизацией.

И действительно, ABI должен поступать правильно по умолчанию. void foo(struct bla) намного легче читать, чем void foo(const struct bla *restrict), не говоря уже о том, что он лучше передает намерение и фактически обеспечивает более сильную семантическую гарантию.

Что ж, такова System V. Как обстоят дела с другими ABI? Microsoft похожа, но она передает структуры с указателем:

Структуры или объединения [не малых] размеров передаются как указатель на память, выделенную вызывающей стороной.

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

Больше ABI! ARM (извините, AAA arch 64):

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

RISC-V:

Агрегаты размером более 2XLEN бит [примечание: какого черта вы говорите о битах?] передаются по ссылке и заменяются в списке аргументов адресом.

[...]

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

PowerPC:

Все [неоднородное] агрегаты передаются в последовательные регистры общего назначения (GPR), в регистры общего назначения и в память, или в память.

MIPS n32:

Структуры (structs), объединения (unions),или другие составные типы рассматриваются как последовательность двойных слов (doublewords), и передаются в целые регистры или регистры с плавающей запятой, как если бы они были простыми скалярными параметрами в той степени, в которой они помещаются, с любым избытком в стеке, упакованным в соответствии с обычной структурой памяти объекта.

Все это повторения одних и тех же двух ошибок.


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

Мотайте на ус, будущие создатели ABI!


Ссылки:

Перевод материала подготовлен в рамках курса "C++ Developer. Professional". Если вам интересно узнать больше о курсе, приглашаем на день открытых дверей онлайн, на котором можно будет узнать о формате и программе курса, познакомиться с преподавателем.

- ЗАПИСАТЬСЯ НА DEMO DAY

Подробнее..

Перевод Пара мыслей о геттерах и сеттерах в C

11.06.2021 18:10:18 | Автор: admin

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

TL;DR: геттеры и сеттеры не очень хорошо подходят для структуроподобных объектов.

Введение

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

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

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

Допустим, у нас есть простая структура с обычными геттерами и сеттерами:

class PersonGettersSetters {  public:    std::string getLastName() const { return m_lastName; }    std::string getFirstName() const { return m_firstName; }    int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }  private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

Сравним эту версию с версией без геттеров и сеттеров.

struct Person {    int age = 26;    std::string firstName = "Antoine";    std::string lastName = "MORRIER";};

Она намного лаконичнее и надежнее. Здесь мы не можем, например, верну фамилию вместо имени.

Оба кода полностью функциональны. У нас есть класс Person с именем (firstName), фамилией (lastName) и возрастом (age). Однако предположим, что нам нужна функция, которая возвращает некоторую сводку по конкретному человеку.

std::string getPresentation(const PersonGettersSetters &person) {  return "Hello, my name is " + person.getFirstName() + " " + person.getLastName() +  " and I am " + std::to_string(person.getAge());}std::string getPresentation(const Person &person) {  return "Hello, my name is " + person.firstName + " " + person.lastName + " and I am " + std::to_string(person.age);}

Версия без геттеров выполняет эту задачу на 30% быстрее, чем версия с геттерами. Почему? Из-за возврата по значению в геттере. При возврате по значению создается копия, что снижает производительность. Давайте сравним производительность person.getFirstName(); и person.firstName.

Как видите, прямой доступ к полю имени без геттера эквивалентен noop.

Геттер по константной ссылке

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

class PersonGettersSetters {  public:    const std::string &getLastName() const { return m_lastName; }    const std::string &getFirstName() const { return m_firstName; }    int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }  private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

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

PersonGettersSetters make() {    return {};   }int main() {    auto &x = make().getLastName();         std::cout << x << std::endl;        for(auto x : make().getLastName()) {        std::cout << x << ",";       }}

Вы можете заметить некоторые странные символы, выведенные в консоли. Но почему? Что произошло, когда мы сделали make().getLastName()?

  1. Вы создаете экземпляр Person.

  2. Вы получаете ссылку на фамилию.

  3. Вы удаляете экземпляр Person.

И вот у нас есть висячая ссылка! Это может привести к крашам (в лучшем случае) или чему-то еще более худшему, чему-то, что можно найти только в фильмах ужасов.

Чтобы предупредить это, мы должны ввести ref-qualified функции.

class PersonGettersSetters {  public:    const std::string &getLastName() const & { return m_lastName; }    const std::string &getFirstName() const & { return m_firstName; }        std::string getLastName() && { return std::move(m_lastName); }    std::string getFirstName() && { return std::move(m_firstName); }        int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }      private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

Вот новое решение, которое будет работать везде. Вам нужно два геттера. Один для lvalue и один для rvalue (как xvalue, так и для prvalue).

Проблемы с сеттерами

Тут особо нечего сказать. Если вы хотите добиться максимальной производительности, вы должны написать один сеттер, который принимает lvalue, и один, который принимает rvalue. Однако, как правило, достаточно иметь всего один сеттер, который принимает перемещаемое значение. Тем не менее, вам придется расплатиться за это дополнительным move. Однако таким образом у вас не получится производить небольшие изменения в переменных. Вы должны заменять всю переменную целиком. Если вы просто хотите заменить одну букву A в имени на D, то вы не сможете сделать это с помощью сеттеров. Однако с помощью прямого доступа так делать можно.

А как насчет иммутабельных переменных?

Кто-то может посоветовать вам просто сделать атрибут члена const. Однако меня это решение не устраивает. Создание константы предотвратит move-семантику и приведет к ненужному копированию.

У меня нет волшебного решения, которое я мог бы предложить вам прямо сейчас. Тем не менее, мы можем написать обертку, которую мы можем назвать immutable<T>. Эта обертка должна быть:

  1. Constructible

  2. Так как она immutable, она не должна быть assignable

  3. Она может быть copy constructible или move constructible

  4. Она должна быть конвертируемой в const T&, будучи lvalue

  5. Она должна быть конвертируемой в T, будучи rvalue

  6. Она должна использоваться, как и другие оболочки, с помощью оператора * или оператора ->.

  7. Получить адрес базового объекта должно быть легко.

Вот небольшая реализация:

#define FWD(x) ::std::forward<decltype(x)>(x)template <typename T>struct AsPointer {    using underlying_type = T;    AsPointer(T &&v) noexcept : v{std::move(v)} {}    T &operator*() noexcept { return v; }    T *operator->() noexcept { return std::addressof(v); }    T v;};template <typename T>struct AsPointer<T &> {    using underlying_type = T &;    AsPointer(T &v) noexcept : v{std::addressof(v)} {}    T &operator*() noexcept { return *v; }    T *operator->() noexcept { return v; }    T *v;};template<typename T>class immutable_t {  public:    template <typename _T>    immutable_t(_T &&t) noexcept : m_object{FWD(t)} {}    template <typename _T>    immutable_t &operator=(_T &&) = delete;    operator const T &() const &noexcept { return m_object; }    const T &operator*() const &noexcept { return m_object; }    AsPointer<const T &> operator->() const &noexcept { return m_object; }    operator T() &&noexcept { return std::move(m_object); }    T operator*() &&noexcept { return std::move(m_object); }    AsPointer<T> operator->() &&noexcept { return std::move(m_object); }    T *operator&() &&noexcept = delete;    const T *operator&() const &noexcept { return std::addressof(m_object); }    friend auto operator==(const immutable_t &a, const immutable_t &b) noexcept { return *a == *b; }    friend auto operator<(const immutable_t &a, const immutable_t &b) noexcept { return *a < *b; }  private:    T m_object;};

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

struct ImmutablePerson {    immutable_t<int> age = 26;    immutable_t<std::string> firstName = "Antoine";    immutable_t<std::string> lastName = "MORRIER";};

Заключение

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

  • 3-х геттеров (или даже 4-х): const lvalue, rvalue, const rvalue и, по вашему усмотрению, для неконстантного lvalue (даже если это уже просто очень странно звучит, так как проще использовать прямой доступ)

  • 1 сеттер (или 2, если вы хотите выжать максимальную производительность).

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

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

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

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

Ну а что думаете вы? Используете ли вы геттеры и сеттеры? И почему?


Перевод материала подготовлен в рамках курса "C++ Developer. Basic". Всех желающих приглашаем на двухдневный онлайн-интенсив HTTPS и треды в С++. От простого к прекрасному. В первый день интенсива мы настроим свой http-сервер и разберем его что называется от и до. Во второй день произведем все необходимые замеры и сделаем наш сервер супер быстрым, что поможет нам понять на примере, чем же все-таки язык С++ лучше других. Регистрация здесь

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru