[HN Gopher] Three ways to think about Go channels
___________________________________________________________________
Three ways to think about Go channels
Author : ingve
Score : 141 points
Date : 2024-06-23 15:39 UTC (3 days ago)
(HTM) web link (www.dolthub.com)
(TXT) w3m dump (www.dolthub.com)
| jgrahamc wrote:
| I'll admit that I like Go channels because Hoare was the
| professor when I was doing my doctorate and so CSP was what I
| used in my thesis[1], but it's worth understanding what
| unbuffered channels give you: it's message passing with
| synchronization. They are very simple to reason about and make
| writing concurrent code a breeze.
|
| [1] https://blog.jgc.org/2024/03/the-formal-development-of-
| secur...
| bsaul wrote:
| i like the pattern, but message passing where the message is
| actually just a pointer to a mutable struct ( as allowed in go)
| kind of defeat the purpose, doesn't it ?
| superb_dev wrote:
| It kinda does yeah, you probably shouldn't do that
| adastra22 wrote:
| It's fine if that's the only (writable) pointer to the
| struct. Which is something go doesn't enforce, but Rust
| does.
|
| I often dream of Go with Rust's borrow checker. Sigh...
| jimbokun wrote:
| Wouldn't that be Rust?
| adastra22 wrote:
| Rust doesn't have a direct analogue to go's concurrency
| primitives. The pieces are there, but you have to jump
| through hoops to use them effectively.
| dgb23 wrote:
| No, it's an idiomatic way of expressing who's in charge of a
| given piece of data.
|
| There's nothing in the type system preventing data races. But
| that's orthogonal to channels. They just help you to
| structure the code.
| bjoli wrote:
| I come from the complete opposite side: I have been writing
| some concurrentML code in guile scheme recently, and it has
| really made me understand why I always disliked go's channels.
|
| There are just so many things that are just slightly wrong to
| make it unpleasant. Better than many other things, but still
| kind of frustrating. I really think unbuffered channels should
| have been the default.
| hedora wrote:
| I've worked with a few large code bases that use channels. In all
| those code bases, they were about as maintainable as GOTO-based
| control flow, except that GOTO makes you have unique label names.
| All the channel-based code I've seen just has 100's of call sites
| like "chan->send()" and "chan->recv()" sprinkled around, and
| doesn't even have the discipline to put related senders and
| receivers in the same source file.
|
| At least old-school syntax like "GOTO foobar_step_23" and "LABEL
| foobar_step_23" is grepable).
|
| I greatly prefer programs that "color" functions sync/async, and
| that use small state machines to coordinate shared state only
| when necessary.
|
| Go technically supports this, but it doesn't seem like it is
| idiomatic (unlike rust, the go compiler won't help with data
| races, and unlike C++, people don't assume that the language is a
| giant foot-gun).
| astockwell wrote:
| Go does have the `- race` flag for test + build commands [1],
| which can help (not 100% iirc, but some) with datarace
| detection.
|
| [1] https://go.dev/doc/articles/race_detector
| hot_gril wrote:
| Golang should have async/await and exceptions, both of which
| can be implemented as syntactic sugar on top of existing
| features.
| throwaway894345 wrote:
| Having used both async/await and goroutines, I much prefer
| the latter. But yes, goroutines and channels are powerful
| tools that require understanding and discipline.
| randomdata wrote:
| Go has exceptions. Always has.
| shepherdjerred wrote:
| Yes, but when writing idiomatic Go one would prefer error
| types over using panic (panic is Go's term for an
| exception).
|
| The parent is really complaining about idiomatic Go. Which
| is fair, because idiomatic Go feels like writing Java 6
| (that is to say, making lots of compromises due to
| limitations of the language).
| randomdata wrote:
| _> panic is Go 's term for an exception_
|
| No. An exception is a datatype. Not completely unlike an
| error type in concept, but intended for erroneous
| conditions that could have theoretically been caught at
| compile time (i.e. the programmer screwed up), as opposed
| to conditions that are not decidable at compile time
| (i.e. something happened in the outside world).
|
| In Go, panic _creates_ an exception, which may be the
| source of your confusion. The exception contains any
| metadata you passed to panic along with other data, like
| a stack trace. This is not panic in and of itself,
| though.
|
| _> but when writing idiomatic Go one would prefer error
| types over using panic_
|
| Not really. Idiomatic Go says that errors are in no way
| special and is faulty to think of them as being special.
| They are just values like any other. Go exceptions allow
| attaching any value as metadata.
|
| The official line is that panic stack traversal should
| not cross package boundaries, but internal use is
| perfectly acceptable. Even the Go standard library does
| it.
| hot_gril wrote:
| This is confusing, so I'll put it this way, Golang
| doesn't have exceptions the way someone coming from
| Javascript, Java, Python, ObjC... would understand:
| thrown anywhere and automatically propagated up the call
| stack unless caught.
| randomdata wrote:
| There is nothing to be confused about. The _syntax_ may
| be slightly different to other languages, but there is no
| meaningful difference in the design. If you understand
| exceptions and exception handlers in those other
| languages, you understand them in Go. There is no exactly
| a lot you can do with exceptions to see them differ in
| any big way.
|
| Why is it that Go attract so many "experts" who have
| clearly never used the language even just once?
| hot_gril wrote:
| I understand how to use them in Golang, it's just
| annoying to have to keep checking and returning errors
| everywhere.
| randomdata wrote:
| The language doesn't impose this upon you. You can use
| the exception handling system to pass values around, just
| like you might in the aforementioned languages. Even Go's
| standard library does it. Read the encoding/json source
| sometime.
|
| You might impose it upon yourself when you start to
| understand the pitfalls of using exceptional handlers for
| anything other than exceptions, but such is engineering.
| Everything comes with tradeoffs.
| hot_gril wrote:
| What's imposed on a Golang user is two choices,
| panic/recover or regular error handling, and almost
| always you take the latter. There's no option of using
| exceptions the same way you would in most other
| languages.
| randomdata wrote:
| Same as all of those other languages mentioned. What
| gives you the impression that Go is somehow magically
| different? Or have I misinterpreted you?
| shepherdjerred wrote:
| Like randomdata said, Go does have this with
| panic/recover, it's just that Go programmers (from what I
| have seen) greatly prefer Error types which do not
| automatically propagate up the stack until caught.
|
| Go _doesn't_ have any form of checked exceptions, though,
| which are required by the compiler to be caught by the
| caller.
| hot_gril wrote:
| Golang programmers indeed don't prefer panic/recover, but
| that's not comparable to exceptions in other languages.
| tsimionescu wrote:
| Panic/recover is perfectly equivalent to try/catch in
| most languages. There is some small difference related to
| exactly how un caught panics are handled (in Java, an un
| caught exception kills the thread that raised it; in Go,
| it kills the whole Go process); and the amount of built-
| in type checking for recover vs catch (but you can do
| manual typechecking and re-panic from a recover if you
| want). But otherwise they are almost perfectly
| equivalent.
|
| Of course, Go code uses panics much more sparingly than
| Java or C# or JS or Python use exceptions.
| hot_gril wrote:
| It's different syntax, less convenient, and less obvious
| what you're try-catching. Also you can't scope it to just
| part of the function. func outer() {
| defer catch() { if r := recover(); r != nil {
| fmt.Println("Recovered:", r) } }()
| // ... doSomething() // Can panic
| doSomethingElse() // Can also panic }
|
| vs JS: const outer = () => { try
| { doSomething() } catch(err) { console.log(err) }
| try { doSomethingElse() } catch(err) {
| console.error("dang it bobby", err) throw err
| } }
|
| But in any real Golang situation, you'll be working with
| libraries or teammates who return (type, error) instead
| of using panics. You might say this isn't inherent to the
| language, but it kinda is when all the built-in standard
| libraries like net/http work this way and the official
| language style guide tells you to do this.
| func outer() { r, err := doSomething() if
| err != nil { fmt.Println("Recovered:", r)
| } r, err := doSomethingElse() if err !=
| nil { fmt.Println("Recovered else:", r)
| } }
| randomdata wrote:
| _> Also you can 't scope it to just part of the
| function._
|
| Why not? func outer() {
| func() { defer catch() {
| if r := recover(); r != nil {
| fmt.Println("Recovered from doSomething:", r)
| } }() doSomething() //
| Can panic }() func() {
| defer catch() { if r := recover(); r
| != nil { fmt.Println("Recovered
| from doSomethingElse:", r) }
| }() doSomethingElse() // Can also panic
| }() }
|
| I don't get why we keep getting these "expert" comments
| from people who have never used Go before.
|
| Hell, if you long for try/catch for some reason, you can
| even get creative: func try(fn func(),
| catch func(any)) { defer func() {
| if r := recover(); r != nil {
| catch(r) } }()
| fn() } func outer() {
| try(func() { doSomething() },
| func(r any) { fmt.Println(r)
| }) try(func() {
| doSomethingElse() }, func(r any) {
| fmt.Println(r) }) }
|
| But then you soon start to realize how awful try/catch
| actually is, so...
| hot_gril wrote:
| Try-catch is awful if you're forcing it in a language
| that doesn't really support it. I provided a JS try-catch
| example above; is there something wrong with it?
|
| The recover is still function-wide in your example, which
| is what I said. You can nest funcs just to deal with
| this, but it's ugly, and code reviewers won't like it. I
| use Go on my team, and yeah I'm not as expert as the team
| who _created_ Go here, but idk why you keep saying I 've
| never used it. My complaint about "if err" is pretty
| common among Go users, who would understand the joke of
| calling it "Errlang."
| randomdata wrote:
| _> I provided a JS try-catch example above; is there
| something wrong with it?_
|
| Yes, it suffers all the same problems. And then doesn't
| even help you with the errors once you get them! You then
| have to resort to this kind of craziness to do something
| with the error: try {
| doSomething() } catch(error) { if
| (error instanceof FooError) {
| console.log('foo') } else if (error
| instanceof BarError) { console.log('bar')
| } else if (error instanceof BazError) {
| console.log('baz') } else {
| console.log('unknown') } }
|
| Which leaves you wondering why you didn't just write:
| err := doSomething() switch { case
| errors.Is(err, ErrFoo): fmt.Println("foo")
| case errors.Is(err, ErrBar):
| fmt.Println("bar") case errors.Is(err, ErrBaz):
| fmt.Println("baz") default:
| fmt.Println("unknown") }
|
| At least Java gives you multiple catch blocks to make
| things slightly more sane.
|
| But I get the impression that those who find benefit in
| passing errors using exception handlers don't handle
| errors. If you don't have to worry about errors in the
| code you write, then I think there is a good case to be
| made that errors as exceptions is a better approach.
|
| That is, after all, the difference between scripts and
| systems. Scripts can simply fail, leaving the user to try
| again. Errors as exceptions, or something in the same
| vein, make sense as the predominant mechanism in
| scripting languages. Systems, on the other, have to deal
| with failure. You don't get to just bubble it up and let
| the user deal with it. This is where errors as exceptions
| becomes a nightmare. Go is unabashedly a systems
| language. It is not meant to be a scripting language.
|
| Different tools for different jobs.
| hot_gril wrote:
| The point of exceptions is to make it convenient to
| propagate errors upwards and unlikely that you
| accidentally ignore an error, as discussed in the other
| thread. This doesn't magically do your error handling
| too, but the Golang example isn't any nicer.
|
| They aren't different jobs, though. The two most common
| uses of JS are backend systems (like you'd often use
| Golang for) and web frontends, not scripts. Backends will
| usually catch errors in one place, an HTTP or similar
| handler, sending back the appropriate status code with
| maybe a payload. Golang backends do something similar,
| which is why you see so much "if err != nil... return
| err" in practice.
|
| Python is more for scripts aka CLIs, but Python backends
| are fairly common too. And Golang CLIs are also common.
| shepherdjerred wrote:
| How are panic/recover not comparable to exceptions?
| hot_gril wrote:
| Well ok, you can draw the similarity in how they both
| automatically bubble up. But the catching behavior is
| different, and they look different. Overall they weren't
| designed for similar uses.
| tsimionescu wrote:
| A minor nitpick, but checked exceptions are a feature
| entirely unique to Java (maybe also one or two JVM
| languages?), so it really shouldn't be discussed so often
| when touching this topic.
| shepherdjerred wrote:
| That's fair! My point is that being forced to check
| exceptions can be beneficial. Go does not have any
| mechanism for this.
|
| If you want to use a Result/Optional type I think that is
| way better than exceptions. As long as the compiler can
| enforce that you are acknowledging (or explicitly
| ignoring) both the happy and error cases. Ideally the
| error case for the Result/Optional type would also have
| an attached stack trace.
| shepherdjerred wrote:
| Thank you for taking the time to reply so thoughtfully!
|
| So, if I'm understanding you correctly:
|
| An exception is an unexpected failure at runtime that
| could have been caught by the programmer. For example,
| you might have a switch block with a default block saying
| "this can never happen". The programmer might miss a case
| in the switch block leading to an exception. This would
| be analogous to an unchecked exception (RuntimeException)
| in Java -- these exceptions are not required to be
| caught.
|
| In Go, `panic` creates an instance of the type
| `exception`. When `panic` is executed it attaches
| metadata like the stack trace to the created exception.
| When I say "exception" this is the behavior that most
| people think about.
|
| An error is for an expected failure, like a file
| potentially not existing when performing I/O operations.
| Like you said (and this matches my understanding), error
| types are just a normal type -- really any type that
| implements the `Error` interface. There are a lot of
| utilities functions/libraries that act on that `Error`
| interface, like assertions for unit tests, the multierr
| library, etc.
|
| `Error` is similar to a checked exception (Exception) in
| Java, though it misses the behavior you expect like
| requiring it to be caught, and the try-catch syntax.
| Additionally, you don't have the stack trace/metadata
| automatically added.
|
| Go programmers seem to _heavily_ prefer errors over
| exceptions, even when they should be using `panic`.
|
| My problem with errors in Go are that they are too easy
| to ignore accidentally and they don't contain stack
| traces, so they can be hard to track down. I inherited a
| codebase where we had _hundreds_ of unchecked errors
| being silently ignored.
|
| I understand that these problems go away if you have
| linters or a very detail-oriented team, but I
| unfortunately have no power over the actions of my team
| before I join. It's also _very_ hard to convince a team
| that they've been doing things incorrectly by ignoring
| errors.
| randomdata wrote:
| _> My problem with errors in Go are that they are too
| easy to ignore accidentally_
|
| This is a problem for values of all types, not just
| errors. One I'm not sure we've figured out how to
| solve[1]. There are a few languages out there that force
| variable assignment to try and address the problem, but
| even then there is really nothing to say that you haven't
| accidentally ignored the variable assigned.
|
| _> It 's also _very_ hard to convince a team that
| they've been doing things incorrectly by ignoring
| errors._
|
| To be fair, if you are able to completely leave out
| entire blocks of logic without anyone noticing even under
| the most cursory of testing, perhaps it wasn't actually
| needed? Forgetting entire code branches isn't exactly a
| subtle bug.
|
| [1] Short of going all the way to formal proofs.
| hot_gril wrote:
| It's a problem for values of all types, which is why
| exceptions are usually handled specially in other
| languages. I get that it seems impure, but everything you
| call has some way to fail <1% of the time that you
| probably want to handle very differently from any other
| outcome. It's sensible for a language to force you to
| either designate a different code path for that or let it
| bubble up (edit: which, to clarify, Golang does not
| enforce with error return types, which is what the other
| person dislikes about it).
| randomdata wrote:
| I've read a lot of code in my life. I see that exception
| handlers are sometimes used to carry errors, virtually
| always exceptions, but almost never anything else. Who is
| it that you are think are passing around email addresses
| and geocooreinates using exception handlers?
|
| But even assuming it is done sometimes, is the developer
| going to actually handle it? I don't know how many times
| I've come across "catch" blocks that are empty or
| something equally inappropriate. Nothing was gained. It
| turns out that programmers will still forget no matter
| how hard you try to hold their hand.
|
| I'm not sure there is any other solution than to test it,
| and once you get into testing, entire blocks of logic
| missing are going to stick out like a sore thumb.
| Forgetting an entire branch is not exactly a subtle bug.
| At that point it really doesn't matter what language
| constructs you do or don't have available.
| shepherdjerred wrote:
| > I don't know how many times I've come across "catch"
| blocks that are empty or something equally inappropriate.
| Nothing was gained. It turns out that programmers will
| still forget no matter how hard you try to hold their
| hand.
|
| You are right, but the difference is that the programmer
| is making the choice to ignore that error versus
| accidentally ignoring the error.
| randomdata wrote:
| There was no indication that it was an active choice. I'm
| speaking to where it was clearly incorrect behaviour, not
| where one honestly wanted to ignore the condition. I'm
| assuming these programmers would write out the
| boilerplate (perhaps even automatically by some IDE
| feature) and then forgot to return to it to fill in the
| logic.
|
| And fair enough. It would be just as easy to forget to do
| that as it would be to forget to handle errors in any
| other language. There is no silver bullet here.
| hot_gril wrote:
| Adding a try{...} with an empty catch{} takes a lot more
| effort than simply forgetting to do something with an
| error return value.
| shepherdjerred wrote:
| > This is a problem for values of all types, not just
| errors. One I'm not sure we've figured out how to
| solve[1]. There are a few languages out there that force
| variable assignment to try and address the problem, but
| even then there is really nothing to say that you haven't
| accidentally ignored the variable assigned.
|
| The difference is that the happy case is well-tested. If
| you aren't handled the error-free route you probably will
| notice that very quickly unless you aren't testing your
| code, even manually.
|
| e.g. it doesn't matter if you ignore normal variable
| assignments because the programmer will usually catch
| that themselves. They will not likely catch all of the
| possible error assignments without some help.
|
| > To be fair, if you are able to completely leave out
| entire blocks of logic without anyone noticing even under
| the most cursory of testing, perhaps it wasn't actually
| needed? Forgetting entire code branches isn't exactly a
| subtle bug.
|
| In this case we were silently ignoring errors which
| caused multiple types of issues that we had to then
| manually track down. This is an entire class of defects
| that can be avoided with better tooling, either at the
| language level with checked exceptions, or with a linter
| like errcheck.
| hot_gril wrote:
| errcheck seems very sensible.
| tptacek wrote:
| Colored functions are a huge hit to take, and the things you
| do to make colored functions tolerable are also things you
| can do to mask off the complexity of channels from library
| callers. Async/await for Go seems like it would not be a win.
|
| I think exceptions are pretty much a dead letter in new
| language designs at this point? You could get people on board
| for real algebraic types and matching.
| hot_gril wrote:
| Since the boat has already sailed for Golang having
| async/await from the start, adding it now would require
| maintaining compatibility with uncolored functions. Waiting
| for a channel to have data from a go func is similar. JS
| has similar compatibility with uncolored code using
| Promises. Python's migration to async/await is a lot worse
| because it didn't have coherent parallelism features to
| begin with.
|
| Exceptions are a smaller deal than async/await. Basically
| instead of manually writing "if err" everywhere, it's
| implicit if you aren't catching it. The most popular
| languages do this. Rust "?" syntax is also a decent
| compromise.
| psanford wrote:
| Every go developer I know who likes the language disagrees
| with this sentiment (including myself). If you really want
| async/await Go is probably not the language for you. Thats
| ok! There are a lot of languages that other people like that
| I do not want to use.
|
| We don't need to have one language to rule them all. Its fine
| for languages to make different choices and trade-offs.
| AtlasBarfed wrote:
| And yet they keep adding features that golang devs scream
| in unison aren't needed, until they are added.
|
| It's a weird cult-like thing.
|
| And go is reaching the point where like Java it gets
| crammed down your throat. Which is probably why golang
| should start adding even more features
| hot_gril wrote:
| Lots of Golang users told me generics aren't needed until
| they were added. But let's leave "cult" accusations out
| of this.
| hot_gril wrote:
| Nowhere did I suggest one language should rule them all.
| Golang has advantages like efficiency and greenthreading,
| but that's not because they omitted async/await and
| exceptions. I just think that was a mistake. And I do use
| Golang on my team.
| neonsunset wrote:
| Evergreen https://www.arp242.net/go-easy.html
| no_wizard wrote:
| though Go has generics now, so there is that.
|
| Also the link to SliceTricks goes to stale content
| bruce343434 wrote:
| Kind of sick of seeing this misconception so I want to clear it
| up: async/await (as most commonly implemented) != pre-emptive
| and truly parallel multithreading.
|
| All parallel execution is asynchronous, but not all
| asynchronous execution is parallel.
|
| async/await interleaves execution in a cooperative fashion: a
| "fiber" runs until it yields by doing something blocking (such
| as blocking I/O, the most often cited use case). There are no
| data races and no need to use synchronization primitives since
| there is never a case where 2 fibers modify a piece of data at
| the same time: because execution is interleaved, the operations
| are always ordered one after the other. Whenever a fiber
| blocks, execution is returned to the scheduler, which resumes
| execution in one of the other fibers which were blocked.
|
| fork(pthread_create)/join(pthread_join) parallelizes execution:
| a "thread" runs until it is done or is killed externally (the
| scheduler can also pause execution). A thread X can wait for
| another thread Y to be done by calling join(Y). There is a need
| to use atomics or other synchronization primitives such as
| channels (or mutexes, or barriers, or semaphores) when 2
| threads share a variable in order to make sure only 1 at a time
| modifies, because although threads can be ran in an
| interleaving fashion (and sometimes they are interrupted (pre-
| empted)), they are usually ran in parallel on different CPU
| cores/threads (if you have a SMT CPU, which most nowadays are:
| n CPU cores and each CPU core has 2 "CPU threads").
|
| P.S. A CPU thread != OS thread
| hot_gril wrote:
| async/await is just syntax. There's nothing saying that two
| async calls can't run fully in parallel. "Blocking" is
| subjective too; in a way even JS is doing some parallelism.
| bruce343434 wrote:
| I covered my ass:
|
| > async/await (as most commonly implemented)
|
| Javascript is famously single threaded, which is probably
| the runtime on which most async/await code runs today.
|
| But yes, Rust for instance has the Tokio async runtime
| which is parallel, although it also has cooperative async
| runtimes.
|
| For those who are reading this and feeling unsure: Go has
| parallel execution, and it calls `fork()` `go`. Go doesn't
| feature a `join()`, for that you must use channels:
| func main() { done := make(chan bool)
| go func() { fmt.Println("goroutine running")
| time.Sleep(2 * time.Second) // simulate some work
| done <- true }() <-done
| fmt.Println("goroutine finished") }
|
| Funny thing: if you have a goroutine which is waiting to
| receive some value from a channel, but it never does this,
| and the other handles to the channel are gone, you get a
| goroutine leak! The goroutine is blocked, and
| unrecoverable/unkillable.
| kfajdsl wrote:
| I mean, the top comment wasn't talking about runtime
| characteristics, they were talking about the patterns
| resulting from using co-routines with messaging vs
| async/await.
| hot_gril wrote:
| That's right. So if Golang wanted to do async/await, it
| would probably be in a fully parallel way.
| fmbb wrote:
| Isn't a WaitGroup a more common way to do join?
|
| https://pkg.go.dev/sync#WaitGroup
| neonsunset wrote:
| Thank you, this is indeed true, though it's important to be
| aware of implementation differences - poll-based Futures that
| are "merged" into a single big Task like Rust, or
| continuation-scheduling granular hot-started Tasks like C#.
|
| e.g. in C#, the following is true: using
| var http = new HttpClient() { BaseAddress =
| new("https://news.ycombinator.com/") };
| // Runs sequentially var page1 = await
| http.GetStringAsync("?p=1"); var page2 = await
| http.GetStringAsync("?p=2"); // Submitted
| sequentially, everything after the first yield completes in
| parallel, IO does not block the caller var page3 =
| http.GetStringAsync("?p=3"); var page4 =
| http.GetStringAsync("?p=4"); // Waits for page3,
| then page4 to complete, they are effectively parallel
| DoSomething(page1, page2, await page3, await page4);
|
| But in Rust, this requires explicit join handle, and if you
| would like to parallelize/fork the execution flow - you have
| to schedule a separate task. It requires more effort but in
| return you get lower async overhead, which is critical for
| areas Rust is applied to (and host-starting Tasks or Futures
| would not be easily compatible with borrow checker I
| presume).
| sureglymop wrote:
| You said "All parallel execution is asynchronous, but not all
| asynchronous execution is parallel."
|
| But later you mentioned threads running in parallel on
| different CPU cores.
|
| The way I always understood it, is that this is the only real
| form of parallelism able to happen on a CPU. But that does
| seem kind of incompatible with your statement at the
| beginning.
| dekhn wrote:
| The way I think about it is, when Rob Pike says
| "Concurrency is not parallelism", I really rewrite it as
| "All concurrency is parallelism, but not all parallelism is
| concurrency." If you just think of concurrency as an
| instance of parallelism, you start to realize there are
| many ways to achieve concurrency, and thread-safe queues
| are one of the better ways to do it.
|
| Individual CPU cores are parallel because they are
| executing multiple instructions simultaneously (even though
| they are from the same instruction stream).
| throwaway894345 wrote:
| I'm confused about why we're comparing GOTO control flow with
| channels, since they're completely unrelated. I guess you can
| make a mess with each when you use them inappropriately? With
| GOTO, you should avoid it because there are better options
| (notably, conditional statements).
|
| Unlike GOTO, there isn't really a better alternative for
| synchronizing parallel programs. Parallel programming (or even
| concurrent programming, for that matter) is just _harder_ than
| sequential programming, and if you don 't know what you're
| doing (regardless of whether you use channels or not) you're
| going to end up with a mess. Channels help to tame parallel
| programs, but they can't compensate for programmers who don't
| know how to write correct parallel programs.
|
| And while Rust can prevent against data races, data races are a
| tiny sliver of parallel programming bugs. Far more common are
| race conditions and deadlocks, against which Rust is powerless.
|
| > I greatly prefer programs that "color" functions sync/async,
| and that use small state machines to coordinate shared state
| only when necessary.
|
| Async/await similarly doesn't solve the problem of
| undisciplined programmers making a mess. If you give
| undisciplined programmers async/await and shared state, they'll
| make a mess as easily as they will with goroutines and
| channels. If you're hiring people who can't be trusted with
| shared memory parallelism, then you have to take away
| parallelism or mutability or you have to train them to write
| correct parallel programs.
| kevindamm wrote:
| I think GP was comparing them because they are both capable
| of producing spaghetti code. But you can create spaghetti
| code with functions and modules, too (and I've seen plenty of
| examples of that). It shouldn't disqualify the entire
| technique.
| billjings wrote:
| > I'm confused about why we're comparing GOTO control flow
| with channels, since they're completely unrelated. I guess
| you can make a mess with each when you use them
| inappropriately? With GOTO, you should avoid it because there
| are better options (notably, conditional statements).
|
| Notes On Structured Concurrency goes into some depth on this
| comparison:
|
| https://vorpus.org/blog/notes-on-structured-concurrency-
| or-g...
|
| If channel sends are being used as invocations, then this
| description can apply just as well to channels as it does to
| the go statement.
| klabb3 wrote:
| > All the channel-based code I've seen just has 100's of call
| sites like "chan->send()" and "chan->recv()" sprinkled around,
| and doesn't even have the discipline to put related senders and
| receivers in the same source file.
|
| Any language that supports true multithreaded parallelism
| provides foot-rail-guns, no matter if you're locking, asyncing
| or channeling. For concurrent data structures, you can't lazily
| rely on the compiler to catch your errors. Not even in eg Rust.
| You should never sprinkle concurrent primitives just because.
|
| For every channel created, you need to determine how the
| invariants of both the channel itself and the contents that
| pass through it. This is, for the most part, an easier exercise
| than with locks, because channels provide a way to think about
| ownership, and crucially, transfer of ownership between threads
| (goroutines). It also decouples the response to an event from
| the production of it, whereas with locks you have to dispatch
| the "next action" from the code that produced the event. I've
| never had a language that's so easy to do things like "spin up
| 10 concurrent requests at a time with individual timeouts,
| cancel outstanding requests if any of them succeeds or canceled
| by the user, and ensure all tasks are torn down before
| returning". It's near-trivial to get right with channels,
| contexts and waitgroups.
|
| In either case, since concurrency is so difficult, I would say
| that the Go docs and resources are quite lightweight on how to
| use their primitives correctly. Right now, it's a bit "now
| finish the rest of the fucking owl". You need discipline to use
| them, but there's no parental figures around to tell you how to
| watch out for the dangers.
| packetlost wrote:
| This is why I think each _instance_ of a channel should be a
| global singleton as soon as they escape a local context. IMO
| you could solve this by having each _instance_ of a channel be
| a unique type that must be annotated as that type everywhere it
| 's used. Channels _are_ generic, but you should not generally
| use them generically.
|
| In Go you can use a type alias to make this work:
|
| type ExitChan <-chan nil
|
| Unfortunately it doesn't really enforce anything at the
| compiler level, but at the very least you can grep for it.
| bcrosby95 wrote:
| You may as well say variables are bad because people are bad at
| naming variables. I mean, yeah? I don't call my channels
| "chan", I call them "outputChannel" or "inputChannel" or
| "foobarProducer" or "twiddleListeners" or whatever.
| inhumantsar wrote:
| it's idiomatic in go to use short names like chan though. I
| ignore that personally and use names like yours, but it's not
| been especially common in the go code or docs I've seen.
| bcrosby95 wrote:
| Copying docs like this as idiomatic code seems problematic
| in any language. The docs have no clue what your domain is
| and couldn't give a sensible name for a variable in it.
|
| For example, lets look at "string". Most examples are gonna
| just call it a "str" or even "s". But if what you have is
| someone's name you should get called out for calling it a
| "str" rather than "name".
|
| You shouldn't name _all_ your mathy variables x, y, i, j.
|
| You shouldn't name your channels "chan" or "c". Unless its
| just generic channel code.
|
| I don't mind short domain variable names. But "chan" isn't
| that. It's like calling all your ints "num" or all your
| strings "str".
| arp242 wrote:
| > All the channel-based code I've seen just has 100's of call
| sites like "chan->send()" and "chan->recv()" sprinkled around
|
| This just sounds like badly written chaotic code. Unfortunately
| there are some people who seem to think "Go has channels,
| therefore, I need to use channels as much as possible", but
| that's almost always a mistake.
|
| Channels shouldn't be used very frequently, but they can be
| very handy especially in combination with select and/or
| buffered channels. Usually there should be a clear and
| "obvious" API.
| kccqzy wrote:
| Function coloring is orthogonal to async or not. I also greatly
| prefer function coloring, but I'm agnostic when it comes to
| either explicit async/await or sync code in green threads. For
| example, Haskell has function coloring, but it also has Go-
| style lightweight threads and sync code. It's the nicest
| experience IMO.
| mlinhares wrote:
| Almost every single usage of channels in Go i've encountered
| was a mistake, buggy and hard to understand. To me nowadays
| seeing channels in use in code i just see it as a code smell
| and think how difficult it would be to remove them.
|
| There are multiple ways to use channels wrong, the compiler
| will not help you and most of the docs and "tutorials" will not
| cover that. The best thing you can do in Go is stay as far away
| from channels as you can.
| kccqzy wrote:
| My thought is that channels isn't bad but they are too low-
| level. Sometimes they are used as a queue. Sometimes they are
| used as a future. Indeed, channels combine features like
| synchronization, signaling, and data transfer. It is this
| versatility that makes code unreadable.
|
| The worst is when people use channels simultaneously with
| mutex.
| onionisafruit wrote:
| I didn't realize why channels are part of the language vs a
| standard library feature until I read this. Now it makes sense
| that it's about compiler optimizations with goroutines.
| adeptima wrote:
| Well writen. Will recommend to all newcomers from other
| languages.
|
| As for uncovered topics or part 2 - long running Go channels can
| be a nightmare.
|
| You need to implement kind of observability for them.
|
| You must find the way to stop/run again/upgrade and even version
| payload to handle your channels/goroutine.
|
| Common problem with channels overuse is so called goroutine
| leaks. Happens more often than most devs think. Especially, if
| lib writers initiate goroutines in init() to maintain cache or do
| some background cleanup job. It's good to scan all used packages
| for such surprises.
|
| You might also find concepts like "durable execution" or
| "workflow" engines down the road.
| parhamn wrote:
| Go channel behaviors are pretty annoying. For one I always forget
| the panic scenarios (e.g. writing to a closed channel), I feel
| like the type system could've done more here.
|
| I recently wrote a simple function that maps out tasks
| concurrently, can be canceled by a context.WithCancel, or if a
| task fails. The things that cancel the task mapper need to
| coordinate very carefully on both sides of the channel so that
| they're closed and publishers stop sending in the right sequence.
| The amount of switches/cancels/signals quickly explode around the
| coordination if you too cute on how to do it (e.g. read from the
| error channel to stop the work).
|
| Frankly I'm not sure I still got it right [1]. And this is
| probably the most unsettling part. Rereading the code I can't
| possibly remember the cancellation semantics and ordering of the
| short mess I created. Now I'm wondering if mutexes would've made
| for more understandable code.
|
| [1]
| https://gist.github.com/pnegahdar/1783f0a4e03dc9a3da43478994...
| iimblack wrote:
| Might have simplified this with errgroup [0].
|
| [0] https://pkg.go.dev/golang.org/x/sync/errgroup
| jerf wrote:
| Writing to a closed channel at all is generally a design smell.
| Generally this is consumers closing channels, which is not a
| good idea. Only producers should close channels, generally only
| a producer that is the sole owner of the channel, and then,
| being the sole owner, it should "know" that it closed the
| channel and by its structure never write to it again. You
| probably are overcomplicating matters and need just one top-
| level channel, which in modern Go is the one contained in a
| context.Context, to be the one and only stop signal in the
| system.
|
| I think everyone goes through a bit of a complication phase
| with channels, I recognize your issues in code I've written
| myself, this is definitely not a "only a bad person would have
| this problem" post. But, yes, there probably is an organization
| that solves this problem. There's an art to using them
| properly. A good 50% of that art may well be that consumers
| should never close channels.
|
| (Another one is the utility of sending a channel as part of the
| message in a different channel. It is intuitively easy to think
| that channels must be very expensive, but when they're not
| participating in a select, they're just a handful of machine
| words in RAM. It is perfectly sensible to send a message to a
| "server" process that contains the channel in it to send the
| reply to, because it only costs a few machine words in
| allocation and precisely the one sync operation it will ever
| participate in. Channels do not have to amortize their costs
| with lots of messages; a 1-message channel is practical. This
| also cleaned up some complicated code I had before, trying to
| prematurely optimize something that was already very cheap.)
|
| The other thing is, if you haven't looked at
| https://pkg.go.dev/golang.org/x/sync/errgroup , you may want
| to. golang.org/x/ is the not-as-well-known-as-it-should-be
| extended standard library; things the Go team are not willing
| to put into the 1.0 backwards compatibility promise so they
| retain the ability to change things if necessary, but otherwise
| _de facto_ as high quality as the standard library, modulo some
| reasonably well-labeled exceptions. Contra some claims that it
| is impossible to abstract in Go, many of these common
| concurrency patterns have been abstracted out and you can and
| should grab them off the shelf.
| parhamn wrote:
| x/sync is great. I use things like singlelfight frequently.
|
| > Generally this is consumers closing channels, which is not
| a good idea.
|
| I've heard this too. I updated my comment to include the code
| snippet. But any general tips on when the consumer needs to
| cancel the rest of the work because of an error?
|
| I don't recall now but I think I ended up writing my own for
| fast-fail and generic support. Really hoping the x/sync
| packages get generic support soon!
|
| > a 1-message channel is practical
|
| Great tip, every time I add a little signal one I feel like I
| did something wrong, maybe that is the move.
| jerf wrote:
| "But any general tips on when the consumer needs to cancel
| the rest of the work because of an error?"
|
| Yes, another channel which everyone watches for
| cancellation.
|
| In modern Go, that should be a context and it's .Done()
| channel.
|
| This is another "I'm not saying you suck because I did this
| myself a while before I figured it out", but having a
| channel around just to cancel things is fine. It doesn't
| cost anything significantly extra. I have some still in my
| code bases where I've just never had any reason to upgrade
| to contexts.
| jrockway wrote:
| People are missing a key insight about channels, which is
| that close is a write. Typically, you want one piece of code
| reading, and another piece of code writing, and that's where
| their code goes wrong. A panic is appropriate in this case,
| as it's always a bug.
|
| The underlying feature that people are looking for is a TCP-
| like "either side can kill the other side" feature. This is
| what contexts are. func read(ctx
| context.Context, c <-chan any) { for {
| select { case <-ctx.Done():
| return case x, ok := <-c:
| if ok { fmt.Println(x)
| } else { return }
| } } } func write(ctx
| context.Context, c chan<- any) { for i := 0; i <
| 10; i++ { select { case
| <-ctx.Done(): close(c)
| return case c <- i: }
| } }
|
| In this example, the type system prevents the read function
| from writing to the channel (avoiding the panic in the writer
| if the reader were to close the channel), and the context
| causes both sides to exit cleanly. You can plumb in the
| cancel function returned from
| context.WithCancel/context.WithCancelCause to allow the
| reader to kill the writer. (Incidentally, the reason you have
| to do this is because context.Done() is type <-chan, not type
| chan, so you can't just close(ctx.Done()).)
|
| The writer can still kill the reader by closing the channel,
| because closes are writes.
| akira2501 wrote:
| More importantly close is a _broadcasting_ write. Which is
| an incredibly useful feature when you want to build nested
| structures out of your channel paths.
| jrockway wrote:
| Yup! This is exactly how context.WithCancel is
| implemented; <-ctx.Done() is the signal that everything
| that cares about cancellation listens on. The returned
| CancelFunc just does `close(thatChannel)`.
|
| I prefer explicitly using contexts for cancellation, but
| in a pinch: c := make(chan struct{})
| foo(c) bar(c) close(c)
|
| is a useful pattern. (I do this a lot in tests that
| involve concurrency; the test calls t.Cleanup(func() {
| close(c) }) and then if the test fails with t.Fatal or
| something, all background work is killed.)
| dlisboa wrote:
| Not speaking on whether the language should make this easier
| without an external library, but wouldn't
| https://github.com/sourcegraph/conc help in that scenario? It
| has context-aware and error-aware goroutine pools, seems like
| the exact fit for what you are trying to do. Although
| admittedly I dive too deep into your code.
| tapirl wrote:
| > One literal interpretation of Pike's quote is that message
| passing is different than sharing memory for pedantic reasons.
| Copying values between sender/receiver stacks is safer than
| sharing memory. But it is also possible to send values with
| pointers into channels, so that doesn't really prevent developers
| from abusing the model. I don't think avoiding shared memory is a
| top of mind consideration for developers deciding whether to use
| channels.
|
| I think the point of Pike's quote is that, when a goroutine gets
| a pointer received from a channel, it gets the ownership of the
| values referenced by the pointer and other goroutines give up the
| ownership. This is a discipline Go programmers should hold but
| not a rule enforced by the language.
| jerf wrote:
| As I like to say, Rust may be one of the only languages that
| has built ownership right into its type system, but the
| problems that "creates" with programming in Rust are actually
| problems _revealed_ by programming in Rust, not created. The
| ownership problems are 100% there in other languages too, it
| just isn 't a compiler error. It can help everyone, in any
| threaded language, to be thinking like Rust, even if the
| compiler and type system do not directly help you.
|
| A simple and useful degenerate case of this is that whenever
| any message is sent, complete ownership of the entire
| transitively-reachable set of values contained in that message
| is by default transferred to the receiver. This is worthwhile
| even if you must explicitly construct some safe value to pass
| in order to maintain this promise. This keeps things generally
| easy to reason about without the full complication and power
| that Rust offers. At the very least, whenever I violate this, I
| have lots of comments about what and why on both sides of that
| transaction in my code base.
|
| (I do sort of wish there was a variant of a "go" statement that
| I could use that made me statically promise that all
| communication in and out of a given goroutine must be solely in
| the form of copied values, so I could guarantee that goroutine
| was an "actor". This is, admittedly, just me wishing very pie-
| in-the-sky. I doubt it could be turned into a practical
| proposal.)
| tick_tock_tick wrote:
| To be technical Rust "creates" a lot of errors that aren't
| their because it doesn't, and can't, have full understanding
| of the control flow. Well it's less create and more complains
| about non issues. It why overtime code that used to be
| invalid Rust has become allowed as they've improved the
| borrow checker.
| withoutboats3 wrote:
| This remark isn't relevant to the subject under discussion.
|
| You're talking about the fact that Rust's lifetime analysis
| was initially a simple lexical check, and has evolved
| toward properly understanding control flow beyond just
| lexical scope (e.g. it is aware that both branches of an
| if/else cannot be taken).
|
| This has nothing to do with ownership, a completely
| separate part of the type system from lifetime analysis,
| and how it prevents data races by controlling the sharing
| of data between concurrent processes, which is what this
| discussion is about.
| samatman wrote:
| > _the problems that "creates" with programming in Rust are
| actually problems revealed by programming in Rust, not
| created._
|
| I would dearly like to see Rust advocates be more careful
| when they talk about this aspect of their beloved language.
|
| The ownership semantics of safe Rust programs prevent
| _programs_ from being written, and in the process, eliminate
| entire classes of bug. But they also prevent a literally
| infinite number of correct programs from being written,
| without resorting to the unsafe escape hatch.
|
| Sometimes that's a good tradeoff. Often you can design your
| program around those restrictions and enjoy the benefits they
| bring. But what you said is much too strong a claim!
| Ownership semantics prevent or inhibit a great deal of simple
| patterns which are in fact possible to implement correctly.
| The doctrine that every difficulty a programmer runs into
| trying to color inside the lines of safe Rust is a case of
| Rust preventing them from doing something wrong, is simply
| incorrect.
|
| Rust's memory model is highly opinionated, and in fact,
| restrictive. The pitch, and it's a strong one, is that the
| benefits of working within that model are worth learning how
| to work within those restrictions. But it's also very clear
| that ownership semantics create problems as well as prevent
| them. Telling people that the tradeoffs are worth it is good
| advocacy. Pretending there are no tradeoffs is not.
| jerf wrote:
| Yeah, I feel like it's generally extraneous to my main
| point to observe that Rust isn't perfect at it, and it can
| be wrong, in consequential ways.
|
| My main point is, the general concept of ownership and
| having to care about it is present everywhere, though. If
| you prefer to say that it _reveals_ it but _imperfectly_ ,
| I'm down with that. But I think it's important to
| understand it isn't entirely 100% responsible for creating
| it. Every shared-state threaded language has the issues, it
| just doesn't have a type system and compiler (imperfectly)
| helping you with them. It's not a license to write Go or
| any other language while oblivious to ownership issues just
| because the compiler won't complain.
| samatman wrote:
| My point wasn't actually that Rust's ownership model
| could be improved. While that's also true, features like
| being able to take disjoint mutable borrows of two fields
| of a struct are generally agreed to be good things to
| have, and I expect the language team to solve them
| eventually.
|
| It's simpler than that: Rust prevents you from doing a
| lot of correct things in the safe subset of the language.
| It does it for a good reason, because that lets the
| compiler prove a bunch of nice properties, but it's a
| fundamental tradeoff: in exchange for not having to deal
| with bugs in shared mutable access to data, it doesn't
| let you write programs that way. I actually think that
| Rust is a great choice for multithreaded programs which
| want to follow a one-writer-many-readers pattern, because
| that's very difficult to do correctly without the borrow
| checker.
|
| But when you say this:
|
| > _The ownership problems are 100% there in other
| languages too, it just isn 't a compiler error._
|
| A straightforward read of that is that anything you can't
| do in safe Rust is a bug. That's very far from true, it's
| in fact backward: what you can do in safe Rust _isn 't_
| an ownership bug, by construction. But I see this
| implication reversed in a lot of Rust advocacy, and it
| directly contributes to the Rust fatigue which you'll see
| on this very website, and many other places.
|
| It's the difference between prohibiting potentially
| unsound ownership policies (what Rust does) and
| prohibiting _only_ unsound ownership policies (which safe
| Rust does not).
|
| > _My main point is, the general concept of ownership and
| having to care about it is present everywhere_
|
| I completely agree with this, fwiw.
| cyberax wrote:
| I must admit, I dislike Go channels. They are a hell to debug, as
| they are unnamed. And they're anonymous, so you can easily get a
| stacktrace filled with thousands of identical stacktraces that
| you can't correlate with logs.
|
| Golang needs to have a way to manage the channels better. Naming
| them and waiting on them would simplify a lot of crusty stuff.
| Naming is becoming possible, goroutines can already have pprof
| labels (that are even inherited between goroutines!), so just
| adding pprof labels to stacktraces will help a lot.
|
| But unfortunately, Go creators are allergic to anything that
| brings thread-local variables closer.
| tombert wrote:
| This almost kind of feels like you're reinventing the Actor
| model (e.g. Erlang/Elixir).
| r14c wrote:
| go concurrency is built on a similar concept (concurrent
| sequential processes), if I'm not mistaken. the languages
| aren't very far apart in this regard to start with!
| tombert wrote:
| Yeah, I'm actually pretty familiar with CSP (I'm getting a
| degree in a related topic using a timed variant of it),
| though Go breaks from the formal CSP defintion language in
| a few ways (if nothing else vanilla CSP doesn't officially
| allow buffered channels).
|
| I do find the Actor model a bit nicer at an engineering
| level, simply because I find being able to refer to things
| by name to be a bit more intuitive, and since Actors (at
| least in Erlang) can manage their own state it's a lot
| easier to kind of shape them into doing anything you want
| (e.g. buffered or non-buffered). I'm much less familiar
| with the formal definitions of Actors though.
| rdtsc wrote:
| > They are a hell to debug, as they are unnamed. And they're
| anonymous
|
| That was one odd thing that stood out to me about Go. I am
| coming from Erlang so a process having process ID we can keep
| track of, terminate it, trace, etc, is fundamental to be able
| to reason about and operate a system. Advertising that they can
| handle millions of lightweight goroutines but then having no
| obvious way to identify them, and monitor their lifetimes is
| kind of strange.
|
| On the lower level, I sort of understand why they did it: they
| focused on typed channels. So they can have multiple channels
| of different types potentially talking to the same goroutine.
| Now, having both named goroutines and channels would be more
| complicated, and they tried to keep things "simple". Erlang's
| processes on the other hand, have implicit mailboxes, there is
| no "mailbox1" and "mailbox2" there is only one, but it's also
| easier there because there is no static typing. There things
| are simple because only processes have identities but not
| mailboxes.
| jimbokun wrote:
| So Erlang unifies both the gorountine and the channel into a
| single construct with an identifier?
| rdtsc wrote:
| We could say that yeah. It's not unlike an os process with
| a pid. You can monitor the process using a pid, you can
| kill it, you can trace it, gets it stack trace etc. But
| unlike an OS process it also has a mailbox associated with
| it, so you can send it message and it can receive and
| pattern match on them.
| vineyardmike wrote:
| With the advent of generics, there's no reason we can't get
| libraries that wrap common objects.
|
| As a thought experiment, I could see Optionals, Named Channels,
| Collections etc being implemented in some common but non-stdlib
| library. Of course now we've recreated all those languages
| people hate when they talk about go... but those features exist
| for a reason.
| tptacek wrote:
| I don't think you're getting useful optionals without
| matching.
| tazu wrote:
| > But unfortunately, Go creators are allergic to anything that
| brings thread-local variables closer.
|
| This is the most frustrating thing about Go for me. They use
| thread-local storage within the stdlib, but refuse to let us
| plebs have access to it.
| neonsunset wrote:
| May I offer you fast thread-locals in C# in these trying
| times?
| jrockway wrote:
| Stacks printed by the receiver are generally incomplete/useless
| for systems that communicate. The error is often in the sender,
| but is reported by the receiver. This isn't unique to Go and
| channels.
|
| Consider a Node app that calls a C++ web service. The C++ web
| service returns "503 Service Unavailable" because your request
| causes the server to segfault. The useful stack trace for
| fixing this problem is on the C++ side in a core dump, not
| whatever Node prints when the HTTP request fails.)
|
| What you want to do on the Go side is have your sender be able
| to relay an error to the receiver, and to wrap errors:
| func doWork(w work) (result, error) { return
| errors.New("too lazy to do work") } func
| doWorkQueue(q <-chan work, r chan<- result) { for w
| := range q { result, err := doWork(w)
| if err != nil { r <-
| errorResult(errors.Wrapf(err, "doWork(workID=%v)", w.ID))
| continue } r <- successResult(w,
| result) } } ... go
| doWorkQueue(...) submitWork(work) for r :=
| range resultCh { if err := r.Err(); err != nil {
| log.Error("failed to do work: %v", errors.Wrap(err, "recv
| result")) } }
|
| This now errors with something like:
| main.go:20: failed to do work: main.go:20: recv
| result: main.go:9: doWork(workID=42):
| main.go:2: too lazy to do work
|
| Or if your `errors` library doesn't grab the caller func/line
| number, just "failed to do work: recv result:
| doWork(workID=42): too lazy to do work". This should be enough
| to debug the problem, regardless of what side the problem
| happens on.
|
| (At work we use a hacked up copy of github.com/pkg/errors,
| which grabs the entire stack trace at each of the
| errors.Wrap/errors.New call sites. This results in an
| exceedingly verbose trace that takes up your entire screen, but
| is at the very least... thorough.)
|
| The reason that error wrapping is essential is because stack
| traces don't capture critical information, like what iteration
| of the loop you're on, how many times your retry loop ran,
| which work id failed and returned "too lazy to do work", etc.
| The error wrapping is where you get to add this in. This is,
| again, the same as every language... the random exception you
| throw in your HTTP client when the server is down isn't aware
| of the work item id that caused this exception, so you have to
| catch and re-throw with that information or you have an
| undebuggable mess.
|
| What's nice about Go is that it's really easy to add this
| contextual information, either with fmt.Errorf in the standard
| library, or with very small functions that capture some
| information automatically (errors.Wrap/errors.Wrapf). I will
| say that not doing this is my #1 complaint, and it's
| exceedingly common to just "return err". I have spent 4 years
| fixing the work codebase to wrap errors, and people still add
| new unwrapped errors (because our linter allows an escape
| hatch, errors.EnsureStack, which some people really like). It
| then results in some oncall engineer wasting a week debugging a
| simple problem. Sigh! But that's humans being humans; Go makes
| it very easy to do the right thing. You just have to tell your
| team to do the right thing and to make them want the right
| thing.
| cyberax wrote:
| Of course stack traces are a silver bullet. However, it helps
| to be able to correlate stacks to requests from logs. It's
| especially helpful when you're debugging deadlocks.
| liampulles wrote:
| As an avid Go user, I think async/await is probably a nicer
| construct for most usecases. But Go channels work fine as long as
| you keep to the basics and documented patterns.
|
| I can recommend making a utility function which accepts a set of
| anonymous functions and a concurrency factor. I've since extended
| this function with a version which accepts a rate limiter, jitter
| factor, and retry count. This handles most cases where I need
| concurrency (batches) in a simple and safe way.
| adastra22 wrote:
| I guess the grass is always greener on the other side. As a
| Rust user, I'm constantly thinking "why didn't they just give
| us go channels instead of this crazy async/await nonsense?"
| steveklabnik wrote:
| Rust has one form of channels in the standard library. More
| kinds available from crates.io. If you prefer channels, use
| them!
| adastra22 wrote:
| Because one crate uses one channel implementation, and
| another crate uses a different one... this is really
| something that should be solved at the language / stdlib
| level, as Go does, but Rust falls a little bit short. There
| mpmc and the Send trait, but they alone are insufficient.
| So instead we get crate dependency hell that is only
| somewhat mitigated by cargo, and colored functions due to
| the async implementation.
| brianolson wrote:
| Rust async/await is less nice than Go coroutines. There are
| things you can't do and weird rules around Rust async code.
| Every Go chan allows multiple readers and multiple writers, but
| Rust stdlib and tokio default to single-reader queues.
| initplus wrote:
| Channels and async/await aren't really equivalent features.
| Beyond the fact that they both deal with concurrency.
|
| You can do channels (message passing) on top of async await.
| doawoo wrote:
| Y'all should really give Erlang/Elixir a try... this stuff is so
| much more trivial to deal with in that ecosystem and it pains me
| that it doesn't get as much attention as Go does.
| tail_exchange wrote:
| I am curious about Elixir, but I really can't bring myself to
| spend time mastering a language with dynamic types. You are
| removing one class of errors from your application, but at the
| same time inviting a new one that has been solved decades ago.
|
| I'm aware that this is a very controversial take, because lots
| of people love duck typed languages, but after working in large
| codebases, they are a hard pass for me. There's a reason why
| TypeScript was created for JavaScript, Sorbet was created for
| Ruby, and type hints is so popular with Python. I think I've
| seen something about gradual typing being introduced in Elixir,
| but gradual typing is still a long ways from enforcing type
| safety. Until then, I'll stick with Go.
|
| If I am mistaken and there is a "TypeScript for Elixir", then I
| would love to know about it.
| innocentoldguy wrote:
| I've worked with strongly typed languages and duck typed
| languages. I've also worked a lot with Go and Elixir. In my
| opinion, the tradeoff between the elegance of Elixir's actor-
| based functional style vs. Go's warty imperative style is
| more compelling than static typing vs. duck typing.
|
| I love working with Elixir. Go is something I tolerate.
| tail_exchange wrote:
| That's a fair trade if you don't like Go, but I love
| working with it, so for me it has to be something more
| compelling than trading safe types for actor-based
| functional style. Go's imperativeness never bothered me.
| innocentoldguy wrote:
| I agree. Having worked a lot with both, I think Elixir is by
| far the better language, especially for web programming.
| tptacek wrote:
| They're very different languages and have different deployment
| dynamics. Elixir is a higher-level language. Other languages
| are lower-level. People will always be frustrated to see a
| language "shoehorned" into a higher- or lower- level setting
| where their preferred language might fit. We're an
| Elixir/Go/Rust shop. You could not use Rust and Go
| interchangeably for what we use them for, and you couldn't use
| Elixir almost anywhere we use Go.
___________________________________________________________________
(page generated 2024-06-26 23:02 UTC)