Как реализовать атомарный (потокобезопасный) и исключительный оператор глубокого копирования?

Мне задали этот вопрос в интервью, и я не мог ответить на него хорошо.

Более конкретно, класс, к которому принадлежит оператор присваивания, выглядит следующим образом:

class A {
private:
B* pb;
C* pc;
....
public:
....
}

Как реализовать атомное (потокобезопасный) и Исключение безопасных, оператор глубокого копирования для этого класса?

12

Решение

Есть две отдельные проблемы (потокобезопасность и безопасность исключений), и, кажется, лучше рассмотреть их отдельно. Чтобы конструкторы, принимающие другой объект в качестве аргумента, могли получить блокировку при инициализации элементов, необходимо в любом случае разделить элементы данных на отдельный класс: таким образом блокировка может быть получена, пока подобъект инициализируется, а класс поддерживает фактические данные. может игнорировать любые проблемы параллелизма. Таким образом, класс будет разделен на две части: class A иметь дело с проблемами параллелизма и class A_unlocked поддерживать данные. Поскольку функции-члены A_unlocked не имеют никакой защиты от параллелизма, они не должны напрямую отображаться в интерфейсе и, таким образом, A_unlocked сделан частным членом A,

Создание оператора присваивания, исключающего исключение, является простым, используя конструктор копирования. Аргумент копируется, а члены меняются местами:

A_unlocked& A_unlocked::operator= (A_unlocked const& other) {
A_unlocked(other).swap(*this);
return *this;
}

Конечно, это означает, что подходящий конструктор копирования и swap() член реализован. Работа с распределением нескольких ресурсов, например нескольких объектов, выделенных в куче, легче всего сделать, имея подходящий обработчик ресурсов для каждого из объектов. Без использования обработчиков ресурсов быстро становится очень грязно корректно очищать все ресурсы в случае возникновения исключения. В целях поддержания кучи выделенной памяти std::unique_ptr<T> (или же std::auto_ptr<T> если вы не можете использовать C ++ 2011) является подходящим выбором. Код ниже просто копирует указанные объекты, хотя нет особого смысла выделять объекты в куче, а не делать их членами. В реальном примере объекты, вероятно, реализуют clone() метод или какой-то другой механизм для создания объекта правильного типа:

class A_unlocked {
private:
std::unique_ptr<B> pb;
std::unique_ptr<C> pc;
// ...
public:
A_unlocked(/*...*/);
A_unlocked(A_unlocked const& other);
A_unlocked& operator= (A_unlocked const& other);
void swap(A_unlocked& other);
// ...
};

A_unlocked::A_unlocked(A_unlocked const& other)
: pb(new B(*other.pb))
, pc(new C(*other.pc))
{
}
void A_unlocked::swap(A_unlocked& other) {
using std::swap;
swap(this->pb, other.pb);
swap(this->pc, other.pc);
}

Для бита безопасности потока необходимо знать, что никакой другой поток не связывается с скопированным объектом. Способ сделать это — использовать мьютекс. То есть, class A выглядит примерно так:

class A {
private:
mutable std::mutex d_mutex;
A_unlocked         d_data;
public:
A(/*...*/);
A(A const& other);
A& operator= (A const& other);
// ...
};

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

A::A(A const& other)
: d_data((std::unique_lock<std::mutex>(other.d_mutex), other.d_data)) {
}

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

Основная логика оператора присваивания также просто делегирует базе, используя его оператор присваивания. Сложность в том, что есть два мьютекса, которые нужно заблокировать: один для объекта, которому назначается, и один для аргумента. Поскольку другой поток может назначить два объекта только противоположным образом, существует вероятность тупиковой блокировки. Удобно, стандартная библиотека C ++ обеспечивает std::lock() алгоритм, который получает блокировки соответствующим образом, чтобы избежать мертвых блокировок. Один из способов использовать этот алгоритм — перейти в разблокированный std::unique_lock<std::mutex> объекты, по одному на каждый мьютекс, который нужно получить:

A& A::operator= (A const& other) {
if (this != &other) {
std::unique_lock<std::mutex> guard_this(this->d_mutex, std::defer_lock);
std::unique_lock<std::mutex> guard_other(other.d_mutex, std::defer_lock);
std::lock(guard_this, guard_other);

*this->d_data = other.d_data;
}
return *this;
}

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

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

12

Другие решения

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

Простейшим решением было бы сделать данные неизменяемыми, написать класс Aref, который использует класс pImpl для хранения неизменяемой ссылки с подсчетом A, а мутантные методы на Aref вызывают создание нового A. Вы можете добиться гранулярности, если иметь неизменные компоненты подсчета ссылок A (например, B и C) по аналогичной схеме. По сути, Aref становится оболочкой pImpl COW (копия при записи) для A (вы можете включить оптимизации для обработки случаев с единственной ссылкой, чтобы избавиться от избыточной копии).

Второй путь — создать монолитный замок (мьютекс или устройство чтения-записи) на А и всех его данных. В этом случае вам нужно либо упорядочить мьютекс в блокировках для экземпляров A (или аналогичного метода) для создания оператора без гонки =, либо принять, возможно, неожиданную возможность условия гонки и выполнить идиому копирования-замены, упомянутую Дитмаром. (Копирование-перемещение также допустимо) (Явное условие гонки в lock-copyconstruct, оператор присваивания блокировки =: Thread1 делает X = Y. Поток 2 делает Y.flag = true, X.flag = true. Состояние после: X .flag имеет значение false. Даже если Thread2 блокирует X и Y на протяжении всего назначения, это может произойти. Это удивит многих программистов.)

В первом случае код без присваивания должен подчиняться семантике копирования при записи. Во втором случае код неприсвоения должен подчиняться монолитному замку.

Что касается безопасности исключений, если вы предполагаете, что ваш конструктор копирования безопасен как исключение, как и ваш код блокировки, то блокировка-копирование-блокировка-замена одна (вторая) безопасна для исключения. Во-первых, если ваш счетчик ссылок, код блокировки и код модификации данных безопасны для исключений, вы хороши: код operator = в любом случае довольно утомителен. (Убедитесь, что ваши блокировки — RAII, сохраните всю выделенную память в держателе указателя RAD std (с возможностью освобождения, если вы в конечном итоге передадите его) и т. Д.)

4

Исключение-безопасности? Операции с примитивами не выполняются, поэтому мы можем получить это бесплатно.

Atomic? Простейшим был бы атомный своп для 2хsizeof(void*)— Я считаю, что большинство платформ действительно предлагают это. Если этого не произойдет, вам придется либо использовать блокировку, либо существуют алгоритмы без блокировки, которые могут работать.

Редактировать: Глубокая копия, а? Вам придется скопировать A и B в новые временные умные указатели, а затем атомарно поменять их местами.

0
По вопросам рекламы [email protected]