Change the old async traits

Since we can use async fn in traits now, I am wondering maybe we need to modify the definitions of a lot traits using poll or poll_next ... etc. i.e., asyncread, stream ..., etc, requires a poll fn to be implemented, which is quite difficult. async fn will be much easier to implement.

The "core" traits want to be poll based, because polling is object safe but async fn is not. Additionally, note that async fn in traits is discouraged for public APIs at the moment for a number of reasons, notably that it's not yet possible to bound whether the resulting future is Send/Sync.

3 Likes

For poll_next and streams, we may in fact want to stick to the poll-based API -- just like we're (obviously) not going to change Future away from poll, some of these low-level APIs are just more expressive that way. Also see this blog post.

The fix to "implementing them is hard" is not necessarily to change the trait. It could also involve giving the compiler native support for writing async gen blocks, which compile to streams the same way async blocks compile to futures and gen blocks compile to iterators.

9 Likes

async gen blocks can already be emulated by using async blocks and macros, see the async-stream crate. It relies on the poll_next definition and the fact that it pins the whole state of the iterator, so it can poll the underlying Future. This wouldn't be possible with the async fn next() definition, as it doesn't pin the state of the whole iterator; in fact even an implementation in the compiler will likely have to disallow borrows across yields (this is also an open problem for gen fns). So it's effectively easier to write streams with the poll_next definition than the async fn next() one.

2 Likes

I think the first-pass classification is that if it's async fn(&mut self) -> T shaped with no parameters other than self, the implementation primitive wants to be a poll fn. This is more flexible for both completion and readiness based implementations, and a separate IntoTrait (e.g. IntoIterator, IntoFuture, or even just fn iter(&self)) bridges the gap for types which are "op-able" but don't have a stateless poll or embed the poll state machine.

For things shaped more like async fn write(&mut self, buf: &[u8]) -> Result<usize> which take extra parameters, however, I do think the async fn is the more ideal primitive. It's the difference between fn(Input) -> Poll<Output> and fn(Input) -> fn() -> Poll<Output>. It's obviously the case when Input is owned (why Sink has start_send and poll_flush), but it's also the case for borrowed Input imo.

This is because of a subtle detail for poll_write with a completion based implementation — if poll_write returns Pending, the next call to poll_write may be a completely different buffer. Thus, you either need to use a readiness model (any writing happens during poll_write) or always have poll_write "succeed" (write to an internal buffer) and poll_flush (or a separately spawned worker) do the actual writing work and produce any IO errors.

Of course, there isn't any way to have entirely buffer-free completion-based IO with the fn(&mut buf) API surface because the borrow lifetime can expire without running cleanup code to terminate the background operation. But IMO we ideally want to minimize behavioral differences between sync and async worlds, and there's a difference between BufWriter explicitly saying write hits an in memory buffer until it gets flushed and File which is supposed to be a direct wrapper around performing IO.

(This is potentially addressable in an object-safe way similar to how Sink does it — have separate "submit," "complete," and "flush" operations, where "complete" flushes transient buffering but not explicit buffering.)

Or, tbh, even just an async equivalent for iter::from_fn. I think this would require lending function (or real async closure) support to work well, but a shim around async fn next back to fn poll_next is relatively straightforward to construct (even if still unsafe due to the self-borrowing involved).

Which look like they're going to produce IntoIterator rather than directly impl Iterator? I appreciate and agree with this direction, but it makes the fact that async functions and blocks produce impl Future instead of impl IntoFuture all the more annoying of a nit-pick.

2 Likes

Can't Rust be changed to make async fn object safe? By turning the resulting future into a trait object itself

That does generally work and is what the #[async_trait] attribute does. However, this has two notable downsides: it becomes even more difficult to control Send/Sync (whatever the trait chooses is what you get, even for concrete implementations) and you don't have any control over how the object gets allocated (making it reliant on having a global dynamic allocator to use, which not all targets have, and removes the option of non-generic callers to not use dynamic dispatch).

Doing this better is what the dyn* experiment looks at. The more important part imo is that non-dynamic usage still is non-dynamic, but it also includes other niceties such as not doing dynamic allocation for pointer-sized futures (i.e. stateless futures that only capture self, i.e. just delegate back to a polling function on self) and making the (de)allocation also dynamic so the impl can choose an allocation strategy that isn't just global alloc.

I fully expect there will be an eventual solution where a trait with async fn can be directly turned into a dyn trait object. But any such solution still relies on theoretical support, and the main next target for std vocabulary is AsyncIterator, which distinctly does want to have a poll_next function which combines the effects, because AsyncIterator is not Iterator<Item=impl Future>, not even with lending iterator support. (What does it mean to partially poll a future then drop it and poll the next one?)

2 Likes