Проектирование поточно-ориентированного копируемого класса

Простой способ сделать класс потокобезопасным — добавить атрибут мьютекса и заблокировать мьютекс в методах доступа.

class cMyClass {
  boost::mutex myMutex;
  cSomeClass A;
public:
  cSomeClass getA() {
    boost::mutex::scoped_lock lock( myMutex );
    return A;
  }
};

Проблема в том, что это делает класс некопируемым.

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

Интересно, есть ли лучший способ?

Я пришел к выводу, что лучшего пути нет. Лучше всего сделать класс потокобезопасным с атрибутом private static mutex: - это просто, это работает и скрывает неудобные детали.

class cMyClass {
  static boost::mutex myMutex;
  cSomeClass A;
public:
  cSomeClass getA() {
    boost::mutex::scoped_lock lock( myMutex );
    return A;
  }
};

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

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


Спасибо за все ответы.

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


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


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

class cSafe {
  boost::mutex myMutex;
  cSomeClass A;
public:
  cSomeClass getA() {
    boost::mutex::scoped_lock lock( myMutex );
    return A;
  }
  (copy constructor)
  (assignment op )

};
class cMyClass {
  cSafe S;
  ( ... other attributes ... )
public:
  cSomeClass getA() {
    return S.getA();
  }
};

person ravenspoint    schedule 21.02.2011    source источник
comment
Я пробовал это один раз и не слишком хорошо. Я думаю, что поделился boost::mutex (сохранил ссылку на мьютекс), но я не могу вспомнить, насколько хорошо это сработало (и такая же проблема, как сделать его статическим). жду с интересом.   -  person Tom    schedule 21.02.2011
comment
Мьютекс нельзя копировать, поэтому конструктор копирования по умолчанию и оператор присваивания не будут работать, но почему вы не можете написать свой собственный?   -  person outis    schedule 21.02.2011
comment
Вы понимаете, что это необходимо только в том случае, если объект модифицируется в то же время, когда он используется для клонирования другого объекта? Я бы скептически отнесся к программе, структурированной таким образом, что это проблема...   -  person André Caron    schedule 21.02.2011
comment
@outis: Ты можешь. Подразумеваемый вопрос заключается в том, как сделать оператор присваивания и конструктор копирования потокобезопасными. Однако это совсем другая история.   -  person André Caron    schedule 21.02.2011
comment
является ли класс, который вы хотите копировать, только данными для ваших потоков или инкапсуляцией потоков?   -  person diverscuba23    schedule 21.02.2011
comment
@André Caron Возможно, мне следует объяснить, что мне нужно сделать. Мне нужно иметь возможность хранить экземпляры класса в контейнере stD::vector, чтобы их можно было копировать. Я также хочу иметь доступ к атрибутам экземпляров класса из нескольких потоков. Таким образом, класс должен быть и копируемым, и потокобезопасным. Я не считаю, что необходимо сделать копирование потокобезопасным: копирование выполняется только из одного потока во время инициализации.   -  person ravenspoint    schedule 21.02.2011


Ответы (3)


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

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

person Puppy    schedule 21.02.2011
comment
Это упражнение убедило меня, что это правильный ответ. Другие подходы настолько сложны, что я никогда не мог быть уверен, что мой код правильный. - person ravenspoint; 22.02.2011

Вы можете определить свой собственный конструктор копирования (и оператор присваивания копии). Конструктор копирования, вероятно, будет выглядеть примерно так:

cMyClass(const cMyClass& x) : A(x.getA()) { }

Обратите внимание, что getA() должен быть квалифицирован как const, чтобы это работало, что означает, что мьютекс должен быть mutable; вы можете сделать параметр неконстантной ссылкой, но тогда вы не сможете копировать временные объекты, что обычно нежелательно.

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

person James McNellis    schedule 21.02.2011
comment
У этого подхода есть очень неприятная проблема: мьютекс разблокируется между назначениями элементов при наличии нескольких элементов данных. - person André Caron; 21.02.2011
comment
@Andre: Верно, однако в данном случае это более общая проблема с интерфейсом класса: вы не можете заблокировать мьютекс и получить доступ более чем к одной переменной-члену (или даже выполнить более одной операции над переменной-членом). Его можно обойти только для реализации конструктора копирования (поскольку он является членом, он может заблокировать мьютекс и скопировать состояние без использования открытого интерфейса), но, вероятно, было бы лучше реорганизовать интерфейс. Я добавил примечание по этому поводу в конец своего ответа. - person James McNellis; 21.02.2011
comment
@AndréCaron: рефакторинг сгруппированных элементов данных в (частный) базовый класс, что следует из наличия одного класса на каждую ответственность (в данном случае управление мьютексом, связанным с определенными объектами). - person Fred Nurk; 21.02.2011

Каким бы простым ни был вопрос, ответить на него не так-то просто. Для начала мы можем работать с конструктором простого копирования:

// almost pseudo code, mutex/lock/data types are synthetic
class test {
   mutable mutex m;
   data d;
public:
   test( test const & rhs ) {
      lock l(m);         // Lock the rhs to avoid race conditions,
                         // no need to lock this object.
      d = rhs.d;         // perform the copy, data might be many members
   }
};

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

class test { // wrong
   mutable mutex m;
   data d;
public:
   test( test const & );
   test& operator=( test const & rhs ) {
      lock l1( m );
      lock l2( rhs.m );
      d = rhs.d;
      return *this;
   }
};

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

test a, b;
// thr1              // thr2
void foo() {         void bar() {
   a = b;               b = a;
}                    }

И это не единственный потенциальный тупик, код небезопасен для самоназначения (большинство мьютексов не являются рекурсивными, и попытка заблокировать один и тот же мьютекс дважды приведет к блокировке потока). Простая вещь, которую нужно решить, - это самоназначение:

test& test::operator=( test const & rhs ) {
   if ( this == &rhs ) return *this; // nothing to do
   // same (invalid) code here
}

Что касается другой части проблемы, вам необходимо обеспечить соблюдение порядка получения мьютексов. Это может быть обработано по-разному (сохранение уникального идентификатора для каждого объекта и сравнение...)

test & test::operator=( test const & rhs ) {
   mutex *first, *second;
   if ( unique_id(*this) < unique_id(rhs ) {
      first = &m;
      second = &rhs.m;
   } else {
      first = &rhs.m;
      second = &rhs.m;
   }
   lock l1( *first );
   lock l2( *second );
   d = rhs.d;
}

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

class test {
   mutable std::mutex m;
   data d;
public:
   test( const test & );
   test& operator=( test const & rhs ) {
      if ( this == &rhs ) return *this;        // avoid self deadlock
      std::lock( m, rhs.m );                   // acquire both mutexes or wait
      std::lock_guard<std::mutex> l1( m, std::adopt_lock );      // use RAII to release locks
      std::lock_guard<std::mutex> l2( rhs.m, std::adopt_lock );
      d = rhs.d;
      return *this;
   }
};

Функция std::lock получит все блокировки, переданные в качестве аргумента, и гарантирует, что порядок получения будет одинаковым, гарантируя, что если весь код, который должен получить эти два мьютекса, сделает это с помощью std::lock, взаимоблокировок не будет. (Вы все еще можете заблокировать их, вручную заблокировав их в другом месте отдельно). В следующих двух строках хранятся блокировки в объектах, реализующих RAII, поэтому в случае сбоя операции присваивания (сгенерировано исключение) блокировки снимаются.

Это можно записать по-другому, используя std::unique_lock вместо std::lock_guard:

std::unique_lock<std::mutex> l1( m, std::defer_lock );     // store in RAII, but do not lock
std::unique_lock<std::mutex> l2( rhs.m, std::defer_lock );
std::lock( l1, l2 );                                       // acquire the locks

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

test& test::operator=( test copy ) // pass by value!
{
   lock l(m);
   swap( d, copy.d );   // swap is not thread safe
   return *this;
}

}

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

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

test a(0), b(0); // ommited constructor that initializes the ints to the value
// Thr1
void loop() { // [1]
   while (true) {
      std::unique_lock<std::mutex> la( a.m, std::defer_lock );
      std::unique_lock<std::mutex> lb( b.m, std::defer_lock );
      std::lock( la, lb );
      ++a.d;
      ++b.d;
   }
}
// Thr1
void loop2() {
   while (true) {
      a = b; // [2]
   }
}
// [1] for the sake of simplicity, assume that this is a friend 
//     and has access to members

С реализациями operator=, которые выполняют одновременные блокировки обоих объектов, вы можете утверждать в любой момент времени (делая это безопасно для потоков, получая обе блокировки), что a и b одинаковы, что кажется ожидаемым при беглом чтении код. Это не так, если operator= реализовано с точки зрения идиомы копирования и замены. Дело в том, что в строке, помеченной как [2], b блокируется и копируется во временную, после чего блокировка снимается. Затем первый поток может получить обе блокировки одновременно и увеличить как a, так и b до того, как a будет заблокирован вторым потоком в [2]. Затем a перезаписывается значением, которое b имело до приращения.

person David Rodríguez - dribeas    schedule 21.02.2011
comment
Ах ах! Дело в том, что мне нужен не только конструктор копирования, но и оператор присваивания. Это была моя ошибка. Помимо этого, я не думаю, что мне нужна вся эта сложность, потому что копирование/назначение не должно быть потокобезопасным. Я думаю. - person ravenspoint; 21.02.2011
comment
Хорошая работа, указывающая на тонкости оператора присваивания копии. Для получения дополнительной информации по этому вопросу (и стандартного решения C++0x) см.: home. roadrunner.com/~hinnant/mutexes/locking.html . Найдите оператор =. Есть одно решение с эксклюзивной блокировкой и другое решение с разделяемой блокировкой. - person Howard Hinnant; 21.02.2011
comment
@ravenspoint: Если вы можете гарантировать, что копии будут выполняться только при отсутствии параллелизма (для двух копируемых объектов), вы можете подумать об этом. Учтите, однако, что реализация реализации, не поддерживающей многопотоковое исполнение, может нанести вам ответный удар: через какое-то время вам может понадобиться скопировать объекты в многопоточной среде, и оператор копирования будет там, и вы будете просто использовать его, и так часто вы будете замечать сбои в программе, но не будете понимать, в чем проблема, даже не будет казаться, что это связано с копиями... - person David Rodríguez - dribeas; 22.02.2011
comment
@dibreas Это было бы проблемой, если бы я писал универсальные объекты для повторно используемой библиотеки, например, строки, когда возможность обещать копируемый класс с защитой от потоков может стоить сложности. Я разрабатываю одноразовые классы для предметной области, инструменты, которые обновляются из потоков мониторинга и доступны из потоков пользовательского интерфейса, поэтому их не нужно использовать повторно, а простота является важным достоинством. - person ravenspoint; 22.02.2011
comment
@ravenspoint: я только что подумал о другом подходе, который должен быть намного проще: test& operator=( test rhs ) { lock l(m); d = rhs.d; } (в качестве альтернативы swap без многопоточности. По сути, он отделяет чтение от записи путем чтения во временное --no проблемы с многопоточностью, а затем запись в фактический объект - person David Rodríguez - dribeas; 22.02.2011
comment
@DavidRodríguez-dribeas: я подумал об этом и получил первый комментарий к вашему сообщению об его использовании, но удалил его в пользу расспрашивая о его преимуществах. Там есть несколько хороших советов от Говарда. - person Fred Nurk; 24.02.2011
comment
Как насчет test& operator=(test& rhs) { test temp; {lock x(rhs.m); test temp = rhs.d;} {lock x(m); d = temp;}}, чтобы избежать взаимоблокировки с обратной стороной двойного копирования. - person Mark Lakata; 30.05.2014
comment
@MarkLakata: Да, за дополнительную плату вы можете гарантировать, что он не блокируется, но я бы реализовал его немного по-другому: test& operator=(test rhs) { lock x(m); d = std::move(rhs.d); } Обратите внимание, что аргумент является потокобезопасным, как в ответе выше, теперь у вас есть временный (гарантировано, что они не будут переданы другим пользователям), и вы можете скопировать их в свою внутреннюю структуру данных или переместить, поскольку у нас есть компиляторы C++11, не так ли? (Я не) - person David Rodríguez - dribeas; 31.05.2014