A strategy to canceling a background task

I have this background job running, I spawn a new thread and want to be able to cancel it, so far, other then some lower level alternatives, the only thing I could think of would be to use a channel to send a message into the thread so it could wrap it up and return. My issue with that approach is when should I check for the cancel message.

I though about setting up a loop and checking if the receiver got anything before progressing with the actual task, but that seems like it could be a performance hit, should I do at arbitrary steps in as the task is executed ?

Are there some better patterns to do this or should I just bite the bullet and go with what I was thinking initially ?

This sounds like a good use case for async, where futures can be cancelled. You can do (non-async) cleanup such as closing sockets or files from drop.

This is assuming an IO bound task (you didn't really give enough information to determine that). For a CPU bound task you might need a different approach.

3 Likes

Thanks, this one is CPU bound though.

But I could implement a future that waits on this thread, and kill the thread if the future is canceled. :thinking:

Killing threads forcefully from the outside is fraught with danger (e.g. pthread_kill). What if your target thread is holding a mutex? What if it is holding the alloc mutex? No drop code will get executed.

As such I would strongly advise against this.

Checking a relaxed atomic every few hundred milliseconds (or tens of milliseconds, depending on your needs) sounds like a better idea.

1 Like

Do you mean something like this ? Might need to keep some state so I can continue work as it loops too:

use std::sync::mpsc::{channel, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};

enum State {
    Foo,
    Bar,
}

fn main() {
    let foo = State::Foo;
    let (sender, receiver) = channel();
    let instant = Instant::now();

    thread::spawn(move || loop {
        if instant.elapsed() >= Duration::from_millis(100) {
            if receiver.try_recv().is_ok() {
                //clean up
                return;
            }
        }
        
        match foo {
            State::Foo => todo!(),
            State::Bar => {
                // Although if I have a big loop in the middle of the task
                // I might need to check inside the loop too from time to time
                for _ in 1..1000 {
                    //
                }
            }
        }
    });
    
    //kill it
    sender.send(()).unwrap();
}

A channel is one option, but if all you need is a single "stop work" (and no other commands or messages) it is possible (likely?) a single relaxed atomic bool will be faster (I haven't benchmarked this though).

You could do it via time, or maybe every N steps of your code, it all depends on your specific use case. I would expect N steps to have less overhead (even though checking time on Linux at least can be done without a syscall thanks to the vdso).

In fact if you naturally have a main loop you return to, perhaps just check the atomic or queue there, it is likely cheaper than checking the time.

Of course if performance is critical you should benchmark and profile (and maybe even look at assembly) to figure out the best option.

3 Likes

For jobs in background threads, toggling an Arc<AtomicBool> that is periodically checked works fine (tip: wrap the check in a function that returns Result, so you can just write check()?).

This is assuming you're cancelling to avoid wasted work, not for correctness. If you have to be sure the thread has stopped, you may need to use try_lock() on Mutex<()> that is unlocked when the thread stops. Alternatively, you could have a dedicated thread that executes commands read from a channel. If there's only one thread consuming the channel you have a guarantee there will be only one thread working.

3 Likes

Or use the thread’s JoinHandle to wait for it to stop after you’ve requested it to via some other mechanism (like AtomicBool).

3 Likes