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

Демонстрация идеи будет проходить на живых примерах кода на современном C++. Большинство описанных решений я применял сначала на собственных проектах, а теперь часть этих подходов уже используется в нашей собственной микроядерной операционной системе «Лаборатории Касперского» (KasperskyOS).

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

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

Типичные ошибки многопоточного кода


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

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

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

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

Код ниже — иллюстрация того, что я имею в виду. Это не реальный пример, просто демонстрация.



Здесь есть некий класс с полями данных, не все из которых нуждаются в защите. Например, _id — не нуждается. Есть первый мьютекс (_mutex1), который защищает _name и _data1, а также _mutex2, который защищает _data2. Возможно, он был добавлен уже позже другим человеком, который не разобрался во всех деталях. В приведенном выше коде сложно запутаться, но классы бывают довольно длинными. Когда нет возможности их переписать, приходится разбираться в чужой логике, и вот в этом случае запутаться с мьютексами очень легко.

Первый метод — dataSize. Здесь мы воспользовались lock_guard, залочили первый мьютекс и обратились к _data1. А вот в методе name про _mutex1 забыли, хотя предполагалось, что первый мьютекс будет защищать и _name.

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

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

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

Существующие методики решения проблемы


Поговорим о том, как эту проблему можно решить.

Повысить качество инспекции кода. Это первое, что приходит в голову. Но человеческий фактор никто не отменял — всегда можно пропустить какие-то моменты, особенно в сложном коде с нетривиальной логикой. Поэтому метод не дает гарантии 100%.

Использовать готовые библиотеки для распараллеливания. Это попытка уйти от прямого использования примитивов синхронизации. Например, Streams в Java 8 сделан удобно — достаточно добавить один вызов, и вся коллекция уже обрабатывается в параллели, а разработчику не нужно думать об этих низкоуровневых сущностях.

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

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

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

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

Использовать определенные языки. В некоторых языках компилятор в состоянии полностью проконтролировать любые обращения к общим данным и запретить некорректные попытки. Например, это доступно в языках программирования D или Rust и отчасти C#. В том же D есть ключевое слово shared, которым объявляются общие данные. Далее компилятор отслеживает все неправильные обращения к ним и подсказывает ошибки на этапе компиляции.

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

Суть идеи


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

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

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

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

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

Интерфейс SharedState


На практике мы передаем в интерфейс SharedState все параметры, необходимые для создания объекта общих данных.

Шаблонный метод для просмотра данных называется view и принимает в качестве параметра std::function, которая будет вызвана с константной ссылкой на общие данные (то есть в этом вызове данные защищены — грубо говоря, мьютекс залочен внутри). Метод view можно специфицировать под любое возвращаемое значение. Возможно, в «боевом» коде вместо std::function лучше использовать шаблон (при обращении к динамической памяти std::function может вызывать снижение производительности), но для наглядности и прозрачности сигнатур здесь будет пример с использованием std::function.

Простой метод modify для изменения общих данных принимает std::function, которая будет вызвана с не константной ссылкой на общие данные. Внутри мы как-то модифицируем данные и выходим. Более сложный случай — это метод modify, который возвращает класс Action. Так можно реализовать более сложные вещи.

В интерфейсе Action есть методы для простой модификации общих данных без оповещения об их изменении access и extract, а также методы для модификации общих данных с одновременным оповещением об их изменении. notifyOne и notifyAll — они введены как раз для того, чтобы предоставить возможность одному или, соответственно, всем ожидающим потокам получать нотификации об изменениях. Также предусмотрен метод when, который принимает std::function, — предикат для определения подходящего состояния общих данных. Метод возвращает тот же экземпляр объекта Action, поэтому можно строить цепочки вызовов:



Здесь на основе SharedState я написал простую реализацию пула потоков. Внутри цикл, в котором каждый воркер из пула потоков ожидает появления новых задач и берет их на выполнение. Мы обращаемся к SharedState, который называется _state, запрашиваем modify(). Он возвращает Action, у которого мы вызываем метод when. Детально разбирать не буду, но смысл в том, что мы проверяем, есть ли в очереди новые задачи, и уже внутри пытаемся получить доступ к общим данным.

Этот код выглядит не идеально. Когда мы делаем экстракт, приходится использовать ключевое слово template — это требование С++. А еще приходится писать много скобок. Все это — минусы (впрочем, если пользоваться Cpp2/CppFront, эти недостатки нивелируются :)). Но когда та же функция была написана с использованием мьютексов и condition variables, выглядело это еще хуже. Сейчас мы ушли от общения с мьютексами, и код стал чище и понятнее.

Посмотрим, что под капотом класса SharedState.

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



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

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



Здесь мы передали в приватный конструктор Action SharedState. Он лочится, и на время жизни Action общие данные заблокированы. Внутри мы делаем access — это простой способ модифицировать данные. Предусмотрены notifyOne и notifyAll. А when — метод condition variables, куда передается предикат. Он останавливает цепочку выполнения до тех пор, пока предикат не выполнится для общих данных.

Как выглядят примеры с SharedState


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



Общие данные, которые предполагалось защищать первым мьютексом, помещаем в одну структуру, а данные, защищаемые вторым мьютексом, — в другую. Создаем два экземпляра SharedState, специфицируем их структурой Data1 и Data2. _id у нас лежат отдельно, потому что не нуждаются в защите.

Чтобы считать размер вектора, мы специфицируем view результатом, который хотим возвращать, в данном случае size_t, и получаем константную ссылку (здесь ее тип не пишем, пользуемся auto). Далее обращаемся к структуре, которая доступна по константной ссылке, и вызываем метод size(). Если мы попробуем вызвать модифицирующий метод (без спецификатора const), компилятор поможет найти эту ошибку.

По сути мы здесь исправляем проблему первого примера — не лочим _id. Явные блоки кода помогают понять, к каким именно общим данным мы обращаемся. Не приходится думать о том, каким мьютексом их надо залочить. Мы просто получаем доступ к данным, а защита осуществляется где-то под капотом.

Преимущества подхода


Какие преимущества я вижу в использовании этого подхода:
  • Мы уходим от явного создания таких примитивов синхронизации, как мьютексы, локеры и condition variables, а заодно и от ручного управления ими.
  • Общие данные представляют собой отдельную сущность. Кажется, что это плохо — мы порождаем лишние сущности. Но если посмотреть немного под другим углом, раньше общие данные представляли собой отдельные поля, разбросанные по классу. Однако у них была какая-то логическая связь (мы же шарим их между потоками). И благодаря отдельной структуре мы эту связь материализуем. Концептуально так и должно быть — данные живут и управляются вместе.
  • Концептуально мы уходим от категорий «заблокирован/разблокирован», а начинаем мыслить в терминах доступа к данным: «просмотр/модификация/ожидание».
  • Автор кода явно выражает свои намерения по отношению к общим данным. Например, объявляет прямо с помощью кода, что в следующих строчках хочет посмотреть общие данные. Такое самодокументирование помогает другим людям в его поддержке.
  • Благодаря использованию std::shared_mutex (C++ 17) несколько потоков могут одновременно читать одни и те же данные, что в отдельных случаях может повысить общую производительность. Мы ничего для этого не делаем специально — просто выражаем намерение именно читать данные.
  • Все методы SharedState реализованы как inline и объявлены прямо в заголовочном файле, поэтому производительность не должна пострадать после перехода на SharedState.
    Примечание: я проверял ассемблерный «выхлоп» clang на Linux, и было видно, что компилятор сумел эффективно встроить лямбда-выражения в место использования. Однако для внедрения в продакшен рекомендую более основательные, системные исследования производительности.


Правила использования SharedState


Вместо итогов хотел бы также упомянуть, что определенные ошибки могут произойти и при использовании SharedState. Вот что нужно учитывать, чтобы с ними не сталкиваться:
  • Объект общих данных должен быть простым и не содержать сложной/неочевидной логики. Желательно обойтись без ссылок на внешние объекты. Основное назначение объекта общих данных — быть тривиальным контейнером, в С++ лучше всего подходит термин «структура».
  • Для улучшения производительности необходимо минимизировать количество кода в лямбдах доступа к общим данным и ограничиться извлечением необходимой информации или ее модификацией. Не стоит вызывать внутри защищенных блоков какие-то тяжеловесные операции. Это верно и при любой работе с мьютексами. Но в данном случае не следует забывать, что все залочено. А поэтому лучше выполнять именно то, ради чего запросили доступ. Взяли доступ на чтение — прочитали, возможно, сохранили в локальную переменную, и уже с ней продолжаем работать. С модификацией аналогично: зашли, модифицировали, вышли. Вся сложная логика должна оставаться снаружи. Также нельзя обращаться к тому же экземпляру SharedState. Мьютексы используются не рекурсивно, поэтому иначе получится взаимоблокировка, deadlock.
  • Сложные манипуляции с извлеченными данными лучше производить в локальных переменных за пределами блока лямбда-выражения. Однако не стоит забывать, что связь извлеченных данных с их источником в объекте общих данных сразу же теряется после выхода из лямбды, так как другой поток может их сразу же изменить.
  • Нельзя сохранять ссылки на объект общих данных и работать с ним в обход регламента. Мы совершенно спокойно можем зайти в лямбду, сохранить указатели на общие данные и работать с ними снаружи. Но в этом случае контракт нарушается и ничего не гарантируется.
  • Рекомендую не сохранять (например, в полях класса) экземпляр класса Action, который возвращает SharedState, потому что во время его существования внутренний мьютекс будет залочен. Лучше использовать его исключительно в цепочке вызовов (как в одном из приведенных выше примеров) или в крайнем случае как локальную переменную в ограниченном скопе (как, например, мьютекс в явно созданном для него блоке кода).


Примеры использования SharedState


Простая реализация Thread Pool: https://sourceforge.net/p/cpp-mate/code/ci/default/tree/src/main/public/CppMate/ThreadPool.hpp
https://cpp-mate.sourceforge.io/doc/classCppMate_1_1ThreadPool.html


Реализация абстрактного кэша: https://sourceforge.net/p/cpp-mate/code/ci/default/tree/src/main/public/CppMate/Cache.hpp

Впрочем, есть и альтернативные решения:

* В библиотеке Boost существуют Synchronized Data Structures и, в частности, Synchronized Values, однако их поддержка пока находится в экспериментальной стадии

* В библиотеке Folly существуют похожие механизмы, описанные вот тут: https://github.com/facebook/folly/blob/main/folly/docs/Synchronized.md

Если вам понравилась идея и вы хотите попробовать ее на C++, у меня готова полная реализация SharedState с документацией (Doxygen).

А в целом, если вы любите ковыряться в подобных вещах именно на С++, приходите к нам в «Лабораторию Касперского». Пройти все этапы собеседований можно за пару дней. «Плюсы» являются одним из ключевых языков в нашем технологическом стеке, так что спектр возможных задач огромен, как и список новых фич. И legacy там нет.

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

Комментарии (86)


  1. vadimr
    19.04.2024 05:49
    +6

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


    1. akornilov Автор
      19.04.2024 05:49

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


    1. andreygn
      19.04.2024 05:49

      Даже в этом случае есть решения. Например данные часто читаются: read write locks, copy on write и т.д.


  1. eao197
    19.04.2024 05:49
    +31

    Вы все еще пишете многопоточку на C++ с ошибками синхронизации?

    Уже давно нет. И многократно рассказывали как это делать.

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

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

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


    1. domix32
      19.04.2024 05:49
      +1

      Хорошо хоть ссылки оказались полностью из ASCII, а то бы щас пол статьи в процентах наблюдали.


    1. whoisking
      19.04.2024 05:49
      +2

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

      Где можно про это почитать?


      1. eao197
        19.04.2024 05:49

        ХЗ. Я говорю исходя из личного опыта.


      1. nv13
        19.04.2024 05:49
        +4

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


        1. alexandrustinov
          19.04.2024 05:49

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

          Так зависнуть может что угодно, при чем тут акторы как таковые? Другое дело, что если сделать классическую модель - под каждый обработчик акторов - свой процесс (не под каждый актор свой процесс, хотя и такое тоже наверное можно), и отдельно к ним процесс PMON/SMON (process/service monitor), который будет проверять, жив ли тот или иной процесс-актор и не ушел ли он в сильную задумчивость, и убивать его через kill() - то тогда будет еще какой-то шанс написать приложение, в котором отдельный пользователь со своей глюкнувшей или зависшей сессией не будет убивать весь сервис целиком (см. архитектуру Oracle database или PostgreSQL).

          Т.е. можно будет относительно безболезненно отстреливать приболевшие части сервиса в виде его отдельных процессов, не пытаясь починить их погулявше поврежденные heap/stack и прочие local state.

          Но это довольно высокая культура разработки, и что самое невероятное - готовых библиотечных решений нет (как минимум не гуглятся), книжек тоже на этот счет не написано, а в США наверное все это еще и густо-часто покрыто патентными ограничениями, других объяснений нет. Хотя архитектуре PMON/SMON уже +30 лет, если не больше.


          1. whoisking
            19.04.2024 05:49
            +4

            Я не профессионал в данной области, но мне кажется, что вы описали принцип работы супервизора в эрланге https://www.erlang.org/doc/man/supervisor.html


            1. alexandrustinov
              19.04.2024 05:49
              +1

              Я не профессионал в данной области, но мне кажется, что вы описали принцип работы супервизора в эрланге https://www.erlang.org/doc/man/supervisor.html

              Примерно похоже, да, но вот то что описано по ссылке выше - это и systemd и какой supervisord может делать.

              Но нужно не только растартовать fail-fast-then-die процессы. Нужно еще и в таймауты уметь, а они для каждого действия могут быть разные.

              И вызвающим сервисам тоже кому-то ответы слать нужно, желательно чуть более осмысленные, чем просто в виде "500 internal error". И периодические плановые рестарты (как профилактика утечек памяти и не только), и изоляция/запрет повторных вызовов, которые опять убивают или вешают акторов (DDoS).


          1. sdramare
            19.04.2024 05:49
            +2

            Это вы сейчас эрланг придумываете?


          1. nv13
            19.04.2024 05:49

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

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


            1. alexandrustinov
              19.04.2024 05:49
              +2

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

              Это все выше и выглядит как классический датабазный dead lock detection. Которые разрешают те самые SMON/PMON/Supervisors - отдельные процессы, которые постоянно мониторят workerов на предмет их нездоровья, и убивают потом виновников или просто кого попало. В т.ч. и Deadlocks так и разрешают - убивают одного из участников клинча.

              Классика, в концепциях еще в 1991-м году была описана.


              1. nv13
                19.04.2024 05:49

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


                1. alexandrustinov
                  19.04.2024 05:49
                  +1

                  Они же и глобальную или какую там память потом туда засунули

                  Засунули. Разделяемую (shm), причем весьма продвинутым образом - там весьма навороченные подходы для обеспечения ее консистентности в конкуретном доступе. Т.е. чтоб изменить разреляемую память - сначала отдельно пишется дельта намерений (лог), потом уже делается изменение на уровне блока в 8к размером, с блокировками, предварительно отдельно пишутся предыдущие версии этого блока и т.д. и т.п. При желании можно это изменение откатить или еще раз накатить.
                  Все это очень и очень небесплатно, но что поделать, деньги то надо как-то считать, чтоб хоть с какой-то гарантией этого вашего ACID.


          1. nv13
            19.04.2024 05:49

            Классический пример потери функциональности - RS триггер. Элемент и-не функционален и ээ.. операционен, а когда 2 таких элемента образуют триггер, у него появляется запрещённая комбинация на входе при которой он и не функционален, и непредсказуем


          1. SpiderEkb
            19.04.2024 05:49

            Т.е. можно будет относительно безболезненно отстреливать приболевшие части сервиса в виде его отдельных процессов, не пытаясь починить их погулявше поврежденные heap/stack и прочие local state.

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


    1. akornilov Автор
      19.04.2024 05:49

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

      P.S. Раз статья вам не пригодилась вежливость требует возвратить вам лучи известной субстанции от благодарного автора :)


      1. eao197
        19.04.2024 05:49
        +1

        Ну а почему, собственно, вопрос дурацкий?

        а) это кликбейт в чистом виде;
        b) претензия на то, что в статье будет описана вот прям "серебряная пуля".

        Если вы давно уже нет, поздравляю от всей души, но позволю усомниться такому самоуверенному заявлению :)

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

        И, во-вторых, вот это:

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

        Я не говорил о полезности статьи вообще. "Благодарность" была высказана исключительно за примеры кода в виде картинок. Но вы, в силу своеобразного восприятия реальности выдумали себе ХЗ что.

        Про Kaspersky OS можно и не беспокоится. Она явно в надежных руках.


  1. Arerad
    19.04.2024 05:49
    +1

    Например, Streams в Java 8 сделан удобно — достаточно добавить один вызов, и вся коллекция уже обрабатывается в параллели, а разработчику не нужно думать об этих низкоуровневых сущностях.

    Только все streams в приложении по умолчанию используют общий fork-join thread pool, и у вас из-за этого может быть много неприятных сюрпризов, если какой-то stream, например, случайно заблокирует все потоки в fork-join pool. Так что тут тоже приходится думать о низкоуровневых сущностях.


    1. Kelbon
      19.04.2024 05:49
      +2

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


      1. akornilov Автор
        19.04.2024 05:49

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


    1. akornilov Автор
      19.04.2024 05:49

      Ну это скорее вопросы к реализации Streams, которые могут повлиять на ваше решение использовать его или нет.


  1. Kelbon
    19.04.2024 05:49
    +10

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

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

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

    Ну и наконец самая главная проблема, что за чертовщина на уровне реализации? Зачем все эти std::function, std::condition_variable_any., прости господи .template extract<packaged_task<....>>? Что с интерфейсом, откуда тут взялись when и подобное? Это что, фьючи?


    1. Kelbon
      19.04.2024 05:49
      +2

      Дальше, ещё конкретнее про реализацию. Совершенно неочевидные наборы перегрузок:

          inline void view(std::function<void(const T&)> block) const {
              LockRead lock(_mutex);
              block(_state);
          }
      
          template<typename R>
          inline R view(std::function<R(const T&)> block) const {
              LockRead lock(_mutex);
              return block(_state);
          }


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

      return (R)block(_state) это сработает и с void с и другими типами

      https://godbolt.org/z/59cazh9dE

      Почему подобной перегрузки с возвращением значения из modify нет - не знаю, кажется очень странным


      1. eao197
        19.04.2024 05:49
        +6

        Тут вообще большой вопрос -- а зачем тянуть std::function для block-а? Почему бы не сделать так:

        template<typename BlockLambda>
        decltype(auto) view(BlockLambda && block) const {
          LockRead lock(_mutex);
          return block(_state);
        }
        


        1. akornilov Автор
          19.04.2024 05:49

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


          1. eao197
            19.04.2024 05:49

            В боевом коде, безусловно, лучше сделать как у вас.

            Т.е. ваш код, на который вы ссылаетесь, он не "боевой"? Это типа вы C++ изучали и в процессе изучения экспериментировали для души?

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


    1. akornilov Автор
      19.04.2024 05:49

      Любой класс с двумя шаблонами N и K может сгенерировать N * K вариантов, если видите в этом проблему, тогда конечно, видимо, лучше не пользоваться шаблонными классами :)

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

      Писать код "не думая" не думал даже рекомендовать - вы, видимо, как-то превратно трактуете прочитанное :)

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

      Насчет "чертовщины", пожалуйста, в церковь :)


  1. SpiderEkb
    19.04.2024 05:49
    +7

    Из личной практики. Очень часто мне не требуется именно работа с какими-то общими данными из нескольких потоков. Чаще задачи двух классов -

    • "конвейерная обработка потока данных"

    • "параллельная обработка большого количества независимых элементов".

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

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

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

    Параллельная обработка - когда есть много (десятки миллионов и более) однотипных элементов и каждый из них нужно независимо от остальных обработать по какому-то алгоритму (может быть достаточно сложный и долгий). Тут подход иной - есть "головное задание", которое формирует пакеты элементов (например, делает выборку из БД по заданным условием, результаты выборки объединяются в пакет, скажем, по 100 элементов) и выкладывает их в очередь. Параллельно работают несколько экземпляров обработчиков, каждый из которых берет из очереди очередной пакет и обрабатывает содержащиеся в нем элементы.

    В обоих случаях нет нужды связываться с разделяемой памятью и синхронизацией доступа к ней. Можно воспользоваться системными средствами (и за всю синхронизацию будет отвечать система). Для конвейерной обработки используется принцип "почтовых ящиков" - у каждого потока есть свой ящик (в Windows можно использовать mailslot, в иных системах - локальный именованный Unix socket) куда любой может писать блоки данных для этого потока.

    При параллельной обработке можно использовать pipe в который головное задание пишет пакеты, а обработчики читают их оттуда. Ну или если истема поддерживатье что-то еще подходящее (сейчас вот с IBM i работаю - там есть очереди - data queue и user queue - ккк раз для такого удобно).

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


    1. IsKaropk
      19.04.2024 05:49
      +3

      При параллельной обработке можно использовать pipe в который головное задание пишет пакеты, а обработчики читают их оттуда. Ну или если истема поддерживатье что-то еще подходящее (сейчас вот с IBM i работаю - там есть очереди - data queue и user queue - ккк раз для такого удобно).

      Использу в качестве трнаспорта zeromq, в т.ч. для параллельной обработки ( см. замечательную книгу https://wikileaks.org/ciav7p1/cms/files/ØMQ - The Guide - ØMQ - The Guide.pdf)


      1. SpiderEkb
        19.04.2024 05:49

        Ну тут что есть под рукой :-) Пайпы в принципе очень просты в работе и не грузят систему.

        Но у нас на платформе есть User Queue - системный объект (т.е. никаких библиотек - поддерживается системными средствами). Преимущество в том, что его не надо каждый раз создавать-удалять. Один раз при развертывании поставки создал (с нужным именем) и оно есть. Только подключайся. Оно может быть FIFO, LIFO или KEYED - когда каждый пакет еще дополнительно снабжается "ключом" и можно этот ключ использовать в качестве условия для извлечения сообщения (равно, не равно, больше, меньше, больше или равно, меньше или равно) - извлекается первое сообщение, подходящее под условие. Основное преимущество перед пайпом - есть возможность "материализации" - получения состояния очереди (в т.ч. максимально возможное количество сообщений и текущее количество сообщений) что позволяет контролировать скорость раздачи и разбора и динамически балансировать систему (если очередь растет - добавить обработчик, если уменьшается - остановить какой-то из обработчиков). А поскольку это системный объект, то даже в случае падения задания (головного, обработчиков) содержимое очереди сохраняется в памяти системы.

        Есть еще Data Queue - примерно тоже самое, но более тяжелая за счет того, что хранит все содержимое свое на диске.

        И та и другая очереди доступны как через API, так и через SQL

        Хотим посмотреть информацию об очерелди

        select *
          from table(USER_QUEUE_INFO('TSTQUE'));

        Получаем

        Хотим посомтреть содержимое (без удаления из очереди - "материализация сообщений", peek)

        select *
          from table(USER_QUEUE_ENTRIES('TSTQUE'));

        Получаем

        Для сопровождения очень полезно


  1. Devastor87
    19.04.2024 05:49
    +7

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

    Сущностей стало куда больше (даже в таком простом примере), код сложнее для восприятия, а при этом всё также остались "правила", которые нужно соблюдать, чтобы ничего не падало.

    То есть задачу 100% эта методика не решила, а сложности добавила.


    1. akornilov Автор
      19.04.2024 05:49

      Предлагаю Оккама оставить в покое и посчитать на пальцах сущности до и после: были mutex, lock guard и condition variable + защищаемые данные, стало SharedState + защищаемые данные, которые могут быть отдельной структурой/классом или просто строкой, например. Т.е. мы уменьшили общее количество сущностей, которыми вынуждены были манипулировать и получили одну новую абстракцию, которая объединяет общие данные со средствами их защиты. А правила всегда будут чем бы вы не пользовались :)


  1. voldemar_d
    19.04.2024 05:49
    +3

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

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


    1. SpiderEkb
      19.04.2024 05:49
      +1

      Да, верное решение. Геттер/сеттер которые работают с данными, защищенными мьютексом.
      Как это будет реализовано уже не суть важно - класс, лямбда... Важно что вы не обращаетесь к данным напрямую, только через get/set, а те уже внутри используют мьютекс.


      1. vadimr
        19.04.2024 05:49
        +4

        Само по себе отсутствие гонок в геттере/сеттере ещё не гарантирует отсутствия гонок вообще и тем более общей корректности параллельного алгоритма.

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


        1. SpiderEkb
          19.04.2024 05:49

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

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


      1. alexandrustinov
        19.04.2024 05:49
        +1

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

        Сомнительное утверждение. Ну вот вернул get-ер строку (указатель+размер), сделав при этом пару CAS-ов или какие тяжеловесные POSIX pthread_mutex_lock, а дальше что? Он вернул шаренные данные, которые уже ничем не защищены, вызывающий может их и изменить прямым доступом к памяти и отдать еще кому-то, защититься от этого не получится. Можно конечно передавать ссылки только на специально сделанные копии, типа вызывающий сам потом их чистит, но это такое, апологеты zero-copy не оценят.


        1. SpiderEkb
          19.04.2024 05:49

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

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

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

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


          1. eao197
            19.04.2024 05:49

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

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

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


            1. SpiderEkb
              19.04.2024 05:49

              Что именно вам кажется ерундой? Контроль над данными с которыми осуществляется совместная работа несколькими потоками? Идея изоляции шареных данных?

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

              Я уже писал тут, что в зависимости от задачи можно выбирать иные подходы. И многопоточная обработка не всегда требует работы с одним массивом шареных данных. А если и требует - см. любую операционную систему где 10 программ могут работать с одним файлом, но все делают это через системное API (все ваши read/writeв конечном итоге приходят в одну точку в ядре системы). И все проблемы конкурентного доступа и блокировок решаются в одном месте - на уровне ОС. А системное API отдает в программы уже безопасную копию данных в их текущем состоянии.

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

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

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

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

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

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


              1. eao197
                19.04.2024 05:49
                +1

                Что именно вам кажется ерундой?

                Попытки рассуждать вслух вокруг да около.

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

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

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

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


                1. SpiderEkb
                  19.04.2024 05:49

                  Попытки рассуждать вслух вокруг да около.

                  А вы все задачи решаете однотипно? Без учета конкретики и граничных условий? Все копипастой старых решений? И никогда "на берегу" не задумываетесь "а что будет если..."?

                  Да куда уж мне, я и программировать-то не умею.

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

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

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

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

                  Вот приходит человек - "нужно сделать то-то, можно так, можно этак - как лучше?". И вот тут зависит от условий. Если это отдельный процесс, то есть одно решение - более простое в реализации и более эффективное. Но в случае актора, вызываемого одновременно из 100500 мест 100млн раз в стуки это решение потащит за собой много накладных расходов от системы и не даст стабильной производительности. Поэтому лучше выбрать другое, которое чуть сложнее в реализации и менее производительное в одном потоке, но зато стабильное в условиях большой плотности параллельных вызовов. Вот то, что я хочу услышать от опытного разработчика. А не умение решать ликодовские задачки и зачитывания наизусть произвольного места из последнего стандарта языка или перечисления всех классов стандартной библиотеки.


                  1. eao197
                    19.04.2024 05:49

                    А вы все задачи решаете однотипно?

                    Я здесь вообще не при чем.

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

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

                    Но программирование и разработка, все-таки, немножко разные вещи.

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

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

                    И в данном конкретном случае (этой конкретной статьи) можно только абстрактно рассуждать.

                    Да что вы говорите?!!

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

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

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

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


          1. alexandrustinov
            19.04.2024 05:49

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

            Это все юношеский идеализм. Или максимализм. В общем попытка решить проблему силой, а не опытом.

            На самом деле в мире существуют более продвинутые модели. К примеру есть такая база данных LMDB, и ее производная MDBX. Продвинутая Berkeley DB на стероидах.

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

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

            И это пока наверное наиболее разумный механизм обеспечения согласованности и защищенности разделяемых данных в общем случае, для типовых бизнес приложений (конкуретные разряженные матрицы для симуляции ядерных реакторов в суперкластерах пока опустим), ну так, чтоб было прям реально дуракоустойчиво. В какой-то степени это реализация https://en.wikipedia.org/wiki/Readers–writer_lock , но несколько круче: ACID + защита памяти от записи и появляется понятие версионности данных.

            Дальше больше. Нужно просто доделать враппер над этой MDBX, чтоб он пользовательские объекты представлял с автоматическим маппингом в этои самые key-value (не каждое поле в отдельный value, это клиника, скорее маппинг всех полей объекта в value, на и в key какой классический PK-идентификатор, вместо указателя ) и все шаренные-конкуретные данные в этой in-memory database и хранить. Хоть свой какой, хоть protocol buffers или аналоги.

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

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


            1. eao197
              19.04.2024 05:49

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

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


            1. zzzzzzerg
              19.04.2024 05:49

              Несмотря на то, что я разделяю, в определенной степени, ваши восторги относительно наличия MDBX и ее реализации (@yleo проделал колоссальную работу, за что ему огромное спасибо) - но:

              1. появляется понятие версионности данных не доступно на пользовательском уровне.

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

              3. Snapshot Isolation и Copy-on-Writer приводят к необходимости сборки мусора - что тоже не самая быстрая операция, а долгие читающие транзакции не рекомендуется делать

              4. Нужно просто доделать враппер над этой MDBX, чтоб он пользовательские объекты представлял с автоматическим маппингом в этои самые key-value  - это не просто, в том смысле что вам надо будет принять некоторые решения с точки зрения вашего API

              5. Книжек про то как реализовать MDBX или аналог действительно не очень много, но его API не требует каких-то больших усилий для использования. Концепция Snapshot Isolation очень сильно упрощает пользовательский код, до той степени, что есть ощущение, что пишешь однопоточный код. Но вопрос согласованности пользовательских данных лежит полностью на ваших плечах, а не MDBX.

              6. mutex в кодовой базе libmdbx встречается более 200 раз, наверное иначе не умеют даже там.


  1. sv91
    19.04.2024 05:49
    +6

    А почему не сделать как, извините, в Rust и вместо манипулировании лямбдой не возвращать из функции, например, modify() обертку, которая в конструкторе будет лочить мьютекс, а в деструкторе разлочивать, и будет давать доступ к переменным внутри обернутого класса?


    1. Kelbon
      19.04.2024 05:49

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

      auto [data, lock] = d.modify();

      но видимо код был идеален и поэтому не изменился



      1. sv91
        19.04.2024 05:49
        +6

        А зачем тут отдельно lock?


      1. akornilov Автор
        19.04.2024 05:49

        Ценю ваш сарказм :) ответил выше.


    1. akornilov Автор
      19.04.2024 05:49

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


  1. Kahelman
    19.04.2024 05:49
    +3

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

    У вас главное правило идеологии C++ нарушено. Object creation is object acquisition.

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

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

    Выше уже об этом писали.

    Если у вас в Kaspersky Osтакая архитектура, то вызывает беспокойство ее светлое будущее.

    Если бы мне пришлось снулая писать микроядерную ОС , я бы посмотрел в сторону Plan-9, и на QNX в качестве источников для вдохновения и руководства по написанию параллельного кода.


    1. akornilov Автор
      19.04.2024 05:49

      Код демонстрирует распространенную проблему, а не создает ее. Причем здесь RAII? Проблема владения ресурсом здесь не затрагивалась, поэтому и нарушить этот принцип никак не могли :)
      Да вы не беспокойтесь насчет будущего KasperskyOS - не вы же один такой уникальный специалист по микроядерным архитектурам :)


  1. rukhi7
    19.04.2024 05:49
    +2

    Здесь есть некая оптимизация на стандарт языка — если используем 17-й стандарт, нам доступны shared-мьютексы, и нам доступны shared-мьютексы, и мы можем позволить нескольким потокам обращаться к данным на чтение.

    вот тут интересно! Если все(!) потоки обращаются к данным только на чтение то данные вроде как лочить не нужно,

    если несколько потоков обращаются к данным на чтение, при этом хотя бы один поток МОЖЕТ в это время обратиться к данным для их изменения данные придется лочить чтобы читатели не прочитали частично измененные данные (то есть не валидные). Но если вы лочите данные перед чтением от записи вы залочите данные и от другого чтения, поэтому мне кажется эта сентенция из статьи несколько надуманной, откуда тут возьмется "некая оптимизация ", или что имеется ввиду?


    1. sv91
      19.04.2024 05:49
      +1

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

      Но если вы лочите данные перед чтением от записи вы залочите данные и от другого чтения

      Нет, в шареном мьютексе вы можете читать данные из разных потоков одновременно


      1. eao197
        19.04.2024 05:49
        +1

        Мне кажется, никакого выигрыша в перфомансе от shared_мьютексов нет.

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

        Если же расшаренные данные -- это пара-тройка int-ов, то да, вопрос открыт.


        1. voldemar_d
          19.04.2024 05:49

          int не проще запихать в atomic?


          1. DirectoriX
            19.04.2024 05:49
            +4

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

            Пример: статистика по вызовам и времени работы функции. С независимыми атомиками может случиться, что на читающей стороне вы получите условные fn_calls_under10ms == 100, fn_calls_under100ms == 10, fn_calls_long == 1, а fn_calls_total == 110 (просто потому что он инкрементируется последним). Иногда это приемлемо, а если нет - придётся или делать atomic<call_stats_struct>, или с мьютексом, с мьютексом как будто бы выглядит проще...


            1. UranusExplorer
              19.04.2024 05:49

              atomic<call_stats_struct>

              Причем в этом случае на многих архитектурах этот самый atomic для большой структуры окажется уже не lock-free, то есть может иметь внутри себя мьютекс.


            1. voldemar_d
              19.04.2024 05:49

              Про мьютекс можно забыть, а с atomic помнить о мьютексе не надо.


          1. eao197
            19.04.2024 05:49

            Когда int один -- проще.
            Но я говорил о нескольких int-ах.


            1. voldemar_d
              19.04.2024 05:49

              Бывает, когда независимость этих int не имеет значения.


              1. eao197
                19.04.2024 05:49

                Тогда у вас нет проблем и этот случай не заслуживает внимания.


      1. rukhi7
        19.04.2024 05:49

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

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

         шареном мьютексе вы можете читать данные из разных потоков одновременно

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


    1. SpiderEkb
      19.04.2024 05:49

      Если все(!) потоки обращаются к данным только на чтение

      Не совсем понятно тогда как это работает? Есть все только читают, то вообще никаких проблем нет.

      А если чтение-запись, то, мне казалось, это решается через critical sections скорее, нежели через мьютексы...


      1. rukhi7
        19.04.2024 05:49

        это решается через critical sections скорее, нежели через мьютексы

        так это в общем то то же самое только critical sections это локальные объекты, а мутексы именованные системные, их видно из других процессов.


        1. SpiderEkb
          19.04.2024 05:49

          Ну если работаем в рамках одного процесса и нескольких нитей, то почему бы не критические секции?

          Если в рамках нескольких процессов и расшареной памяти - то да, только системные объекты синхронизации (мьютексы, может быть где-то как-то семафоры)


  1. Dominux
    19.04.2024 05:49

    Ждём статьи с названием "Вы все ещё пишете на С--?"


  1. DungeonLords
    19.04.2024 05:49
    +1

    Спасибо за статью! Позвольте вопрос по теме многопоточности.
    Можно ли обращаться к полям и методам объекта А из объекта Б, если они в разных нитях живут? Evgenii Legotckoi говорит что можно, но так ли это? И если всё же нельзя, то как быть?


    1. vadimr
      19.04.2024 05:49

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


  1. NickDoom
    19.04.2024 05:49

    Нет, не пишу.

    Потому что всё ещё пишу многопоточку на де-факто Си, без ошибок синхронизации.

    Синхра — это как кровоток в организме. Туда ничего постороннего в принципе не должно попадать. Никому туда нельзя руками залезать.

    Сначала делается фреймворк, потом в нём отлаживается многопоточка, а потом к нему подключаются плагины, которым вообще не нужно знать, как она устроена. Когда пишешь новый плагин, нельзя «забыть поставить мьютекс» — плагины не ставят мьютексы. Плагины просто обращаются к API фреймворка. Иначе постоянно будет возникать ситуация, когда снова что-то забыли и снова грохнулась вся система.

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


  1. Hellpain
    19.04.2024 05:49

    Вы все еще пишете многопоточку на C++ с ошибками синхронизации?
    Тогда rust идет к вам!


    1. KanuTaH
      19.04.2024 05:49

      1. Hellpain
        19.04.2024 05:49

        это уже фиксанули


      1. Dgolubetd
        19.04.2024 05:49

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


        1. KanuTaH
          19.04.2024 05:49

          Ну да, просто на ровном месте решили "обхитрить", от нечего делать, видимо.


          1. Dgolubetd
            19.04.2024 05:49

            А разве нет?

            "Make any value Send + Sync but only available on its original thread. Don't use on multi-threaded environments!" - что-то не похоже на "ровное место".


            1. KanuTaH
              19.04.2024 05:49

              А разве нет?

              Нет. Я этим крейтом никогда не пользовался, но из его описания и примеров я так понимаю, что он может применяться например для FFI, поскольку наличие (или отсутствие) трейтов Send и Sync помогает только непосредственно в растокоде. Внешний код, который работает с неким растовским объектом, все еще может попытаться работать с ним из разных потоков, и этот крейт пытается контролировать этот момент в рантайме. "От нечего делать" такие вещи не делают, видимо, имеется потребность.


  1. gen1lee
    19.04.2024 05:49
    +3

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

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

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


    1. gen1lee
      19.04.2024 05:49

      Название класса вообще не говорит о том что это такое, лучше назвать максимально конкретно типа ReadWriteLockedState. И да, если реализация поменяется, лучше и название менять (еще лучше - создать новый класс с новой реализацией), чтобы было понятно что это и зачем без изучения кода. Названия методов тоже зачем то отличаются от того, что реально делается. Зачем выдумывать новые слова когда уже есть read и write в коде, вместо ваших view и modify? Зачем странные методы типа when? И без единого комментария о том, что он делает.

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


    1. Aldrog
      19.04.2024 05:49

      если это внутрення реализация компании и вы можете позволить себе использовать новую версию везде

      Одно из другого не следует, если проект кросс-платформенный. У нас, например, ради поддержки легаси-платформ один и тот же код компилируется и в C++17 и в C++98.


      1. gen1lee
        19.04.2024 05:49
        +1

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


  1. cdriper
    19.04.2024 05:49
    +1

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

    зы. код скриншотами и #ifdef вокруг C++17 это какой-то ;№""@#