Как реализовать потокобезопасный контейнер с естественным синтаксисом? - PullRequest
0 голосов
/ 20 февраля 2019

Предисловие

Ниже код приводит к неопределенному поведению, если используется как:

vector<int> vi;
...
vi.push_back(1);  // thread-1
...
vi.pop(); // thread-2

Традиционный подход состоит в том, чтобы исправить это с помощью std::mutex:

std::lock_guard<std::mutex> lock(some_mutex_specifically_for_vi);
vi.push_back(1);

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

Цель

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

Concurrent<vector<int>> vi;  // specific `vi` mutex is auto declared in this wrapper
...
vi.push_back(1); // thread-1: locks `vi` only until `push_back()` is performed
...
vi.pop ()  // thread-2: locks `vi` only until `pop()` is performed

В текущем C ++ этого добиться невозможно.Тем не менее, я попытался код, где, если просто изменить vi. на vi->, то все работает, как и ожидалось в комментариях кода выше.

Код

// The `Class` member is accessed via `->` instead of `.` operator
// For `const` object, it's assumed only for read purpose; hence no mutex lock
template<class Class,
         class Mutex = std::mutex>
class Concurrent : private Class
{
  public: using Class::Class;

  private: class Safe
           {
             public: Safe (Concurrent* const this_,
                           Mutex& rMutex) :
                     m_This(this_),
                     m_rMutex(rMutex)
                     { m_rMutex.lock(); }
             public: ~Safe () { m_rMutex.unlock(); }

             public: Class* operator-> () { return m_This; }
             public: const Class* operator-> () const { return m_This; }
             public: Class& operator* () { return *m_This; }
             public: const Class& operator* () const { return *m_This; }

             private: Concurrent* const m_This;
             private: Mutex& m_rMutex;
           };

  public: Safe ScopeLocked () { return Safe(this, m_Mutex); }
  public: const Class* Unsafe () const { return this; }

  public: Safe operator-> () { return ScopeLocked(); }
  public: const Class* operator-> () const { return this; }
  public: const Class& operator* () const { return *this; }

  private: Mutex m_Mutex;
};

Демо

Вопросы

  • Использование временного объекта для вызова функции с перегруженным operator->() приводит к неопределенному поведению в C ++?
  • Эта небольшая утилитакласс служит для обеспечения безопасности потоков для инкапсулированного объекта во всех случаях ?

Пояснения

Для взаимозависимых операторов требуетсяболее длинный замок.Следовательно, введен метод: ScopeLocked().Это эквивалент std::lock_guard().Однако мьютекс для данного объекта поддерживается внутренне, поэтому он все же лучше синтаксически.
Например, вместо ниже ошибочного дизайна (как предложено в ответе):

if(vi->size() > 0)
  i = vi->front(); // Bad: `vi` can change after `size()` & before `front()`

Следует полагаться на следующий дизайн:

auto viLocked = vi.ScopeLocked();
if(viLocked->size() > 0)
  i = viLocked->front();  // OK; `vi` is locked till the scope of `viLocked`

Другими словами, для взаимозависимых операторов следует использовать ScopeLocked().

Ответы [ 5 ]

0 голосов
/ 01 марта 2019

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

  1. Как уже отвечено: это не приводит к неопределенному поведению, потому что временное существует для всего выполнения строки кода, в которой оно появляется.
  2. ВашУтилита класса может использоваться так же универсально, как std::lock_guard.std::lock_guard - это механизм перехода в C ++ 11 для обеспечения безопасности потоков, независимо от того, с какими объектами вы работаете.

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

Некоторые проблемы, которые я вижу вВаш код:

  • В стандартной библиотеке различаются std::lock_guard и std::unique_lock, и я считаю важным сохранить это различие.Первый - для ежедневной блокировки мьютекса, второй - для использования, например, с std :: condition_variable.
  • Вы явно вызываете lock() и unlock() для мьютекса, вы предотвращаете выгодное использование общих мьютексов, поскольку у них есть метод lock_shared для доступа только для чтения.
  • Вы предоставляете доступ к инкапсулированному объекту через константный указатель / константную ссылку.Доступ только для чтения по-прежнему требует блокировки мьютекса, поскольку другой поток может одновременно изменять объект: возможно, вы читаете частично обновленную информацию.
  • Ваш класс менее гибкий, чем стандартные.Например, std::lock_guard может принять уже заблокированный мьютекс с помощью тега std::adopt_lock, и это может быть очень полезно.

Я буду рад указать вам на мою собственную реализацию, если выинтересно.

0 голосов
/ 27 февраля 2019

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

Для этого требуется как минимум общий мьютекс, а также для этого необходимобыть объявленным mutable, чтобы его можно было заблокировать в пути const.В этот момент лучше иметь в виду, что реализации std::shared_mutex также все нарушают спецификацию, вводя дополнительные точки синхронизации из-за преждевременной стратегии планирования «сначала исключено», скопированной из boost.Относитесь к ним как к оптимизации производительности с теми же ограничениями, что и std::mutex, не полагайтесь на спецификацию.

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

Таким образом, вам также требуется ScopedLock для доступа const.


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

0 голосов
/ 20 февраля 2019

Не делайте этого.

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

Рассмотрим следующий экземпляр предложенного вами класса Concurrent.

Concurrent<vector<int>> vi;

Разработчик может прийти и сделать это:

 int result = 0;
 if (vi.size() > 0)
 {
     result = vi.at(0);
 }

И другой поток может сделать это изменение между вызовами первых потоков size() и at(0).

vi.clear();

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

vi.size()  // returns 1
vi.clear() // sets the vector's size back to zero
vi.at(0)   // throws exception since size is zero

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

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

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

vi_mutex.lock();
vi.push_back(1);
vi_mutex.unlock();

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

{
    lock_guard<mutex> lck(vi_mutex);
    vi.push_back(1);
}

Затем другой код впрактика становится поточно-ориентированной ала:

{
     lock_guard<mutex> lck(vi_mutex);
     result = 0;
     if (vi.size() > 0)
     {
         result = vi.at(0);
     }
}

Обновление:

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

Concurrent<list<int>> g_list;

void thread1()
{
    while (true)
    {
        if (g_list->size() > 0)
        {
            int value = g_list->front();
            cout << value << endl;
        }
    }

}

void thread2()
{
    int i = 0;
    while (true)
    {
        if (i % 2)
        {
            g_list->push_back(i);
        }
        else
        {
            g_list->clear();
        }
        i++;
    }
}

int main()
{

    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join(); // run forever

    return 0;
}

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

0 голосов
/ 20 февраля 2019

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

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

Поток 1:

 void thread1_func(Concurrent<vector<int>> &cq)
 {
       cq.push_back(1);
       cq.push_back(2);
 }

Тема 2:

 void thread2_func(Concurrent<vector<int>> &cq)
 {
       ::std::copy(cq.begin(), cq.end(), ostream_iterator<int>(cout, ", "));
 }

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

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

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

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

0 голосов
/ 20 февраля 2019

Использует временный объект для вызова функции с перегруженным оператором -> () приводит к неопределенному поведению в C ++

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

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

Ваша функция Iterator не возвращает реальный итератор, насколько я могу судить.Сравните Safe<Args...>(std::forward<Args>(args)...); со списком аргументов Iterator(Class::NAME(), m_Mutex).Что такое Base, когда аргумент в Args выводится из Class::NAME()?

Служит ли этот небольшой служебный класс для обеспечения безопасности потоков для инкапсулированного объекта во всех случаях?

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

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

...