Hacker News new | ask | show | jobs
by j03b 1685 days ago
The takeaways in this article are much more why Go's memory model becomes difficult at scale. When you're dealing with memory at Discord scale garbage collection is hard, for example on the mentioned Discord microservice:

> There are millions of Users in each cache. There are tens of millions of Read States in each cache. There are hundreds of thousands of cache updates per second.

This is something that is probably never going to be hit locally in the development toolchain. You can certainly prefer the Rust memory system to Go and have that be a valid reason to use Rust over Go for something like dev toolchains, but you're not going to hit scale problems like those mentioned in the article.

2 comments

Not just at scale, Go doesn't make it obvious when an allocation is on the heap or stack. The escape analysis can change (hopefully without regressions) from version to version of Go, and that can mean performance regressions can happen. It also means small refactors risk de-optimizing code without an obvious reason why that would be the case.

I think more engineers are becoming skeptical of optimizing compilers with that sort of implicit, potential "spooky action at a distance" where nonlocal code can cause performance regressions.

In Haskell, laziness and iterator fusion can do the same thing. Nonlocal code can cause local expressions to compile differently, resulting in - from an engineer's POV - nondeterministic performance, memory usage, GC pressure. "Space leaks" can also occur, again, due to nonlocal effects.

In JavaScript, the JIT can similarly cause local regressions due to nonlocal code. A single expression deep in a call stack mutating an object can result in it no longer fitting an optimized "shape", resulting in function deoptimization.

Just say no to spooky action at a distance in programming languages, in my opinion. It leads to tremendously difficult to debug regressions.

> the escape analysis can change (hopefully without regressions) from version to version of Go, and that can mean performance regressions can happen.

Because it's not the case with Rust or any compiled language? LLVM has shown regressions wich is what Rust uses to compile code.

2sec search on google: https://github.com/rust-lang/rust/issues/24194

Of course there have been regressions, but the semantics of the code don't typically change. A "dead heap allocation", or removing an unused call to malloc, is an interesting example but I think you'd be hard pressed to find a case where it caused a massive performance regression.

I think there may be some edge cases (constant folding, dead code removal) of course, but a change to LLVM cannot cause the 3 sorts of changes I identified in Go, Haskell, and JavaScript.

>Not just at scale, Go doesn't make it obvious when an allocation is on the heap or stack. The escape analysis can change

I thought heap allocations are only triggered by taking pointers, i.e. using reference semantics (including casting to interface and calling a method whose receiver is by-ref)? If I'm careful to only use variables by-value (for types that can afford it, i.e. excluding map/array), what else can trigger heap allocation?

Making a closure usually requires allocation. For example:

    package main

    import "fmt"

    func main() {
        closure := make_closure()
        fmt.Println("closure():", closure())
    }

    func make_closure() func() int {
        x := 1
        return func() int { return x }
    }
This prints:

    $ go run main.go
    closure(): 1

If x were allocated on the stack, it would get nuked after we returned from make_closure(). In Rust you could move x to the closure, but I think in this Go example x would be heap allocated, assuming the Go compiler doesn't notice it can inline all of this and avoid allocating. Maybe assume a more complex example with a struct that had to be computed via a function argument or something :)
Oh yes, closures too. Coming from C++ background, you kinda intuitively understand when things escape and when they don't, because you assume that Go would implement it in the most straightforward way (like it usually does), and in the case of closures, heap-allocating a captured variable is the simplest implementation (just let GC handle it!) considering the implicit reference semantics (that you can change "x" anywhere outside of the closure and it should be immediately visible in the closure, too). I.e. assume the worst (and don't expect Go to do some clever special-casing similar to move semantics in C++/Rust), and it's often the right answer :)
I don't know, building JavaScript things involves similar orders of magnitude.