Consider the following simple reference counting functions (to be used with boost::intrusive_ptr):
class Foo {
// ...
std::atomic<std::size_t> refCount_{0};
friend void intrusive_ptr_add_ref(Foo* ptr)
{
++ptr->refCount_; // ❶
}
friend void intrusive_ptr_release(Foo* ptr)
{
if (--ptr->refCount_ == 0) { // ❷
delete ptr;
}
}
};
I'm still learning memory ordering, and I'm wondering if the default memory ordering for fetch_add/sub (memory_order_seq_cst) is too strict in this case. Since the only ordering I want to ensure is between the ❶ and ❷, I think we can replace ❶ with
ptr->refCount_.fetch_add(1, std::memory_order_release);
and ❷ with
if (ptr->refCount_.fetch_sub(1, std::memory_order_consume) == 1) {
But memory ordering is still new and subtle to me, so I'm not sure if this will work correctly. Did I miss anything?
Consulting the libc++ implementation of std::shared_ptr, you might want memory_order_relaxed for increment and memory_order_acq_rel for the decrement. Rationalizing this usage:
If the number increases, then all that matters is its consistency. The current thread is already sure that it's greater than zero. Other threads are unsynchronized so they will see the update at an indeterminate time before the next atomic modification, and perhaps at a time inconsistent with updates of other variables.
If the number decreases, then you need to be sure that the current thread has already finished modifying it. All updates from other threads must be visible. The current decrement must be visible to the next one. Otherwise, if the counter raced ahead of the object it was guarding, the object could be destroyed prematurely.
Cppreference has a nice page on memory ordering. It includes this note:
The specification of release-consume ordering is being revised, and the use of
memory_order_consumeis temporarily discouraged.
It also insinuates that no current compilers or CPUs implement consume; it's effectively the same as acquire.
Incrementing the reference count does not require any synchronization in a correct program, just atomicity.
We pretend that references are owned by threads. A thread may only use a referenced object if the reference counter is at least one, and is guaranteed not to drop to zero while the object is being used, which either means that the thread has incremented the reference counter during its use of the object, or there is another mechanism that ensures this condition is met.
Thus, we assume that the thread incrementing the reference count owns the reference that ensures that it may access the object's reference counter, so no other thread may decrement the reference counter to zero while it is trying to increment the counter. The only thread allowed to drop the initial reference is either the current thread (after incrementing the reference count), or another thread once the current thread has signaled that its shared use of the object (i.e. the "ownership" of the original reference) has ceased -- both of these are visible effects.
On the other hand, decrementing the reference counter requires acquire and release semantics, as the object may be destroyed afterwards.
The CPP Reference's page on std::memory_order says
Typical use for relaxed memory ordering is incrementing counters, such as the reference counters of std::shared_ptr, since this only requires atomicity, but not ordering or synchronization (note that decrementing the shared_ptr counters requires acquire-release synchronization with the destructor).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With