Скопировать конструктор с неконстантным аргументом, предложенным правилами безопасности потока?

9

У меня есть обертка для какого-то унаследованного кода.

class A{
   L* impl_; // the legacy object has to be in the heap, could be also unique_ptr
   A(A const&) = delete;
   L* duplicate(){L* ret; legacy_duplicate(impl_, &L); return ret;}
   ... // proper resource management here
};

В этом унаследованном коде функция, которая «дублирует» объект, не является поточно-ориентированной (при вызове того же первого аргумента), поэтому она не отмечена constв оболочке. Я предполагаю следующие современные правила: https://herbsutter.com/2013/01/01/video-you-dont-know-const-and-mutable/

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

class A{
   L* impl_; // the legacy object has to be in the heap
   A(A const& other) : L{other.duplicate()}{} // error calling a non-const function
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

Так какой же выход из этой парадоксальной ситуации?

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

Я могу думать о многих возможных сценариях:

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

(2) С другой стороны, копирование объекта по своей природе не является потокобезопасным в том смысле, что копирование простого типа может найти источник в полу-модифицированном состоянии, поэтому я могу просто пойти дальше и, возможно, сделать это,

class A{
   L* impl_;
   A(A const& other) : L{const_cast<A&>(other).duplicate()}{} // error calling a non-const function
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

(3) или даже просто объявить duplicateconst и лгать о безопасности потоков во всех контекстах. (В конце концов, устаревшая функция не заботится, constпоэтому компилятор даже не будет жаловаться.)

class A{
   L* impl_;
   A(A const& other) : L{other.duplicate()}{}
   L* duplicate() const{L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

(4) Наконец, я могу следовать логике и создать конструктор копирования, который принимает неконстантный аргумент.

class A{
   L* impl_;
   A(A const&) = delete;
   A(A& other) : L{other.duplicate()}{}
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

Оказывается, это работает во многих контекстах, потому что эти объекты обычно не const.

Вопрос в том, является ли это действительным или распространенным маршрутом?

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

(5) Наконец, хотя это кажется излишним и может иметь большие затраты времени выполнения, я мог бы добавить мьютекс:

class A{
   L* impl_;
   A(A const& other) : L{other.duplicate_locked()}{}
   L* duplicate(){
      L* ret; legacy_duplicate(impl_, &ret); return ret;
   }
   L* duplicate_locked() const{
      std::lock_guard<std::mutex> lk(mut);
      L* ret; legacy_duplicate(impl_, &ret); return ret;
   }
   mutable std::mutex mut;
};

Но принуждение к этому похоже на пессимизацию и делает класс больше. Я не уверена. В настоящее время я склоняюсь к (4) или (5) или их комбинации.


РЕДАКТИРОВАТЬ 1:

Другой вариант:

(6) Забудьте обо всех бессмысленных повторяющихся функциях-членах и просто вызовите legacy_duplicateконструктор и объявите, что конструктор копирования не является потокобезопасным. (И, если необходимо, создайте другую поточно-ориентированную версию этого типа A_mt)

class A{
   L* impl_;
   A(A const& other){legacy_duplicate(other.impl_, &impl_);}
};

РЕДАКТИРОВАТЬ 2:

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

void legacy_duplicate(L* in, L** out){
   *out = new L{};
   char tmp = in[0];
   in[0] = tmp; 
   std::memcpy(*out, in, sizeof *in); return; 
}

РЕДАКТИРОВАТЬ 3: Я недавно узнал, что std::auto_ptrбыла похожая проблема с неконстантным конструктором копирования. Эффект был в том, что auto_ptrнельзя было использовать внутри контейнера. https://www.quantstart.com/articles/STL-Containers-and-Auto_ptrs-Why-They-Dont-Mix/

alfC
источник
1
« В этом устаревшем коде функция, которая дублирует объект, не является потокобезопасной (при вызове того же первого аргумента) » Вы уверены в этом? Есть ли какое-то состояние, не содержащееся внутри, Lкоторое изменяется путем создания нового Lэкземпляра? Если нет, то почему вы считаете, что эта операция не является поточно-ориентированной?
Николь Болас
Да, это ситуация. Похоже, что внутреннее состояние первого аргумента изменяется во время исключения. По какой-то причине (некоторая «оптимизация» или плохой дизайн или просто по спецификации) функция legacy_duplicateне может быть вызвана с одним и тем же первым аргументом из двух разных потоков.
alfC
@TedLyngmo Хорошо, я сделал. Хотя технически в c ++ pre 11 const имеет более размытое значение при наличии потоков.
alfC
@TedLyngmo да, это довольно хорошее видео. Жаль, что видео только касается правильных участников и не затрагивает проблему построения (где константность находится на «другом» объекте). В перспективе не может быть собственного способа сделать этот поток-оболочку безопасным при копировании без добавления еще одного уровня абстракции (и конкретного мьютекса).
alfC
Да, это меня смутило, и я, наверное, один из тех, кто не знает, что на constсамом деле значит. :-) Я бы не подумал дважды о том, чтобы взять const&в свой экземпляр ctor, если я не изменяю other. Я всегда думаю о безопасности потоков как о чем-то, что добавляется к тому, что необходимо получить из нескольких потоков, через инкапсуляцию, и я действительно жду ответов.
Тед Люнгмо

Ответы:

0

Я бы просто включил обе ваши опции (4) и (5), но явно включил небезопасное поведение, когда вы считаете, что это необходимо для производительности.

Вот полный пример.

#include <cstdlib>
#include <thread>

struct L {
  int val;
};

void legacy_duplicate(const L* in, L** out) {
  *out = new L{};
  std::memcpy(*out, in, sizeof *in);
  return;
}

class A {
 public:
  A(L* l) : impl_{l} {}
  A(A const& other) : impl_{other.duplicate_locked()} {}

  A copy_unsafe_for_multithreading() { return {duplicate()}; }

  L* impl_;

  L* duplicate() {
    printf("in duplicate\n");
    L* ret;
    legacy_duplicate(impl_, &ret);
    return ret;
  }
  L* duplicate_locked() const {
    std::lock_guard<std::mutex> lk(mut);
    printf("in duplicate_locked\n");
    L* ret;
    legacy_duplicate(impl_, &ret);
    return ret;
  }
  mutable std::mutex mut;
};

int main() {
  A a(new L{1});
  const A b(new L{2});

  A c = a;
  A d = b;

  A e = a.copy_unsafe_for_multithreading();
  A f = const_cast<A&>(b).copy_unsafe_for_multithreading();

  printf("\npointers:\na=%p\nb=%p\nc=%p\nc=%p\nd=%p\nf=%p\n\n", a.impl_,
     b.impl_, c.impl_, d.impl_, e.impl_, f.impl_);

  printf("vals:\na=%d\nb=%d\nc=%d\nc=%d\nd=%d\nf=%d\n", a.impl_->val,
     b.impl_->val, c.impl_->val, d.impl_->val, e.impl_->val, f.impl_->val);
}

Вывод:

in duplicate_locked
in duplicate_locked
in duplicate
in duplicate

pointers:
a=0x7f85e8c01840
b=0x7f85e8c01850
c=0x7f85e8c01860
c=0x7f85e8c01870
d=0x7f85e8c01880
f=0x7f85e8c01890

vals:
a=1
b=2
c=1
c=2
d=1
f=2

Это следует руководству по стилю Google, в котором constсообщается о безопасности потоков, но код, вызывающий ваш API, может отказаться, используяconst_cast

Михаил Грачик
источник
Спасибо за ответ, я думаю, что это не изменит ваш ответ, и я не уверен, но лучшая модель для legacy_duplicateмогла бы быть void legacy_duplicate(L* in, L** out) { *out = new L{}; char tmp = in[0]; /*some weird call here*/; in[0] = tmp; std::memcpy(*out, in, sizeof *in); return; }(то есть неконстантной in)
alfC
Ваш ответ очень интересен, потому что его можно комбинировать с опцией (4) и явной версией опции (2). То есть A a2(a1)может пытаться быть потокобезопасным (или быть удаленным) и A a2(const_cast<A&>(a1))вообще не пытаться быть потокобезопасным.
alfC
2
Да, если вы планируете использовать Aкак в поточно-безопасном, так и в поточно-небезопасном контекстах, вы должны перетащить const_castкод вызова, чтобы было ясно, где известно, что поточная безопасность нарушена. Можно добавить дополнительную безопасность за API (мьютекс), но нельзя скрывать небезопасность (const_cast).
Михаил Грачик
0

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

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

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

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

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

Исходя из вышеизложенного, у вас есть два пути, по которым вы можете следовать:

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

Б) Вы подозреваете, что копирование из нескольких потоков будет происходить достаточно часто, чтобы возникла дополнительная блокировка. Тогда у вас действительно есть только один вариант - исправить код дублирования. Если вы не исправите это, вам все равно понадобится блокировка, будь то на этом уровне абстракции или где-то еще, но она понадобится вам, если вы не хотите ошибок - и, как мы установили, на этом пути вы предполагаете такая блокировка будет слишком дорогой, поэтому единственный вариант - исправить код дублирования.

Я подозреваю, что вы действительно находитесь в ситуации A, и просто добавление спин-блокировки / вращающегося мьютекса, который практически не снижает производительности при неоспоримых результатах, будет работать очень хорошо (хотя и не забывайте тестировать его).

Теоретически существует другая ситуация:

C) В отличие от кажущейся сложности функции дублирования, она на самом деле тривиальна, но по какой-то причине не может быть исправлена; это настолько тривиально, что даже неоспоримая спин-блокировка приводит к недопустимому снижению производительности при дублировании; дублирование в параллельных потоках используется редко; Дублирование в одном потоке используется постоянно, что делает снижение производительности абсолютно неприемлемым.

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

Просто добавьте этот мьютекс / спин-блокировку и тест.

DeducibleSteak
источник
Можете ли вы указать мне материал о спин-блокировке / предварительном вращении мьютекса в C ++? Это что-то более сложное, чем то, что обеспечивается std::mutex? Функция дублирования не секрет, я не упомянул ее, чтобы держать проблему на высоком уровне и не получать ответы о MPI. Но так как вы зашли так глубоко, я могу дать вам больше деталей. Унаследованная функция - это, MPI_Comm_dupи эффективная безопасность без потоков описана здесь (я подтвердил это) github.com/pmodels/mpich/issues/3234 . Вот почему я не могу исправить дубликаты. (Также, если я добавлю мьютекс, у меня будет соблазн сделать все вызовы MPI потоко-безопасными.)
alfC
К сожалению, я не знаю много std :: mutex, но я предполагаю, что он вращается, прежде чем дать процессу спать. Хорошо известным устройством синхронизации, где вы можете управлять этим вручную, является: docs.microsoft.com/en-us/windows/win32/api/synchapi/… Я не сравнивал производительность, но кажется, что std :: mutex теперь лучше: stackoverflow.com/questions/9997473/… и реализовано с использованием: docs.microsoft.com/en-us/windows/win32/sync/…
DeducibleSteak
Похоже, это хорошее описание общих соображений, которые необходимо учитывать: stackoverflow.com/questions/5869825/…
DeducibleSteak
Еще раз спасибо, я в Linux, если это имеет значение.
alfC
Вот несколько подробное сравнение производительности (для другого языка, но я думаю, что это информативно и указывает на то, что ожидать): matklad.github.io/2020/01/04/… TLDR - это - спин-блокировки выигрывают очень маленькими маржа, когда нет раздора, может сильно потерять, когда есть раздор.
DeducibleSteak