Полезные идиомы многопоточности С++


    Введение

    Данная статья является продолжением цикла статей: Использование паттерна синглтон [1], Синглтон и время жизни объекта [2], Обращение зависимостей и порождающие шаблоны проектирования [3], Реализация синглтона в многопоточном приложении [4]. Сейчас я хотел бы поговорить о многопоточности. Эта тема настолько объемна и многогранна, что охватить ее всю не представляется возможным. Здесь я заострю внимание на некоторых практичных вещах, которые позволят вообще не думать о многопоточности, ну или думать о ней в крайне минимальном объеме. Если говорить точнее, то думать о ней только на этапе проектирования, но не реализации. Т.е. будут рассмотрены вопросы о том, как сделать так, чтобы автоматически вызывались правильные конструкции без головной боли. Такой подход, в свою очередь, позволяет значительно уменьшить проблемы, вызванные состояниями гонок (race condition, см. Состояние гонки [5]) и взаимными блокировками (deadlock, см. Взаимная блокировка [6]). Этот факт уже сам по себе представляет немалую ценность. Также будет рассмотрен подход, который позволяет иметь доступ к объекту из нескольких потоков одновременно без использования каких-либо блокировок и атомарных операций!

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

    Сущности для использования:
    Описание интерфейса мьютекса:
    struct Mutex
    {
        void lock();
        void unlock();
        
    private:
        // OS specific
        ...
    };
    
    RAII примитив (exception-safe):
    struct Lock
    {
        Lock(Mutex& mutex_) : mutex(mutex_) { mutex.lock(); }
        ~Lock()                             { mutex.unlock(); }
    private:
        Mutex& mutex;
    };
    
    Класс для издевательств в качестве простого примера:
    struct A
    {
        int data;
        mutable Mutex m;
    };
    
    Примеры использования:
    Пример 1. Примитивный подход: C-style
    A a;
    a.m.lock();
    a.data = 10;
    a.m.unlock();
    
    Пример 2. Продвинутый подход: RAII-style
    A a;
    {
        Lock lock(a.m);
        a.data = 10;
    }
    
    Пример 3. Практически идеал: инкапсуляция локов
    struct B
    {
        void setData(int data_)
        {
            Lock lock(m);
            data = data_;
        }
        
        int getData() const
        {
            Lock lock(m);
            return data;
        }
    
    private:
        mutable Mutex m;
        int data;
    };
    
    B b;
    b.setData(10);
    int x = b.getData();
    
    Здесь стоит отметить, что последний вариант редко встретишь в статьях про многопоточность, что является очень печальным фактом: Многопоточность, общие данные и мьютексы [9], Кросс-платформенные многопоточные приложения [10], Потоки, блокировки и условные переменные в C++11 (Часть 2) [11]. В этой статье будут рассмотрены интересные вопросы, которые помогут существенно упростить работу с многопоточными примитивами (см. Потоки, блокировки и условные переменные в C++11 (Часть 1) [12], Два простых правила для предотвращения взаимных блокировок на мьютексах [13]). В чем-то данная статья будет являться развитием идей, взятых из Enforcing Correct Mutex Usage with Synchronized Values [14]. Однако приведенные ниже идеи и способы реализации были разработаны независимо от приведенной статьи.

    Инвариант

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

    Для тех, кто не знаком с понятием “инвариант”, посвящен этот абзац. Остальные могут его смело пропустить и переходить сразу к реализации. Итак, в ООП мы работаем, как это ни странно, с объектами. Каждый объект имеет свое собственное состояние, которое при вызове неконстантных функций его изменяет. Так вот, как правило, для каждого класса существует некий инвариант, который должен выполняться при каждом изменении состояния. Например, если объект представляет собой счетчик элементов, то очевидно, что для любого момента времени выполнения программы величина этого счетчика не должна быть отрицательной, т.е. в данном случае инвариант — это неотрицательное значение счетчика. Таким образом, сохранение инварианта дает некоторую гарантию того, что состояние объекта консистентно.

    Представим, что у нашего класса есть метод isValid, который возвращает true в случае сохранения инварианта, и false — когда инвариант нарушен. Рассмотрим следующий “неотрицательный” счетчик:

    struct Counter
    {
        Counter() : count(0) {}
    
        bool isValid() const
        {
            return count >= 0;
        }
    
        int get() const
        {
            return count;
        }
    
        void set(int newCount)
        {
            count = newCount;
        }
        
        void inc()
        {
            ++ count;
        }
        
        void dec()
        {
            -- count;
        }
        
    private:
        int count;
    };
    

    Использование:
    Counter c;
    c.set(5);
    assert(c.isValid());    // вернет true
    c.set(-3);
    assert(c.isValid());    // вернет false и сработает assert
    

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

    Здесь мы будем использовать инструментарий, разработанный в предыдущих циклах статей: Использование паттерна синглтон [1], Синглтон и время жизни объекта [2], Обращение зависимостей и порождающие шаблоны проектирования [3], Реализация синглтона в многопоточном приложении [4]. Ниже приведу для справки реализацию, которая подвергнется припарированию:
    An.hpp
    #ifndef AN_HPP
    #define AN_HPP
    
    #include <memory>
    #include <stdexcept>
    #include <string>
    
    // декларативные определения, см. [1]
    #define PROTO_IFACE(D_iface, D_an)    \
        template<> void anFill<D_iface>(An<D_iface>& D_an)
    
    #define DECLARE_IMPL(D_iface)    \
        PROTO_IFACE(D_iface, a);
    
    #define BIND_TO_IMPL(D_iface, D_impl)    \
        PROTO_IFACE(D_iface, a) { a.create<D_impl>(); }
    
    #define BIND_TO_SELF(D_impl)    \
        BIND_TO_IMPL(D_impl, D_impl)
    
    // Основной класс, реализующий подход DIP - dependency inversion principle
    template<typename T>
    struct An
    {
        template<typename U>
        friend struct An;
    
        An()                                             {}
    
        template<typename U>
        explicit An(const An<U>& a) : data(a.data)       {}
    
        template<typename U>
        explicit An(An<U>&& a) : data(std::move(a.data)) {}
    
        T* operator->()                                  { return get0(); }
        const T* operator->() const                      { return get0(); }
        bool isEmpty() const                             { return !data; }
        void clear()                                     { data.reset(); }
        void init()                                      { if (!data) reinit(); }
        void reinit()                                    { anFill(*this); }
        
        T& create()                                      { return create<T>(); }
    
        template<typename U>
        U& create()                                      { U* u = new U; data.reset(u); return *u; }
        
    private:
        // извлекает объект
        // при первом доступе происходит его заполнение
        // с использованием внешней функции anFill,
    	// которая должна быть переопределена для
    	// каждого конкретного типа T
        T* get0() const
        {
            // ленивая инициализации при первом доступе к объекту
            const_cast<An*>(this)->init();
            return data.get();
        }
    
        std::shared_ptr<T> data;
    };
    
    // заливка экземпляра, см. [1]
    // по умолчанию бросается исключение
    // необходимо переопределять, используя макросы выше
    // заливать можно как новые экземпляры,
    // так и синглтоны. см. [3]
    template<typename T>
    void anFill(An<T>& a)
    {
        throw std::runtime_error(std::string("Cannot find implementation for interface: ")
                + typeid(T).name());
    }
    
    #endif
    

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

    template<typename T>
    struct An
    {
        // ...
        T* get0() const
        {
            const_cast<An*>(this)->init();
            assert(data->isValid());    // добавляем assert
            return data.get();
        }
        // ...
    };
    

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

    c->set(2);
    c->set(-2);   // здесь assert не сработает 
    c->set(1);    // и только здесь он сработает, хотя значение уже валидно!
    

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

    template<typename T>
    struct An
    {
        // ...
        struct Access
        {
            Access(T& ref_) : ref(ref_) {}
            
            ~Access()
            {
                assert(ref.isValid());
            }
            
            T* operator->()             { return &ref; }
            
        private:
            T& ref;
        };
    
        // соответственно, вызов (только неконстантный, т.к. константный не должен менять значение):
        Access operator->()             { return *get0(); }
    
        // ...
    };
    

    Использование:
    An<Counter> c;
    c->set(2);
    c->set(-2);        // теперь assert сработает здесь
    c->set(1);
    

    Что и требовалось.

    Умный мьютекс

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

    Для его приготовления нам потребуется “обычный” мьютекс (так же, как и для умного указателя необходим обычный указатель):

    // noncopyable
    struct Mutex
    {
    // публичный интерфейс мьютекса
        void lock();
        void unlock();
    
    private:
        // ...
    };
    

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

    template<typename T>
    struct AnLock
    {
        // ...
        template<typename U>
        struct Access
        {
            // в конструкторе захватываем мьютекс
            Access(const An& ref_) : ref(ref_)
            {
                ref.mutex->lock();
            }
            
            // в деструкторе мьютекс автоматически освобождаем
            ~Access()
            {
                ref.mutex->unlock();
            }
            
            U* operator->() const                { return ref.get0(); }
            
        private:
            const An& ref;
        };
    
        // изменяем реализацию операторов доступа к объекту
        Access<T> operator->()                   { return *this; }
        Access<const T> operator->() const       { return *this; }
    
        // модифицируем функцию создания
        template<typename U>
        U& create()                              { U* u = new U; data.reset(u); mutex.reset(new Mutex); return *u; }
    
    private:
        // ...
        std::shared_ptr<T> data;
        std::shared_ptr<Mutex> mutex;
    };
    

    Использование:
    AnLock<Counter> c;
    c->set(2);    // изменяем значение
    std::cout << "Extracted value: " << c->get() << std::endl;
    

    Стоит отметить, что при использовании константной ссылки изменение значения будет приводить к ошибке компиляции (в отличие от прямого использования shared_ptr):
    const AnLock<Counter>& cc = c;
    cc->set(3); // эта строчка не скомпилируется
    

    Рассмотрим, что же у нас получилось. Добавив вывод на экран для методов класса Counter и Mutex, получаем следующий вывод на экран при изменении значения:
    Mutex::lock
    Counter::set: 2
    Mutex::unlock
    

    Последовательность действий при выводе на экран:
    Mutex::lock
    Counter::get: 2
    Extracted value: 2
    Mutex::unlock
    

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

    Можно спросить: а что делать, если мне надо, например, 2 раза вызвать inc, причем сделать это атомарно? Нет проблем! Добавим сначала в наш класс AnLock парочку typedef для удобства:

    template<typename T>
    struct AnLock
    {
        // ...
        typedef Access<T> WAccess;         // доступ на запись
        typedef Access<const T> RAccess;   // доступ на чтение
        // ...
    };
    

    А затем используем следующую конструкцию:
    {
        AnLock<Counter>::WAccess a = c;
        a->inc();
        a->inc();
    }
    

    Что, в свою очередь, дает следующий вывод:
    Mutex::lock
    Counter::inc: 1
    Counter::inc: 2
    Mutex::unlock
    

    Чем-то напоминает транзакцию, не так ли?

    Умный RW-мьютекс

    Итак, теперь мы можем попробовать реализовать несколько более сложную конструкцию под названием read-write mutex (см. Readers–writer lock [7]). Суть использования достаточно простая: допускать возможность чтения данных объекта из нескольких потоков, в то время как одновременное чтение и запись или запись и запись должны быть запрещены.

    Предположим, у нас есть уже реализация RWMutex с таким интерфейсом:
    // noncopyable
    struct RWMutex
    {
    // публичный интерфейс мьютекса
    
    // для чтения
        void rlock();
        void runlock();    
    
    // для записи
        void wlock();
        void wunlock();
    
    private:
        // ...
    };
    

    Казалось бы, все, что надо сделать, это слегка изменить реализацию, чтобы наши прокси-типы RAccess и WAccess использовали разные функции:

    template<typename T>
    struct AnRWLock
    {
        // ...
    
        // доступ при чтении
        struct RAccess
        {
            RAccess(const AnRWLock& ref_) : ref(ref_)
            {
                ref.mutex->rlock();
            }
            
            ~RAccess()
            {
                ref.mutex->runlock();
            }
            
            const T* operator->() const       { return ref.get0(); }
            
        private:
            const AnRWLock& ref;
        };
    
        // доступ при записи
        struct WAccess
        {
            WAccess(const AnRWLock& ref_) : ref(ref_)
            {
                ref.mutex->wlock();
            }
            
            ~WAccess()
            {
                ref.mutex->wunlock();
            }
            
            T* operator->() const             { return ref.get0(); }
            
        private:
            const AnRWLock& ref;
        };
    
        WAccess operator->()                  { return *this; }
        RAccess operator->() const            { return *this; }
    
        // ...
    
        // модифицируем функцию создания
        template<typename U>
        U& create()                           { U* u = new U; data.reset(u); mutex.reset(new RWMutex); return *u; }
        
    private:
        // ...
        std::shared_ptr<T> data;
        std::shared_ptr<RWMutex> mutex;
    };
    

    Использование:
    AnRWLock<Counter> c;
    c->set(2);
    

    Результат:
    RWMutex::wlock
    Counter::set: 2
    RWMutex::wunlock
    

    Пока все отлично! Но последующий код:
    std::cout << "Extracted value: " << c->get() << std::endl;
    

    Дает:
    RWMutex::wlock
    Counter::get: 2
    Extracted value: 2
    RWMutex::wunlock
    

    Для некоторых это не станет неожиданностью, а для остальной части я поясню, почему тут не работает так, как ожидалось. Ведь мы использовали константный метод, поэтому по идее должен был быть использован константный метод operator->. Однако, компилятор так не считает. И связано это с тем, что операции применяются последовательно: сначала применяется операция -> к неконстантному объекту, а затем вызывается константный метод Counter::get, но поезд ушел, т.к. неконстантный operator-> уже был вызван.

    В качестве тривиального решения можно предложить вариант с приведением типа к константному перед обращением к объекту:
    const AnRWLock<Counter>& cc = c;
    std::cout << "Extracted value: " << cc->get() << std::endl;
    

    С результатом:
    RWMutex::rlock
    Counter::get: 2
    Extracted value: 2
    RWMutex::runlock
    

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

    Для решения этой проблемы введем новый оператор длинная стрелка --->, который бы осуществлял запись в объект, т.е. доступ к неконстантным методам, а обычную (короткую) стрелку -> оставим для чтения. Доводы использования короткой стрелки для чтения и длинной — для записи, а не наоборот, следующие:
    1. Визуальный. Сразу видно, где какая операция используется.
    2. Смысловой. Чтение — это как бы поверхностное использование объекта: потрогал и отпустил. А запись — это более глубинная операция, изменение, так сказать, внутренностей, поэтому и стрелка длиннее.
    3. Прагматичный. При замене обычного мьютекса на RW-мьютекс необходимо просто поправить ошибки компиляции, заменив короткую стрелку на длинную в этих местах, и все будет работать наиболее оптимальным образом.

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


    Давайте смотреть на реализацию:
    // длинная стрелка, запись:
    WAccess operator--(int)         { return *this; }
    // короткая стрелка, чтение:
    RAccess operator->() const      { return *this; }
    

    Использование:
    AnRWLock<Counter> c;
    // не будет компилироваться: c->set(2)
    c--->set(2);
    

    Последовательность действий:
    RWMutex::wlock
    Counter::set: 2
    RWMutex::wunlock
    

    Все как и раньше, за исключением использования длинной стрелки. Смотрим дальше:
    std::cout << "Extracted value: " << c->get() << std::endl;
    

    RWMutex::rlock
    Counter::get: 2
    Extracted value: 2
    RWMutex::runlock
    

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

    Если читатель не совсем разобрался, как это работает
    Если читатель не совсем разобрался, как это работает, то я приведу следующий эквивалентный код использования “длинной” стрелки в качестве подсказки:
    (c--)->set(2);
    

    Copy-on-write

    Далее рассмотрим следующую интересную и полезную идиому: copy-on-write (COW), или Копирование при записи [8]. Как понятно из названия, основная идея заключается в том, что непосредственно перед изменением данных некоторого объекта сначала происходит копирование в новое место памяти, и уже затем изменение данных по новому адресу.

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

    Итак, как и для RW-мьютекса, нам необходимо отличать операции чтения от операции записи. При чтении ничего особенного не должно происходить, а вот при записи, если у объекта более одного владельца, то предварительно необходимо этот объект скопировать.
    template<typename T>
    struct AnCow
    {
        // ...
    
        // запись с использованием длинной стрелки
        T* operator--(int)            { return getW0(); }
        // чтение с использованием короткой стрелки
        const T* operator->() const   { return getR0(); }
    
        // функция клонирования (только для неполиморфных объектов!)
        void clone()                  { data.reset(new T(*data)); }
    
        // ...
    private:
        T* getR0() const
        {
            const_cast<An*>(this)->init();
            return data.get();
        }
    
        T* getW0()
        {
            init();
            // клонирование в случае “неуникальности” объекта
            if (!data.unique())
                clone();
            return data.get();
        }
        
    

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

    Перейдем к использованию:
    Код Вывод в консоли
    AnCow<Counter> c;
    c--->set(2);
    
    Counter::set: 2
    
    std::cout << "Extracted value: " << c->get() << std::endl;
    
    Counter::get: 2
    Extracted value: 2
    
    AnCow<Counter> d = c;
    std::cout << "Extracted value: " << d->get() << std::endl;
    
    Counter::get: 2
    Extracted value: 2
    
    d--->inc();
    
    Counter copy ctor: 2
    Counter::inc: 3
    
    c--->dec();
    
    Counter::dec: 1
    
    В самом начале не происходит ничего интересного: установка значения и вывод на экран этого же значения — 2. Далее происходит присвоение значения новой переменной, при этом данные объекта используются одни и те же. При выводе значения d->get() используется тот же самый объект.

    Далее при вызове d--->inc() происходит самое интересное: сначала копируется объект, а затем получившееся значение увеличивается до 3-х. При последующем вызове c--->dec() копирования не происходит, т.к. владелец теперь только один и у нас имеется две различные копии объекта. Думаю, этот пример наглядно иллюстрирует работу COW.

    Key-value хранилище в памяти

    Напоследок рассмотрим некоторые вариации реализации key-value хранилища в памяти при работе в многопоточной среде с использованием разработанных методик.

    Будем использовать следующую реализацию для наших хранилищ:

    template<typename T_key, typename T_value>
    struct KeyValueStorageImpl
    {
        // добавление значения в хранилище
        void set(T_key key, T_value value)
        {
            storage.emplace(std::move(key), std::move(value));
        }
        
        // получение значения
        T_value get(const T_key& key) const
        {
            return storage.at(key);
        }
        
        // удаление значения
        void del(const T_key& key)
        {
            storage.erase(key);
        }
    
    private:
        std::unordered_map<T_key, T_value> storage;
    };
    

    Привяжем хранилище к синглтону для упрощения дальнейших манипуляций (см. Использование паттерна синглтон [1]):
    template<typename T_key, typename T_value>
    void anFill(AnRWLock<KeyValueStorageImpl<T_key, T_value>>& D_an)
    {
        D_an = anSingleRWLock<KeyValueStorageImpl<T_key, T_value>>();
    }
    

    Таким образом, при создании экземпляра AnRWLock<KeyValueStorageImpl<T,V>> будет “заливаться” объект, извлеченный из синглтона, т.е. AnRWLock<KeyValueStorageImpl<T,V>> всегда будет указывать на единственный экземпляр.

    Для справки приведу используемую инфраструктуру:
    AnRWLock.hpp
    #ifndef AN_RWLOCK_HPP
    #define AN_RWLOCK_HPP
    
    #include <memory>
    #include <stdexcept>
    #include <string>
    
    #include "Mutex.hpp"
    
    // fill
    #define PROTO_IFACE_RWLOCK(D_iface, D_an)    \
        template<> void anFill<D_iface>(AnRWLock<D_iface>& D_an)
    
    #define DECLARE_IMPL_RWLOCK(D_iface)    \
        PROTO_IFACE_RWLOCK(D_iface, a);
    
    #define BIND_TO_IMPL_RWLOCK(D_iface, D_impl)    \
        PROTO_IFACE_RWLOCK(D_iface, a) { a.create<D_impl>(); }
    
    #define BIND_TO_SELF_RWLOCK(D_impl)    \
        BIND_TO_IMPL_RWLOCK(D_impl, D_impl)
    
    #define BIND_TO_IMPL_SINGLE_RWLOCK(D_iface, D_impl)    \
        PROTO_IFACE_RWLOCK(D_iface, a) { a = anSingleRWLock<D_impl>(); }
    
    #define BIND_TO_SELF_SINGLE_RWLOCK(D_impl)    \
        BIND_TO_IMPL_SINGLE_RWLOCK(D_impl, D_impl)
    
    template<typename T>
    struct AnRWLock
    {
        template<typename U>
        friend struct AnRWLock;
        
        struct RAccess
        {
            RAccess(const AnRWLock& ref_) : ref(ref_)
            {
                ref.mutex->rlock();
            }
            
            ~RAccess()
            {
                ref.mutex->runlock();
            }
            
            const T* operator->() const            { return ref.get0(); }
            
        private:
            const AnRWLock& ref;
        };
    
        struct WAccess
        {
            WAccess(const AnRWLock& ref_) : ref(ref_)
            {
                ref.mutex->wlock();
            }
            
            ~WAccess()
            {
                ref.mutex->wunlock();
            }
            
            T* operator->() const                  { return ref.get0(); }
            
        private:
            const AnRWLock& ref;
        };
    
        AnRWLock()                                 {}
    
        template<typename U>
        explicit AnRWLock(const AnRWLock<U>& a) : data(a.data) {}
    
        template<typename U>
        explicit AnRWLock(AnRWLock<U>&& a) : data(std::move(a.data)) {}
    
        WAccess operator--(int)                    { return *this; }
        RAccess operator->() const                 { return *this; }
        bool isEmpty() const                       { return !data; }
        void clear()                               { data.reset(); }
        void init()                                { if (!data) reinit(); }
        void reinit()                              { anFill(*this); }
        
        T& create()                                { return create<T>(); }
    
        template<typename U>
        U& create()                                { U* u = new U; data.reset(u); mutex.reset(new RWMutex); return *u; }
        
    private:
        T* get0() const
        {
            const_cast<AnRWLock*>(this)->init();
            return data.get();
        }
    
        std::shared_ptr<T> data;
        std::shared_ptr<RWMutex> mutex;
    };
    
    template<typename T>
    void anFill(AnRWLock<T>& a)
    {
        throw std::runtime_error(std::string("Cannot find implementation for interface: ")
                + typeid(T).name());
    }
    
    template<typename T>
    struct AnRWLockAutoCreate : AnRWLock<T>
    {
        AnRWLockAutoCreate()                       { this->create(); }
    };
    
    template<typename T>
    AnRWLock<T> anSingleRWLock()
    {
        return single<AnRWLockAutoCreate<T>>();
    }
    
    #endif
    
    AnCow.hpp
    #ifndef AN_COW_HPP
    #define AN_COW_HPP
    
    #include <memory>
    #include <stdexcept>
    #include <string>
    
    // fill
    #define PROTO_IFACE_COW(D_iface, D_an)    \
        template<> void anFill<D_iface>(AnCow<D_iface>& D_an)
    
    #define DECLARE_IMPL_COW(D_iface)    \
        PROTO_IFACE_COW(D_iface, a);
    
    #define BIND_TO_IMPL_COW(D_iface, D_impl)    \
        PROTO_IFACE_COW(D_iface, a) { a.create<D_impl>(); }
    
    #define BIND_TO_SELF_COW(D_impl)    \
        BIND_TO_IMPL_COW(D_impl, D_impl)
    
    #define BIND_TO_IMPL_SINGLE_COW(D_iface, D_impl)    \
        PROTO_IFACE_COW(D_iface, a) { a = anSingleCow<D_impl>(); }
    
    #define BIND_TO_SELF_SINGLE_COW(D_impl)    \
        BIND_TO_IMPL_SINGLE_COW(D_impl, D_impl)
    
    template<typename T>
    struct AnCow
    {
        template<typename U>
        friend struct AnCow;
        
        AnCow()                                    {}
    
        template<typename U>
        explicit AnCow(const AnCow<U>& a) : data(a.data) {}
    
        template<typename U>
        explicit AnCow(AnCow<U>&& a) : data(std::move(a.data)) {}
    
        T* operator--(int)                         { return getW0(); }
        const T* operator->() const                { return getR0(); }
        bool isEmpty() const                       { return !data; }
        void clear()                               { data.reset(); }
        void init()                                { if (!data) reinit(); }
        void reinit()                              { anFill(*this); }
        
        T& create()                                { return create<T>(); }
    
        template<typename U>
        U& create()                                { U* u = new U; data.reset(u); return *u; }
        
        // TODO: update clone functionality on creating derived instances
        void clone()                               { data.reset(new T(*data)); }
    
    private:
        T* getR0() const
        {
            const_cast<AnCow*>(this)->init();
            return data.get();
        }
    
        T* getW0()
        {
            init();
            if (!data.unique())
                clone();
            return data.get();
        }
        
        std::shared_ptr<T> data;
    };
    
    template<typename T>
    void anFill(AnCow<T>& a)
    {
        throw std::runtime_error(std::string("Cannot find implementation for interface: ")
                + typeid(T).name());
    }
    
    template<typename T>
    struct AnCowAutoCreate : AnCow<T>
    {
        AnCowAutoCreate()                          { this->create(); }
    };
    
    template<typename T>
    AnCow<T> anSingleCow()
    {
        return single<AnCowAutoCreate<T>>();
    }
    
    #endif
    

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

    Пример 1. Простейшее использование.

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

    // для простоты объявим класс для дальнейшего использования
    template<typename T_key, typename T_value>
    struct KeyValueStorage : AnRWLock<KeyValueStorageImpl<T_key, T_value>>
    {
        typedef T_value ValueType;
    };
    

    Пример использования:
    Код Вывод в консоли
    // ключ - имя
    // значение - возраст
    KeyValueStorage<std::string, int> kv;
    kv--->set("Peter", 28);
    
    RWMutex::wlock
    Key-value: inserting key: Peter
    RWMutex::wunlock
    
    kv--->set("Nick", 25);
    
    RWMutex::wlock
    Key-value: inserting key: Nick
    RWMutex::wunlock
    
    std::cout << "Peter age: " << kv->get("Peter") << std::endl;
    
    RWMutex::rlock
    Key-value: extracting key: Peter
    Peter age: 28
    RWMutex::runlock
    
    В первой строчке мы создаем объект kv, в который заливается единственный экземпляр хранилища, используя синглтон (см. функцию anFill). Далее добавляются записи Peter и Nick, а затем выводится возраст Peter.

    Думаю, из вывода понятно, что при записи автоматически берется write-lock, а при чтении — read-lock.

    Пример 2. Вложенные RW-мьютексы.

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

    // объявляем новый класс, который может в себе хранить объекты с AnRWLock
    template<typename T_key, typename T_value>
    struct KeyValueStorageRW : KeyValueStorage<T_key, AnRWLock<T_value>>
    {
    };
    
    // объявим тип для наших счетчиков
    typedef KeyValueStorageRW<std::string, Counter> KVRWType;
    

    Пример использования:
    Код Вывод в консоли
    KVRWType kv;
    // AnRWLockAutoCreate - автоматическое создание экземпляра
    kv--->set("users", AnRWLockAutoCreate<Counter>());
    
    RWMutex::wlock
    Key-value: inserting key: users
    RWMutex::wunlock
    
    kv--->set("sessions", AnRWLockAutoCreate<Counter>());
    
    RWMutex::wlock
    Key-value: inserting key: sessions
    RWMutex::wunlock
    
    kv->get("users")--->inc();
    
    RWMutex::rlock
    Key-value: extracting key: users
    RWMutex::wlock
    Counter::inc: 1
    RWMutex::wunlock
    RWMutex::runlock
    
    kv->get("sessions")--->inc();
    
    RWMutex::rlock
    Key-value: extracting key: sessions
    RWMutex::wlock
    Counter::inc: 1
    RWMutex::wunlock
    RWMutex::runlock
    
    kv->get("sessions")--->dec();
    
    RWMutex::rlock
    Key-value: extracting key: sessions
    RWMutex::wlock
    Counter::dec: 0
    RWMutex::wunlock
    RWMutex::runlock
    
    Как говорится, вуаля!

    Пример 3. Оптимизация доступа.

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

    Ниже приведены различные варианты использования для сравнения.

    Вариант 1: обычный
    Код Вывод в консоли
    kv->get("users")--->inc();
    
    RWMutex::rlock
    Key-value: extracting key: users
    RWMutex::wlock
    Counter::inc: 2
    RWMutex::wunlock
    RWMutex::runlock
    
    Вариант 2: оптимальный
    Код Вывод в консоли
    auto users = kv->get("users");
    
    RWMutex::rlock
    Key-value: extracting key: users
    RWMutex::runlock
    
    users--->inc();
    
    RWMutex::wlock
    Counter::inc: 3
    RWMutex::wunlock
    
    Во втором примере видно, что второй мьютекс на запись для объекта Counter берется только после отпускания первого, который контролирует key-value хранилище. Такая реализация обеспечивает более оптимальное использование мьютексов, хотя и получаем в результате более длинную запись. Эту оптимизацию стоит иметь в виду при использовании вложенных мьютексов.

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

    Предположим, что нам необходимо атомарно увеличить на 100 один из счетчиков, например “users”. Конечно, для этого можно позвать 100 раз операцию inc(), а можно сделать так:

    Код Вывод в консоли
    auto c = kv->get("users");
    
    RWMutex::rlock
    Key-value: extracting key: users
    RWMutex::runlock
    
    KVRWType::ValueType::WAccess cw = c;
    cw->set(cw->get() + 100);
    
    RWMutex::wlock
    Counter::get: 4
    Counter::set: 104
    RWMutex::wunlock
    
    Обратите внимание, что при использовании WAccess далее все операции идут с обычной “короткой” стрелкой, т.к. доступ к объекту на запись уже получен. Также стоит обратить внимание на то, что операции get и set идут под одним и тем же мьютексом, чего мы и хотели достичь. Это очень похоже на то, что мы как будто открыли транзакцию при действии над объектом.

    Этот же трюк можно использовать совместно с описанной выше оптимизацией для доступа непосредственно к счетчикам.
    Вариант 1: обычный
    Код Вывод в консоли
    kv->get("users")--->inc();
    
    RWMutex::rlock
    Key-value: extracting key: users
    RWMutex::wlock
    Counter::inc: 4
    RWMutex::wunlock
    RWMutex::runlock
    
    kv->get("sessions")--->dec();
    
    RWMutex::rlock
    Key-value: extracting key: sessions
    RWMutex::wlock
    Counter::dec: -1
    RWMutex::wunlock
    RWMutex::runlock
    
    Вариант 2: оптимальный
    Код Вывод в консоли
    AnRWLock<Counter> c1, c2;
    {
        KVRWType::RAccess r = kv;
        c1 = r->get("users");
        c2 = r->get("sessions");
    }
    
    RWMutex::rlock
    Key-value: extracting key: users
    Key-value: extracting key: sessions
    RWMutex::runlock
    
    c1--->inc();
    
    RWMutex::wlock
    Counter::inc: 5
    RWMutex::wunlock
    
    c2--->dec();
    
    RWMutex::wlock
    Counter::dec: -2
    RWMutex::wunlock
    
    Опять же, во втором примере мьютексы используются более оптимально: read lock берется ровно один раз, write lock берется вне read-lock, что дает бОльшую производительность при конкурентном доступе.

    Пример 5. COW.

    Предположим, что у нас есть сотрудники со следующей информацией:

    struct User
    {
        std::string name;
        int age;
        double salary;
        // много более другой информации...
    };
    

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

    Посмотрим, как это можно реализовать с использованием COW. Подготовительный этап:
    Использование COW при создании экземпляра User
    BIND_TO_SELF_COW(User)
    
    Объявляем новый класс, который может в себе хранить объекты с AnCow
    template<typename T_key, typename T_value>
    struct KeyValueStorageCow : AnRWLock<KeyValueStorageImpl<T_key, AnCow<T_value>>>
    {
    };
    
    Объявление нашего хранилища: int — id пользователя, User — пользователь
    KeyValueStorageCow<int, User> kv;
    
    Добавление информации о пользователе Peter
    AnCow<User> u;
    u--->name = "Peter";
    u--->age = 35;
    u--->salary = 12345;
    kv--->set(1, u);
    
    Добавление информации о пользователе George
    AnCow<User> u;
    u--->name = "George";
    u--->age = 31;
    u--->salary = 54321;
    kv--->set(2, u);
    
    Изменение информации о возрасте сотрудника
    AnCow<User> u = kv->get(2);
    ++ u--->age;
    kv--->set(2, u);
    
    Проведение баланса:
    Получение нужных пользователей
    AnCow<User> u1 = kv->get(1);
    AnCow<User> u2 = kv->get(2);
    
    Расчет необходимых параметров. Все данные будут самосогласованны и останутся неизменны вплоть до окончания операций.
    double totalSalary = u1->salary + u2->salary;
    double averageSalary = totalSalary/2.;
    double averageAge = (u1->age + u2->age)/2.;
    double averageSalaryPerAge = (u1->salary/u1->age + u2->salary/u2->age)/2.;
    // ...
    
    Таким образом, при проведении длительной операции происходит фиксация информации о пользователях на момент ее извлечения. В каждый момент времени возможно изменить информацию о сотруднике, однако это не повлияет на текущие расчеты. А при следующем проведении баланса будут использованы самые свежие данные. Такой подход гарантирует самосогласованность расчетов с возможностью изменения данных в любой момент, не дожидаясь окончания длительных расчетов.

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

    Анализ и обобщение

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

    COW в деталях

    Рассмотрим последовательность операций последнего примера. Ниже приведена общая схема при извлечении значения из контейнера:


    Здесь Data — это User в описанном выше примере, а shared_ptr<Data> — это содержимое объекта AnCow. Последовательность операций:
    N Операция Описание
    1 lock Блокировка хранилища, делается автоматически при вызове operator->
    2 copy Копирование shared_ptr<Data>, т.е. фактически происходит простое увеличение счетчиков (use_count и weak_count внутри объекта shared_ptr<Data>)
    3 unlock Снятие блокировки хранилища деструкторе временного объекта
    После этих операций можно делать различные действия над объектом. При чтении никаких дополнительных действий не происходит: просто берем и считываем данные объекта непосредственно из занимаемой области данных. Тут стоит упомянуть очень неожиданный нюанс: чтение данных объекта происходит без взятия блокировки! Это неожиданное свойство я и хотел подчеркнуть, разбирая COW.

    Что же происходит при записи данных в объект. Смотрим:


    Последовательность операций при этом выглядит следующим образом:
    N Операция Описание
    4 clone Клонирование объекта, вызывается конструктор копирования объекта Data, т.е. копирование всех его полей в новую область памяти. После этой операции shared_ptr начинает смотреть на вновь созданный объект.
    5 modify Непосредственно операция модификации. Может происходить длительное время, т.к. является неблокирующей.
    6 lock Перед обновлением хранилища прозводится взятие блокировки.
    7 replace Замена старого значения shared_ptr<Data> на новое, модифицированное на 5-м шаге.
    8 unlock Снятие блокировки хранилища.
    Как и в случае чтения, запись в объект происходит без участия блокировок, т.к. мы — единственный владелец созданного объекта. В принципе, ту же схему операций можно наблюдать при манипуляциях с объектом простого типа, например, типа int. Существенное отличие состоит в том, что при COW данные могут использоваться из одной и той же области памяти в нескольких потоках одновременно.

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

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

    struct UserInfo
    {
        AnCow<AccountingInfo> accounting;
        AnCow<CommonInfo> common;
        AnCow<WorkInfo> work;
    };
    
    struct AccountingInfo
    {
        AnCow<IncomingInfo> incoming;
        AnCow<OutcomingInfo> outcoming;
        AnCow<BalanceInfo> balance;
    };
    
    struct CommonInfo
    {
        // и т.д.
    };
    
    // и т.п.
    

    Введя на каждом из уровней объекты COW, можно значительно уменьшить количество копирований. При этом, сама операция копирования будет состоять лишь в атомарном увеличении счетчиков. И лишь непосредственно сам объект изменения будет скопирован с использованием конструктора копирования. Можно легко показать, что минимальное количество копирований достигается при количестве объектов на каждом из уровней равным 3-м.

    Обобщенная схема

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

    В первом примере использовались вложенные AnRWLock объекты:


    Key-value хранилище в указанном примере был положен в синглтон и сверху обернут “умным” мьютексом. Значения при этом также были обернуты в “умный” мьютекс.

    Следующая схема описывает пример с COW:


    Здесь значение было обернуто в объект AnCow для реализации семантики COW.

    Соответственно, обобщенную схему можно представить в виде:


    Понятно, что объекты AnLock и An(RW)Lock взаимозаменяемы: можно использовать как один, так и другой. Также можно цепочку повторять несколько раз, как показно на примере ниже:


    Стоит, однако, помнить, что семантика объектов An(RW)Lock и AnCow существенно отличается:
    Свойство Умный мьютекс COW
    Доступ к полям объекта Блокируется на время чтения/записи Не блокируется
    Изменение объекта в контейнере Изменение “на месте” (in-place) После изменения необходимо положить новое значение обратно в контейнер

    Выводы

    Итак, в статье были рассмотрены некоторые идиомы, повышающие эффективность написания многопоточных приложений. Стоит отметить следующие преимущества:
    1. Простота. Нет необходимости явного использования многопоточных примитивов, все происходит автомагически.
    2. Гибкость. Решение применимо для широкого класса многопоточных задач с возможностью комбинирования различных идиом. Здесь также стоит упомянуть поддержку транзакционности (в памяти) на некотором начальном уровне.
    3. Качество. Использование такого подхода позволяет существенно уменьшить количество проблем, связанных с состоянием гонки (race condition) и взаимной блокировки (deadlock). Состояния гонки устраняются наличием автоматических блокировок благодаря умным мьютексам. Вероятность взаимной блокировки существенно уменьшается благодаря гранулярному (fine-grained) доступу к данным на каждом уровне, и фиксированным автоматическим порядком захвата и снятия блокировок.

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

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

    Литература

    [1] Хабрахабр: Использование паттерна синглтон
    [2] Хабрахабр: Синглтон и время жизни объекта
    [3] Хабрахабр: Обращение зависимостей и порождающие шаблоны проектирования
    [4] Хабрахабр: Реализация синглтона в многопоточном приложении
    [5] Википедия: Состояние гонки
    [6] Википедия: Взаимная блокировка
    [7] Wikipedia: Readers–writer lock
    [8] Википедия: Копирование при записи
    [9] Хабрахабр: Многопоточность, общие данные и мьютексы
    [10] Хабрахабр: Кросс-платформенные многопоточные приложения
    [11] Хабрахабр: Потоки, блокировки и условные переменные в C++11 [Часть 2]
    [12] Хабрахабр: Потоки, блокировки и условные переменные в C++11 [Часть 1]
    [13] Хабрахабр: Два простых правила для предотвращения взаимных блокировок на мьютексах
    [14] DrDobbs: Enforcing Correct Mutex Usage with Synchronized Values

    P.S. Решение задачи про количество копирований
    Предположим, на каждом из уровней имеется n объектов, количество уровней — k, а общее число элементов — a. Тогда количество элементов a = n^k, или k = ln a/ln n. Сокращая ln получаем k = a/n. Количество копирований = n*k (необходимо пройти каждый слой и скопировать на каждом слое n раз). Т.е. число копирований = n*ln a/ln n или просто n/ln n, т.к. a — const. Легко найти локальный минимум, он достигается при n = e, что ближе всего соответствует целому числу 3.

    И напоследок — опрос. На повестке дня 2 вопроса:
    Понравилась ли статья?
    • 85%Да, понравилась. Жду продолжения.158
    • 15%Ничего интересного, только зря потратил время на прочтение.27
    Понятно ли, откуда взялось число 3 для оптимизации количества операций копирования при рассмотрении вложенных COW-объектов?
    • 17%Да, я сам получил такое же число, все понятно.14
    • 83%Нет, непонятно. Вывод в студию! (уже в P.S.)69

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

    Метки:
    Поделиться публикацией
    Комментарии 46
    • +4
      Честно говоря, мне «Пример 3. Практически идеал: инкапсуляция локов» нравится больше, чем KeyValueStorage как с точки зрения кода, так и с точки зрения дальнейшего использования. Длинная стрелка, которая ещё иногда и короткой становится — совершенно неочевидная штука. Дополнительной проблемой может стать сокрытие локов от программиста, в результате можно получить разный порядок взятия локов (и это будет не сразу видно из-за врапперов), а это — прямой путь к дедлокам.
      • 0
        В целом, инкапсуляция локов, конечно же, самый предпочтительный вариант. Но у него есть один недостаток, который отсутствует в приведенном подходе: это то, что нельзя атомарно сделать несколько операций. Для примера, возьмем Counter и инкапсулируем локи для всех его методов. Тогда возникает вопрос: а как мне атомарно увеличить счетчик на 100? Ответ — делать новый метод и тогда все получится. Но ведь существует еще массу других вариантов использования: уменьшить на число, удвоить, утроить и т.п. Что, добавлять каждый такой метод в класс? Более того, не всегда нужно использовать Counter с учетом многопоточности, иногда просто хочется его использовать в одном потоке.

        В описанном подходе просто используем WAccess и сразу получаем результат. Хочется — можно использовать AnLock, а можно и AnRWLock или даже AnCow. Т.е. гораздо большая гибкость, плюс автоматом берутся локи, т.е. не надо в каждый метод напихивать scoped-локи.

        В любом случае, выбирать, какой метод использовать целиком и полностью ложится на плечи программиста. Мне хотелось привести более другие подходы к частым вопросам многопоточности.
      • +7
        Для решения этой проблемы введем новый оператор длинная стрелка --->
        Это из этой серии что-то?

        Вообще, не очень понятно, стоит ли эта сложность того?

        И не будет ли в реальном проекте куча такого кода?

        AnRWLock<Counter> c;
        c--->set(2);
        c--->inc();
        c-->somethingElse();
        


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

        С COW — вообще отдельная песня. Не всегда он так хорош, как хотелось бы. Иногда скопировать объект быстрее.
        • –1
          С тем же успехом можно сказать: зачем нам C++, когда все можно написать C. Безусловно, можно. Вопрос в простоте и удобстве, в качестве и предсказуемости.

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

          С COW — вообще отдельная песня.
          Да, это — не панацея. Собственно, в статье детально изложены плюсы и минусы использования.
          • +4
            Ну если в реальном проекте не смущает использование "." или "->" в приведенных операциях, то я не вижу ничего зазорного использовать --->.
            Ну вот видите, вы сами не заметили, в чем подвох. А дело в том, что такой код будет большую часть времени заниматься захватом и возвращением мьютексов в ядре ОС, вместо того, чтобы выполнять свои прямые обязанности. Поэтому, я считаю, некоторые вещи лучше делать явно.

            Прогресс от С к С++ и от С++ к, там, Java, состоит в упрощении вещей. Ваш код же не упрощает, а усложняет. Да, можно писать чуть меньше кода и сложнее забыть что-нибудь заблокировать. Зато большое количество «магии» делает систему сложной в понимании.
            • 0
              А дело в том, что такой код будет большую часть времени заниматься захватом и возвращением мьютексов в ядре ОС, вместо того, чтобы выполнять свои прямые обязанности.
              Не совсем понятно, как предлагается работать: без мьютексов? Или имелось в виду частое использование? Для этого описаны способы, как оптимизировать использование.

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

              shared_ptr — это тоже магия. Мало кто знает, как он на самом деле работает. Но тем не менее, его используют и ничего. И на его тормоза тоже смотрят сквозь пальцы. Так что все зависит от задач и общего рецепта я бы не давал. Я лишь рассмотрел подход к штанге многопоточности с другой стороны.
        • +4
          Автор, между прочим, неверно понимает RAII (ну или мало его использовал на практике, судя по первому примеру). На его структурах Mutex и Lock можно написать вот такой код:

          {
              Mutex m;
              Lock l(m);
              m.unlock();
          }
          


          и после вызова деструктора Lock мы получаем неопределённое поведение. На Windows XP, к примеру, будет deadlock. Пишете о RAII — так реализуйте его уже по-настоящему, с приватными методами lock\unlock и friend-классом.

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

          • 0
            и после вызова деструктора Lock мы получаем неопределённое поведение.
            Почему?
            std::mutex и std::lock_guard выглядят точно так же.
            • 0
              Потому что нигде не указано, что будет со счетчиком локов при переходе нуля в отрицательную сторону. Ни спецификации ОС (ну по крайней мере Винды), ни объявление вышеуказанных классов не отвечают на этот вопрос. На счёт глюка с этим делом в WinXP — я не фантазирую, попробуйте локнуть критическую секцию, потом дважды релизнуть её, а потом локнуть снова. Поведение неопределённое, в примерно 80% случаев всё ок, в 20% — дедлок.

              Это дело, конечно, пофиксили начиная с Висты, но что это за «полезная идиома» такая, если она на 25% компьютеров не будет работать?
              • 0
                То, что было приведено выше — это не «полезная идиома», а пример, который иногда используют (см. [9] Хабрахабр: Многопоточность, общие данные и мьютексы). В приведенной статье такое сделать просто невозможно.
                • 0
                  А, ну в качестве «пример как не надо делать» вполне пойдёт. Просто я как-то не сразу это уловил.
                • 0
                  Где вы видите критическую секцию и Windows XP? Я вижу только класс Mutex. И если при его использовании возможен повторный unlock, то разработчики об это позаботятся.
                  • +1
                    Критическую секцию я вижу в документации к ОС. Или этот мьютекс будет на святом духе работать? Позаботятся разработчики или нет — откуда я знаю? Вы что — верите разработчика библиотек?

                    Убранные в приват методы мьютекса были бы явным указанием «юзайте RAII и не выпендривайтесь, всё будет ок»
                    Отсутствие класса Lock было бы явным указанием «менеджерите локи\анлоки сами вручную»

                    А так — и не то и не то. Ощущения как от плохо документированного кода — неопределённость.
                    • 0
                      Или этот мьютекс будет на святом духе работать?
                      На атомарном счетчике ссылок, как все изветсные мне реализации.

                      Вы что — верите разработчика библиотек?
                      Да. И это меня еще не подводило.
                      • 0
                        Ну, мы с Вами расходимся во мнениях только потому, что лично я видел и библиотечный мьютекс, написанный на критической секции. И именно это меня и подвело когда-то. Опыт у всех свой.
            • 0
              Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?
              Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).
              • 0
                Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?
                У меня практически всегда удается корректно и быстро работать. Что я делаю не так?

                Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).
                Очень сильно зависит от задач. Если правильно шарить данные, то никаких проблем не бывает. И мне очень интересно узнать, как реализовать, например, консистентное кеширование данных в памяти с использованием фьючерсов или тредпулов.
                • 0
                  как реализовать, например, консистентное кеширование данных в памяти

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

                  К сожалению, у меня нет возможности сравнить производительность такого решения с чем-то вроде Cache из Google.Guava. Хотелось бы услышать мнение профессионалов по этому поводу.
                  • 0
                    Как предлагаете делать очередь без локов? Только давайте практические реализации, проверенные, т.к. во многих учебных примерах, например, есть ABA проблема.
                    • 0
                      Я, может, напишу статью про mapreduce в пределах одного процесса. Там, в том числе, используется кольцевой буффер (по сути реализующий интерфейс очереди) без блокировок.
                      • +1
                        Кто сказал, что очередь обязательно должна быть без локов? Можно и с локами (хотя есть атомарные без локов, взгляните на Google Concurrency Library for C++).

                        Я не говорю, что локи не нужны. Разумеется, нужны. Я говорю о том, что редко есть необходимость использовать именно их в качестве примитивов для построения приложения. Конкурентная очередь уже написана, бери и используй. Даже если нет, реализовать очередь самому и использовать её в качестве основы гораздо лучше, чем втыкать тут и там блокировки и общедоступные ресурсы.
                        • +1
                          Да, но с очередью вы сразу добавляете много накладных расходов. Если потоков больше, чем ядер (а это нормальная ситуация, если вы работаете с сетью) вы получаете +2 кванта времени (80мс на современных серверных ядрах линкса), которые ушли на решедулинг, хотя в случае мьютекса хватило бы одного спинлока и времени бы вообще не потеряли. 6 обращений к кэшу — и вы внезапно получили плюс пол секунды к времени работы функции.

                          Lock-less структуры данных это штуки, которые практически невозможно оттестировать и крайне сложно отловить в них баги. Избегайте их как только можете, потому что в реалиях многоядерных систем мьютекс, а точнее фьютекс, превращается в спинлок, который работает очень быстро в случае лёгкий операций типа «добавить собщение в очередь».
                • НЛО прилетело и опубликовало эту надпись здесь
                  • 0
                    На самом деле нет. В этом «идеале» нет composability.
                    Судя по всему, вы пропустили слово «практически». Я не настаиваю на том, что это — идеал, но очень близко к нему. Этот пример приведен в качестве затравки. Более того, моя статья посвящена несколько другим аспектам, где этот недостаток («composability») сильно сглажен (см. комментарий).

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

                    В общем, цель, это обычно баланс между fine и coarse granularity, а данный подход как раз фокусируется только на первом. Это полхо.
                    С тем же успехом можно сказать, что я в своей статье использовал С++ и ничего не сказал про Java, а это плохо. Или что ничего не сказал о conditional variable или атомарных конструкциях. Странный аргумент. Вместе с тем, подход не исключает использование coarse granularity. Для этого всего лишь нужно создать один An(RW)Lock объект, не включающий в себя другие An(RW)Lock объекты.
                    • НЛО прилетело и опубликовало эту надпись здесь
                      • 0
                        Если один поток ждет данные а другой — предоставляет, то, если оба потока выполняются, никакого переключения контекста не будет, мьютекс там, либо атомарная переменная.
                        Если поток пытается захватить мьютекс, а он уже захвачен, то это приведет к переключению контекста. В случае lock free очереди — переключения контекста не будет, поток потратит свой квант времени на busy wait на этой самой очереди. На практике, накладные расходы появляются тогда, когда очереди сильно выростают, начинают жрать память и приводить к кэш промахам.

                        Если есть только один мьютекс, и 2 потока работают с защищаемой им переменной, то переключение контекста будет в том случае, если оба потока одновременно выполняются (очевидно, многоядерная система должна быть) и лочат на чуть большее время, чем работает спинлок, либо если один залочил и его зарешедулила ОС. Если операция лёгкая, то чаще всего никого не зарешедулят и всё ограничится спинлоком. Если одновременно работает 1 поток — то всё вообще отлично.

                        Когда появляется очередь, приходится использовать кондишены. Если разгребающий поток не будет активным — то ожидающий наверняка будет зарешедулен. Если очередь более-менее длинная, то ожидающий опять же будет зарешедулен. То есть если у вас 200 потоков на 16 ядер, то с высокой вероятностью постановка задания в очередь и получение результата в одном кванте не выполнится. Если операция долгая, то ничего страшного. Если операция быстрая, типа Key value store — то очередь только замедлит всё.

                        Пишите, если я где-то не прав. Выше я что-то маху дал на счёт 40мс, при HZ=250 квант всё же 4мс.
                        • НЛО прилетело и опубликовало эту надпись здесь
                          • 0
                            Вы рассматриваете варинат std::queue + condition variable вместо специализированой очереди?

                            Какая бы очередь не была, нам всё равно надо каким-то образом получить результат, правда? Это может быть не кондишн, а while(cond) со слипом внутри, не суть важно. Главное, что мы ждём. Вы можете возразить, что мы можем вместо ожидания вернуть в ивентлуп и продолжать делать полезную работу, но это уже совсем другая песня.

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

                            Это если потоков столько же, сколько ядер. В реальности, если вы работаете с данными на диске, то потоков будет сильно больше, и consumer будет регулярно вытесняться. Я всё к тому, что реальная latency очереди может оказаться больше, чем кажется на первый взгляд, и про неё уже нельзя забывать, если время ответа всего сервиса измеряется в десятках миллисекунд.
                            • НЛО прилетело и опубликовало эту надпись здесь
                              • 0
                                latency(положить в очередь) + latency(обработать) + latency(получить результат)
                                будет больше, чем
                                latency(взять лок) + latency(обработать) + latency(отпустить лок)
                                из-за решедулингов. А они, в свою очередь, зависят от количества ядер и количества активных потоков.

                                Дисковое IO в линуксе на самом деле всегда синхронное, просто при использовании aio_* блокироваться будет не ваш поток, а специально созданный ядерный поток. Я упомянул IO в контексте того, что потоков на порядок, а то и на несколько порядков больше, чем ядер процессора. Начал какой нибудь logrotate или syslogd синкать данные на диске, скушал много процессора — и очередь резко стала работать медленней.
                                • НЛО прилетело и опубликовало эту надпись здесь
                                  • 0
                                    То, что исползуя очередь, мы жертвуем латентностью, ради увеличения пропускной способности — не секрет. Я только не пойму, откуда возьмутся решедулинги и почему они испортят производительность.

                                    Проблема не в блокировках consumer на извлечении элементов из очереди, тут то как раз всё хорошо обычно. Проблема в том, что при 160 активных тредах на 16 физических ядер шансы того, что consumer тред активен одновременно с нашим, запихивающим данные, 10%. А когда consumer отработает, в следующий квант, то наш уже будет вытеснен. Производительность, если не использовать спин-вейты, не упадёт, а вот latency вырастет. Можно повысить приоритет consumer треда, и это даже может помочь. А может всё, наоборот, станет медленнее. И прирост от нагрузки может сильно зависеть.

                                    Я совершенно согласен с вами, что если работа по обработке достаточно большая по времени, то очереди становятся неплохой идеей. Что такое «достаточно большая» — вопрос, который надо исследовать на реальной системе с реальной нагрузкой. В таком исследовании я бы отталкивался от цифры в 10 квантов.
                                    • НЛО прилетело и опубликовало эту надпись здесь
                                      • 0
                                        Всё, я понял ваше недопонимание. В современных операционных системах, работающих на многоядерных процессорах, когда вы создаёте mutex на самом деле создаётся futex. Различие в том, что он сначала делает spin wait некоторое время (достаточное, чтобы несколько раз обратиться к памяти мимо всех кэшей), то есть ведёт себя как спинлок, а если после этого лок всё ещё не взят, то делается уже классический ядерный мьютекс и поток засыпает. В линуксе это точно так, вот страничка из мана: linux.die.net/man/2/futex.

                                        Таким образом в большинстве случаев при правильном использовании (брать мьютекс на чуть-чуть) на многоядерных (это важно! если ядро одно — в любом случае будет вытеснение, если лок уже взят) системах получается очень быстрая реакция.
                                        • НЛО прилетело и опубликовало эту надпись здесь
                                          • 0
                                            Это я развивал мысль, которая с комментария roman_kashitsyn началась:
                                            Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?
                                            Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).

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

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

                                                Обычно как раз не ожидаем, а делаем что-то ещё (например, посылаем сразу другие таски и потом уже ждём всё сразу), иначе смысла во фьючерсе мало, проще было бы предоставить синхронный интерфейс.
                                                • 0
                                                  Если рассматривать в контексте времени обработки одного запроса — то разницы особо нету, просто ждать или делать другую полезную работу.
                        • 0
                          практически

                          Практически тоже не композируются. То есть вообще. Два атомарных действия не равны двум действиям, совершённым атомарно. Такими свойствами обладает STM (реализованная в стандартной библиотеке Clojure, Haskell и в Akka), но у неё всё ещё есть проблемы с производительностью и не только.
                          • 0
                            См. «Пример 4. Поддержка атомарности изменений» в статье при рассмотрении key-value хранилища.
                      • 0
                        У Вас в коде из коробки дедлок!
                        RWMutex::rlock
                        Key-value: extracting key: users
                        RWMutex::wlock
                        Counter::inc: 4
                        RWMutex::wunlock
                        RWMutex::runlock

                        Нельзя сначала залочить на чтение, а потом апгредится до записи! Привожу пример: два потока залочили на чтение, а потом оба решили записать, и мы получили дедлок на одной единственной RW секции.
                        • 0
                          На самом деле там 2 разных лока:
                          первый — это лок на таблице key-value
                          второй — на самом Counter

                          Поэтому никакого дедлока нет. Это называется fine-grained locks. Про это, собственно, и статья (но и не только про это).
                          • 0
                            Упс, и правда разные, прошу прощения. Давно дело было.

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