Atomics and Concurrency
redixhumayun.github.io
redixhumayun.github.io
(If you're interested in concurrency in a Linux context as seen from a broader systems point of view, you might like Paul McKeneny's 'Is Parallel Programming Hard, And If So What Can You Do About It?" https://mirrors.edge.kernel.org/pub/linux/kernel/people/paul... )
edit: already mentioned elsethread.
Thread 1 Memory Thread 2
--------- ------- ---------
| | |
| write(data, 100) | |
| -----------------------> | |
| | |
| ====Memory Barrier====== | |
| store(ready, true) | |
| -----------------------> | |
| ====Memory Barrier====== | |
| | |
| | ===Memory Barrier======= |
| | load(ready) == true |
| | <---------------------- |
| | ====Memory Barrier===== |
| | |
| | read(data) |
| | <---------------------- |
| | |
I.e. barriers prevent reordering of operations within a thread, not across threads. It also makes immediately obvious why the seq_cst ordering of both the thread 1 atomic store and the thread 2 atomic load can be relaxed: The last barrier in Thread 1 does not prevent any reordering in this example, hence it can be omitted, leaving only the barrier before the store making it a release operation. Similarly, we can omit the barrier before the first load in thread 2, leaving only the barrier after, making it an acquire operation.[1] well, it is showing the effect of sequential consistency as opposed to acquire-release, so a logical barrier spanning threads is not necessarily wrong, but then you would still need to show a barrier before the last store and the first load.
Assuming that the memory barrier is syncing across a single variable (in this case ready), why would it be correct to think of it as two separate barriers? If it were correct to think of it as two separate barriers on two separate threads, wouldn't there need to be some form of synchronization or linkage between the two barriers themselves so that memory barriers can be coupled together?
For instance, if I had release-acquire models on two variables, ready and not_ready, using separate barriers as representation might look something like this
```
Thread 1 Memory Thread 2
--------- ------- ---------
| | |
| write(data, 100) | |
| -----------------------> | |
| | |
| ====Memory Barrier====== | |
| store(ready, true) | |
| -----------------------> | |
| ====Memory Barrier====== | |
| | |
| ====Memory Barrier====== | |
| store(not_ready, true) | |
| -----------------------> | |
| ====Memory Barrier====== | |
| | |
| | ===Memory Barrier======= |
| | load(ready) == true |
| | <---------------------- |
| | ====Memory Barrier===== |
| | |
| |.===Memory Barrier======= |
| | load(not_ready) == true|
| | <---------------------- |
| | ====Memory Barrier===== |
| | |
| | read(data) |
| | <---------------------- |
| | |
```Now, how does the processor know which memory barriers are linked together? I ask because without understanding which barriers are linked together, how is instruction re-ordering determined?
edit: AFAIK, seq_cst ordering (as opposed to acq_rel) is only relevant when you have more than two threads and you care about things like IRIW. In this case acquires and releases are not enough to capture the full set of constraints, although at the hardware level it is still everything local.
edit2: I guess the missing bit is that beyond the hardware fences you have the hardware cache coherency protocol that makes sure that a total order of operations always exist once load and stores reach the coherence fabric.
>I guess the missing bit is that beyond the hardware fences you have the hardware cache coherency protocol that makes sure that a total order of operations always exist once load and stores reach the coherence fabric.
Can you explain more about this?
In order to enqueue an element, you have to change two atomic pointers: tail->next and tail. That cannot be done atomically.
enqueue() assumes that the queue is not empty.
enqueue() happily overwrites current_tail->next even if it is not null (which may happen if some other producer has enqueued something since we read current_tail).
Then there is the old-time favourite ABA problem.
By the way, when used in a loop, compare_exchange_weak is preferred to compare_exchange_strong, and there is no need to reload the atomic every time: compare_exchange_* do that for you by updating the argument containing the expected value.
With added indirection, there are ways of doing this atomically.
I added in a note about the ABA problem but perhaps you're seeing a cached version of the post.
"enqueue() happily overwrites current_tail->next even if it is not null (which may happen if some other producer has enqueued something since we read current_tail)."
Its probably one of the bigger problems of this queue, basically negates the whole structure with this bug.
Memory allocation gets forgotten in the "lock free" algorithms all the time, especially in java where allocation is forgotten about and brushed aside.
Just because jemalloc has a mutex.c file, that doesn't mean that common paths aren't meant to be lock free and in the case of lots of little allocations that can go into small bucket sizes in jemalloc they should be.
It is still putting your head in the sand since at some point they have to go to the OS and map in memory which should lock and lots of small allocations are a terrible way to anything for performance, but it is possible to have some paths in an allocator not have locks.
Also if there are thread local heaps, those won't lock either.
> Technically you could replace your allocator with jemalloc or something similar, but most people probably don't.
which suggests that the new/delete "blocking" nature can be solved just by replacing it with the jemalloc. That's nonsense because new/delete in itself is a plain stupid wrapper around the malloc/free. So, I still don't get the point of your commentary. It reads flawed and contradicting.
I didn't say replace new and delete with jemalloc, I said replace your allocator with jemalloc, which would mean new and delete end up calling that instead. This is a common and easy use of a different malloc implementation and is something I and many other people have done. jemalloc is also not the only allocator replacement and not the only one to focus on concurrency (tcmalloc and ptmalloc). There are also allocators like windows' built in thread local heaps. Some default malloc implementations now have some concurrency built in so they don't usually block.
What this comes down to is that new and delete don't have to block (in the common execution path) because the underlying allocator doesn't have to. This is a well worn problem and I have seen first hand parallel programs go from only using a single core while executing due to the default allocator blocking on every call to all cores being used with a new allocator. It is a problem created by too much allocation but the different implementations do deliver on their promise.
This is a separate issue from "lock free data structures" using memory allocation on every transaction which is a poor way to make any data structure and pushes a lot of the concurrency issues on to the memory allocator.
Hope that helps and that you learned something.
You wondered out loud how it was even possible to do that kind of analysis, and that's where my mind went. Evidently people think it's a bad take. That's as deep as it goes.
It is very easy to create an ABA problem in safe Rust. Data race free sequential consistency, which Rust has, is almost completely orthogonal to the ABA problem.
This is an area of active PLT research, we haven't come anywhere close to addressing the problem in the general case.
I suspect we'll be seeing all kinds of bugs caused by a generation of programmers thinking everything has guard rails in Rust because "safety", so they can turn their brain off and not think. In reality, those promises of safety largely disappear when threads, files, signals, and networks are involved.
At the end of the day, your programs run on computers which exist in the physical world. The abstractions are mostly isomorphic, but it's at the margins where the abstractions aren't isomorphic that all the interesting things happen.
In safe Rust, if I have a mutable reference to Foo, and Foo contains a shared reference to Bar, then no other thread has a mutable reference to Foo or Bar. So no other thread will make a CAS on my reference to Bar, or drop Bar and then allocate something at the same memory address, etc.
You could have some higher level ABA problem I suppose, where you acquire a lock, read a value, give up the lock, and then make spurious assumptions about what happens while you've let the lock go. But that's obviously not what we're talking about if we're talking about CAS. (ETA: or if these were application level references, eg indices into a list.)
If we're going to implement a lockfree data structure, we're going to need unsafe Rust to hand-roll interior mutability. Because we're going to be sharing mutable state. Which isn't allowed in safe Rust.
Or am I mistaken?
Substitute the sleep with a combination of doing computation/work and the OS thread scheduler, and you can see how the bug surfaces.
Yes, if you don't hold a lock on a value, or exert some kind of control at the API level (eg making it monotonic so your CAS will work), you can't make assumptions about it. I think you'll find that Rust developers understand that concept about as well as any other community of concurrent developers.
But yes, granted, the semantic information about these integers isn't represented in Rust's type system, and won't be caught by it's static analysis.
Crucially, they don’t have to try all interleavings to reach all terminal states, making the enumeration quite fast.
Wired: livelocks
So in practice LL/SC, in higher level languages, is used to implement CAS, XCHG and other atomic primitives which don't allow taking advantage of the ABA resistance. As an additional downside, you get a weak CAS that you always need to call in an loop.
Every thread has its own unique tag.
long original = me->realend;
int tag = (original & TAG_MASK);
changed = (((original & END_MASK) >> 32)) % me->size;
long new = (data->thread_tag) | (((changed + 1) % me->size) << 32);
Then compare and swap as usual. If another thread updates then they shall fail the compare and swap and we have to reloop and try again.I am still learning TLA+ to write a model.
Is it really? Sorry, but this is barely an introduction.
Some additional links to important documentation:
https://en.cppreference.com/w/cpp/language/memory_model
https://en.cppreference.com/w/cpp/atomic/memory_order
Effective Concurrency series by Sutter: https://herbsutter.com/2009/07/15/effective-concurrency/
The Art of Multiprocessor Programming by Maurice Herlihy, Nir Shavit et al. ISBN: 978-0124159501
An excellent book that I used for my Parallel Programming course in my undergrad. While looking up Nir Shavit (back then), I came across a "Summer School on Practice and Theory of Concurrent Computing (2017)" [1] which had notable people give talks about interesting & fundamental topics related to Parallel Computing such as "Wait-free computing 'for dummies'", "Lock-free concurrent data structures", and "Locking, from traditional to modern" (taught by Nir Shavit). [2] is a link to a YouTube playlist containing all the videos from that Summer School. I highly recommend it.
[1] https://neerc.ifmo.ru/sptcc/courses.html
[2] https://www.youtube.com/playlist?list=PLVe-2wcL84b9G9o7KPubp...
I think for someone who has no exposure to it before, its quite dense (perhaps long wasn't the best choice of wording)
Also, I've been meaning to read The Art of Multiprocessor Programming. I've heard great things about it!
There's also an "advanced" playlist and there're plenty of other interesting lectures about the memory.
Just for example, right off the bat:
> Atomics are simply operations or instructions that cannot be split by the compiler or the CPU or re-ordered in any way.
C++ atomics can in fact be reordered by the compiler or CPU, depending on ordering semantics. Acquire loads cannot be reordered after subsequent program-order loads or stores. But they can be reordered before previous program-order operations. Similarly, release stores cannot be reordered before prior program-order operations, but can be reordered after subsequent program-order operations. Relaxed atomic operations can be reordered arbitrarily (other than with accesses to the same object).
[0] https://en.cppreference.com/w/cpp/thread#Safe_Reclamation