This blog post will be a reflection on our recent experience of porting a reasonably large (~30KLOC) JRuby application to Google Go, talking about the many things we liked about the language and ecosystem, and the couple of things that I found grating about it.
It was a good experience, Go is generally very nice, and we are all comfortable that this was the correct decision for many reasons. I'd recommend that anyone curious about Golang to give it a try; in particular, commit to persisting through the first 2 weeks, where you will most likely find some of Golang's ideologies grating at first (like "why is every line an if statement?")
At UpGuard we are pushing towards agentless deployments. Previously, there was an agent installed on every machine, and the agent would just scan the machine it was running on. Agent performance was never a concern previously because there was never a resource bottleneck. Our future deployments will involves a small number of 'connection managers,' which are agents that SSH into target nodes to scan them remotely. In this scenario, there will be many concurrent SSH connections taking place. The critical problem for us was that the Ruby SSH library is not thread safe when operating on multiple SSH sessions per SSH channel. We rely on this heavily for performance when scanning remote nodes. So we could not achieve our goals using threads within Ruby. One possibility is process separation rather than thread separation, which has many other advantages anyway, but one of the costs of JRuby is the billions of gigs of ram that anything in the JVM requires. If we use process isolation, we must instantiate multiple JVMs, and napkin math yields saddening conclusions quickly. 8 processes at a billion gigs of ram each is 8 billion gigs of ram, which is more than my laptop currently has installed, and is awkward to write down in requirements documents.
So a decision was made to re-write. That's never an easy decision–it means there will be a time when no new business value is being generated as we work to attain feature parity–but now that we've completed the project, we're satisfied that we have made the correct decision. After several months immersed in Golang, I now have a 90% good / 10% bad view of it. Prior to this project I had never used Go before.
What was great about go for us? In approximate order of importance.
My personal, bike-sheddy, highly opinionated (correct) list of nice things about go includes:
Go satisfies all of these points. Especially point 6.
What did we find bad about it? (Gratuitous rant warning...)
There have been a few decades of language progress since C that were seemingly ignored... no operator overloading (except for string, which is apparently important enough to deserve it). Nil (not null, the spelling has been ...upgraded?) pointers are no better than they were back in C/C++/Java land 20 years ago. Some syntactic sugar to allow list comprehensions (map, filter etc) would be extremely helpful. Tim Sweeney (whom is a god of sorts) produced a slide deck in 2006 with one snippet that I found particularly interesting:
"For loops in Unreal:
40% are functional comprehensions
50% are functional folds"
Guess which codebases other than the Unreal engine this is true in? ALL OF THEM. The golang justification seems to be that 5 lines to declare a new list, iterate, perform a transform, write next result into the new list is not much. And as a one-off or a ten-off, it isn't much. There are hundreds of these in our (...every) codebase, all adding a paragraph of mess and potential bugs. Various other 'programming good practices' like separating concerns, passing minimal amounts of information etc does have a tendency to encourage constructs like map & filter. The very dangerous alternative is to just pass big objects around, which results in a more tightly coupled (a.k.a. worse) codebase.
Even without language level support for map/filter/folds, generics would go 99% of the way towards solving this because I could just implement this:
func Map(inputList []<InputType>, fn func(<InputType>) <OutputType>) []<OutputType> {
outputList := make([]<OutputType>, len(inputList))
for i, elt := range inputList {
outputList[i] = fn(elt)
}
return outputList
}
But I can't do this either.
And for the grand ranting finale.... the **worst** thing about Go (even worse than the corruptible slices): lack of const correctness, or any compiler enforced immutability except for basic constants. This was a terrible mistake, especially when trying to use concurrency extensively. When using libraries with some complex types, const is a great way to ensure that this particular call graph will not mutate my data structure. It can force some structural separation in the code of the mutable vs immutable data.
Slices and maps in Go are basically pass by reference. Yes, passing a reference by value is passing a reference. Slices can introduce pointer aliasing, and maps are not thread safe through mutation. Concurrent access that will mutate a map must be protected with mutexes, or serialized through a dedicated mutator goroutine, with a few channels set up for the goroutines to talk amongst themselves. What if the data is immutable? Perhaps there is a routine to prepare a data structure, then several other threads that act on the data once available. What I would like to do is have call graph A prepare the data structure, then pass it to call graph B with a const qualifier. B can spawn as many goroutines as is appropriate, read as much as it wants, never write, therefore access to the data within call graph B doesn't require mutexes or any controlled mutator routines. This basic pattern (prepare rw, use threaded ro) is very common in our codebase. Now later on, someone unfamiliar with the codebase realises that they can support X feature requests with a few small tweaks to the data structure in call graph B. Without language level const operators there is no way to prevent this from happening except for red tape. In C/C++, call graph B would have a const-qualified reference to the data, and we're done. In Go, we just have to hope that:
What is actually likely to happen: a competent engineer will get a feature request, then have a thought process that resembles: "This looks like an easy place to add it. Few lines here... ok done. Add a unit test, existing tests pass, cool I'll post it for review, job done". This is a perfectly reasonable approach, but they have actually introduced a bug. And no, unit tests do not reliably capture subtle race conditions (however, I must admit that I need to play with the go race detector). Three months later, the section starts crashing more regularly, then we burn a week debugging, decide we need to re-write the section because it is has race conditions, and possibly introduce a regression after changing it.
What would be infinitely better:
"Hey I want to do X but your const is being a pain in the arse. What am I supposed to do?"
"Put it there or there instead and I'll be all good."
"Ok rad, thanks."
And then there are no landmines left to step on a few months later. Const makes this sort of thing enforceable, basically inflicting better design upon the codebase through certain restrictions. The long term affect is better code, fewer bugs, higher overall productivity.
Go is a really nice language, with rich, easily composable, well written libraries. It is easier to write a clean, fast, parallel program in go than in most other languages, and the crucial thing that Go does well is to allow a team to write a clean, fast, parallel program with relative ease. The pros described here far outweigh my boo-hoo list of why-can't-Go-be-rust (but seriously? no const?). I don't see this portion of the product requiring a tech refresh for a long time.