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

    Глава из книги "Современное программирование на C++" называется "В сто первый раз об интеллектуальных указателях". Все бы ничего, но книга была издана в 2001 году, так стоит ли в очередной раз возвращаться к этой теме? Мне кажется что как раз сейчас и стоит. За эти пятнадцать лет поменялась сама точка зрения, тот угол под которым мы смотрим на проблему. В те далекие времена только-только вышла первая де-факто стандартная реализация — boost::shared_ptr<>, до этого каждый писал себе реализацию по потребности и как минимум представлял себе детали, сильные и слабые стороны своего кода. Все книги по C++ в то время обязательно описывали одну из вариаций умных указателей в мельчайших деталях.


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


    А еще на stackoverflow например снова и снова задается вопрос — "потокобезопасны ли умные указатели из стандартной библиотеки?". Ответы даются обычно категоричные, но какие-то мало информативные. Если бы я например не знал о чем идет речь, то наверное бы не понял. И кстати, все сравнительно новые книги описывающие новый стандарт C++ этому вопросу тоже уделяют мало внимания.


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


    Сразу определимся с терминологией, речь не идет о защите данных на которые ссылается указатель, это может быть обьект произвольной сложности и многопоточный доступ к нему требует в общем случае отдельной синхронизации. Под потокобезопасностью умного указателя мы подразумеваем защищенность собственно указателя на данные и валидности внутреннего счетчика ссылок. Образно говоря, если указатели создаются через std::make_shared<>() то никакие присвоения, передача в функции или другие потоки, свопы, уничтожение, не могут привести его в невалидное состояние. До тех пор пока мы не вызвали reset() или get(), мы вправе ожидать что указатель ссылается на некоторый валидный обьект, хотя и не обязательно тот на который мы подразумеваем.


    Один из популярных ответов на вопрос заголовка: "It is only the control block itself which is thread-safe.". Вот мы и посмотрим, что понимается конкретно под control block и под safe.
    Для экспериментов использовался g++-5.4.0.


    Начнем с примера


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


    Конечно существуют стандартные решения, boost::shared_mutex и его юный отпрыск std::shared_mutex, позволяющие два уровня доступа — разделяемый на чтение и исключительный на запись. Однако же я, прослышав что std::shared_ptr дает потокобезопасный доступ к контрольному блоку (и не очень понимая что это значит), а так же что операции над ним реализованы lock-free, хочу предложить свое изящное решение:


    // разделяемые данные
    std::shared_ptr<SHARED_DATA> data;
    
    reading_thread {
      // создаем резервную ссылку на разделяемые данные
      auto read_copy=data;
      // читаем данные, целостность гарантирована
      ...
      // деструктор read_copy
    }
    
    writing thread {
      // создаем измененную копию данных 
      auto update=std::make_shared<SHARED_DATA>(...args);
      // атомарно(?) обновляем данные
      data=update;
      // ссылка на старые данные теряется, но обьект будет уничтожен 
      //  в памяти только когда последний читатель закончит цикл чтения
    }

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


    Ну так что, сработает? Разумеется нет! Но почему именно?


    Как оно устроено


    Если взглянуть на саму структуру разделяемого указателя
    /usr/include/c++/5/bits/shared_ptr_base.h: 1175


    template<typename _Tp, _Lock_policy _Lp>
    class __shared_ptr
    {
        ...
    private:
        _Tp*           _M_ptr;         // Contained pointer.
        __shared_count<_Lp>  _M_refcount;    // Reference counter.
    };

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


    Давайте разбираться, роем глубже


    auto data=std::make_shared<int>(0);
    
    void read_data() {
    // имитация чтения, создается резервная копия данных
        for(;;)
            auto read_copy=data;
    }
    
    int main()
    {
        std::thread(read_data).detach();
    // имитация записи, весь указатель целиком заменяется другим
        for(;;)
            data=std::make_shared<int>(0);
        return 0;
    }

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


    auto data=std::make_shared<int>(0);
    
    void read_data() {
        for(;;)
            auto sp=std::atomic_load(&data);
    }
    
    int main(int argc, char**argv)
    {
        std::thread(read_data).detach();
        for(;;) {
            std::atomic_exchange(&data, std::make_shared<int>(0));
            assert(std::atomic_is_lock_free(&data));
        }
        return 0;
    }

    здесь все прекрасно работаетло бы если бы не assert() в теле цикла. То есть атомарные операции над std::shared_ptr определены, но они блокирующие. Ну, это не наш путь, на мьютексах я и сам могу. Еще один вариант:


    std::shared_ptr<int> variant[]={
        std::make_shared<int>(0),
        std::make_shared<int>(0)
    };
    auto data=variant[0];
    
    void read_data() {
        for(;;)
            auto sp=data;
    }
    
    int main()
    {
        std::thread(read_data).detach();
        for(size_t n=0;; ++n) {
            data=variant[n%2];
        }
    
        return 0;
    }

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


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


    Рассмотрим поближе читающий поток:


        auto sp=data;

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


    вот выжимки из исходного кода
    //L1#shared_ptr_base.h : 662
          __shared_count(const __shared_count& __r) noexcept
          : _M_pi(__r._M_pi)
          {
        if (_M_pi != 0)
          _M_pi->_M_add_ref_copy();
          }
    //L2#shared_ptr_base.h : 134
          void
          _M_add_ref_copy()
          { __gnu_cxx::__atomic_add_dispatch(&_M_use_count, 1); }
    
    //L1#shared_ptr_base.h : 658
         ~__shared_count() noexcept
          {
        if (_M_pi != nullptr)
          _M_pi->_M_release();
          }
    //L2#shared_ptr_base.h : 147
        if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1)
          {
                _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_use_count);
            _M_dispose();
          }


    которые, если отбросить все лишнее, сводятся к


    // copy ctor
    __gnu_cxx::__atomic_add_dispatch(&_M_use_count, 1);
    // old instance dtor
    if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1)
        _M_dispose();

    или, если перейти к псевдокоду


    ++_M_pi->_M_use_count;
    if(--_M_pi->_M_use_count == 0)
        dispose();

    Здесь операторы инкремента и декремента подразумеваются атомарными, а функция dispose() очищает память и в частности инвалидирует сам указатель на счетчик ссылок _M_pi. Надо сказать что для привыкшего многопоточности выражение:


    if(--cnt == 0)
    do_something();


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


    Тем не менее, что в это время происходит в другом, пишущем, потоке?


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

    где-то вот так
    //L1#shared_ptr.h : 291
          shared_ptr&
          operator=(shared_ptr&& __r) noexcept
          {
        this->__shared_ptr<_Tp>::operator=(std::move(__r));
        //L2#shared_ptr_base.h : 997
             __shared_ptr&
              operator=(__shared_ptr&& __r) noexcept
              {
            __shared_ptr(std::move(__r)).swap(*this);
            //L#3shared_ptr_base.h : 932
             __shared_ptr(__shared_ptr&& __r) noexcept
              : _M_ptr(__r._M_ptr), _M_refcount()
              {
            _M_refcount._M_swap(__r._M_refcount);
                //L#4shared_ptr_base.h : 684
                 void _M_swap(__shared_count& __r) noexcept
                  {
                _Sp_counted_base<_Lp>* __tmp = __r._M_pi;
                __r._M_pi = _M_pi;
                _M_pi = __tmp;
                  }
        //L2#shared_ptr_base.h : 1073
         void
          swap(__shared_ptr<_Tp, _Lp>& __other) noexcept
          {
        std::swap(_M_ptr, __other._M_ptr);
        _M_refcount._M_swap(__other._M_refcount);
          }
             //L3#shared_ptr_base.h : 684
              void
              _M_swap(__shared_count& __r) noexcept
              {
            _Sp_counted_base<_Lp>* __tmp = __r._M_pi;
            __r._M_pi = _M_pi;
            _M_pi = __tmp;
              }
         //L2#shared_ptr_base.h : 658
          ~__shared_count() noexcept
          {
        if (_M_pi != nullptr)
          _M_pi->_M_release();
          }
             //L3#shared_ptr_base.h : 142
             void
              _M_release() noexcept
              {
                // Be race-detector-friendly.  For more info see bits/c++config.
                _GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(&_M_use_count);
            if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1)
              {
                    _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_use_count);
                _M_dispose();
        //L1#shared_ptr.h : 93 (destructor)
         //L2#shared_ptr_base.h : 658
          ~__shared_count() noexcept
          {
        if (_M_pi != nullptr) //_M_pi == nullptr - true here
          _M_pi->_M_release();
          }

    Если отбросить все лишнее останутся примерно такие фрагменты кода:


        _Sp_counted_base<_Lp>* __tmp = __r._M_pi;
        __r._M_pi = _M_pi;
        _M_pi = __tmp;
        if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1)
             _M_dispose();

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


    if(--_M_pi->_M_use_count == 0)
        dispose();

    то самое выражение, которое мы чуть выше сочли безопасным. Вот тут наступает момент истины:
    // assert(_M_use_count == 1);


    // writing thread // reading thread
    if(--_M_pi->_M_use_count == 0) //true .
    . ++_M_pi->_M_use_count; // count=1
    . if(--_M_pi->_M_use_count == 0) //true
    dispose(); // я первый! BANG!! dispose(); // нет я! BANG!!

    Вот так комбинация атомарного инкремента и атомарного декремента приводит к гонке между потоками и std::shared_ptr<> не является потокобезопасным, даже на уровне контрольного блока. Вот теперь действительно точка.


    Немного позже я нашел афористически краткое изложение этого принципа в документации к boost::shared_ptr<>. Звучит примерно так: "Указатели безопасны либо только на запись либо только на чтение, и небезопасны при конкурентном чтении/записи." Чуть развив эту мысль и сообразив что голая запись без чтения данных практически бессмысленна, мы видим что стандартные умные указатели безопасны на чтение, то есть настолько же насколько безопасны обычные константные указатели. То есть, вся эта сложная внутренняя машинерия создана для того чтобы достичь уровня обычных указателей, но не более, жирная точка.


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


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


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


    • добавление целой константы (постфиксное или префиксное) — atomic_fetch_and_add и его вариации
    • обмен двух значений (на самом деле несимметричный, только один из операндов изменяется атомарно) — atomic_swap
    • условный обмен или присвоение с проверкой на равенство — atomic_compare_and_swap

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


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

    Поделиться публикацией

    Похожие публикации

    Комментарии 52

      –1
      Использование goto в многопоточных системах принципиальный момент. Дейкстру — в топку!
        +1
        Спорить тут, в общем-то, не получится, потому что так оно и есть. В новом стандарте обещали запилить atomic_shared_ptr.
          0

          В новом это в котором? Это не вот это?: /usr/include/c++/5.4.0/bits/shared_ptr_atomic.h
          Но он к сожалкнию блокирующий, маленькое лукавство — atomic не обязательно подразумевает non blocking.

            0
            Это не лукавство, а разумность: с ростом конкуренции блокировки выигрывают у неблокирующих. Если пишешь на non blocking библиотеках, то должен учитывать, что с масштабированием есть небольшие проблемы, нужно оптимизировать приложения, уменьшая вероятность захвата ресурса.
            0
            Нет, не этот. По ссылке же черновик еще, в текущий компиляторах искать бесполезно. Я даже не уверен, что он появится в С++17, скорее на дальнюю перспективу.
          +6
          >> Ну, это не наш путь, на мьютексах я и сам могу
          А в чём проблема с мьютексами? Почему нужно без них? Это требование ТЗ, или желание в многопоточном приложении иметь 100% неблокируемый код? Если второе, то это невозможно при разделяемых объектах, и скорее относится к религиозному чем к рациональному.
            0

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

            +2
            Майерс в «Эффективном и современном с++» упоминал, что операции со счетчиками атомарны.
            Вот вы исследовали реализацию gcc, а что по поводу потокобезопасности разделенного указателя говорит стандарт?
              –9
              Давно замутил свои умные ссылки, которые легко расширять и имеют кучу преимуществ, а вы дальше обсасывайте версии стандартной библиотеки.
                0

                И часто вы их расширяете? :)

                  +3
                  Я сам когда-то таким занимался — делал свои умные указатели с poly-based дизайном (для настройки всяких вещей вроде политик владения, многопоточности, источников привязываемых объектов, и т.д.). Даже статью на хабру писал по теме… Но понял в какой-то момент, что мои реализации работают намного хуже стандартных. Выбросил свои реализации, превратив уже сделанные классы в template-врапперы над классами из stl. Работа не совсем напрасная — теперь я могу подменять реализации, если случится чудо и кто-то сделает лучше чем stl — но неприятный осадок остался.

                  Если ваши указатели действительно лучше стандартных указателей — напишите статью, интересно будет почитать. Только не забудьте привести доказательства преимуществ вашего решения по каким-нибудь метрикам (быстродействие, расход памяти, удобство использования, и т.д.).
                    –5
                    это ненужная борьба, все кому не нравится C++ давно сделали свои ЯП
                    0
                    А может напишите статью о вашей реализации или поделитесь исходниками?
                      +6
                      на словах ты дональд кнут…
                        +1
                        … а на деле — простой пряник.
                      0
                      Тема неблокирующих контейнеров и алгоритмов (абстракций уровня более высокого, чем std::atomic<> и их интерефейса) сложная и интересная, более того, в настоящий момент ведутся активные исследования на эту тему (пишутся диссертации (!), берутся патенты и т.д.). Настоятельно рекомендую автору познакомиться с замечательной книгой C++ Concurrency in Action — Practical Multithreading Э. Уильямса (в русском переводе: Параллельное программирование на С++ в действии. Практика разработки многопоточных программ). Главы 4-7 весьма сложны для понимания, но в них рассматривается модель памяти нового стандарта и некоторые рецепты, как с её помощью и с новыми примитивами можно строить lock-free структуры. Также в книге детально разобрано поэтапное построение lock-free очереди, из которого можно извлечь весьма ценные уроки работы с CAS и вообще atomic'ами (как, например, одновременное измененние структуры наподобие указатель-счётчик (или как без этого можно обойтись), что, уверен, автору будет особенно интересно).
                        0

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

                          0
                          Меня смутила некоторая категоричность выводов статьи. Действительно, если пользователи каким бы то ни было образом получили доступ к raw-указателю (под капотом ли он shared_ptr или нет) — да, никакая сила не спасёт многопоточную среду от гонки. Но, например, в озвученной книге довольно часто обращалось внимание на тот факт, что для достижения lock-free зачастую приходится чем-то жертвовать, что в реализации выливалось в использование proxy-классов возврата или идиомы «создать-и-обменять» (что близко к понятию транзакций). Если абстрагироваться от святого желания сделать shared_ptr таким же быстрым, как и raw-указатели, думаю, с использованием сложных proxy и, возможно, в будущем transactional memory (если появится и войдёт в стандарт) или её эмуляцией в текущих реалиях стандарта, реализовать lock_free shared_ptr всё-таки возможно.
                            0
                            … хотя тут надо не запутаться в пресловутом lock-free — что и где мы считаем свободным от блокировок. А то может сложиться впечатление, что простая обёртка в такой atomic_shared_ptr любого контейнера сделает его и весь его интерефейс lock-free.
                              0

                              Ну наверное вы в чем-то правы, но вообще пост не о lock-free — просто попытка разобраться из любопытства что именно мешает std::shared_ptr<> быть thread safe. Тема неблокирующих алгоритмов возникает фактически только потому что атомарные операции там уже присутствуют при работе со счетчиком.

                                0
                                Да, прошу прощения, унесло немного не туда :)
                                К вопросу о thread-safe текущей реализации — думаю, в свете упомянутого, цена такого решения в виде громоподобно хрустящих compare_exchange под капотом не совсем соотносится с возможным выигрышем от всей этой кухни, поэтому комитету (и пользователям) было и будет проще пока что довольствоваться блокирующим доступом и специализациями std::atomic_xxx(shared_ptr<...>) или собственноручно добавленным мьютексом/спинлоком, благо в стандарте чётко оговаривается этот момент (мол, «шарьте, но не один указатель»).
                                  0

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

                          0

                          ошибся веткой

                            +1
                            Как хорошо что есть rust…
                              +1

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

                                0
                                Ну как минимум через пару лет там не появится еще один atomic_uniq_shared_locked_fast_super_auto_ptr.
                                  0

                                  Правильно я понимаю, что проблема только в названии? Какой-нибудь Arc<Mutex<Box<_>>> будет (сильно) лучше?


                                  Если что мне весьма нравится раст, но именно эта претензия не понятна.

                                    0
                                    Конечно сильно. Arc, Mutex и Box я могу использовать (или не использовать) в любом порядке с любой вложенностью. Ну и не надо ждать 15 лет выхода очередного стандарта что бы что-то новое появилось в компиляторе.
                                    Ничего не поделать, legacy всегда начинает убивать монстров. И это правильно.
                                      +2
                                      Arc, Mutex и Box я могу использовать (или не использовать) в любом порядке с любой вложенностью.

                                      Это удобно, не спорю.


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

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

                                    –1
                                    Вот как раз чтобы избежать такого, в плюсах есть традиция использовать policy-based design и typedef для стандартных специализаций подобных шаблонов (ну, и ещё auto для ленивых — хотя я лично отношусь к нему отвращением).
                                      0

                                      М-да? А если будет придуман новый алгоритм, лучший во всех отношениях, и в расте он не появится? Это хорошо или наоборот плохо?

                                        0
                                        для яп еще никто (и хорошо) не вводит копирайты на алгоритмы. Появится наилучшая реализация из возможных? — она будет использоваться везде
                                  0

                                  Да, нужно использовать atomic-операции для свапа: C++ 11 std::atomic_...<std::shared_ptr>

                                    0
                                    Вот так комбинация атомарного инкремента и атомарного декремента приводит к гонке между потоками и std::shared_ptr<> не является потокобезопасным, даже на уровне контрольного блока. Вот теперь действительно точка.

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

                                      0

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

                                        0

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

                                          0

                                          возможно тогда "разделяемых"?

                                            0

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

                                      –1
                                      Как ответили выше, это в общем-то и в документации есть. То, что Вы хотите можно сделать при помощи std::weak_ptr. std::weak_ptr::lock должна быть атомарна.
                                      Псевдо-код:
                                      int main() {
                                        uint32_t i = 10000;
                                        while (i-- > 0) {
                                          auto sp = std::make_shared<int>();
                                          std::weak_ptr p = sp;
                                          thread([p]{
                                            if (auto sp = p.lock()) {
                                              // pointer is valid, Cool, use sp
                                            } else {
                                              // pointer is already expired, ignore
                                            }
                                          }).detach();
                                        }
                                        return 0;
                                      }
                                      
                                        0

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

                                          0
                                          Исходный sp удаляется сразу после std::thread::detach, потому что «заканчивается scope», при этом в другом потоке мы пытаемся получить к нему доступ через std::weak_ptr.
                                            0

                                            И что дальше? Какое отношение это имеет к ситуации, разобранной автором поста?

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

                                        Однако же я, прослышав что std::shared_ptr дает потокобезопасный доступ к контрольному блоку (и не очень понимая что это значит), а так же что операции над ним реализованы lock-free, хочу предложить свое изящное решение:

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

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

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

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

                                            0

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


                                            void read_data() {
                                                for(;;)
                                                    std::weak_ptr<int> sp=data;
                                            }
                                            
                                            int main()
                                            {
                                                std::thread(read_data).detach();
                                                for(;;)
                                                    data=std::make_shared<int>(0);
                                                return 0;
                                            }
                                              0
                                              Тут та же самая ошибка, если один из потоков использует неконстантные методы доступа, то будет data race.
                                              All member functions (including copy constructor and copy assignment) can be called by multiple threads on different instances of shared_ptr without additional synchronization even if these instances are copies and share ownership of the same object. If multiple threads of execution access the same shared_ptr without synchronization and any of those accesses uses a non-const member function of shared_ptr then a data race will occur; the shared_ptr overloads of atomic functions can be used to prevent the data race.
                                              http://en.cppreference.com/w/cpp/memory/shared_ptr

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

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

                                              На самом деле я был бы рад ошибиться, возможно есть что-то, что я не знаю или понимаю неправильно?
                                              А не посмотрите реализацию std::experimental::atomic_shared_ptr?

                                                0
                                                А не посмотрите реализацию std::experimental::atomic_shared_ptr?

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

                                                  +1
                                                  Вот тут, похоже, интересные мысли с использованием Differential Reference Counting.
                                                  boostcon/cppnow 2016/implementing_a_lock_free_atomic_shared_ptr.pdf
                                                  И там дальше по ссылке литературы www.1024cores.net differential-reference-counting
                                                    0

                                                    Ссылки интерсные, спасибо. Отпишусь когда прочитаю.

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

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

                                            Самое читаемое