Порядок памяти в деструкторе разделяемого указателя
Я пытаюсь выяснить наиболее расслабленный (и правильный) порядок памяти для деструктора общего указателя. На данный момент я имею в виду следующее:
~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
называется, что будет произведена "синхронизация". Точно не более того, что нужно.