Как реализовать потокобезопасный контейнер с естественным синтаксисом?
Предисловие
Код ниже приводит к неопределенному поведению, если используется как есть:
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 ответов
Не делай этого.
Почти невозможно создать потокобезопасный класс коллекции, в котором каждый метод получает блокировку.
Рассмотрим следующий пример предложенного вами класса 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;
}
В неоптимизированной сборке вышеприведенная программа вылетает за считанные секунды. (Розничная торговля немного сложнее, но ошибка все еще есть).
Это усилие чревато опасностями и проблемами с производительностью. Итераторы, как правило, зависят от состояния всей структуры данных и обычно становятся недействительными, если структура данных изменяется определенным образом. Это означает, что итераторам либо нужно хранить мьютекс во всей структуре данных при их создании, либо вам нужно определить специальный итератор, который тщательно блокирует только то, от чего зависит в данный момент, что, вероятно, больше, чем состояние узла / элемента, на который он в данный момент указывает. И это потребует внутренних знаний о реализации того, что оборачивается.
В качестве примера, подумайте о том, как эта последовательность событий может закончиться:
Тема 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
и затем решение, стоит ли повторять его, является лишь одним примером.
Использование временного объекта для вызова функции с перегруженным оператором ->() приводит к неопределенному поведению в C++
Нет. Временные уничтожаются только в конце полного выражения, которое привело их к жизни. И используя временный объект с перегруженным operator->
"декорировать" доступ к элементу именно поэтому перегруженный оператор определяется так, как он есть. Он используется для регистрации, измерения производительности в специализированных сборках и, как вы сами обнаружили, блокирует доступ всех членов к инкапсулированному объекту.
Диапазон, основанный на синтаксисе цикла, в этом случае не работает. Это дает ошибку компиляции. Как правильно это исправить?
Ваш Iterator
насколько я могу судить, функция не возвращает реальный итератор. сравнить Safe<Args...>(std::forward<Args>(args)...);
со списком аргументов Iterator(Class::NAME(), m_Mutex)
, Что такое Base
когда аргумент в Args
выводится из Class::NAME()
?
Служит ли этот небольшой служебный класс в целях безопасности потока для инкапсулированного объекта во всех случаях?
Это выглядит довольно безопасно для простых типов значений. Но, конечно, это зависит от того, какой доступ осуществляется через оболочку.
Для более сложных контейнеров, где учитывается аннулирование итераторов, создание единственного доступа к элементу не обязательно предотвратит состояние гонки (как было отмечено в комментариях). Я полагаю, вы можете создать оболочку итератора, которая блокирует контейнер на время его существования... но тогда вы потеряете большую часть полезного API контейнера.
Я не могу удержаться, чтобы ответить на это, так как я работаю над такой библиотекой уже несколько месяцев. Естественно, я думаю, что идея очень хорошая: она приводит к гораздо более ясному и безопасному коду. Чтобы ответить на вопросы:
- Как уже ответили: это не приводит к неопределенному поведению, потому что временное существует для всего выполнения строки кода, в которой оно появляется.
- Ваш служебный класс может быть использован так же, как
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
тег, и это может быть очень полезным.
Я буду рад указать вам на мою собственную реализацию, если вы заинтересованы.
В дополнение к другим вопросам, ваше предположение о const
тоже не правы. Для многих из stl
типы, const
Методы все еще требуют, чтобы контейнер был защищен от модификации на время выполнения.
Для этого вам нужен хотя бы общий мьютекс, и он также должен быть объявлен mutable
так что он может быть заблокирован в const
дорожка. В этот момент лучше знать, что std::shared_mutex
Все реализации также нарушают спецификацию, вводя дополнительные точки синхронизации из-за преждевременной стратегии "эксклюзивного первого" планирования, скопированной из boost. Относитесь к ним как к оптимизации производительности с теми же ограничениями, что и std::mutex
, не полагайтесь на спецификацию.
При использовании константных итераторов (cbegin
, cend
) вы также должны иметь возможность получить блокировку для всей транзакции.
Итак, вам требуется ScopedLock
за const
доступ тоже.
Тот же вердикт, что и в других ответах ->
прямо на Concurrent
это опасный выбор дизайна. Типичный пистолет, направленный прямо на вашу ногу. В значительной степени гарантировано, что это будет дуть, когда наивно рефакторинг от .
в ->
оператор.