A map using a lock-free concurrency using left-right algo, written in Go
at main 127 lines 6.7 kB view raw view rendered
1# Left-Right Map in Go 2 3This is experimental code for educational purposes. Do not use in production! 4 5It appears that this project became a testbed for me to test Go's recent new 6features. I.e. it began as 7 8* an exercise to prove that programming in Go w/o generic primitives, but with 9crude `sed` substitution is perfectly fine and has higher dev velocity that using 10actual generics. 11 12* I got the idea to emulate "weak pointers" by wrapping the read handlers into 13an outer shim onto which I attached a runtime.Finalizer that on GC cleanup 14calls its inner Close(). I did this with the idea that creating read handlers 15is expensive and should be amortized by using a sync.Pool. However, without 16such weak pointer + finalizer, there was no caller for the Close method. 17 18* Then go2 experimental generics came up and I have used that. 19 20* Then go generics became beta and I've used that. 21 22* Then atomic.Pointer became a thing and I've used that. Before I used 23unsafe.Pointer with uint64(uintptr) and atomic.XXUint64 crutches. 24 25* Then the iter interfaces were introduced and I've implemented that. 26 27* Then weak.Pointer was introduced and the runtime.Finalizer was superseeded by 28runtime.AddCleanup. I have ported those, making the complex outer/inner 29construct that emulated weak pointers obsolete, which makes the code a lot more 30understandable again. 31 32* There is a good chance that the next niche feature in Go that has barely any 33use case except in extremely non trivial edge cases does provide some value in 34this code. Which is ironic because I don't have a good use case for this code 35except for testing niche Go features. 36 37Seriously, DO NOT USE THIS CODE anywhere near production or even at all! (I 38don't) 39 40How concurrency characteristics of this code relate to the memory model is not 41clear. 42 43## Inspiration and prior art 44 45Inspired by Jon Gjengset's [left-right](https://github.com/jonhoo/left-right). 46 47While trying to learn Rust I've watched [Jon's stream](https://www.youtube.com/watch?v=eLNAMEoKAAc) 48on how he refactored his [rust-evmap](https://github.com/jonhoo/rust-evmap) 49into a generic version, then named left-right. This made me want to see in how 50few lines one would be able to make a reasonable implementation in 51(non-generic) Go. Also, I plan to use this project as a toy for learning the 52upcoming Type Parameters (Generics) in Go. 53 54Apparently there is prior work by Pedro Ramalhete and Andreia Correia (2013): 55"Left Right: A Concurrency Control Technique with Wait-Free Population 56Oblivious Reads" [blog post](https://concurrencyfreaks.blogspot.com/2013/12/left-right-concurrency-control.html), 57[preprint paper (pdf)](https://iweb.dl.sourceforge.net/project/ccfreaks/papers/LeftRight/leftright-extended.pdf) 58(Warning: Sourceforge). 59 60## What is Left-Right? 61 62A left-right collection (in this experimental implementation: map[K]V[K, V]) is 63an attempt to eliminate locks entirely from hot paths in concurrent 64applications where read operations are massive and write operations relatively 65rare. Above authors show that in their implementations reads scale linearly 66with the number of readers. 67 68### A metaphorical explanation: 69 70There are two "arenas" (left, right). When a reader "enters", the reader reads 71a sign (an atomically read pointer), that points to which arena they should go. 72Once entered an arena, the reader is welcome to do as many reads as they please 73while the arena presents a consistent read-only state. Meanwhile, the writers 74are free to do any write operations on the other arena, undisturbed by the 75presence of readers. Every write operation is also appended to an operations 76log (like a redo log, write ahead log). Once the writer is done with its write 77operation, the "sign" is swapped and any reader that enters, goes to the 78recently-written-to arena. The writer waits until all readers that were still 79in the other arena to leave. Once the writer is convinced all readers have 80left the other arena, all operation from the log are re-done in order to sync 81up both arenas to the same state. Thence the writer can continue doing write 82operations. Rinse, repeat. 83 84The writer does synchronize any actions with a central mutex lock. Keys and 85values are kept at most three times: Once for each arenas, plus any 86non-commited value in the operations log. The writer is in control over how 87long that operations log is: It is truncated after each commit operation. The 88delay of each commit is dependent on the readers: They need to preemptively 89leave the arena to allow the writer to carry out the commit operation. 90 91The "sign", which is in fact a simple pointer, is written by the writer and 92read by the readers with atomic primitives. The signaling of where the readers 93are is done with an unsigned integer counter (named "serial") that is atomically 94incremented by 1 when entering an arena and when leaving an arena. Thus, if a 95reader has entered an arena the serial is odd, and even if the reader has left 96the arenas. The writer atomically reads each serial and spins over every serial 97that happened to be odd at that first read until all of those serials differ 98from the recorded odd serial. That ensures that every reader has at least once 99read the pointer after the writer swapped it (and also handles the case of 100overflowed serials). 101 102## Performance 103 104Once a reader has entered, all reads are as cheap as normal native reads from 105the collection (map in this case) which is as fast as it gets. So fast in 106fact, that in my early tests all the read operations where inlined in tight 107loops, such that Go's scheduler was unable to yield to other goroutines (see 108`runtime.Gosched()`). 109 110However, readers need to preempt by regularly `Enter()`'ing and `Leave()`'ing 111which---although this does not encounter locks---is not free, because atomic 112operations impact cache lines. It is entirely possible that `sync.RWLock` 113performs similarly, thus benchmarking the actual use cases is obligatory. 114`sync.RWLock` does not allow simultaneous reading/writing and concurrency of 115reads drains down to zero when a writer wants to write, though. 116 117Creating readers is relatively expensive. Each reader registers itself in the 118writer (which involves locks), because the writer must observe if any readers 119are in the live arena. If this algorithm is e.g. considered in HTTP handlers, 120each request is handled in its own Goroutine and thus also needs an own reader. 121Creating one reader for each request quickly destroys any benefit. This 122implementation offers a sync.Pool of reusable readers to alleviate this 123bottleneck, but if that in total provides any benefit can only be answered with 124use case specific benchmarks. 125 126I did not perform systematic benchmarks myself. 127