Go Concurrency Distilled

(antonz.org)

84 points | by chmaynard 13 hours ago

5 comments

  • Segv77 4 minutes ago
    Go concurrency seems simple on the surface, but mastering select and proper error handling takes practice. Good to see this topic distilled.
  • SamInTheShell 3 hours ago
    The concurrency and threading in Go just feels like magic compared to every other language. I'm a goroutine addict and I refuse to be rehabilitated.

    Just from observations over the years, I don't think there's any other language quite like this, in terms of how things can end up happening in any thread.

    • hank1931 38 minutes ago
      How about Erlang or Elixir using BEAM?

      Supposedly WhatsApp scaled to serving over 1 billion users with Erlang and BEAM.

      RabbitMQ, used by Reddit, uses Erlang and BEAM.

      Discord uses Elixer and BEAM.

      I just traveled down the BEAM rabbit hole. Fascinating story. The Ericsson Computer Science Laboratory cranked out some amazing products in the early 1990's.

      Their goal was five nines of reliability for Ericsson telephone switches.

      According to Joe Armstrong (an interesting fellow from Ericsson), the AXD301 ATM switch achieved nine nines over a nine-month period using Erlang and BEAM in 2002. That calculates out to 24 milliseconds of downtime.

      • SamInTheShell 9 minutes ago
        I looked at BEAM about a year or so ago, similar conversation here. I don't think BEAM is the same when you start looking at what part of code is executing in which thread. There's tradeoffs depending on what you're solving for, like Go makes it really simple to distribute your work across threads concurrently, but when you start looking at integrating with stuff, you run into having to do tricks to do things with unshare (ref: docker/podman/containers...) and you haven't been able to integrate into libnss since they started using some "unused linux signal" for concurrency controls (PAM used that signal).
    • osigurdson 2 hours ago
      >> in terms of how things can end up happening in any thread

      Doesn't that describe pretty much any green thread style concurrency implementation.

      • dlisboa 1 hour ago
        No. Preemptive scheduling plus M:N mapping combination that Go has is not common in other major implementations.

        Other languages and their implementations of green threads usually have cooperative scheduling or M:1 mapping

        • dwattttt 1 hour ago
          I'm curious; using hardware threads is M logical threads preemptively scheduled on N physical cores. In what way does this not satisfy the original criteria?
          • dlisboa 1 hour ago
            That's just not what is referred to as green threads.
            • dwattttt 14 minutes ago
              Yes, in implementation they are not. I'm curious what the difference in subjective experience is.
    • signa11 1 hour ago
      erlang would like to have a word.
    • kccqzy 2 hours ago
      I learned Haskell before that, and frankly the concurrency in Go feels similar, but is a definite downgrade due to the lack of STM.

      You can implement channels and select using STM, so these don’t have to be in the standard library. And the contentious design choices like what happens when you close the channel twice can be your choice! And going from STM to managing mutexes is a definite downgrade in abstraction power.

      The concurrency design in Haskell feels like true magic.

      • tombert 2 hours ago
        The concurrency design in Haskell is cool, though I gotta admit that I don't find it much fun to write.

        It's not because the language is "hard". I remember when I first learned Haskell a million years ago I thought it was the coolest thing ever because I had never seen anyone work at that abstract of a level before, especially in a compiled language. I got to understand the theory well enough and I know how to write a program with it, but the entire language kind of feels slapped together to me. Every time I've written anything in Haskell, I feel like I have to do a million compiler extensions, or rely on third party libraries' liberal use of Template Haskell (e.g. Lens) to make the language feel anywhere near "modern".

        Yes yes yes, I know this is a complaint about GHC, not "Haskell", but given that GHC is basically the only Haskell compiler that gets serious use I don't think it's weird to conflate the compiler and the language.

        • saghm 8 minutes ago
          > the entire language kind of feels slapped together to me

          The slogan "avoid success at all costs" definitely is accurate for Haskell

        • kccqzy 1 hour ago
          Template Haskell is actually pretty cool. Using it to generate lenses in a type is a perfectly fine use case (of course hand-writing lenses is just one line anyways). Running computation at compile time is really a great feature; people rave about comptime in Zig but of course Haskell has had it earlier.
          • tombert 1 hour ago
            I don't dispute the coolness of any given Haskell feature. Haskell does have a lot of really neat features, but that doesn't mean that the language is fun to use.

            C++ also has a lot of really cool features but I also do not enjoy writing it, actually for similar reasons as Haskell (though I don't think Haskell is nearly as irritating as C++).

            > of course hand-writing lenses is just one line anyways

            The lenses themselves aren't hard to write; I was referring to the annoying quirk of Haskell where records couldn't have the same field names. Lens has a nice helper macro `makeFields` so that you could more or less automatically have the generated lenses have the clashing names.

            To be fair it actually always worked fine for me but it always felt janky until `DuplicateRecordFields` was released.

    • chimbambum 3 hours ago
      any downsides to it in your experience?
      • unscaled 3 hours ago
        Managing channels and making sure they are closed just once is quite messy compared to other languages. The Go channel axioms[1] don't make much sense: why does closing a channel multiple times panic, but reading from a closed channel returns a zero value?

        Kotlin gets this right. On send/receive, you can use trySend or tryReceive if you want to avoid exceptions. Considering Kotlin also has coroutines and structured concurrency, concurrency in Kotlin feels more ergonomic to me than Go. At least if you want to get concurrent code with least amount of bugs and not just least amount of extra keywords.

        [1] https://dave.cheney.net/2014/03/19/channel-axioms

        • Groxx 3 hours ago
          Yeah, channels are the main pain point. In addition to the axioms being simply weird (because it's an easy set to implement), another major problem is that you're essentially forced to use them because they're the only things that can work with `select`, and that's the only reasonable option for many operations. Especially if you touch other code, like the stdlib.

          That and the lack of tooling around mutex usage / concurrency correctness. The race detector is legitimately excellent and every language needs it, but it can only catch races that you trigger in tests/builds with it enabled, and few projects write anywhere near sufficient concurrent tests to catch issues in practice. There isn't even a "this var claims to be protected by lock X, but it is not held [here]" lint, or "this var is atomic but used non-atomically [here]" (though this one is significantly less of an issue with generics, as safe zero-cost abstractions now exist).

          • bbkane 46 minutes ago
            Go doesn't monomorphize, so benchmark your generics- they might not be zero cost
        • jerf 2 hours ago
          Once I understood the idioms of Go channels they make sense, but those axioms, while true, aren't the idioms. The idioms would be something more like:

          Channels are all intrinsically multi-producer, multi-consumer, and unbounded in size (which is to say, they can carry an indefinite number of messages, not related to channel buffering), but you should still know what the characteristics of your channels are, namely, single or multiple producer and consumer and whether there's some sort of bound on the number of messages. Particularly because you should only ever close a channel if it is single-producer and you are the producer.

          It is OK to only use a fraction of a channel's power. For instance, a single-producer, single-consumer channel that is guaranteed (by code, not type system) to only ever have either 0 or 1 messages sent on it is a fairly common pattern.

          Never just buffer a channel blindly to try to fix a problem. You should only ever buffer a channel with a size that corresponds to something particular; I know this may receive exactly N messages, 1 from each of N threads, and I want to decouple the possibility the receiver will give up early without hanging the producers, or something like that. Never just slap down a "10" or something and hope it makes things better. The vast majority of channels should be unbuffered.

          Putting those two together, the correct way to tear down complicated structures after an error or something is often a channel whose sole purpose is to indicate the liveness of the system in question. In any even remotely modern Go, that should actually be a context.Context and not a channel, which is still basically "a channel with a defined close mechanism" under the hood but adds some other features that are almost always useful at some point.

          The reason for all of the above is the select statement. You can in some sense look at "select" as the dual of the channel (being a bit free with the term "dual" here) and consider its functionality as the functionality the Go runtime is actually trying to provide you, from which the characteristics of channels are derived. From this point of view it is then trivially obvious why sends and receives to nil channels block forever... "block forever" is the channel-focused way of seeing the dual statement "the select statement will never select this channel". Some of the other details of channel behavior make more sense if you view them from the select side of the coin.

          From this we can also derive a rule of thumb in Go, which is, if your "concurrency" is never going to be involved in a select, it probably doesn't need to be a channel. For example, a simple atomic counter really shouldn't be wrapped behind a channel with a goroutine reading from it or something, just use atomic integers. I have a number of mutexes in my real code. However, never ever take more than one mutex at a time. As soon as you feel like you need to do that, switch to channels, and a proper architecture that uses them somehow to do whatever it is you are trying to do.

          (Trying to take multiple mutexes at a time is what led to threading hell in the 1990s. Contrary to popular belief, not just the mere act of threading, but the attempt to do so based on taking multiple mutexes, which at the time was thought to be the only technique available by a lot of the community, leading "threading" to take the heat for what should have been laid at the feet of "taking lots of mutexes at a time in one thread".)

          I don't know much about Kotlin, but your cite of "trySend" and "tryReceive" makes it sound like you can do that on only one channel at a time. The fundamental thing about Go channels is that they can be put into select statements which can atomically send from or receive from multiple channels at a time, guaranteed to select exactly one of the possible outcomes. Many "I implemented Go concurrency in X" (often C) flop here. Some kind of queue than can be sent and received on is ubiquitous. Go channels aren't unique, because by the time Go came around pretty much every primitive had been tried somewhere, but it is to my knowledge the only langauge that lifted channels up into the language itself and made them first class.

          But stepping up one level of abstraction, "lifting up a particular concurrency primitive to the language level" is not itself anything particularly special and I'm not claiming it is. For instance BEAM had a very particular concept of "mailbox" that it had lifted up into the language and runtime around 15 years earlier, which I have compared and contrasted before here: https://news.ycombinator.com/item?id=34564228 which is, overall, a richer concept than Go's channels, particularly because of its ability to pluck messages out of the mailbox out of the receiving order. Whether that richness is a good thing is something that could be debated a lot.

      • eddythompson80 1 hour ago
        Others have mentioned the main issues, but to add; you often end up writing “ugly” code to do basic concurrency operations. Often setting up channels or workgroups then a `go func {}(…); wg.Wait();` just feels wrong and makes you thing “I must be doing something wrong, there must a better way, but that’s IS the way. It’s just go syntax quirks at the end of the day, and makes you appreciate go’s simplicity over high abstractions.

        In my experience the main hurdle was getting developers on the team onboard with go’s way. It felt like swimming upstream for my 6 year stint in go. I was in a very Java heavy “enterprise” but we were writing a kubernetes operator and I pushed to use golang because (a) I liked it, and (b) it was 2019 and the entire kubernetes ecosystem was primarily go.

        To me golang was very simple and I drank Rob Pike’s and Google’s narrative of how easy it’s to get a competent “compute science major in college”-person to pick up go. What I experienced was a form of “you can’t teach an old dog new tricks”. Lazy (and I hate to use this word) developers who gotten so used to frameworks and IDEs doing all the heavy lifting for them in Java or C# had 0 appetite forgetting all the questionable patterns they learned over the years and adopt Go’s simplicity. It was very frustrating at time, yet gave me a good eye for the actual skilled talent in the organization vs the average enterprise developer persona.

      • Joel_Mckay 3 hours ago
        Go and Julia are fun languages.

        In production, Go has proven solid for several years. It is best when used with the native code people ported.

        There are only two issues I encountered:

        1. getting the legacy ancient C source meta-circular Go compiler working to port the Go boot-strap compiler upgrade chain is a kick in the pants. However, once it is on a architecture it has proven rather resilient.

        2. memory limited systems can develop reliability issues, as Go programs will often ungracefully throw hard to diagnose unrelated errors during each crash. A good metric is 3:1 of your average load as a safety margin (if you see 2GiB in average RAM use, make sure to over-provision the host with 8GiB RAM etc.)

        Other than the above short list of edge cases, if you join a pure Go project it is usually pretty reliable. Most community folks interested in the language seem fairly competent at building stuff that is fun. =3

  • DreamOfXM 1 hour ago
    [flagged]
  • hostdefi_dev 2 hours ago
    [flagged]
  • kccqzy 1 hour ago
    For many people, besides learning what you should do, it is more helpful to read anti-patterns and things you should not do in Go, and none is better than this article about data race patterns in Go: https://www.uber.com/us/en/blog/data-race-patterns-in-go/