8 ms·
A lock-free ring-buffer with contiguous reservations (2019)
- stmblast 3y agoAwesome article! Bookmarked.
- cozzyd 3y ago[flagged]
- fritzo 3y agoSee also the Java LMAX Disruptor https://github.com/LMAX-Exchange/disruptor https://github.com/LMAX-Exchange/disruptor I've built a similar lock-free ring buffer in C++11 https://github.com/posterior/loom/blob/master/doc/adapting.md https://github.com/posterior/loom/blob/master/doc/adapting.m...
- GenericCanadian 3y agoI also wrote an LMAX Disruptor in Crystal: https://github.com/nolantait/disruptor.cr https://github.com/nolantait/disruptor.cr Here is one in Ruby: https://github.com/ileitch/disruptor https://github.com/ileitch/disruptor Both languages are quite readable and I've used these to teach the concepts to beginners.
- rdtsc 3y agoMy favorite ring buffer structure is like the one described in https://www.gnuradio.org/blog/2017-01-05-buffers/ https://www.gnuradio.org/blog/2017-01-05-buffers/ > It asks the operating system to give it memory-mappable memory, and map twice, “back to back”. Blocks then get called with pointers to the “earliest” position of a workload within this memory region – guaranteeing that they can access all memory they were offered in a linear fashion, as if they’d actually be dealing with hardware ring buffers. It imposes limitations on hardware and OS support in a way, but I think it's pretty neat. This also used by the kernel BPF ring buffer: https://www.kernel.org/doc/html/latest/bpf/ringbuf.html https://www.kernel.org/doc/html/latest/bpf/ringbuf.html > ...data area is mapped twice contiguously back-to-back in the virtual memory. This allows to not take any special measures for samples that have to wrap around at the end of the circular buffer data area, because the next page after the last data page would be first data page again, and thus the sample will still appear completely contiguous in virtual memory
- dist1ll 3y agoUnfortunately that means the chunks are only contiguous in virtual memory. So it won't work with the DMA use case mentioned in the article, which requires contiguous physical addresses. But it's still a nice trick, I like it when people get creative with HW features.
- jnwatson 3y agoTheoretically, the same trick could be used to double map addresses coming from an external master via an IOMMU.
- speed_spread 3y agoIt is unlikely that cheap microcontrollers where DMA is most helpful will have an IOMMU.
- gpderetta 3y agoBut the hardware only needs to see on copy of the duplicated memory and you can let it deal with the wraparound. The software can use the convenience of the double mapping.
- gpderetta 3y agoAKA the Magic Ring Buffer. It extremely convenient not having to deal with split messages, especially if you support variables size payloads.
- scottlamb 3y agoThis is a cool trick, and iirc there are a few Rust crates that implement it, including slice-deque. ...but I think there are a few significant downsides, even in userspace: * The most obvious one: you have to write `unsafe`, non-portable code. The Linux, macOS, and Windows implementations are totally different from each other. * The setup and teardown of each ring is a bit expensive (few system calls). For a regular allocation, the malloc implementation typically caches that for you. Here you have to do your own pooling if you might be frequently creating them. * Using whole pages, and two mappings per ring, is wasteful in terms of not only RAM (often no big deal) but also TLB space (which often turns into significant CPU usage). If you just allocate 32 64 KiB rings from the standard allocator, on x86-64 you might be talking about a single 2 MiB huge page mapping. If you do this instead, you're talking about 1024 4 KiB page mappings.
- jamesmunns 3y agoOh hey, one of the authors of this post here (James), happy to answer any questions. This post has been discussed here a couple times, but AMA :) edit, the most commented version of this post was the original: https://news.ycombinator.com/item?id=20096946 https://news.ycombinator.com/item?id=20096946. This is what I'm up to these days: https://onevariable.com/ https://onevariable.com/
- agentultra 3y agoHas there been a formal specification/model that we can check? I love circular buffers but I'm curious how we know that the design is correct with respect to the stated properties. I did a bit of link diving to the various blog posts and sites but haven't been able to find it. Would be nice, if it exists, to have it front and centre.
- jamesmunns 3y agoAs far as I know, it hasn't been formally verified. Andrea Lattuada (https://andrea.lattuada.me/ https://andrea.lattuada.me/) is more likely to have done work in his implementation of the algorithm than I did on mine. I have run the testing with various dynamic analysis tools, but for sure this isn't formal verification. If someone is interested in doing it, happy to chat with them!
- anonymousDan 3y agoShouldn't be that hard to at least model check it in TLA+ I would have thought (albeit potentially more complex if trying to account for weak memory).
- Fiahil 3y agoI used the same approach while designing a lock-free bounded broadcast log (as in a "RWLock<Vec<T>>"; a MCMP, append-only Vec). It's quite easy to do because it's bounded. However, I could not find a way to make it both unbounded and efficient. Any ideas ?
- ChrisMarshallNY 3y agoThat watermark is a simple, elegant idea. I haven't really had the need for that kind of thing, in many years, but I like the idea.
- loeg 3y agoRecently (2022) I designed a ring buffer for use as a 'flight recorder' type tracing system. (I.e., there is typically no reader; the writer needs to write over old records without blocking on any reader. If the reader is triggered, it flips a switch that disables the writer temporarily while the buffer contents are persisted.) In that design I subdivided the ring into several subbuffers (~8). Each subbuffer has its own equivalent of a watermark. That way, the valid portion of the ring always starts at the beginning of one of the subbuffers, and the writer could 'free' the next subbuffer worth of space trivially (without having to scan through old contents record by record). (Any write that did not fit in the current subbuffer was advanced to the start of the next one.)
- monocasa 3y ago> Contended writes from multiple threads on the same memory location are a lot harder for the CPU's cache coherence protocol to handle FWIW, those are the same location according to most cache coherency protocols, since cache coherency generally works on the cache line level. You'd want to split the two contexts to their own cache lines.
- stefanha 3y agoAnother cache optimization trick some ring-buffer implementations use is to keep a shadow copy of the read or write pointer to avoid frequently fetching the other context's cache line. The latest version of the read pointer is only needed when the writer catches up with their shadow copy and vice versa.
- anonymousDan 3y agoYes this is absolutely crucial for performance.
- liquid153 3y agoAren't lock free buffers usually just as expensive or more expensive to use as locks.
- CyberDildonics 3y agoNo, where are you getting that information?
- zengid 3y agoNot if your program needs to be realtime or near realtime safe. Locks are controlled by the OS typically, and can have non-deterministic latency.
- loeg 3y agoEven ignoring mutexes and OS scheduling, plain spinlocks add contention that would not otherwise exist in a SPSC ringbuffer. https://news.ycombinator.com/item?id=39551575 https://news.ycombinator.com/item?id=39551575
- loeg 3y agoNo -- for a lock-free design with a single producer and consumer, it's possible both are typically writing to independent regions of memory. With a lock, both have to write the same cache line to take and release the lock.
- ww520 3y agoLock free has two advantages: the checking code can run in user mode and non-contested access is very cheap with just one instruction. To do it correctly, lock needs to be done in the kernel thus obtaining a lock requires calling into the kernel which is more expensive. I think you meant the memory barrier for syncing cache is just as expensive as the lock version, which is true.
- scaredginger 3y agoObtaining an uncontested lock absolutely doesn't require calling into the kernel
- piterrro 3y agoThis is great article! Very detailed and explains things on a low-level. For a more high-level implementation, I just released yesterday a blog post about ring buffer in Golang: https://logdy.dev/blog/post/ring-buffer-in-golang https://logdy.dev/blog/post/ring-buffer-in-golang
- samsquire 3y agoI tried to write a lock free ringbuffer with weak atomics, I haven't proved it right with TLA+ yet but I started writing a model in it. I use tagging to avoid the ABA problem. they're all on https://github.com/samsquire/assembly https://github.com/samsquire/assembly, i tried to write multiple disruptor with multiple consumers, then one with multiple producers then one with multiple consumers AND multiple producers, inspired by LMAX Disruptor. (There's files for each of them and table in the repo. it's not proven yet!) the contention on the same memory address (the read/write index) is the thing that seems difficult to address. One thing I've learned about thread safety: I think if you have thread-owned values then you can be thread safe with a simple semaphore, providing that you have unique, DISTINCT values for each thread. If you have two threads that have this in a hot loop in parallel: // thread 0 if buffer[x].available == 1: // do stuff buffer[x].available = 0 // thread 1 if buffer[x].available == 0: // do stuff buffer[x].available = 1 Due to causality, no matter the interleaving, thread 0 owns the buffer[x].available and body of the if statement when it is 1 and thread 1 owns the body of the if statement buffer[x].available when it is 0. The CMP is a cheap mutex with distinct valued memory locations. Even though thread 1 is writing to buffer[x].available and thread 0 is writing to buffer[x].available it doesn't matter because the causality is mutually exclusive. There is no interleaving of buffer[x].available = x because of the if statement. The buffer[x].available = 0 will never run while buffer[x].available is equal to 0 overwriting or causing a data race when setting buffer[x].available to 1. So the second line cannot happen in parallel. I need to write a TLA model to assert its safety. If you have more than 2 threads, then you need different tokens to provide admissability to the if statement. Remember to use compiler memory barrier asm volatile ("" ::: "memory"); so you don't need volatile struct values.
- loeg 3y ago> The buffer[x].available = 0 will never run while buffer[x].available is equal to 0 overwriting or causing a data race when setting buffer[x].available to 1. In particular, because loads and stores of the same variable cannot be reordered out of program order. Once your algorithm involves other variables, you would (likely) need to be a little careful about loading/storing with acquire/release semantics to prevent reordering other accesses relative to this protocol. > Remember to use compiler memory barrier I would highly recommend using the language atomic types (and barriers if truly needed) instead of gcc inline assembly syntax.
- loeg 3y ago> The safe thing to do here is to always choose Ordering::SeqCst, "sequential consistency", which provides the strongest guarantees. ... This is often good enough in practice, and switching to a weaker Ordering is only necessary to squeeze out the last bit of performance. If you're going to write lock-free algorithms using atomics, the least you can do is learn about ordering semantics and use the correct abstract semantics for your design's actual needs. It is much easier to do it at design time than to try to figure out if it is safe to relax SeqCst later. (This is one of the major flaws of C++ std::atomic's default SeqCst semantics.) If you aren't going to bother understanding ordering semantics, it is unlikely you can write a safe lock-free algorithm anyway. (It's really hard to do!)
- jamesmunns 3y agoBack then, there weren't as good references for explaining atomic ordering, and the blog post had gotten long enough. Mentioning SeqCst was a bit of a cop out, though both Andrea and I didn't end up using SeqCst past the inital impl anyway. Today I would have just linked to https://marabos.nl/atomics/ https://marabos.nl/atomics/, Mara does a much better job of explaining atomics than I could have then or now.
- hinkley 3y agoBack then? Do you mean 2019? Or a different “then”? Because there was plenty of material in CS about this subject even in 2010. Java was wrestling with this twenty years ago, and databases long before that.
- jamesmunns 3y agoI meant 2019, and there weren't any materials that I would consider as clear and well defined as Mara's linked docs explaining the different orderings used by C, C++, and Rust (Relaxed, Release, Acquire, AcqRel, and SeqCst). I'm very sure there were discussions and teaching materials then, but none (that I was aware of) focused on Rust, and something I'd link to someone who had never heard of atomic ordering before.
- 3y ago
- magnat 3y ago> A typical serial port configuration is "115200 8N1", which means 115,200 baud (or raw bits on the wire per second), with no parity, and 1 stop bit. This means that for every data byte sent, there will be 8 data bits, 1 unused parity bit, and 1 stop bit, to signal the end of the byte, sent over the wire. This means that we will need 40 bits on the wire to receive a 4 data bytes. 8N1 means there is 1 start bit, 8 data bits and 1 stop bit (10 bits total), not 8 data bits, 1 unused parity bit and 1 stop bit (also 10 bits total).
- jamesmunns 3y agoYep, good catch! That's a whoops in my explanation. I don't work at FS any more, so I'm not sure I could PR that change, but you're definitely right :)
- bfrog 3y agoI have to say after looking at various DMA hardware I much much prefer the scatter gather list type than the ring type of DMA. The entire need of a bipbuffer allocation for DMA then goes way. You can have a simple pool of fixed sized blocks to throw at the DMA. Pretty easily done with a free list. I do think the implementation here is cool though, and its nice to see some work in this area.
- 95014_refugee 3y agoTo make scatter/gather go fast, you either spend a lot of effort caching descriptor lists for pinned buffers (because you expect to see them often), or heavily optimising your VM's v2p translation machinery, or some combination of the two. And then you wind up discovering that you the driver writer aren't actually trusted and so you need to insert at least one if not several IOMMUs between the peripheral and the memory(ies) that they may access, managed by another software component in a different address space. Then someone asks you to make all of this work for clients in VMs. At which point you start wondering why you didn't just allocate a physically contiguous buffer at startup and copy to/from your client buffers using the CPU... Sorry for sounding triggered... 8)
- bfrog 3y agoNo need to apologize at all. I haven’t seen these issues. But I’ve worked with this setup without mmu involvement.
- LAC-Tech 3y agoTIL about BipBuffers. I've been struggling with a similar data structure, and to see it already has a name, and a better implementation than what I've been doing, is very welcome.
- dnedic 3y agoBipartite buffers are amazing and criminally underused. For those looking for C and C++ implementations you can check out my libraries: lfbb and lockfee: https://github.com/DNedic/lfbb https://github.com/DNedic/lfbb, https://github.com/DNedic/lockfree https://github.com/DNedic/lockfree (although lockfree contains more data structures as well)
- nyanpasu64 3y ago>In Andrea's implementation of the lock-free ring-buffer, spsc-bip-buffer, some of the orderings are relaxed for performance. This has the downside that it can introduce subtle concurrency bugs that may only show up on some platform (ARM, for example): to be a bit more confident that everything's still fine, Andrea's has continous integation tests both on x86 and ARM. It might be worth testing/forking the library to test on Loom (https://github.com/tokio-rs/loom https://github.com/tokio-rs/loom), which can model atomic orderings and check for concurrency errors to some degree (though I last used it years ago). TSAN might be able to check for ordering errors in visited execution traces (though I haven't tried using it in the past).
- KingOfCoders 3y agoSounds a little like LMAX architecture.
- cultureswitch 3y agoI was faced with the same constraints a couple of years ago and came up with an almost verbatim solution. It's an interesting problem where a lot of very subtle bugs can happen, it's good to know that I went with the same solution as people who put a lot more effort into making sure it is correct.