Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Explicit task::Context argument to poll #2

Merged
merged 4 commits into from Feb 12, 2018
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
195 changes: 195 additions & 0 deletions task-context.md
@@ -0,0 +1,195 @@
- Feature Name: task-context
- Start Date: 2018-01-22
- RFC PR:
- Rust Issue:

# Summary
[summary]: #summary

Learnability, reasoning, debuggability and `no_std`-compatibility improvements achieved by
making task wakeup handles into explicit arguments.

# Motivation
[motivation]: #motivation

## Learnability

The `futures` task wakeup mechanism is [a regular source of confusion for new
users][explicit task issue]. Looking at the signature of `Future::poll`, it's
hard to tell how `Future`s work:

```rust
fn poll(&mut self) -> Poll<Self::Item, Self::Error>;
```

`poll` takes no arguments, and returns either `Ready`, `NotReady`, or an error.
From the signature, it looks as though the only way to complete a `Future` is
to continually call `poll` in a loop until it returns `Ready`. This is
incorrect.

Furthermore, when implementing `Future`, it's necessary to schedule a wakeup
when you return `NotReady`, or else you might never be `poll`ed again. The
trick to the API is that there is an implicit `Task` argument to `Future::poll`
which is passed using thread-local storage. This `Task` argument must be
`notify`d by the `Future` in order for the task to awaken and `poll` again.
It's essential to remember to do this, and to ensure that the right schedulings
occur on every code path.

[explicit task issue]: https://github.com/alexcrichton/futures-rs/issues/129

## Reasoning about code

The implicit `Task` argument makes it difficult to tell which functions
aside from `poll` will use the `Task` handle to schedule a wakeup. That has a
few implications.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can tell which functions will use the Task handle because they return Async.

Today, you can look at any function in the h2 library and know if it requires a task context or not.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't tell that easily from the call site, which is the key difference in code reasoning.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are also functions like need_read which don't return Async but do rely on an implicit Task.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't tell that easily from the call site, which is the key difference in code reasoning.

But you can, it's determined by the return value. Unless you mean when people rely on type inference for the return value and don't actually check that it's Async?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe I've seen code use Async as a "general purpose" return value to indicate readiness, without necessarily touching a Task internally. Perhaps this practice should be discouraged if the "Async as a return value means we touched a Task" convention is to take hold.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But you can't easily.


First, it's easy to accidentally call a function that will attempt to access
the task outside of a task context. Doing so will result in a panic, but it
would be better to detect this mistake statically.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only real advantage as far as I can tell. If &mut Context is explicit, you actually can't call it.

However, today with the Async return value, if you accidentally try to call a function and use the return value, you will statically have to interact w/ the Async value, which will let you know the function requires a task context.

If you do something like let _ = my_async_fn().unwrap(), then you will not get compiler warnings.


Second, and relatedly, it is hard to audit a piece of code for which calls
might involve scheduling a wakeup--something that is critical to get right
in order to avoid "lost wakeups", which are far harder to debug than an
explicit panic.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, you can do this by looking at the Async return value, though this does take a bit more work than searching for a variable.


## Debuggability

Today's implicit TLS task system provides us with fewer "hooks" for adding debugging
tools to the task system. Deadlocks and lost wakeups are some of the trickiest things
to track down with futures code today, and it's possible that by using a "two stage"
system like the one proposed in this RFC, we will have more room for adding debugging
hooks to track the precise points at which queing for wakeup occurred, and so on.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this is true. Making the task argument explicit has no functionality change. You can hook into the task system to add debugging logic today. It is just that nobody has actually done it.


More generally, the explicit `Context` argument and distinct `Waker` type provide greater
scope for extensibility of the task system.

## no_std

While it's possible to use futures in `no_std` environments today, the approach
is somewhat of a hack. Threading an explicit `&mut Context` argument makes no_std
support entirely straightforward, which may also have implications for the futures
library ultimately landing in libcore.

# Guide-level explanation
[guide-level-explanation]: #guide-level-explanation

The `Future`, `Stream`, `AsyncRead`, and `AsyncWrite` traits have been modified
to take explicit `task::Context` arguments. `task::Context` is a type which
provides access to
[task-locals](https://docs.rs/futures/*/futures/macro.task_local.html) and a `Waker`.

In terms of the old `futures` task model, the `Waker` is analogous to the `Task`
returned by [`task::current`][current], and the `wake` method is analogous to
the old [`Task::notify`][notify].

# Reference-level explanation
[reference-level-explanation]: #reference-level-explanation

The `task::Context` interface looks like this:

```rust
mod task {

/// The context for the currently running task.
pub struct Context {

/// Returns a `Waker` which can be used to wake up the current task.
pub fn waker(&self) -> Waker { ... }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not Rust syntax, is Context a trait or a struct?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sorry-- Context is a struct, this should be pub struct Context { ... }, impl Context { ...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible alternative design:

pub fn waker(&self) -> (Waker, DidGetWaker)

#[must_use = "You must eventually call wake to allow the future to be polled again"]
pub struct Waker {
}

pub enum Async<T> {
    /// Represents that a value is immediately ready.
    Ready(T),

    /// Represents that a value is not ready yet, but may be so later.
    NotReady(DidGetWaker),
}

That way you:

  • Must get the Waker in order to construct a new NotReady.
  • Will get a warning if you never do anything with the Waker.


/// Runs the provided closure with the task-local pointed to by the
/// provided `key`.
///
/// `LocalKey`s are acquired using the `task_local` macro.
pub fn with_local<T, R, F>(&mut self, key: LocalKey<T>, f: F) -> R
where F: FnOnce(&mut T) -> R
{
...
}
}

/// A handle used to awaken a task.
pub struct Waker {

/// Wakes up the associated task.
///
/// This signals that the futures associated with the task are ready to
/// be `poll`ed again.
pub fn wake(&self) { ... }
}

...
}
```

The modified traits will look as follows:

```rust
pub trait Future {
type Item;
type Error;
fn poll(&mut self, ctx: &mut Context) -> Poll<Self::Item, Self::Error>;
}

pub trait Stream {
type Item;
type Error;
fn poll_next(&mut self, ctx: &mut Context) -> Poll<Option<Self::Item>, Self::Error>;
}

pub trait AsyncRead {
unsafe fn initializer(&self) -> Initializer { ... }

fn poll_read(&mut self, buf: &mut [u8], ctx: &mut Context)
-> Poll<usize, io::Error>;

fn poll_vectored_read(&mut self, vec: &mut [&mut IoVec], ctx: &mut Context)
-> Poll<usize, io::Error> { ... }
}

pub trait AsyncWrite {
fn poll_write(&mut self, buf: &[u8], ctx: &mut Context)
-> Poll<usize, io::Error>;

fn poll_vectored_write(&mut self, vec: &[&IoVec], ctx: &mut Context)
-> Poll<usize, io::Error> { ... }

fn poll_close(&mut self, ctx: &mut Context) -> Poll<(), io::Error>;
}
```

# Drawbacks
[drawbacks]: #drawbacks

The primary drawback here is an ergonomic hit, due to the need to explicitly thread through
a `Context` argument.

For combinator implementations, this hit is quite minor.

For larger custom future implementations, it remains possible to use TLS internally to recover the existing ergonomics, if desired.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO suggesting that people do something non-standard is a weak argument. If the non-standard strategy is better, then why is it not the standard strategy?

Sticking w/ the idioms is the best option.


Finally, and perhaps most importantly, the explicit argument is never seen by users who only use
futures via combinators or async-await syntax, so the ergonomic impact to such users in minimal.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the explicit argument is never seen by users who ony use futures via combinators or async-await syntax, then why not make the ergonomics solid for those of us who do?

Really, it seems that this proposal hinges on:

  • How often do users transition from combinators only -> writing Async functions?
  • Is it worth making that initial transition smoother at the cost of making the experience of writing Async functions worse?
  • How much smoother will that transition actually be with an explicit task argument?

Copy link

@skade skade Feb 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my experience, there's a point (and it comes pretty quickly) where you want to implement a future on you own. I'd not push that towards being an edge-case.

Then again, these are mostly futures that proxy the calls through to child futures. The biggest danger there, though, is forgetting to make sure there's always a future polled, which leaves the future group in waiting for poll. The task argument doesn't fix that.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The biggest danger there, though, is forgetting to make sure there's always a future polled, which leaves the future group in waiting for poll. The task argument doesn't fix.

That is a good point. The explicit task argument doesn't actually statically prevent you from making the mistake that this RFC is attempting to protect you from.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd question the notion that the TLS solution is more ergonomic. While it does reduce the amount of characters need to write a poll implementation that is at best a weak argument in favor of it. On the other hand I'd argue that keeping things explicit improves "ergonomics" just by virtue of being explicit.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd question the notion that the TLS solution is more ergonomic.

Having written a lot of custom impl Future, it definitely is more ergonomic.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seanmonstar

Having written a lot of custom impl Future, it definitely is more ergonomic.

I assume there is something more than not needing to pass a context that makes this more ergonomic? If so, what is it?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

impl Future is explicit, than

struct Foo;
impl Future for Foo {}



# Rationale and alternatives
[alternatives]: #alternatives

The two-stage design (splitting `Context` and `Waker`) is useful both for efficial task-local storage, and to provide space for debugging "hooks" in later iterations of the library.

# Unresolved questions
[unresolved]: #unresolved-questions

- Can we come up with a better name than `task::Context`? `Task` is
confusing because it suggests that we're being passed the task,
when really we're _in_ the task. `TaskContext` is roughly equivalent
to `task::Context` but doesn't allow users to `use futures::task::Context;`

- What is the best way to integrate the explicit `task::Context` with
async/await generators? If generators are changed to accept arguments to
`resume`, a pointer could be passed through that interface. Otherwise, it
would be necessary to pass the pointer through TLS when calling into
generators.

- The `task::Context::with_local` function can now likely be replaced by
`HashMap`-like entry and accessor APIs. What should the precise API
surface for accessing task-locals look like?