Reputation: 5265
Is it ok to use std::memory_order_relaxed
for skip flag, like in iterate
:
constexpr static const std::size_t capacity = 128;
std::atomic<bool> aliveness[capacity];
T data[capacity]; // immutable/atomic/etc.
template<class Closure>
void iterate(Closure&& closure){
for(std::size_t i = 0; i<capacity; i++){
if (!aliveness[i].load(std::memory_order_relaxed)) continue;
closure( data[i] );
}
}
void erase(std::size_t index){
aliveness[index].store(false, std::memory_order_relaxed);
}
Or I should use release/acquire, instead?
aliveness[i]
may become "alive" again.
iterate
and erase
called from multiple threads simuteniously. Consider data
immutable/atomic/synchronized under some other external lock.
Upvotes: 3
Views: 521
Reputation: 365717
Assumption: other thread(s) can be running iterate()
at any time while you're using erase
. (An early version of the question didn't specify immutable. This answer is still relevant if the locking (or lack thereof) for updating data[i]
isn't ordered wrt. writes to alive[i]
.)
If data is truly immutable, then mo_relaxed
is definitely fine, unless you need the global visibility of those stores to be ordered with respect to something else the thread is doing. mo_relaxed
stores will always eventually become visible to other threads (and on current CPUs, will do so very quickly).
If you're going to modify the non-atomic data[i]
while alive[i]
is false, you need to make sure other threads aren't using its value while it's being modified. That would be UB in C++, and an actual correctness problem on real hardware depending on T
and closure
.
Acquire semantics will work for iterate
. Access to data[i]
logically happens after alive[i]
, because the one-way barrier is in the correct direction. The acquire-load won't reorder with later loads or stores, only earlier ones.
But the store in erase
is a problem. It needs to be globally visible before any modification of data[i]
. But release-stores are allowed to reorder with later stores. What you need is a release fence to block reordering of stores in both directions.
void erase(std::size_t index){
aliveness[index].store(false, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_release);
// or don't do that, this probably wouldn't be enough synchronization
// and whatever you do that syncs the data might make this unnecessary.
}
If T
was an atomic type, a release-store to data[i]
would do the trick. But don't do that; that will suck if T
is too large to be lock-free atomic.
(Update: I'm not sure this is fully valid. The ISO C++ memory ordering rules are defined in terms of synchronizes-with / happens-before, and what values a given load is allowed to see. Not in terms of reordering per-se between atomic and non-atomic operations.
Also, this is like what you'd do for a SeqLock, but that also depends on data-race UB for reads. If another thread is going to read data[i]
, if it checks alive[i]
then reads, there's a TOCTOU race: our write to alive
could happen after that read, and then our write to data[i]
could happen simultaneously with another thread reading.
So this is probably insufficient to erase / modify / un-erase an element, even on a strongly-ordered non-weird machine like x86.)
On some implementations, a seq-cst store would also work, but I think only as an implementation detail. It usually results in a store + a full-barrier asm instruction. (e.g. x86 MFENCE). So it works only because compilers implement seq-cst stores as store + thread_fence(seq_cst)
. And that's not the case on AArch64 where STLR is just a release operation; only the interaction with LDAR gives SC-DRF. (seq_cst for data-race-free programs).
Note that iterate
isn't safe if closure
modifies data[]
, unless only one thread can call it at a time. In which case, what's the point of this? So probably you should use
void iterate(Closure&& closure) const
{ ... }
so iterate
only works on const
objects of your container.
Upvotes: 4