Show HN: Lockless Ringbuffer Built with Go Generics
github.com
github.com
Been a Go dev for almost 3 years now. I am basically shitting myself over the prospect of all the Go code out there turning from something I understand, to something riddled with generics, of which I have no concept whatsoever. I've built some pretty complex stuff entirely without them and never felt there was anything missing, but maybe I just never knew what I was missing.
Now, I also don't know what a ring buffer is, so perhaps this is the perfect time for someone very generous to explain to me what a ring buffer is, why generics are what enabled this and how on earth it's multiple times faster than channels, which have been a first-class primitive in Go since the beginning (and should therefore have had the living daylights optimised out of them...)
Many thankings :)
A ring buffer is just a normal buffer, except that when it "runs out" of space instead of overflowing or returning an error, it "loops back" on itself, and starts writing from the start. You can still grow it, if needed.
The nice thing about generics here is that it allows you to have a RingBuffer[string], RingBuffer[int], and RingBuffer[MyType] without having to implement them separately. The compiler will even take care of memory sizing/layout, and all the other razzledazzle.
As for performance, it's not fully related to generics, but a sibling comment explained the implementation detail and how it affects performance.
https://pkg.go.dev/container/ring#example-Ring.Do
The casting back and forth from T and `interface{}` is expensive, boilerplatey/extracode, and panic-prone.
Go channels (and slices and maps) are special built-in types that are already "generic", i.e. they have type parameters: "make(chan Foo, 10)".
Is there some property of the built-in buffered channel contract that makes it hard to implement as efficiently as this ring buffer?
Anyway. The ability to implement user-defined data structures with the full power of the language (e.g. building your own 'chan T') allows one to choose some different guarantees and target different use cases
Compile-time generics can often perform similar behavior but with a far better memory layout resulting in better performance.
Purely speculation though about `chan`, i have no clue.
/usr/local/go/src/runtime/chan.go
There are some reflection calls in there, together with mutex lock/unlock and calls to 'typedmemmove' which seems to involve the use of memory barriers (mbarrier.go in the same directory).I started with Python (and Kivy). Then went on to Go (which felt like a revelation over Python. For me, anyway). Along with that came JS, HTML, CSS and React.
Because of Go, I had become prejudiced against interpreted, dynamically typed languages. But over time I actually came to quite like JS. If wielded correctly, it can be very quick to get stuff working in JS and now I think either kinds of languages have their merits. Though, admittedly, React still eludes me. It feels incredibly confusing to me.
My next port of call will probably be something like Rust, but I have a product that takes up all of my time, so it's a bit tricky to find the time to catch up on my CS learning.
There is probably nothing that can replace a full-on university education in CS, but I'm not sure when or where I'd fit that into my life (I have a degree and thus know what it takes). I really hope to be able to do as much of that education on my own, however.
Any tips or hints welcome!
I’m also struggling to see the great benefit of generics apart from “it’s quicker than using interface types, trust us”.
I didn’t know a ring buffer was a “formal” data structure thingy but implemented one for a toy multiplayer game I’m building very slowly. There’s a “circular list” of the 5 last received messages from the server and if the position as indicated by the server deviated above a certain threshold I warp the player back to where they should be according to the server.
I knew these message objects were gonna be allocated/nuked constantly so I just have 5 one pointing to the other and the last pointing back to the first and I go around overwriting them.
During runtime, the program doesn't know the actual type of the variable, it just knows it's a io.Reader and dereferences to the actual type vs generating the code for the type that is actually being used.
Here's an interesting read on issues with interfaces at scale https://segment.com/blog/allocation-efficiency-in-high-perfo...
It was a natural progression for me, and i quite enjoyed it. I also love Rust, the stdlib (and supporting crates) gave me a UX quite similar to Go's batteries-included stdlib.
How suitable is it for web backends?
I am just dying to get started with it.
In my experience, you can write Rust a lot like you write Go. A little more learning perhaps, because Go will do things "automagically", but you can reference count just like you would in Go/Python and it makes your code _far, far_ simpler. You also can use Rust's "interfaces" (`dyn Foo`) which behave a lot like Go's interfaces of `Foo { }`.
You waste a bit of memory, but who cares when you're coming from Go or Python. You aren't trying to write the most efficient program ever (usually).
As far as web backends, it's what we use it for at my shop. The only rough part there is framework maturity. The best novice framework (imo) is Rocket, though it's not quite mature. The "best" framework is probably Actix, but it's a bit more complex for newcomers.
So yea, my advice is to be careful to learn only what you think you need to know. Don't bite off everything at once. Generics is the same story, learn the basics of what a generic is, how to use it, etc, and then rarely use it. You didn't have them in Go, right? Write Rust like Go.
Learning a pinch of everything is useful though so that when a complex annoying thing occurs you have some idea how to handle it or where your knowledge is deficient. Also embracing Rust communities like /r/learnrust/ is useful too. Very friendly.
Hope this helps :)
sync.Mutex.Lock is implemented like this:
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}
lockSlow is, of course, a bit more complicated.I do not doubt majority of cases a mutex is faster/more appropriate. However the key there is looking at what m.lockslow() or unlockSlow() does which is called on lock contention. They become spinlocks that are not entirely dissimilar to my implementation.
The idea is that a lockless algorithm is trading a worse best case i.e m.lockSlow is not called for a better worse case. Again if this is true in this exact case, I am not sure as I have not benchmarked it against a naive mutex based ringbuffer.