Async-std tasks not being scheduled as expected

Hey there! Hope you're doing well.

I have long-running, blocking tasks that I expect will be running in perpetuity as long as the program is running. I've spawned them as tasks and not threads as I may have potentially thousands of these long-running jobs and I would like to account for scale.

I empirically noticed that some tasks are either never run or run once and then not run again. The more tasks I have, the less consistent the behavior in my environment.

The API I'm using is the plain task::spawn function call, and I pass in an async block or function. An example is below, in which the task reads from an async TCP stream and writes the content to a crossbeam-channel:

    Ok(ReadOnlyTcpChannel {
        rx_chan: chan,
        task:    task::spawn(crate::tcp::read_from_stream(stream, sender))
    })

I run the program with the following flag as well: ASYNC_STD_THREAD_COUNT=8 .

One thing I'm not doing is awaiting any of these tasks. As I expected them to be perpetually blocking and never complete, await seemed unnecessary.

My main question is: how do I ensure my tasks are being scheduled reliably?

If I need to await tasks in order for them to be scheduled, what is the best approach when dealing with long-running, blocking tasks that can't be awaited on?

Thanks in advance!

1 Like

That is a bounded crossbeam channel? Then writing to it blocks. You may not block in async code.

Async code is able to run many things concurrently by swapping the currently running task at .await. However it can only swap things at .await, so if you block the thread in a manner without an .await, other tasks are unable to run once you run out of threads (8 in your case).

2 Likes

I came here because I'm facing a similar issue and couldn't find anything closer to that. I'm unsure if the cause is the same as yours but we can check that.

My high-level scenario is very similar (though I'm using async_std channels) and I'm observing that task::spawn works fine unless I already awaited something in the same async function. Here's what I mean:

async fn foo() {
    task::spawn(async move {
        // This runs and prints out the message.
        println!("Hello, world");
    });
}

But:

async fn foo() {
    // This runs.
    task::sleep(ONE_SECOND).await;

    task::spawn(async move {
        // This never runs.
        println!("Hello, world");
    });

    // This also runs.
    println!("Hello, another world");
}

There may be some additional conditions, because my main is also async and does some awaits before calling the foo, but in the first case it still works, the latter doesn't work.

Does that sound like your case?

What does your main() look like? The program will terminate as soon as it exits; if that happens before the scheduler has an opportunity to run the spawned task, the task will be dropped without running.

At least, I assume this is true. The task::spawn docs say nothing except that it’s analogous to std::thread::spawn, which works this way:

The join handle will implicitly detach the child thread upon being dropped. In this case, the child thread may outlive the parent (unless the parent thread is the main thread; the whole process is terminated when the main thread finishes).

1 Like

Async tasks don’t run unless you await a result. That can either be an await call inside the task or you can call await on the return value of spawn which is a JoinHandle.

Additionally what @2e71828 said is also true that the end of the program will not wait for all tasks to finish necessarily. So if it’s a short lived program you’ll want some mechanism to make sure the tasks stay alive.

With a bit more detail on your problem, we can give a more directed answer.

1 Like

These both don't explain why my first example works. Also, this is not in-line with the docs that says:

This function is similar to std::thread::spawn

The other community/SO threads also explain that one doesn't have to await on a JoinHandle.

But I also just wanted to check with the topic-starter, whether it's a similar issue or still a different one. Then we can go deeper.

I guess the caveat to what I said would be that it depends on how the initial async code is started. Some methods of starting the async code will wait for all tasks to finish. I'm not as familiar with async-std, but I assume that holds true there. Do you have a functioning sample including main() that illustrates the problem you were having? Just looking at what you've shown, its not obvious to me why you're getting two different outcomes.

In both cases, the task is spawned during the last poll() call to the future returned by foo(). If foo().await is the last thing done before main exits, you have a race between the secondary task executing and the executor shutting down.

This is exactly parallel to thread::spawn(): the new thread/task will run independently, but will be terminated at system shutdown. If you don’t synchronize it with the main thread/task somehow, there’s no way to tell whether it has done any work before that happens.

I suspect you’re not doing that synchronization, so it’s mostly random whether or not the worker thread actually runs. If the executor is deterministic, this randomness will manifest as differing behavior from seemingly-inconsequential changes, like you’ve described here.

1 Like

I just did some quick tests using #[async_std::main] and @2e71828 is correct. If you add a sleep in the spawned task, it definitely will not run because the task loses the race. So I guess the answer is that its not strictly needed to include an await for a task to be run, since the executor will try to finish the task, but if you want to ensure its finished before the executor shuts down you will need to do some sort of synchronization (such as await on the JoinHandle) to guarantee it runs.

1 Like

It was an unbounded channel for my tests.

I was treating async-std tasks as if they were threads, but after this comment I swapped out crossbeam-channels for async-channels like @eigenein. I sprinkled in awaits where necessary due to the change in API, and just like that the blocking recv and send calls were now async. This change allowed my long-running tasks to be scheduled properly, and my program now runs as expected.

I'm only awaiting one of these spawned tasks, same as before. I guess the lesson for me here is: if there are blocking function calls within a task, make them async.

Thanks @alice!

1 Like

I ran the second code example, and I found it printed both statements. Here is my code, including the main routine.

use async_std::task;
use std::time::Duration;

fn main() {
    task::block_on(foo())
}

async fn foo() {
    // This runs.
    task::sleep(Duration::from_secs(1)).await;

    task::spawn(async move {
        // This never runs.
        println!("Hello, world");
    });

    // This also runs.
    println!("Hello, another world");
}

Here is my program output:

Hello, another world
Hello, world

I assume our differences are due to the main routine, as others have noted.

Like @drewkett states, you probably have to await the foo task as I've done in the main routine.

Even with my original problem case, I had my main routine await a task like so: task::block_on(read_task);.

I suspect that I messed up with some blocking operation(s) on the main thread where the executor is running. Looks like a race condition, indeed. At least, I didn't manage to make a minimal reproducible example, there task::spawn works as expected.

That I'll manage to figure out myself, thanks for the hints anyway!

1 Like