3 ms·
Terrific post. I think several of the more disagreeable commenters here are engaging with it piecemeal and projecting stronger opinions onto it than it actually
by zbentley 3y ago
Terrific post. I think several of the more disagreeable commenters here are engaging with it piecemeal and projecting stronger opinions onto it than it actually has. I interpreted it as more of a review/analysis/set of musings by a qualified expert in an area than a bunch of decisive hot takes.
It definitely makes me wonder if the original sin of Rust and many of the other colored-function-based concurrency systems was limiting the enforcement of color-can-only-call-color to only async functions. Would a language that disallows calls back from async functions into blocking functions make sense? Probably not. But what if we split "blocking" into two categories: functions that provably blocked for constant time (or even constant-less-than-X time; simple systems like BPF can verify that already, so I'd assume it's possible to get more nuanced here with effort) versus functions that did not (including I/O, while-true-with-non-statically-verifiable-condition)? If we disallowed calls from async functions into functions of the latter category but not the former, would that be ergonomic/accessible for programmers?
(A minor quibble/criticism of an otherwise great post: I think the discussion of "blocking functions" should get a crisp definition up front, qualifying that "blocking" in this case means undesirably long or potentially indeterminate blocking of executor threads, so readers don't get confused and wonder whether the implication is that any synchronous function call is inherently--rather than just potentially--toxic to good async behavior).
Continuing to think about the idea of "async code can't call 'bad blocking' code": I suspect that one of the reasons this isn't widespread is that it really does require compiler/runtime validation that code down a call stack is good/bad. Erlang almost got there, but (just like Boats points out happened with Rust) the C FFI chimera stopped them in the home stretch and they settled for asking users to tag dirty vs. clean NIFs. But maybe compiler awareness of call path blocking risk is advanced enough today that such a system could be implemented? It definitely wouldn't be simple (...he said, as if he were anything but a rank amateur when it came to understanding compilers). It definitely would solve the mutex-held-across-await-point class of problems.
Two other points that stood out to me that I thought were worth mentioning:
1. The rejoinder to the blocking-reqwest-vs-ureq situation is well put. Honestly, Boats could have gone further here: I think that a large proportion of the complaints/reluctance around using synchronous APIs that wrap block_on or whatever are fundamentally aesthetic/emotional objections arising from people's bad vibes around including or separately instantiating an async runtime inside a library just to provide a blocking API. In practice, the compile overhead is rarely severe and nearly always paid extremely infrequently, and the runtime overhead in time/memory is negligible in release builds. Supporting the emotional resistance here is, I think, a lot of user ignorance around the practical differences between, say, how a current_thread Tokio entry point runs Rust futures and how a libuv loop or a boost::asio context or whatnot run futures in JS/C++/etc. While Rust and Tokio aren't magic (and while I would love more crates to be async-to-syncable with pollster alone), I think that folks often project assumptions about heaviness/performance from other systems onto parts of the Rust ecosystem where they don't hold as true.
2. The Golang callout is fair (and was hopefully cathartic to write), but in addition to being punchy I think there's a lesson to be learned there, albeit a bitter one: Go is widely considered to have wildly succeeded at/"solved" the ergonomics of concurrent colored-function programming for a bunch of technical reasons that Boats calls out ("this is for network servers, C interop's not priority 1", growable stacks, GC, nonzero cost scheduler, etc.), but I think it's also important to note that the problem was addressed to completion in the initial version of the language. Rust's async functionality was not; it was added later. And while it was added carefully, I think that the fact that it was not part of the first coat of paint inevitably increases the severity of some of its pain points, technical constraints aside. I think the lesson that features in the first draft are often the most beloved/powerful holds true in other ways here: consider the trait system and borrow checker, two of the most important features of Rust that were present in v0 (or at least very early on? I don't honestly know) compared to Go's slow and acrimonious adoption of generics in 1.18. I guess another way of putting the lesson is "get it right the first time", alternatively phrased as "hindsight is 20/20". Or maybe I'm extrapolating patterns from unrelated technical phenomena, who knows?
- zozbot234 3y ago> was addressed to completion in the initial version of the language. Rust's async functionality was not; it was added later. Rust async support is explicitly not complete. They've got an entire working group that's focused on improving it as per https://rust-lang.github.io/wg-async/vision/roadmap.html https://rust-lang.github.io/wg-async/vision/roadmap.html . > consider the trait system and borrow checker, two of the most important features of Rust that were present in v0 This is quite wrong in fact, early versions of Rust were very Go-like, with a focus on green threads and GC. Even Rust 1.0, released in 2015, had a very restrictive borrow checker compared to what's available today. Rust's editions system has allowed for a whole lot of pretty seamless evolution.
- withoutboats3 3y agoAll great points. Re async not being there from day one, this is indeed a big problem. it resulted in certain painful technical compromises (eg Pin) & probably at least ans importantly resulted in cultural / educational issues. The narrative around concurrency in rust initially centered how ownership & borrowing solve data races and it’s hard to see how stackless coroutines fit into that. To many users it clearly feels like an alien invasion.
- zozbot234 3y agoPin is painful but we're still quite not sure how the alternative (probably involving some ?Move trait) would have worked (or, for that matter, how it could work in some future edition). How do you create a !Move object when we don't even have construct-in-place in current Rust?
- withoutboats3 3y agoThe definition of !Move was always the (rather baroque) "cannot be moved once its address has been taken;" this allowed you to construct them, allocate them, etc until you start manipulating them. Pin just took that contract and instead expressed it by having the value that is "the address of the !Move type" encode it. The questions with adding Move were strictly around backward compatibility & useability, not with determining the semantics.