Порядок памяти в деструкторе разделяемого указателя

Я пытаюсь выяснить наиболее расслабленный (и правильный) порядок памяти для деструктора общего указателя. На данный момент я имею в виду следующее:

~shared_ptr() {
   if (p) {
     if (p->cnt.fetch_sub(1, std::memory_order_release) == 1) {
       p->cnt.load(std::memory_order_acquire);
       delete p;
     }
   }
 }

В принципе, я думаю, что все предыдущие fetch_sub() должно произойти до delete p;и p->cnt.load(std::memory_order_acquire);Я создаю последовательность выпуска, которая обеспечивает это.

Я новичок в модели памяти C++, и не совсем уверен. Верны ли мои рассуждения верны, а порядок памяти, который я указал, правильный и самый расслабленный?

1 ответ

Решение

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

Но на практике почти нет ЦП, который бы предоставлял инструкции, которые бы идеально отображались для получения / освобождения порядка памяти (возможно, в будущем появится ARMv8.3-A). Таким образом, вы должны будете проверить для каждой цели сгенерированный код.

Например на x86_64 fetch_sub(std::memory_order_acq_rel) а также fetch_sub(std::memory_order_release) приведет к точно такой же инструкции.

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

std::atomic<int> cnt;
int* p;
void optimal_in_therory() {
     if (cnt.fetch_sub(1, std::memory_order_release) == 1) {
       cnt.load(std::memory_order_acquire);
       delete p;
   }
}
void optimal_in_practice_on_x86_64() {
     if (cnt.fetch_sub(1, std::memory_order_acq_rel) == 1) {
       delete p;
   }
}

Монтаж:

optimal_in_therory():
  lock sub DWORD PTR cnt[rip], 1
  je .L4
  rep ret
.L4:
  mov eax, DWORD PTR cnt[rip]  ;Unnecessary extra load
  mov rdi, QWORD PTR p[rip]
  mov esi, 4
  jmp operator delete(void*, unsigned long)
optimal_in_practice_on_x86_64():
  lock sub DWORD PTR cnt[rip], 1
  je .L7
  rep ret
.L7:
  mov rdi, QWORD PTR p[rip]
  mov esi, 4
  jmp operator delete(void*, unsigned long)

Однажды я буду жить в Теории, потому что в Теории все идет хорошо - Пьер Деспрогес


Почему компилятор сохраняет эту дополнительную нагрузку?

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

cnt.load(std::memory_order_acquire);
cnt.load(std::memory_order_acquire);
cnt.load(std::memory_order_acquire);

С GCC или Clang в сборке появятся три нагрузки:

mov eax, DWORD PTR cnt[rip]
mov eax, DWORD PTR cnt[rip]
mov eax, DWORD PTR cnt[rip]

Это действительно плохая пессимизация. Мое мнение таково, что оно сохраняется как есть из-за исторической путаницы между "волатильностью" и "атомарностью". В то время как почти все программисты знают, что volatile не обладает свойствами атомарной переменной, во многих кодах все еще написана идея о том, что atomic имеет свойство volatile: "атомарный доступ - наблюдаемое поведение". Согласно стандарту это не так (явный пример примечания об этом факте в стандарте). Это повторяющийся вопрос о SO.

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

Обходной путь может состоять в том, чтобы заменить нагрузку на atomic_thread_fence, как это было предложено Китом в его комментарии. Я не эксперт по аппаратным средствам, но я полагаю, что такой барьер может вызвать больше "синхронизации" памяти, чем необходимо (или, по крайней мере, теоретически;)).

Почему я считаю, что ваш код является оптимальным в теории?

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

Так delete p; должен "произойти" после вызова деструктора всех других общих указателей, которые совместно использовали один и тот же заостренный объект.

В стандарте это происходит раньше, определяется следующим абзацем:

[intro.races] / 9:

Промежуточный поток оценки происходит перед оценкой B, если:

  • А синхронизируется с В или [...]
  • любая комбинация с "секвенированным до", и это переходное правило.

[intro.races] / 10:

Оценка A происходит до оценки B (или, что то же самое, B происходит после A), если:

  • A секвенируется перед B, или

  • Интер-поток происходит до B.

Так что должно быть отношение "синхронизировать с" между fetch_sub это последовательность перед delete p и другие fetch_sub,

Согласно [atomics.order] / 2:

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

Так delete p должен быть упорядочен после операции получения, которая загружает значение, которое находится в последовательности освобождения всех остальных fetch_sub,

Согласно [expr.races]/5 последний fetch_sub (в порядке модификации cnt) будет принадлежать последовательности выпуска всех остальных версий fetch_sub потому что fetch_sub является операцией чтения-изменения-записи, как есть fetch_add (предположим, что никаких других операций не происходит cnt).

Так delete p произойдет после всех других fetch_sub, и это только до delete p называется, что будет произведена "синхронизация". Точно не более того, что нужно.

Другие вопросы по тегам