First, this post has enough misconceptions about the state of async Rust that I don't think you've used it much, or at least not recently. Second, it demonstrates a huge misunderstanding of how Rust works vs Go that I don't think you appreciate why the feature set is different.
To answer the first, tokio is compatible with Rust's standard async support, and there are alternative runtimes to tokio as well that are also compatible with std. I'm currently working on an async-heavy project, and tokio is an implementation detail and only imported when initializing the runtime loop, most of it is just standard async/await syntax. So the user doesn't need to care most of the time what async engine is running.
Tokio and other runners can be configured to be single or multithreaded, the API is designed such that it doesn't matter which you pick. I run it multi-threaded, and I use channels as well to communicate across async contexts. My code is structured very similarly to how I'd structure it in Go, it just doesn't have the same, clean syntax.
What makes it so difficult
A few things:
- Go has a massive runtime which handles everything from memory management to scheduling
- Go has very limited memory safety guarantees, and nothing to prevent you from shooting yourself in the foot with concurrent memory access
- Go was designed around goroutines and channels as first class features, Rust adds it as an option (compare regex in perl vs other languages, lists in lisp vs other languages, etc)
The first two are the most important here.
Rust has no runtime, so it can't and won't do things like provide a default async runtime or scheduler implementation. This means you'll always need to configure and start your async engine, which means it needs to be modular. It turns out Rust doesn't need a default implementation to help libraries abstract over async, it just needs a handful of traits. This also means standard libraries won't plug in to the scheduler (e.g. when a socket or file read blocks in Go, it yields to the scheduler, whereas in Rust it just blocks), so you'll need an async-aware alternative if you want to avoid blocking. With Rust, you don't pay a penalty if you don't want async, but you do in Go because it's baked in to the scheduler, standard library, and language syntax.
One final quip about Go since I have used and loved it since day 1 (convinced boss to switch once 1.0 landed and we didn't look back). For all of the features Go provides over async, there are still a lot of surprises. For example, why is map not threadsafe? The standard library bakes in async, why not make map atomic? Slices have a mostly safe interface, but they also do surprising things (e.g. you can copy over its backing array outside the bounds of the slice), and they could easily be made atomic and thus (mostly) async-safe. So Go gives you a lot of built-in features, but doesn't go far enough to make it actually safe.
And that brings me to my second point: memory safety. Rust won't let you do stupid things like write to a Vec or HashMap from multiple parallel execution contexts, whereas Go will. So while you give up certain syntax niceties, you preserve memory safety, so your code in Rust will always be memory safe even if you get lost in the syntax (which isn't that bad imo). And since everything is modular, you can replace parts as needed if you need to hit certain performance targets or something.
You can still use channels and spawn coroutines, the syntax just isn't built in. And that's my third point, Rust isn't designed around any given use case, whereas Go is. So if syntax matters more than the benefits Rust provides, use Go. But if correctness is more important and you're willing to put up with some verbosity, use Rust.