Подтвердить что ты не робот

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

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

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

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

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

4b9b3361

Ответ 1

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

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

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

Основная логика оператора присваивания также просто делегирует базу, используя оператор присваивания. Сложный бит состоит в том, что есть два мьютекса, которые необходимо заблокировать: одно для объекта, которому назначено, и одно для аргумента. Поскольку другой поток может назначать два объекта в обратном порядке, существует вероятность блокировки. Удобно, что стандартная библиотека С++ предоставляет алгоритм 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;
}

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

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

Ответ 2

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

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

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

В первом случае код неприсоединения должен подчиняться семантике copy-on-write. Во втором случае код неприсоединения должен подчиняться монолитной блокировке.

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

Ответ 3

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

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

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