Когда именно следует использовать мьютексы? С++
В своем изучении С++ дошел до потоков. И передо мной встал вопрос. Если мы пишем универсальный код и рассматриваем возможность использования нашей библиотеки, например, в нескольких потоках. Значит ли это, что все операции записи в переменные мы должны заключать в мьютексы? Например у нас есть класс, который будет использоваться в нескольких потоках. Это значит, что все методы, изменяющие класс, должны внутри содержать мьютексы, чтобы не было состояния гонки данных? Или же есть какие-то особые случаи, когда нужно использовать мьютекс, а не тыкать его везде и всюду?
Ответы (2 шт):
Упрощенно так:
если функция const, то мьютекс, ВОЗМОЖНО, не нужен.
Дальше надо смотреть, нет ли в классе mutable данных,
и не являются ли некоторые члены как будто константными,
а на самом деле требующими многопоточной "защиты".
"Это значит, что все методы, изменяющие класс, должны внутри содержать мьютексы, чтобы не было состояния гонки данных?"
Нет . Если есть хоть один поток, который изменяет состояние объекта (а не класса), то все потоки, которые считывают это состояние, должны использовать синхронизацию.
Мы можем "защитить" члены объекта, но не сам объект. Пусть shared_obj имеет следующие методы: read() const , write(), ready() const, is_full() const каждый из которых "защищен" мьютексом внутри объекта. Однако поведение двух следующих конкурента являются непредсказуемым (но формально - не неопределенным).
Поток 1, Поток 2
if(!shared_obj.is_full())
shared_obj.write(data);
Поток 3
if(!shared_obj.ready())
data = shared_obj.read();
Возможна потеря данных или даже провокация определенного поведения если объект полагается на состояния своего внутреннего контейнера. Почему? Потому что время между чтением состояния и операцией записи может быть использовано другим потоком.
Проблема может показаться очевидной, но тем не менее это -- распространенная ошибка, вытекающая из идеи подобной "защиты". В своей практике мне пришлось вычистить сотню-другую подобных реализаций написанных начинающими студентами а потом использованных программистами опытными, но не знающими как работать в потоках ("старая гвардия"!). Интерфейс можно перепланировать на атомарный, но это помогает только если мы заранее знаем что будет происходить. В вышеописанном случае это возможно, так как у нас только один объект.
Поток 1, Поток 2
if(!shared_obj.write(data))
// мы записали данные
else
// ждем слудующего раза
Поток 3
if(shared_obj.read(data))
// мы считали данные, можно обработать
else
// ждем следующего раза
А если shared_obj - это элемент динамически изменяемого списка? Должен только список быть защищен или каждый элемент тоже? Защита всего на свете будет очень медленной и тем не менее может содержать ошибки, причем ошибки формально алгоритмические и обусловленные непредсказуемостью поведения потоков, а не вызовы неопределенного поведения. Например, следует учесть, что защищаемый объект тривиально не копируем, так как мьютекс нельзя скопировать. Его следует обойти вручную, а следовательно операции присвоения, перемещения, создания не защищены внутренним мьютексом.
ЕЩе одна частая ошибка, встречаемая мной:
smart_ptr_with_mutex<MyData> ptr;
Глобальный объект, где smart_ptr_with_mutex<T> является аналогом scoped_ptr c внутренним мьютексом и защитой в операциях ->, * и =.
Что-то вроде этого (реальные реализации были разными):
template<class T>
class smart_ptr_with_mutex {
mutex m;
T* data;
public:
// создание не защищено и не может быть защищено!
// мьютекс создается где-то тут и другой поток
// рискует обратиться к неинициализированному мьютексу.
smart_ptr_with_mutex () : data(nullptr) {}
void operator=(T* v) { lock_guard(&m); delete data; data = v;}
T* operator*() { lock_guard(&m); return data; }
T& operator->() { lock_guard(&m); return *data; }
};
Обращение к нему:
ptr->a = something;
ptr->b = something;
ptr->c = something;
Умный указатель защищенный от совместного... "доступа". Да, адрес объекта защищен, но после выполнения операции ptr->a мьютекс снова открыт. Но программист ожидал что он закрывается в конце строки (если кто-нибудь скажет что таких людей нет, я устану показывать пальцем).
И злая ирония в том, что пока код компилировался под Visual Studio 2005\2008\2010, это работало правильно и даже создавало ожидаемый код, подтверждая теорию программиста. Почему? А потому что у компилятора есть нестандартная "фича" вставлять синхронизацию в многопотоковом режиме. А при компиляции на gcc все сломалось.
"Хорошо", сказал программист и переписал код:
MyData value = {something};
*ptr = value;
Так ничего не поменялось! Операция преравнивания принадлежит MyData! Паника, надо быстро исправить. Добавим пару методов:
MyData value = {something};
ptr.lock();
*ptr = value;
ptr.unlock();
Теперь программа зависла. Почему? Мьютекс блокируется дважды. Придется его делать рекурсивным, а следовательно, еще медленнее. А после нескольких трепетаний еще и MyData получил свой мьютекс, чтобы избежать ситуации с shared_obj выше. А указатели хранились в доморощенном классе-очереди, кооорому понадобился еще один мьютекс. Только в результате мьютекс в указателе ничего полезного не делает и только тратит время: очередь использовала memcpy при перераспределении памяти (и это уже UB) и скопированный мьютекс имеет независимое состояние, в то время как старый может быть вообще потерян.
Мораль всей истории такова. Библиотека может использовать мьютексы для защиты своих внутренних операций, куда пользователь библиотеки не может залезть или когда сценарий использования интерфейса однозначен. Например, так защищен поток обработки сигналов в Qt Framework и методы управления потоками. Делать синхронизацию частью интерфейса объектов чрезвычайно сложно, неэффективно и пользователь все равно найдет способ использовать его неправильно.