11 ms·
A million ways to die from a data race in Go
- bilbo-b-baggins 11mo ago[flagged]
- speedgoose 11mo agoI assume you are aware of "the billion dollar mistake" from Tony Hoare?
- landr0id 11mo agoFirst sentence: >I have been writing production applications in Go for a few years now. I like some aspects of Go. One aspect I do not like is how easy it is to create data races in Go. Their examples don't seem terribly convoluted to me. In fact, Uber's blog post is quite similar: https://www.uber.com/blog/data-race-patterns-in-go/ https://www.uber.com/blog/data-race-patterns-in-go/
- kryptiskt 11mo agoTo me it looks like simple, clear examples of potential issues. It's unfortunate to frame that as "crapping on Go", how are new Go programmers going to learn about the pitfalls if all discussion of them are seen as hostility? Like, rightly or wrongly, Go chose pervasive mutability and shared memory, it inevitably comes with drawbacks. Pretending they don't exist doesn't make them go away.
- bayindirh 11mo ago> Pretending they don't exist doesn't make them go away. It's generally assumed that people who defend their favorite programming language are oblivious to the problems the language has or choose to ignore these problems to cope with the language. There's another possibility: Knowing the footguns and how to avoid them well. This is generally prevalent in (Go/C/C++) vs. Rust discussions. I for one know the footguns, I know how bad it can be, and I know how to avoid them. Liking a programming language as is, operating within its safe-envelope and pushing this envelope with intent and care is not a bad thing. It's akin to saying that using a katana is bad because you can cut yourself. We know, we accept, we like the operating envelope of the languages we use. These are tools, and no tool is perfect. Using a tool knowing its modus operandi is not "pretending the problems don't exist".
- bloppe 11mo agoWhile I agree with you in principle, there is a small but important caveat about large codebases with hundreds of contributors or more. It only takes 1 bad apple to ruin the bunch. I'll always love a greenfield C project, though!
- kryptiskt 11mo ago> Using a tool knowing its modus operandi is not "pretending the problems don't exist". I said that in response to the hostility ("crap on Go") towards the article. If such articles aren't written, how will newbies learn about the pitfalls in the first place?
- bloppe 11mo agoGo famously summed up their preferred approach to shared state: > Don't communicate by sharing memory; share memory by communicating.
- dontlaugh 11mo agoWhich they then failed to follow, especially since goroutines share memory with each other.
- bayindirh 11mo agoThreads share the same memory by definition, though. When you isolate these threads from a memory PoV, they become processes. Moreover, threads are arguably useless without shared memory anyway. A thread is invoked to work on the same data structure with multiple "affectors". Coordination of these affectors is up to you. Atomics, locks, queues... The tools are many. In fact, processes are just threads which are isolated from each other, and this isolation is enforced by the processor.
- dontlaugh 11mo agoGoroutines aren't posix threads. They could've lacked shared memory by default, which could be enforced by a combination of the compiler and runtime like with Erlang.
- bloppe 11mo agoWho is "they"? This isn't Rust. It's still up to the developer to follow the advice. Anyway, I would stop short of saying "Go chose shared memory". They've always been clear that that's plan B.
- dontlaugh 11mo agoGo's creators said "Don't communicate by sharing memory", but then designed goroutines to do exactly that. It's quite hard to not share memory by accident, actually. It's not like it's a disaster, but it's certainly inconsistent.
- marhee 11mo agoConcurrent programming is hard and has many pitfalls; people are warned about this from the very, very start. If you then go about it without studying proper usage/common pitfalls and do not use (very) defensive coding practices (violated by all examples) then the main issue is just naivity. No programming language can really defend against that.
- gf000 11mo agoYou are completely dismissing language design. Also, these are minimal reproducers, the exact same mistakes can trivially happen in larger codebases across multiple files, where you wouldn't notice them immediately.
- LtWorf 11mo agoThe whole point of not using C is that such pitfalls shouldn't compile in other languages.
- littlestymaar 11mo agoDuring the short time I was working on a Go project I spent a significant amount of time debugging an issue like the one described in his first example in a library we depended on, so it's definitely not a problem of “super convoluted example”.
- __loam 11mo agoIn the first one, he complains that one character is enough to cause an issue, but the user should really have a good understanding of variable scope and the difference between assignment and instsntiation if they're writing concurrent go code. Some ides warn the user when they do this with a different color. Races with mutexes can indicate the author either doesn't understand or refuses to engage with Go's message based concurrency model. You can use mutexes but I believe a lot of these races can be properly avoided using some of the techniques discussed in the go programming language book.
- konart 11mo ago>he complains that one character is enough He complains that language design offers no way of avoiding it (in this particular case) and relies only on human or ide. Humans are not perfect and should not be a requirement to write good code.
- xlii 11mo agoWhatever the case Go's tooling (i.e. IDE part) is one of the best in class and I think it shouldn't be be dismissed in the context of some footguns that Go has.
- TheDong 11mo ago"best in class"? I feel like Java's IDE support is best in class. I feel like go is firmly below average. Like, Java has great tooling for attaching a debugger, including to running processes, and stepping through code, adding conditional breakpoints, poking through the stack at any given moment. Most Go developers seem to still be stuck in println debugging land, akin to what you get in C. The gopls language server generally takes noticeably more memory and cpu than my IDE requires for a similarly sized java project, and Go has various IDE features that work way slower (like "find implementations of this interface"). The JVM has all sorts of great knobs and features to help you understand memory usage and tune performance, while Go doesn't even have a "go build -debug" vs "go build -release" to turn on and off optimizations, so even in your fast iteration loop, go is making production builds (since that's the only option), and they also can't add any slow optimizations because that would slow down everyone's default build times. All the other sane compilers I know let you do a slower release build to get more performance. The Go compiler doesn't emit warnings, insisting that you instead run a separate tool (govet), but since it's a separate tool you now have to effectively compile the code twice just to get your compiler warnings, making it slower than if the compiler just emit warnings. Go's cgo tooling is also far from best in class, with even nodejs and ruby having better support for linking to C libraries in my opinion. Like, it's incredibly impressive that Go managed to re-invent so many wheels so well, but they managed to reach the point where things are bearable, not "best in class". I think the only two languages that achieved actually good IDE tooling are elisp and smalltalk, kinda a shame that they're both unserious languages.
- iambvk 11mo agoOnly looked at the first two examples. No language can save you when one writes bad code like that.
- deleted 11mo ago[deleted]
- unscaled 11mo agoYou can argue about how likely is code like that is, but both of these examples would result in a hard compiler error in Rust. A lot of developers without much (or any) Rust experience get the impression that the Rust Borrow checker is there to prevent memory leaks without requiring garbage collection, but that's only 10% of what it does. Most the actual pain dealing with borrow checker errors comes from it's other job: preventing data races. And it's not only Rust. The first two examples are far less likely even in modern Java or Kotlin for instance. Modern Java HTTP clients (including the standard library one) are immutable, so you cannot run into the (admittedly obvious) issue you see in the second example. And the error-prone workgroup (where a single typo can get you caught in a data race) is highly unlikely if you're using structured concurrency instead. These languages are obviously not safe against data races like Rust is, but my main gripe about Go is that it's often touted as THE language that "Gets concurrency right", while parts of its concurrency story (essentially things related to synchronization, structured concurrency and data races) are well behind other languages. It has some amazing features (like a highly optimized preemptive scheduler), but it's not the perfect language for concurrent applications it claims to be.
- questioner8216 11mo agoRust concurrency also has issues, there are many complaints about async [0], and some Rust developers point to Go as having green threads. The original author of Rust originally wanted green threads as I understand it, but Rust evolved in a different direction. As for Java, there are fibers/virtual threads now, but I know too little of them to comment on them. Go's green thread story is presumably still good, also relative to most other programming languages. Not that concurrency in Java is bad, it has some good aspects to it. [0]: An example is https://news.ycombinator.com/item?id=45898923 https://news.ycombinator.com/item?id=45898923 https://news.ycombinator.com/item?id=45903586 https://news.ycombinator.com/item?id=45903586 , both for the same article.
- deleted 11mo ago[deleted]
- questioner8216 11mo agoI dislike some of this article, my impression is similar to some of the complaints of others here. However, are Go programs not supposed to typically avoid sharing mutable data across goroutines in the first place? If only immutable messages are shared between goroutines, it should be way easier to avoid many of these issues. That is of course not always viable, for instance due to performance concerns, but in theory can be done a lot of the time. I have heard others call for making it easier to track mutability and immutability in Go, similar to what the author writes here. As for closures having explicit capture lists like in C++, I have heard some Rust developers saying they would also have liked that in Rust. It is more verbose, but can be handy.
- silisili 11mo agoGo is a weird one, because it's super easy to learn -if- you're familiar with say, C. If you're not, it still appears to be super easy to learn, but has enough pitfalls to make your day bad. I feel like much of the article falls into the latter camp. I recently worked with a 'senior' Go engineer. I asked him why he never used pointer receivers, and after explaining what that meant, he said he didn't really understand when to use asterisks or not. But hey, immutability by default is something I guess.
- badc0ffee 11mo agoHe must have been a senior in some other sense, not in Go experience.
- Someone 11mo ago> However, are Go programs not supposed to typically avoid sharing mutable data across goroutines in the first place? C programmers aren’t supposed to access pointers after freeing them, either. “Easy to do, even in clean-looking code, but you shouldn’t do it” more or less is the definition of a pitfall.
- lenkite 11mo agoThere is a LOT of demand for explicit capture clauses. This is one thing that C++ got right and Rust got wrong with all its implicit and magic behaviour. https://www.reddit.com/r/rust/comments/1odrf9s/explicit_capture_clauses/ https://www.reddit.com/r/rust/comments/1odrf9s/explicit_capt...
- someone_1234 11mo agoSomeone write similar for Erlang..
- rustystump 11mo agoOn a phone and the formatting of the snippets is unreadable with the 8 space tabs… That said, i think about all languages have their own quirks and footguns. I think people sometimes forget that tools are just that, tools. Go is remarkably easy to be productive in which is what the label on the tin can claims. It isnt “fearless concurrency” but get shit done before 5 pm because traffics a bitch on Wednesdays
- ViewTrick1002 11mo ago> Go is remarkably easy to be productive in which is what the label on the tin can claims. To feel productive in.
- logicchains 11mo agoIt feels productive because you're not waiting ages for it to compile again after every change.
- ViewTrick1002 11mo agoI would say all the boiler plate and extra typing, while the language not preventing you from shooting yourself in the foot.
- broken_broken_ 11mo agoAuthor here, thanks for the feedback on legibility, I have now just learned about the CSS `tab-size` property to control how much space tabs get rendered with. I have reduced it, should be better now.
- rustystump 11mo agoThanks, much nicer now
- truth_seeker 11mo agohttps://www.scss.tcd.ie/jeremy.jones/CS4021/lockless.pdf https://www.scss.tcd.ie/jeremy.jones/CS4021/lockless.pdf
- minus7 11mo agoAll code is inherently not concurrency-safe unless it says so. The http.Client docs mention concurrent usage is safe, but not modification. The closure compiler flag trick looks interesting though, will give this a spin on some projects.
- Mawr 11mo ago> The http.Client docs mention concurrent usage is safe, but not modification. Subtle linguistic distinctions are not what I want to see in my docs, especially if the context is concurrency.
- lenkite 11mo ago> Subtle linguistic distinctions are not what I want to see in my docs, especially if the context is concurrency. Which PL do you use then ? Because even Rust makes "Subtle linguistic distinctions" in a lot of places and also in concurrency.
- ViewTrick1002 11mo ago> Because even Rust makes "Subtle linguistic distinctions" in a lot of places and also in concurrency. Please explain
- lenkite 11mo agoRuntime borrow checking: RefCell<T> and Rc<T>. Can give other examples, but admittedly they need `unsafe` blocks. Anyways, the article author lacks basic reading skills, since he forgot to mention that the Go http doc states that only the http client transport is safe for concurrent modification. There is no "subtlety" about it. It directly says so. Concurrent "use" is not Concurrent "modification" in Go. The Go stdlib doc uses this consistently everywhere.
- aystatic 11mo ago> Runtime borrow checking: RefCell<T> and Rc<T>. Can give other examples, but admittedly they need `unsafe` blocks. Where are the “subtle linguistic distinctions”? These types do two completely different things. And neither are even capable of being used in a multithreaded context due to `!Sync` (and `!Send` for Rc and refguards)
- gethly 11mo agoEvery language has an arsenal of footguns. Go is no different. I would say that overall it is not too bad, comparatively. From all the listed cases, only the first one is easy to get caught by, even as an experienced developer. There, the IDE and syntax highlighting is of tremendous help and for general prevention. The rest is just understanding the language and having some practice.
- lenkite 11mo agoThe first one should be caught by the Go race detector AFAIK. It will warn about the conflicting write accesses to err when both goroutines run.
- deleted 11mo ago[deleted]
- p2detar 11mo agoI'm still relatively new to Go, but I've never seen closures used that often thus far in production code. Is it really a common practice?
- deleted 11mo ago[deleted]
- ad_hockey 11mo agoThat first example is an unintended closure, since the err at the top level actually has nothing to do with the errs in the goroutines. I have seen that sometimes, although the use of = rather than := normally makes it obvious that something dodgy is going on. As to whether it's a common pattern, I see closures on WaitGroups or ErrGroups quite often: workerCount := 5 var wg sync.WaitGroup wg.Add(workerCount) for range workerCount { go func() { // Do work wg.Done() }() } wg.Wait() You can avoid the closure by making the worker func take a *sync.WaitGroup and passing in &wg, but it doesn't really have any benefit over just using the closure for convenience.
- gethly 11mo ago
- euroderf 11mo agoOT: This page uses the term "Learnings" a lot. As a Murrcan in tech comms in Europe, I always corrected this to something else. But, well, is it some sort of Britishism ? Or is it some weird internet usage that is creeping into general usage ? Likewise for "Trainings". Looks weird to Murrcan eyes but maybe it's a Britishism.
- shaftoe444 11mo agoI'm English and "learnings" is one piece of corporate speak that really annoys me. It just means "lessons", for people apparently unaware that noun already exists. British corpo drones seem to need their verb/noun pairs to be identical like action/action learnings/learning trainings/training asks/ask strategising/strategy
- euroderf 11mo agolettuce not forget strategerise/strategery
- dmoy 11mo agoI gotta say, of all the corpo speak things, the whole verb/noun normalizing thing is maybe the least distasteful to me. Not that I particularly like it, but compared to all the other stuff it at least seems tolerable. The penchant for deflecting questions and not answering directly, the weasel wording done to cover your ass, the use of words to mean something totally other than the word (e.g. "I take full responsibility" meaning "I will have no personal or professional repercussions"), etc. Some of it seems like it comes out of executive coaching, some of it definitely comes out of fear of lawsuits.
- exasperaited 11mo ago"In the end I did what I believed was right" meaning "I concede I did not do the right thing but accept no blame". Mind you there are so many expressions like this and we British are masters of them, like "with the greatest of respect,", which conveys meaning slightly more severe than "you are a total fucking idiot and".
- rollulus 11mo agoTL;DR. Author with “years of experience of shipping to prod” mutates globals without a mutex and is surprised enough to write a blog.
- allcentury 11mo agoThere’s an example of a mutex too…
- scoodah 11mo agoAn example where they’re creating a new mutex every time they call a function and then surprised when multiple goroutines that called that function and got entirely different mutexes somehow couldn’t coordinate the locks together. That isn’t a core misunderstanding of Go, that’s a core misunderstanding of programming.
- deleted 11mo ago[deleted]
- pbohun 11mo agoThe first Go proverb Rob Pike listed in his talk "Go Proverbs" was, "Don't communicate by sharing memory, share memory by communicating." Go was designed from the beginning to use Tony Hoare's idea of communicating sequential processes for designing concurrent programs. However, like any professional tool, Go allows you to do the dangerous thing when you absolutely need to, but it's disappointing when people insist on using the dangerous way and then blame it on the language. https://www.youtube.com/watch?v=PAAkCSZUG1c https://www.youtube.com/watch?v=PAAkCSZUG1c
- izend 11mo agoMeaning similar to Erlang style message passing?
- pbohun 11mo agoNot quite. Erlang uses the Actor model which delivers messages asynchronously to named processes. In Go, messages are passed between goroutines via channels, which provide a synchronization mechanism (when un-buffered). The ability to synchronize allow one to setup a "rhythm" to computation that the Actor model is explicitly not designed to do. Also, note that a process must know its consumer in the Actor model, but goroutines do not need to know their consumer in the CSP model. Channels can even be passed around to other goroutines! Each have their own pros and cons. You can see some of the legends who invented different methods of concurrency here: https://www.youtube.com/watch?v=37wFVVVZlVU https://www.youtube.com/watch?v=37wFVVVZlVU There's also a nice talk Rob Pike gave that illustrated some very useful concurrency patterns that can be built using the CSP model: https://www.youtube.com/watch?v=f6kdp27TYZs https://www.youtube.com/watch?v=f6kdp27TYZs
- asa400 11mo agoIt's true that message sends with Erlang processes do not perform rendezvous synchronization (i.e., sends are nonblocking), but they can be used in a similar way by having process A send a message to process B and then blocking on a reply from process B. This is not the same as unbuffered channel blocking in Go or Clojure, but it's somewhat similar. For example, in Erlang, `receive` _is_ a blocking operation that you have to attach a timeout to if you want to unblock it. You're correct about identity/names: the "queue" part of processes (the part that is most analogous to a channel) is their mailbox, which cannot be interacted with except via message sends to a known pid. However, you can again mimic some of the channel-like functionality by sending around pids, as they are first class values, and can be sent, stored, etc. I agree with all of your points, just adding a little additional color.
- neillyons 11mo agoDoes Elixir have any footguns like this? As it is immutable I don't think any of these are possible.
- asa400 11mo agoSorry, this is going to be a slightly longer reply since this is a really interesting question to ask! Elixir (and anything that runs on the BEAM) takes an entirely different perspective on concurrency than almost everything else out there. It still has concurrency gotchas, but at worst they result in logic bugs, not violations of the memory model. Stuff like: - forgetting to update a state return value in a genserver - reusing an old conn value and/or not using the latest conn value in Plug/Phoenix - in ETS, making the assumption nothing else writes to your key after doing a read (I wrote a library to do this safely with compare-and-swap: https://github.com/ckampfe/cas) - same as the ETS example, but in a process: but doing a write after doing a read and assuming nothing else has altered the process state in the interim - leaking processes (and things like sockets/ports), either by not supervising them, monitoring them, or forgetting to shut them down, etc. This can lead to things like OOMs, etc. - deadlocking processes by getting them into a state where they each expect a reply from the other process (OTP timeouts fix this, try to always use OTP) - logical race conditions in a genserver init callback, where the process performs some action in the init that cannot complete until the init has returned, but the init has not returned yet, so you end up with a race or an invalid state - your classic resource exhaustion issues, where you have a ton of processes attempting to use some resource and that resource not being designed to be accessed by 1,000,000 things concurrently - OOMing the VM by overfilling the mailbox of a process that can't process messages fast enough Elixir doesn't really have locks in the same sense as a C-like language, so you don't really have lock lifetime issues, and Elixir datastructures cannot be modified at all (you can only return new, updated instances of them) so you can't modify them concurrently. Elixir has closures that can capture values from their environment, but since all values in Elixir are immutable, the closure can't modify values that it closes over. Elixir really is designed for this stuff down to its core, and (in my opinion) it's evident how much better Elixir's design is for this problem space than Go's is if you spend an hour with each. The tradeoff Elixir makes is that Elixir isn't really what I'd call a general purpose language. It's not amazing for CLIs, not amazing for number crunching code, not amazing for throughput-bound problems. But it is a tremendous fit for the stuff most of us are doing: web services, job pipelines, etc. Basically anything where the primary interface is a network boundary. Edited for formatting.
- ascendantlogic 11mo ago> "Go is often touted for its ease to write highly concurrent programs. However, it is also mind-boggling how many ways Go happily gives us developers to shoot ourselves in the foot." In my career I've found that if languages don't allow developers to shoot themselves (and everyone else) in the foot they're labelled toy languages or at the very least "too restrictive". But the moment you're given real power someone pulls the metaphorical trigger, blows their metaphorical foot off and then starts writing blog posts about how dangerous it is.
- xnoreq 11mo agoThough a good language would point out that what the junior (or in some cases even senior) dev is holding in their hand is in fact a gun and not a gun disguised and marketed as this nice and easy to use toy, which is especially true for Go. One must keep in mind that devs manage to implement even flawed logic that is directly reflected by the code. I'd rather not give them a non-thread safe language that provides a two letter keyword to start a concurrent thread in the same address space. Insane language design.
- kiitos 11mo ago> I have been writing production applications in Go for a few years now. sorry, what? https://gaultier.github.io/blog/a_million_ways_to_data_race_in_go.html#improper-lifetime-of-a-mutex https://gaultier.github.io/blog/a_million_ways_to_data_race_... this code is obviously wrong, fractally wrong why would you create a new PricingService for every request? what makes you think a mutex in each of those (obviously unique) PricingService values would somehow protect the (inexplicably shared) PricingInfo value?? > the fix https://gaultier.github.io/blog/a_million_ways_to_data_race_in_go.html#the-fix-2 https://gaultier.github.io/blog/a_million_ways_to_data_race_... what? this is in no way a fix to the problem. it's impossible to believe the author's claims about their experience in the language, this is just absolute beginner stuff..
- pipe01 11mo agoThat fix really confused me as well. Not only does the code behave differently from the problematic code, but why do they even need the mutex at that point?
- evanelias 11mo agoI had a similar reaction to that, glad it's not just me. Meanwhile with the 4th item, this whole example is gross, repeatedly polling a buffer every 100ms is a massive red flag. And as for the data race in that item, the idiomatic fix is to just use io.Pipe, which solves the entire problem far more cleanly than inventing a SyncWriter. The author's last comment regarding "It would also be nice if more types have a 'sync' version, e.g. SyncWriter, SyncReader, etc" probably indicates there's some fundamental confusion here about idiomatic Go.
- scoodah 11mo agoYeah this whole section of the article threw me all the way off. What even is this code? There’s so many things wrong with it, it blows my mind. About the only code example I saw in here and thought “yeah it sucks when that happens” is the accidental closure example. Accidentally shadowing something you’re trying to assign to in a branch because you need to handle an error or accidentally reassigning something can be subtle. But it’s pretty 101 go. The rest is… questionable at best.