A map using a lock-free concurrency using left-right algo, written in Go
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