1. 64
  1. 25

    My personal opinion as a go programmer is that rust is fantastic, however there is one thing that really bothers me:

    The rust community has (in my opinion) prematurely embraced async to the point that simple tasks become very complicated. It seems like the async obsession is such that you can’t do things like make simple http requests without buying into an async runtime. I consider myself a competent programmer, but struggle at times with the extra setup and mental complexity of using async.

    Forcing users into async due to the infection of the whole ecosystem made me wonder why bother with rust over Go to begin with. I fear it will get to the point no useful libraries will exist that are not using async, and the whole ecosystem will become worse because of it.

    One of the main selling points of rust was that it isn’t supposed to require a runtime, so don’t force an async one on me by essentially starving the synchronous ecosystem of mind share.

    1. 16

      I’ve been using Rust for a long time, and I don’t think I’ve used async anything even once. Granted, my domain (low level text search) is kind of what lets me do that. But I guess that’s my point: there are entire domains where you don’t need to touch async at all.

      Plus, there are HTTP libraries in pure Rust that don’t use async at all.

      With that said, I think you have a valid point. The async ecosystem is extremely overwhelming right now, even for me. But, I’m not disheartened just yet as it’s still quite early early days for the async ecosystem. But, I don’t think Rust will ever get to the level of Go here. This is exactly where Go really shines. I mean, they built it into the language. It’s hard to compete with the user friendliness of that with a runtime-as-a-library. I think the question is whether Rust can get to that “close enough” sweet spot.

      1. 10

        My most pleasant uses of rust have been non web related cli programs dealing with POSIX, threads and other low level things.

        My most frustrating uses have always been when dipping my toes into the web ecosystem and dealing with async. I spent a while trying to find a way to avoid async to write a simple web application, but was irritated to find nearly every web framework that isn’t async has an open github issue to migrate to async, which scared me off as I didn’t want to deal with breaking changes like that.

        I then decided to look into just a rust CGI binary, however all the database drivers I saw that I wanted to use were also async.

        1. 1

          Which database are you looking to use? I know rusqlite and rust-postgres are both synchronous. I think even diesel is as well.

          1. 1

            w.r.t rust-postgres

            This crate is a lightweight wrapper over tokio-postgres.

            So I still need to deal with tokio and and a bunch of confusing dependency issues. I’m not saying they aren’t usable, I’m just saying they were more annoying to use and install than they needed to be.

            I was also bitten by two different libs depending on two different versions of tokio.

            1. 1

              Diesel is a good option then. And the postgres crate, while it’s built on top of tokio-postgres, should do what you want… you shouldn’t need to deal with tokio directly to use it.

      2. 10

        At least for me, one of Go’s best strengths is painless async. Just taking something synchronous and being able to throw it in a goroutine is super powerful.

        I’ve seen exactly what you’ve mentioned with some crates, but I also saw it early on with Go as well. My preference is for parsing packages to not come with specific transport or concurrency requirements. It’s much easier to throw something in the background than to try and force it into the foreground (at least in go).

        As I mentioned in the article, async is a huge pain point in Rust at the moment, but if the community manages to figure that out and the ecosystem stabilizes a bit, I don’t know if I’d end up using Go much any more.

        The Go ecosystem has had multiple years to improve and the Rust async ecosystem is less than a year old. I’m hopeful it will improve.

        1. 1

          It’s true that some http clients switched to async-per-default (for example reqwest), but there are still enough capable of handling sync. For me this meant changing my use reqwest::{Client,ClientBuilder} to use reqwest::blocking::{Client,ClientBuilder}. Yes it’ll create a new thread and run a single threaded tokio engine inside that, but that’s fine for how well reqwest works. Also I’d rather have them implement the logic once than twice, creating bit rot for one path.

          1. 2

            Implicit background threads aren’t the nicest thing to deal with when you want to do things like privilege separation and setuid, which is one of the reasons i would chose rust over go, better integration with the host OS.

            1. 2

              I don’t think that’s the complaint. As someone who just started writing something in Rust coming from Go, the problem seems to be that making everything async increases API complexity (and program setup) for simple things that would be perfectly served by a sync call. The converse thing in Go was when people would use channels for everything even when they weren’t needed (for a bit, using channels and a goroutine was seen as a way to implement Python-like iterators, without consideration of what really happens with those channels and goroutines if they aren’t properly consumed).

              I can see the async-craze taking over Python too, to a certain extent. It seems like everyone and their mom are writing async versions of every non-async library in the world, without first wondering if it’s worth doing.

              1. 2

                Yeah, I think rust people love to make maximally generic implementations, so when given the choice between async or not, they just say “Well, async can be called anywhere with a wrapper, so it is more generic to always choose async”. However as you said, people don’t consider the complexity or overhead in this decision.

        2. 9

          I kind of disagree on the compile errors. IMO, the compile errors in Rust generally tend to be much more clear and helpful than other compiled languages. The worst-case messages when doing complex things with generics can get pretty ugly and indecipherable, sure. I suppose it’s better to be able to do complex things with types and suffer the errors when you get it wrong then to be forbidden from doing complex things at all.

          I mostly agree on the async. I just finished updating a few of my basic projects to async/await, and I still find it confusing exactly what it’s doing. Though most of my projects are at the scale where I’d rather just run whatever it is synchronously, since they’re just simple CLI tools that don’t really do anything else. It seems like the way to work now is just sprinkling async/await everywhere like magic pixie dust, and it’ll all work somehow, but you have no idea what it’s really doing.

          Definitely agree on package management. It’s very disappointing that Go package management is still kind of a mess, mostly as a result of many projects not having really bought into the mod system yet, since Go existed for many years with either no module system or like 5 competing ones to choose from.

          Also result and option. They’re great, and so is the built-in ? pattern. IMO, Go could really stand to have some syntax sugar around the if err != nil { return err } pattern.

          I also don’t understand the complaint about switches needing to be exhaustive. You can easily set a default case with _. You just have to create it explicitly and define exactly what happens there. Seems like a good thing to me.

          I haven’t had much trouble with the borrow checker and lifetimes lately either. Maybe I’m just writing simple things, or maybe I’ve come to a more rust-y way of thinking that makes it easier to avoid those kinds of errors before they happen.

          1. 4

            The compile errors are generally good, but complex generics will make compilation errors in any language incomprehensible.

            Exhaustive switches are really a small issue for me, just a difference. I understand why they need to be exhaustive and why that can be good. That‘a why they were in the middle category.

            Async started making more sense to me once I started putting stuff in Arcs, but because everything gets moved into async blocks, it can be painful to copy everything in. Especially if the goal is to just run something like the DB in a different thread pool. You have to clone all values you need to use for lookups (and the DB handle) outside the async block then queue up the task to await on it… this can be fairly painful if you need to run multiple queries, doing this multiple times… maybe I’m missing something

            1. 2

              There’s an RFC to solve the “clone everything before moving into closure” issue:

              https://github.com/rust-lang/rfcs/issues/2407

              1. 1

                The compile errors are generally good, but complex generics will make compilation errors in any language incomprehensible.

                Agreed. Just ran into some of those when working with some Typescript. All of the generics and inference everywhere tends to make things messy.

                Async started making more sense to me once I started putting stuff in Arcs, but because everything gets moved into async blocks, it can be painful to copy everything in. Especially if the goal is to just run something like the DB in a different thread pool. You have to clone all values you need to use for lookups (and the DB handle) outside the async block then queue up the task to await on it… this can be fairly painful if you need to run multiple queries, doing this multiple times… maybe I’m missing something

                Now I’m not completely sure about this - like I said, I don’t feel like I really understand async that well, and I don’t know your DB library. But I think that async is meant to fix that exact problem. Full threads generally need things to be moved into them, because the compiler can’t prove that they end at a particular time. It should be possible to borrow when calling an async function that you’re awaiting though, because now the compiler does know that the task will end before the rest of your function.

                1. 2

                  At least with tokio, I believe when you try to use something like spawn_blocking, it requires you to use an async move block meaning you can’t pass borrows, even if you await on that spawn_blocking because it requires that the async block is 'static as well.

                  EDIT: with additional research, I’ve stumbled across block_in_place which doesn’t have the static requirement and should still pass control back. Just need to figure out how to best clean up the API and I think that will be enough of an improvement.

                  1. 1

                    Note that I was advised to not use any of the block methods from Tokio in an async/await application. Seems you’re just supposed to await a Future in an async function, and let Tokio worry about how to handle the execution.

            2. 8

              I’ve never used go, but I have to agree that Rust is a very hard language to learn.

              For me the pain points were the size of the language (lots of commonly-used data structures: Box, RefCell, Cell, Rc, Arc, …), error handling and the borrow checker.

              Over time I’ve gotten used to it, but I totally sympathize with new users and sometimes I still get caught out.

              Switches must be exhaustive.

              I think that’s a good thing. You can always wimp out using the _ pattern if need be.

              It’s been a long time since I’ve had to think about actual memory management, so having to wrap everything in an Arc (and make sure it’s owned rather than borrowed) in order to make it work with async has been frustrating.

              It’s true that Rust forces you to think about memory, but on the other hand it does give you some control over the performance trade-offs. For example, Arc has reference counting overheads, which may or may not be acceptable, depending on what you are doing.

              I pretty much agree with al of the rest, although I have not used async much, so can’t comment on that.

              1. 4

                I thought for a second you posted the same article. :)

                Generics, traits, and macros all had a similar learning curve for me.

                As a C programmer, I aways wanted C with generics and interfaces (in this case traits), without inheritance.

                Switches must be exhaustive.

                You can use _ => to make a pattern for all other cases. This feature is pretty useful for some one that works a lot with states (game development here).

                String/&str/Cow. There are places for all of these, but understanding where they fit in has taken a lot of time and experimentation.

                Agreed. Not that this is bad, but it’s not straight forward for a newcomer.

                1. 4

                  You can use _ => to make a pattern for all other cases. This feature is pretty useful for some one that works a lot with states (game development here).

                  Yep, there’s even #[non_exhaustive] which you can apply to an enum to cause an error if that default case isn’t handled for forward compatibility in libraries. At first I thought it did the opposite.

                  String/&str/Cow. There are places for all of these, but understanding where they fit in has taken a lot of time and experimentation.

                  Agreed. Not that this is bad, but it’s not straight forward for a newcomer.

                  Yeah, playing around with an IRC message type that can either be owned or borrowed (similar to Cow) was what made some of it click for me.

                  This article was also really insightful in regards to strings. https://fasterthanli.me/blog/2020/working-with-strings-in-rust/

                2. 3

                  This mentions the two usual points of difficulty: the borrow checker and the distinction between String and &str. It would be interesting to see a meta analysis of posts of this genre to see if these bother people with a C++ background less than these bother people coming from garbage-collected languages.

                  After all, if you are already writing OK C++, you need to write in ways that would satisfy the (post-NLL) borrow checker. Also, C++ also has the distinction between std::string and std::string_view.

                  1. 2

                    This is a very well written article with a balanced, fresh perspective. I liked it a great deal and learned to see things in a new way as well.

                    1. 1

                      Switches must be exhaustive. Because one of my main personal projects deals with strings, I’m often dealing with matching against them. With IRC, you only have a small number of message types you probably want to match on, but Rust enforces you to cover all cases.

                      How does this work in Go? Will Go allow you to write a match statement that only encompasses a finite number of exact strings? What happens if the value to match on is a string that isn’t in the set, does it crash?

                      Idiomatic rust would suggest creating an enum type to represent every IRC command you care about, converting the raw string to that type early on (or failing if the input string isn’t a valid IRC command), and then using that type in the rest of the code.

                      1. 1

                        Switches in Go have a ‘default’ case (which is optional / ‘noop if not specified’).

                        There are type switches too, but I don’t think you could use subtyping with string enums the way you could in rust (I could be wrong, but I’ve done quite a bit of go and have never seen that sort of technique used).

                        1. 6

                          The reason why Rust cannot have the default case is that match in Rust is an expression while it is statement in Go. That mean that it is possible to do something like

                          let foo = match value {
                            Foo => 1,
                            Bar => 2
                          }
                          

                          In Go this would need to be written:

                          var foo int
                          
                          switch value {
                          case "foo":
                            foo = 1
                          case "bar":
                            foo = 2
                          }
                          
                          1. 4

                            in Rust is an expression while it is statement in Go.

                            That’s an interesting observation. I’m about to expand it; this is mostly for my own understanding, I don’t think I’m about to write anything you don’t already realize.

                            Go’s switch constructs are imperative and each branch contains statements, which means every branch’s type is effectively Any-with-side-effects-on-the-scope.

                            Rust’s match constructs are expressions, which means (in Rust) that every match arm is also an expression, and all must have the same type T-with-no-side-effects-on-the-scope.

                            (Both languages are free to perform side effects on the world inside their match arms.)

                            Then, if I understand what you’re getting at, statement blocks have an ‘obvious’ null/default value of ‘no-op, do nothing’, which is why Go’s compiler can automatically add a default handler ‘do nothing if no match’. If the programmer know that is the wrong default action, they must explicitly specify a different one.

                            Types, on the other hand, have no notion of a default value. Which is why the Rust programmer must match exhaustively, and specify a correct value for each match arm. The compiler can’t add `_ => XXX_of_type_T’, because it cannot know what XXX should be for any type T.

                            1. 3

                              Yes, in theory it could use Default::default() if defined, but it is not defined for all values, so it would be confusing for the users. Also forcing exhaustive matching reduces amount of bugs, in the end you can always write:

                              match value {
                                1 => foo(),
                                2 => bar(),
                                _ => unreachable!()
                              }
                              

                              To tell compiler that value should be 1 or 2 and it can optimise rest assuming that this is true (and will result with meaningful error otherwise). unreachable!() returns bottom type ! which match any type (as it is meant for functions that do not return at all).

                              1. 3

                                Small nit: unreachable!() doesn’t allow for optimizations, that’s unreachable_unchecked. On the other hand, _unchecked can cause undefined behavior if your expression can actually be reached.

                      2. [Comment removed by author]

                        1. 2

                          I think you might have been looking at the other article, “I want off Mr. Golang’s Wild Ride”.

                          1. 2

                            Apologies