Date: Sun, 16 Feb 2025 02:22:11 +0000
> And it seems clear that shared_ptr does have to avoid data races on
> freeing the control block, which requires there to be some amount of
> ordering.
But this ordering can be provided by the single total order on RMW
operations of mo_relaxed, no?
If you read a refcount of 0 it implies that all other existent
shared_ptrs have also completed their destructors at least to the point
of decrementing the refcount. By definition there cannot be anyone else
who could potentially use the control block while the last owner is
inside the == 0 block. Any reads that happen as part of the delete could
potentially leak outside of the if statement but any writes have to
remain inside, lest a write be invented. The only thing that the acquire
barrier could be synchronising in that case is the *content* of the
control block, which shouldn't need to be synchronised since it's all
trivially destructible and not read as part of the delete.
The mo_release on the decrement also seems unnecessary if you're just
synchronising the state of the control block. The only case you'd need
that would be if you were modifying part of the control block and needed
that to be visible on another thread when it does the acquire, which
we've established isn't nessecary so the paired release isn't either,
putting aside the fact that the refcount is the only thing being
modified.
As I understand it if shared_ptr doesn't provide any ordering guarantees
on accesses to the object then all it requires is a STO on the
increments/decrements, which mo_relaxed provides.
> I'd expect it to say something like this: if D is the invocation of the
> destructor that invokes the deleter, and D' is any other invocation of
> the destructor of a `shared_ptr` that shares ownership with it, then D'
> strongly happens before the invocation of the deleter.
Second this as being a good wording to provide ordering between the
destructor and the deleter, but would still need additional wording
beyond this to ensure that the accesses to the shared object
happens-before the destructor also (although it would need to perhaps
cover more than just accesses to the shared object? If a thread modifies
an object via a pointer stored in the shared object, should that
modification be visible in the deleter? Current library behaviour with
rel-acq ordering is yes)
On 2025-02-16 01:49, Nate Eldredge via Std-Discussion wrote:
>> On Feb 15, 2025, at 17:38, Brian Bi <bbi5291_at_[hidden]> wrote:
>>
>>
>> It is puzzling that all the standard library implementations have the
>> acquire fence but the standard doesn't seem to require it. Maybe it
>> does, and we all just failed to find it.
>
> Well, the typical implementation of shared_ptr is something like
>
> template<class T>
> shared_ptr {
> // ...
> struct control_block {
> T *obj;
> atomic<long> refcount;
> };
> control_block *cb;
> };
>
> And not only the managed object, but also the control block, must be
> freed when the reference counter reaches 0. And it seems clear that
> shared_ptr does have to avoid data races on freeing the control block,
> which requires there to be some amount of ordering. So it's natural to
> have an implementation of ~shared_ptr<T> or reset() that does something
> like
>
> if (cb->refcount.fetch_sub(1, memory_order_release) == 1) {
> atomic_thread_fence(memory_order_acquire);
> delete cb->obj;
> delete cb;
> }
>
> Or simply acq_rel ordering on the fetch_sub. But the concern is that
> someone could theoretically try
>
> if (cb->refcount.fetch_sub(1, memory_order_release) == 1) {
> delete cb->obj;
> atomic_thread_fence(memory_order_acquire);
> delete cb;
> }
>
>> I'd expect it to say something like this: if D is the invocation of
>> the destructor that invokes the deleter, and D' is any other
>> invocation of the destructor of a `shared_ptr` that shares ownership
>> with it, then D' strongly happens before the invocation of the
>> deleter.
>
> I'd like to see that too.
> freeing the control block, which requires there to be some amount of
> ordering.
But this ordering can be provided by the single total order on RMW
operations of mo_relaxed, no?
If you read a refcount of 0 it implies that all other existent
shared_ptrs have also completed their destructors at least to the point
of decrementing the refcount. By definition there cannot be anyone else
who could potentially use the control block while the last owner is
inside the == 0 block. Any reads that happen as part of the delete could
potentially leak outside of the if statement but any writes have to
remain inside, lest a write be invented. The only thing that the acquire
barrier could be synchronising in that case is the *content* of the
control block, which shouldn't need to be synchronised since it's all
trivially destructible and not read as part of the delete.
The mo_release on the decrement also seems unnecessary if you're just
synchronising the state of the control block. The only case you'd need
that would be if you were modifying part of the control block and needed
that to be visible on another thread when it does the acquire, which
we've established isn't nessecary so the paired release isn't either,
putting aside the fact that the refcount is the only thing being
modified.
As I understand it if shared_ptr doesn't provide any ordering guarantees
on accesses to the object then all it requires is a STO on the
increments/decrements, which mo_relaxed provides.
> I'd expect it to say something like this: if D is the invocation of the
> destructor that invokes the deleter, and D' is any other invocation of
> the destructor of a `shared_ptr` that shares ownership with it, then D'
> strongly happens before the invocation of the deleter.
Second this as being a good wording to provide ordering between the
destructor and the deleter, but would still need additional wording
beyond this to ensure that the accesses to the shared object
happens-before the destructor also (although it would need to perhaps
cover more than just accesses to the shared object? If a thread modifies
an object via a pointer stored in the shared object, should that
modification be visible in the deleter? Current library behaviour with
rel-acq ordering is yes)
On 2025-02-16 01:49, Nate Eldredge via Std-Discussion wrote:
>> On Feb 15, 2025, at 17:38, Brian Bi <bbi5291_at_[hidden]> wrote:
>>
>>
>> It is puzzling that all the standard library implementations have the
>> acquire fence but the standard doesn't seem to require it. Maybe it
>> does, and we all just failed to find it.
>
> Well, the typical implementation of shared_ptr is something like
>
> template<class T>
> shared_ptr {
> // ...
> struct control_block {
> T *obj;
> atomic<long> refcount;
> };
> control_block *cb;
> };
>
> And not only the managed object, but also the control block, must be
> freed when the reference counter reaches 0. And it seems clear that
> shared_ptr does have to avoid data races on freeing the control block,
> which requires there to be some amount of ordering. So it's natural to
> have an implementation of ~shared_ptr<T> or reset() that does something
> like
>
> if (cb->refcount.fetch_sub(1, memory_order_release) == 1) {
> atomic_thread_fence(memory_order_acquire);
> delete cb->obj;
> delete cb;
> }
>
> Or simply acq_rel ordering on the fetch_sub. But the concern is that
> someone could theoretically try
>
> if (cb->refcount.fetch_sub(1, memory_order_release) == 1) {
> delete cb->obj;
> atomic_thread_fence(memory_order_acquire);
> delete cb;
> }
>
>> I'd expect it to say something like this: if D is the invocation of
>> the destructor that invokes the deleter, and D' is any other
>> invocation of the destructor of a `shared_ptr` that shares ownership
>> with it, then D' strongly happens before the invocation of the
>> deleter.
>
> I'd like to see that too.
Received on 2025-02-16 02:22:19