> I very much like the async/await mechanism offered by C# or Python because it makes side-effects explicit.
It's also potentially faster and more efficient than goroutines, because it packs the state to be shared on context switches into what is typically a very tight structure instead of saving the entire stack.
> I think that error handling is still a subject of tension in every language and the problem is not fully solved (even in Rust, Haskell or Erlang). Time will tell.
Can you elaborate as to what the problems you see with error handling in those languages are?
It's also potentially faster and more efficient than goroutines, because it packs the state to be shared on context switches into what is typically a very tight structure instead of saving the entire stack.
I'm sure you already know this, but for anyone new to design in concurrency backends, not only does async/await (CPS) have the potential to really trounce goroutine-style concurrency in modern systems, but if you have a really smart compiler and some OS support, it can really fly. See Joe Duffy's blog post about asynchrony in Midori for more info[1].
Well it's not like the Either monad (Haskell) or erlang's {err, Reason} pattern is much better. Go's just bad at sharing details about new errors, in that interfaces are a pretty poor tool for that sort of work. At least Either has an Applicative and Monadic form, which is really nice for decoupling flow from handling.
Error handling is tricky because it requires richness both in how you talk about types and how you handle control flow.
But using GADTs and to a lesser extend F#'s discriminated unions for errors does have nice static checking properties. That's definitely an improvement over Go's "I just regex'd the string please send help" approach.
> It's also potentially faster and more efficient than goroutines, because it packs the state to be shared on context switches into what is typically a very tight structure instead of saving the entire stack.
A context switch to another goroutine doesn't need to "save the entire stack". The stack is already there. The runtime just needs to keep a pointer to the stack of the suspended goroutine. And the stack is usually just a few kilobytes.
Moreover, keeping the stack is useful for debugging because you can print a nice stack trace.
You have to keep the stack around in memory, as opposed to having a fixed structure. And fixed structures really pull away when you think about allocation: you can use a segregated fit/free list structure, whereas you can't with a variable sized thing like a stack. If the stack starts small and grows, you're paying the costs of copying and reallocation whenever it does: another loss. Allocation in a segregated fit scheme is an order of magnitude faster than traditional malloc or allocation in a nursery and tenuring.
For these reasons and others, nginx could never be as fast as it is with a goroutine model.
I agree that a fixed structure is more efficient than a growable stack, especially in terms of memory allocation. But I don't understand how you apply this to an evented server. Asynchronous callbacks are often associated to closures. But closures can vary in size, which makes hard to store them in a fixed sized structure. What am I missing?
I haven't read nginx source code, but I guess they are able to store continuations in a fixed structure because they don't use closures and they know in advance what information must be kept. I don't see how this approach can be used as a general purpose concurrency mechanism for a programming language. But I'd like to learn something :-)
This is really interesting, but I feel like Erlang is a counter example. Not sure if that's a good comparison, so I decided to ask and risk sounding stupid.
Rust error handling can be concise thanks to the try! macro, but macros bring their own problems (like making more difficult to write refactoring and static analysis tools).
Haskell error handling can be concise thanks to monads, but they need higher kinded types which bring their own share of complexity.
The conversation on the "RFC: Stabilize catch_panic", found on Rust's issue tracker, illustrate some unsettled questions I had in mind (https://github.com/rust-lang/rfcs/pull/1236).
For example, kentonv wrote:
All code can fail, because all code can have bugs. Obviously, we don't want every single function everywhere to return Result<T, E> as a way to signal arbitrary "I had a bug" failures. This is what panic is for.
graydon wrote:
Currently you've adopted a somewhat-clunky error-type with manual (macro-assisted) propagation. Some rust code uses that correctly; but much I see in the wild simply calls unwrap() and accepts that a failure there is fatal.
ArtemGr wrote:
The only way to maintain both the safety and the no-panic invariants is to remove the panics from the language whatsoever. Explicit errors on bounds check. No assertions (you should make the assertion errors a part of the function interface instead, e.g. Result). Out of memory errors returned explicitly from every heap and stack allocation.
If you'd like to keep the assertions, the smooth allocations and other goodies then you either need a way to catch the panics or end up making programs that are less reliable than C. No modern language crashes the entire program on an out-of-memory or an integer overflow, but Rust will.
The libraries we have, they do panic, it's a matter of fact. Withing the practical constraints and without some way of catching panics you can't make a reliable program that uses external crates freely.
BurntSushi wrote:
If something like catch_panic is not stabilized, what alternative would you propose? (Option<T> and Result<T, E> are insufficient.)
On the same topic, there is this post about introducing a `?` operator or a `do` notation (inspired by Haskell) to streamline error handling:
But I'm sure you're quite aware of these discussions :-)
My general feeling is that, whatever programming language you consider (Python, JavaScript/Node, Go, Rust, Haskell, Erlang, etc.), the right way to handle errors is still an open question.
> Rust error handling can be concise thanks to the try! macro, but macros bring their own problems (like making more difficult to write refactoring and static analysis tools).
No, it's not more difficult to write static analysis tools. You use libsyntax as a library. Refactoring tools, maybe, but it's a lot better than refactoring with code generation :)
> For example, kentonv wrote:
How does that describe an unsolved problem? It illustrates that Rust's bifurcation of errors into Result and panics works.
> graydon wrote:
I think it's a relatively minor issue that would be solved with "?" or something like what Swift does. Switching to Go's system would make it worse; Graydon's criticism applies even more so to Go than to Rust.
> ArtemGr wrote:
Catching panics is important, yes. No argument there. It doesn't change the overall structure of Rust's error handling story, though.
> No, it's not more difficult to write static analysis tools.
I agree it's solvable, but I'd argue it's a bit more difficult to write static analysis tools when macros are implied. But maybe I'm missing something.
Here is an example:
The subsystem types in the sdl2 crate starting in 0.8.0 are generated by a macro, so racer has issues evaluating the type of video(). A workaround is to explicitly declare the type of renderer. (source: https://github.com/phildawes/racer/issues/337)
But my real concern is how to write refactoring tools when macros are implied. It's seems a lot harder than writing static analysis tools, because the refactoring tool wants to examine the source code with macros expanded, but has to modify the source code with macros unexpanded. In other words, the tool has to map from source with expanded macros, back to source with unexpanded macros. How do you solve that?
As a sidenote, I agree that refactoring generated code doesn't sound fun too :-)
> How does that describe an unsolved problem? It illustrates that Rust's bifurcation of errors into Result and panics works.
I quoted kentonv here because it shows that Rust and Go have converged towards structurally similar solutions to error handling, by using two complementary mechanisms: explicit error checking one one hand (using Result<T,E> in Rust and using multiple return values in Go) and panic/recover on the other hand.
The big difference is that Rust have sum types (instead of using multiple return values in Go) and macros (try! instead of repeating `if err != nil { return err }` in Go).
> Catching panics is important, yes. No argument there. It doesn't change the overall structure of Rust's error handling story, though.
> a bit more difficult to write static analysis tools when macros are implied. But maybe I'm missing something.
Most Rust static analysis tools hook into the compiler and get this for free.
Racer has that problem because racer implements a rudimentary minicompiler that's much faster than Rust. When you want autocompletion, it needs to be fast. Running a full type check is a non-starter here. So you implement your own "type searcher" which is able to perform some level of inference and search for items. Being deliberately incomplete, it doesn't handle some cases; looks like macros are one of them. Since racer uses syntex handling macros would not be much harder (just run the macro visitor first; three lines of code!), but I assume it doesn't for performance reasons or something.
(disclaimer: I only have a rough idea of racer's architecture; ICBW)
> But my real concern is how to write refactoring tools when macros are implied
This is a problem with refactoring whether or not you're using tools. And like you mention there's exactly the same problem with generated code. If anything, Rust macros being hygenic are nicer here, since you can trace back where the generated code comes from and _attempt_ to refactor the source.
And macros like try do not affect refactoring tools at all; being self-contained. Its user-defined macros that mess things up.
> Most Rust static analysis tools hook into the compiler and get this for free. Racer has that problem because racer implements a rudimentary minicompiler that's much faster than Rust.
I didn't know that. Understood. Thank you for the explanation.
> And macros like try do not affect refactoring tools at all; being self-contained. Its user-defined macros that mess things up.
What do you mean by "self-contained"? How is it different from user-defined macros?
It doesn't introduce any new identifiers or anything. As far as refactoring is concerned, it's just another block with nothing interesting inside it. This is sort of highlighted by the fact that we can and do plan to add syntax sugar for try!() -- if it was a language feature it wouldn't cause refactoring issues, so why is that the case here?
User defined macros (there may be some exported library macros that do this too, but try is not one of them) may define functions or implement traits or something, which might need to be modified by your refactor, which might need fiddly modification of the macro internals.
(Also, note that due to Rust's macro hygiene, all variables defined within a macro are inaccessible in the call region, unless the identifier was passed in at the call region. This helps too)
> like making more difficult to write refactoring and static analysis tools
As one of the people behind a lot of the out-of-tree static analysis in Rust (clippy, tenacious, Servo's lints) I'd disagree. Performing static analysis across macro boundaries is easy.
The only problem Clippy has with macros is that the UX of the linting tool is muddled up at times. Clippy checks for many style issues, but sometimes the style issue is internal to the macro.
For example, if Clippy has a lint that checks for `let foo = [expression that evaluates to ()]`, it's quite possible that due to the generic nature of macros, a particular macro invocation will contain a let statement that assigns to a unit value. Now, this isn't bad, since the style violation is inside the macro, and not something the user should worry about. So we do some checking to ensure that the user is indeed responsible for the macro before emitting the lint. Note that this isn't much work either, the only hard part is remembering to insert this check on new lints if it's relevant.
But anyway, the UX of clippy is orthogonal to the static analyses provided.
(I also don't recall us ever having issues with `try!`)
> The conversation on the "RFC: Stabilize catch_panic",
FWIW most of the points are fixed with the catch and ? sugar that you mention later.
> My general feeling is that, whatever programming language you consider (Python, JavaScript/Node, Go, Rust, Haskell, Erlang, etc.), the right way to handle errors is still an open question.
Sure, however this isn't a very useful statement when comparing languages. The OP was making a relative statement; compared to C#. Saying that "all languages have problems with error handling" doesn't add much, since the question being discussed was whether Go's error handling is nicer than C#.
It's also potentially faster and more efficient than goroutines, because it packs the state to be shared on context switches into what is typically a very tight structure instead of saving the entire stack.
> I think that error handling is still a subject of tension in every language and the problem is not fully solved (even in Rust, Haskell or Erlang). Time will tell.
Can you elaborate as to what the problems you see with error handling in those languages are?