Tasks and channels is the way. You can get something that feels like programming a real preemptive concurrency model like BEAM languages or golang but with minimal overhead.
When you send a message in Erlang, nothing the recipient does with the message impacts anything on the sender side. That's good!
In principle, they could have used something like copy-on-write for this, but in practice they really just make a copy of the bytes.
Alas in Go, when you mutate what you received on a channel, you mutate the object the sender might still be holding. That's pretty annoying. It gets worse, because Golang has no way to declare something as `const` (like in C) nor that you are holding an immutable borrow (like in Rust). So you need to rely on conventions and perhaps a linter.
Slighty less of a tangent: task and channels and software transactional memory (STM) are all great. I see mutexes as more of an implementation detail that you can use to implement these higher level abstractions (but they aren't the only way).
I honestly stopped taking Go channels seriously when I found out that reading from a closed channel is indistinguishable from reading the zero value, but writing to a closed channel will panic. I don't want to have to use booleans and write `true` in order to ping another task and have it tell the difference between getting pinged or being hung up on, and I really don't want to have to architect things so that I need to have my tasks know when the other side of a channel is closed before writing to it (or catch panics instead of using regular error handling) because that feels like it defeats the whole purpose of not needing direct knowledge of the state of the other side. It genuinely seems like those design decisions basically came from a desire to use special operators on channels rather than just having `send` and `receive` methods (or worse, to intentionally avoid "tedious" error handling/optional checks), which is mind-boggling.
I hadn't even considered the implications of sending a reference over a channel, but I'll add that to the existing reasons I have to never want to touch Go again.
Historically, Go was also really weird in having a very arrogant design: the language designers allowed themselves a lot of generic operators and structures (like arrays, map, channel, the 'make' function etc), but they were considered taboo for the hoi polloi which had to make do with the equivalent of void* pointers and runtime casting (the empty interface shenanigans).
I say historically, because they got generics a while ago.
Yeah, I honestly was surprised they did end up eventually getting generics. My brief stint with it ended a while before then, but my impression at the time was that they were bizarrely insistent on trying to come up with a design from first principles rather than taking the time to understand the literal decades of designs that came before. It was impossible for me to tell the difference between their position and "we don't like Java generics and are either not aware of languages like ML or are too arrogant to read about them".
I've long pined for an ergonomic way of defining actors in Rust. I feel like there must be some way to abstract things that doesn't leak a bunch of a details into the mental model around how to think about starting/stopping/communicating between actors, but every time I've tried to figure it out (or use a solution someone else made) it ends up being way more complicated than it feels like it needs to be, and not worth it over writing a bunch of manual tasks wrapping private structs that have a bunch of channels in them. I feel like I've tried everything I could think of in terms of API design to make this work in a way that doesn't require users either having to learn a bunch of bespoke rules for the implementation or spend a lot of extra effort on manual boilerplate, including some very wacky things (like a macro where you pass a name and a function and it defines you a new macro with that name for spawning the actor task), but nothing ends up like I'd want.
Yeah the Actor model just doesn't fit well with Rust without feeling like a DSL. Tokio works well because Rust's ownership model overlays with tokio nicely. The language is already doing the hard data safety stuff for tokio, tokio just adds a lot of conveniences.
Erlang and Go make their concurrency models work because it is embedded into the fabric of the language. Neither cares about zero cost abstractions or minimal runtime (and runtime transparency). Both languages accept that there is a runtime that the developer cannot fully control as part of the deal for their concurrency models.
For Rust to have this, you have to break assumptions Rust developers have about writing Rust code. You would effectively be writing a runtime in Rust and then code that uses this Actor library would essentially run on it. But it wouldn't feel right because it would look like Rust code but feel like something else. That sort of heavy framework stuff doesn't mesh super well with Rust even if the language is capable of it.
I guess in the framing you're describing, the issue I ran into is that Tokio still just isn't anywhere close to as opinionated as a runtime as the other ones you mention. Even just taking message passing as an example, Go gives you one channel, and that's what you have to use for everything; Tokio gives you a single use channel, both a bounded and unbounded channel for an arbitrary number of senders to a single receiver, and two separate multi-producer-multi-consumer channels with different properties. I could make a bunch of opinionated choices of my own about how this should work, but at that point I'm adding a bunch of potentially leaky abstractions on top of a runtime that already isn't core to the language (and as the blog post indicates, there's a learning curve about how to get the most out of it).