I’ve spent several hours studying memory orderings, but I still have some contradictions in my head. One of them concerns the Acquire/Release memory orders.
Currently, my understanding is:
No operation after an Acquire can be reordered before it, and no operation before a Release can be reordered after it.
Using Acquire/Release on the same memory location ensures that operations after the Acquire see all side effects that happened before the Release.
I’ve also seen people say that Acquire/Release only works in pairs. That’s where my mental model breaks down a bit.
Let’s take an example: I’m implementing an SPSC (single-producer single-consumer) ring buffer queue. I have two methods: maybe_push and consumer_pop_many.
bool consumer_pop_many(T* dst, size_t& count) {
Index head_val = head.unsync_load(); // only consumer can change head (unsync_load is a custom function)
Index tail_val = tail.load(std::memory_order_acquire); // (1)
size_t available = len(head_val, tail_val);
size_t n = std::min(count, available);
if (n == 0) {
count = 0;
return false;
}
size_t head_idx = static_cast<size_t>(head_val % CAPACITY);
size_t right = CAPACITY - head_idx;
// copy data code
head.store(head_val + n, std::memory_order_release); // (2)
count = n;
return true;
}
bool producer_maybe_push(T&& value) {
Index tail_val = tail.unsync_load(); // only the producer can change tail (unsync_load is a custom function)
Index head_val = head.load(std::memory_order_relaxed); // (3)
if (len(head_val, tail_val) == CAPACITY) {
return false;
}
size_t idx = static_cast<size_t>(tail_val % CAPACITY);
new (&buffer[idx]) T(std::move(value));
tail.store(tail_val + 1, std::memory_order_release); // (4)
return true;
}
My reasoning:
At (1) I use Acquire so that I can see the write from (4) (the side effect). All other operations in the function already depend on this load anyway, but Acquire guarantees visibility of that write.
At (2) I use Release to make sure the data copy happens strictly before the head moves forward.
At (4) I use Release to ensure the write of the value happens strictly before the tail update, so that (1) observes it correctly.
But can I use Relaxed in (3)?
I don’t care about instruction reordering in that method, since I could load the tail later and everything else depends on the result of that load anyway. But what about side effects?A read itself isn’t a side effect, but it is ordered against the Release in consumer_pop_many. Could it happen that:
Thread 1 (consumer) reads and advances the head,
Thread 2 (producer) then reads the head, but does so “before” the consumer’s read (in terms of visibility)?
This looks impossible, but examples with SeqCst show strange situations, and I can’t shake this question out of my head.