ZH Eng
ZH Eng

Reputation: 23

Abortable: Dangling Futures?

I am using the Abortable crate to suspend the execution of a Future. Say I have an abortable future in which the async function itself awaits other async functions. My question is, if I abort the root Future, would the child Futures be aborted instantly at the same time, or would they be dangling?

I read the source code for Abortable, in particular the code for try_poll:

fn try_poll<I>(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        poll: impl Fn(Pin<&mut T>, &mut Context<'_>) -> Poll<I>,
    ) -> Poll<Result<I, Aborted>> {
        // Check if the task has been aborted
        if self.is_aborted() {
            return Poll::Ready(Err(Aborted));
        }

        // attempt to complete the task
        if let Poll::Ready(x) = poll(self.as_mut().project().task, cx) {
            return Poll::Ready(Ok(x));
        }

        // Register to receive a wakeup if the task is aborted in the future
        self.inner.waker.register(cx.waker());

        // Check to see if the task was aborted between the first check and
        // registration.
        // Checking with `is_aborted` which uses `Relaxed` is sufficient because
        // `register` introduces an `AcqRel` barrier.
        if self.is_aborted() {
            return Poll::Ready(Err(Aborted));
        }

        Poll::Pending
    }

My understanding is that once abort is called, it will propagate to the downstream Futures in the sense that when the root Future is aborted, it will stop polling its child Future (because Poll::Ready(Err(Aborted)) will be returned), which will in turn stop polling its child. If this reasoning is true, then the effect of calling abort is immediate.
Another argument is that if Future is pull-based, the root node should be invoked first and then propagate to the sub tasks until the leaf one is invoked and aborted (and then go back to root). This means that there is a latency between when the abort method is called and when the leaf Future actually stops polling. Might be relevant, but this blogpost mentions dangling tasks, and I am concerned this is the case.
For example, here is a toy example that I wrote:

use futures::future::{AbortHandle, Abortable};
use tokio::{time::sleep};
use std::{time::{Duration, SystemTime}};

/*
 *       main
 *         \
 *       child
 *         | \
 *        |   \
 *    leaf1   leaf2
 */
 

async fn leaf2() {
    println!("This will not be printed")
}

async fn leaf1(s: String) {
    println!("[{:?}] ====== in a ======", SystemTime::now());
    for i in 0..100000 {
        println!("[{:?}] before sleep i is {}", SystemTime::now(), i);
        sleep(Duration::from_millis(1)).await;
        println!("[{:?}] {}! i is {}", SystemTime::now(), s.clone(), i);
    }
}
 
async fn child(s: String) {
    println!("[{:?}] ====== in child ======", SystemTime::now());
    leaf1(s.clone()).await;
    leaf2().await
}
 
#[tokio::main]
async fn main() {
    let (abort_handle, abort_registration) = AbortHandle::new_pair();
    let result_fut = Abortable::new(child(String::from("Hello")), abort_registration);

    tokio::spawn(async move {
        println!("{:?} ^^^^^ before sleep ^^^^^", SystemTime::now());
        sleep(Duration::from_millis(100)).await;
        println!("{:?} ^^^^^ after sleep, about to abort ^^^^^", SystemTime::now());
        abort_handle.abort();
        println!("{:?} ***** operation aborted *****", SystemTime::now());
    });

    println!("{:?} ====== before main sleeps ======", SystemTime::now());
    sleep(Duration::from_millis(5)).await;
    println!("{:?} ====== after main wakes up from sleep and now getting results \
            ======", SystemTime::now());
    result_fut.await.unwrap();
}

Rust playground
I am personally leaning more towards the first argument that there is no latency between abortion of the root and abortion of leaf because the leaf doesn't need to know it needs to abort (the leaf only pulls when root tells it to). The example above prints the time the child is executed and the time when the root is aborted. The execution of child is always before the root is aborted, but I am not sure if this can prove that my first argument is true, so I would like to know what y'all think!

Upvotes: 2

Views: 773

Answers (2)

First_Strike
First_Strike

Reputation: 1089

  1. Dropping a Future type equals to drop all the states within that Future, including its child Futures. Remember Futures in Rust are all state machines. If you succeeded in safely dropping a Future's states, then it means the execution must have already been stopped before the drop, otherwise it is a data race.

  2. Specifically, dropping a tokio JoinHandle conceptually only drops the handle itself, but does nothing about the task that the handle represents. Or in other words, if you use tokio::spawn(), then whatever Future you throw into that task became unrelated to your current Future (except you can receive back a result from the JoinHandle and explicitly call abort). So they call tokio's tasks as detached by default.

  3. Let's talk about timing issues. For Abortable, it does not directly "drop" your Future. Instead, it "drops" in an indirect way. If you call AbortHandle::abort(), then immediately an atomic boolean flag is flipped and then a Waker::wake() is called, but the async executor would be unaware of this at this moment. The executor would still think your Abortable is either progressing or suspended at this moment. We should discuss them separately:

    1. The executor was still polling your Future at the time of the atomic boolean flip. Then according to the source code

      1. If this polling happens to be the one that completes the future, then the Abortable would return a completion.
      2. If this polling returns Pending in the end, then Abortable has a high chance of return an aborted error, small chance of going back into suspense, depending on when the atomic flag flip became observable to the thread of this polling.

      If either a completion or an aborted error is returned, the Abortable as a Future would be considered as completed and eventually(*) get dropped. When it gets dropped, it would mean all its child Futures would have also been dropped.

      If you happen to be coding in a very poor style and this polling happens to have touched a long computation / a blocking call, then unfortunately, everything else would have to wait however long for this poll to return, and the executor may be starved during the process.

    2. Your Future was suspended at the time of the atomic boolean flip. The Waker::wake() call tells the executor to poll this Abortable at least once sometime in the future. Some time later, the executor will finally decide to give your Abortable a poll. Then almost immediately the poll returns an aborted error. Then the same thing happens, your Abortable as a Future would be considered as completed and eventually get dropped.

    In summary, no, the abort does not happen at the same time as the drop.

*: I'm not sure when the Future gets dropped after its completion. It would really depend on how Rust compiler transforms the .await point and I haven't looked into it yet.

Upvotes: 2

Chayim Friedman
Chayim Friedman

Reputation: 70820

Yes, because a future needs to polled to executed but it will not be polled if it is aborted, the child futures will not be polled either and therefore the execution will stop immediately.

Of course, execution will stop only after reaching the next yield point, and spawned tasks using tokio::spawn() will not stop.

Upvotes: 2

Related Questions