Crate cancel_this

Crate cancel_this 

Source
Expand description

This crate provides a user-friendly way to implement cooperative cancellation in Rust based on a wide range of criteria, including triggers, timers, OS signals (Ctrl+C), memory limit, or the Python interpreter linked using PyO3. It also provides liveness monitoring of “cancellation aware” code.

Why not use async instead of cooperative cancellation? In principle, async was designed to solve a different problem, and that’s executing IO-bound tasks in a non-blocking fashion. It is not really designed for CPU-bound tasks. Consequently, using async adds a lot of unnecessary overhead to your project which cancel_this does not have (see also the Performance section in the project README).

Why not use stop-token, CancellationToken or other cooperative cancellation crates? So far, all crates I have seen require you to pass the cancellation token around and generally do not make it easy to combine the effects of multiple tokens. In cancel_this, the goal was to make cancellation dead simple: You register however many cancellation triggers you want, each trigger is valid within a specific scope (and thread) and can be checked by a macro anywhere in your code.

§Current features

  • Scoped cancellation using thread-local “cancellation triggers”.
  • Out-of-the-box support for triggers based on atomics and timers.
  • With feature ctrlc enabled, support for cancellation using SIGINT signals.
  • With feature pyo3 enabled, support for cancellation using Python::check_signals.
  • With feature memory enabled, support for cancellation based on memory consumption returned by memory-stats.
  • With feature liveness enabled, you can register a per-thread handler invoked once the thread becomes unresponsive (i.e., cancellation is not checked periodically within the desired interval).
  • Practically no overhead in cancellable code when cancellation is not actively used.
  • Very small overhead for “atomic-based” cancellation triggers and PyO3 cancellation.
  • All triggers and guards generate log messages (trace for normal operation, warn for issues where panic can be avoided).

§Simple example

A simple counter that is eventually canceled by a one-second timeout:

fn cancellable_counter(count: usize) -> Cancellable<()> {
    for _ in 0..count {
        is_cancelled!()?;
        std::thread::sleep(Duration::from_millis(10));
    }
    Ok(())
}

let result: Cancellable<()> = cancel_this::on_timeout(Duration::from_secs(1), || {
    cancellable_counter(5)?;
    cancellable_counter(10)?;
    cancellable_counter(100)?;
    Ok(())
});

assert!(result.is_err());

§Complex example

This example uses most of the features, including error conversion, never-cancel blocks, and liveness monitoring.

enum ComputeError {
    Zero,
    Cancelled
}

impl From<Cancelled> for ComputeError {
    fn from(value: Cancelled) -> Self {
       ComputeError::Cancelled
   }
}


fn compute(input: u32) -> Result<String, ComputeError> {
    if input == 0 {
        Err(ComputeError::Zero)
    } else {
        let mut total: u32 = 0;
        for _ in 0..input {
            total += input;
            is_cancelled!()?;
            std::thread::sleep(Duration::from_millis(10));
        }
        Ok(total.to_string())
    }
}

let guard = LivenessGuard::new(Duration::from_secs(2), |is_alive| {
    eprintln!("Thread has not responded in the last two seconds.");
});

let result: Result<String, ComputeError> = cancel_this::on_timeout(Duration::from_millis(200), || {
    let r1 = cancel_this::on_sigint(|| {
        // This operation can be canceled using Ctrl+C, but the timeout still applies.
        compute(5)
    })?;

    assert_eq!(r1.as_str(), "25");
    // This will be canceled. Instead of using `?`, we check
    // that the operation actually got canceled.
    let r2 = compute(20);
    assert!(matches!(r2, Err(ComputeError::Cancelled)));
    // Even though the execution is now canceled, we can still execute code in
    // the "cancel-never" blocks.
    let r3 = cancel_this::never(|| compute(10))?;
    assert_eq!(r3.as_str(), "100");
    compute(10) // This should get immediately canceled.
});

// The liveness monitoring is active while `guard` is in scope. Once `guard` is dropped here,
// the liveness monitoring is turned off as well.

§Multi-threaded example

Virtually all triggers and guards provided by cancel_this only apply to the current thread. However, since triggers can be safely shared across threads, it is possible to transfer them from one thread to another. Note that the transferred triggers also inherently update the liveness guard of the original thread.

let guard = LivenessGuard::new(Duration::from_millis(10), |is_alive| {
    // In this test, the liveness guard should never trigger, even though the original
    // thread goes to sleep for a long time, waiting to join with the spawned thread.
    assert!(is_alive);
});

let result: Cancellable<u32> = cancel_this::on_timeout(Duration::from_millis(100), || {
    let active = cancel_this::active_triggers();
    let t1: JoinHandle<Cancellable<u32>> = std::thread::spawn(|| {
        cancel_this::on_trigger(active, || {
            let mut result = 0u32;
            // This cycle is eventually going to get canceled
            // by the timer which is "transferred" from the spawning thread.
            for i in 0..50 {
                result += 1;
                is_cancelled!()?;
                std::thread::sleep(Duration::from_millis(5));
            }
            Ok(result)
        })
    });
    // Put the spawning thread to sleep until `t1` finishes.
    t1.join().unwrap()
});

assert!(result.is_err());

Doing the same without transferring cancellation triggers will cause the spawning thread to be registered as unresponsive and the compute thread to never actually get canceled:

let guard = LivenessGuard::new(Duration::from_millis(10), |is_alive| {
    // In this test, the liveness guard should never trigger, even though the original
    // thread goes to sleep for a long time, waiting to join with the spawned thread.
    assert!(!is_alive);
});

let result: Cancellable<u32> = cancel_this::on_timeout(Duration::from_millis(100), || {
    let t1: JoinHandle<Cancellable<u32>> = std::thread::spawn(|| {
        let mut result = 0u32;
        // This cycle is never going to get canceled because we didn't transfer
        // the timeout trigger from the original thread.
        for i in 0..50 {
            result += 1;
            is_cancelled!()?;
            std::thread::sleep(Duration::from_millis(5));
        }
        Ok(result)
    });
    // Put the spawning thread to sleep until `t1` finishes.
    t1.join().unwrap()
});

assert!(result.is_ok());

§Cached triggers example

If you need the absolute lowest overhead, you might want to sacrifice some of the ergonomics provided by cancel_this. To reduce overhead, you can create a local copy of the thread-local triggers the same way as in the multithreaded example and use it directly with is_cancelled. This significantly reduces the overhead of each cancellation check.

let result: Cancellable<u32> = cancel_this::on_timeout(Duration::from_millis(100), || {
    let cache = cancel_this::active_triggers();
    let mut result = 0u32;
    while true {
        // The overhead of this `is_cancelled` call is reduced because triggers
        // are cached in a local variable. However, the cache is only valid within
        // the scope where it was obtained.
        is_cancelled!(cache)?;
        result += 1;
    }
    Ok(result)
});
assert!(result.is_err());

Macros§

is_cancelled
Call this macro every time your code wants to check for cancellation. It returns Result<(), Cancelled>, which can typically be propagated using the ? operator.

Structs§

CancelAtomic
Implementation of CancellationTrigger that is canceled manually by calling CancelAtomic::cancel. See also on_atomic.
CancelChain
Implementation of CancellationTrigger which chains together several trigger implementations.
CancelNever
Implementation of CancellationTrigger that is never canceled.
CancelTimer
Implementation of CancellationTrigger that is canceled once the specified Duration elapsed. The “timer” is started immediately upon creation.
Cancelled
Cancellation error type. Should include the cause of cancellation (name of the crate::CancellationTrigger type that caused the error).

Constants§

UNKNOWN_CAUSE
The “default” Cancelled cause, reported when the trigger type is unknown.

Traits§

CancellationTrigger
Defines an object that can be used to trigger cancellation.

Functions§

active_triggers
Get a snapshot of the current thread-local cancellation trigger.
check_cancellation
Returns Cancelled if CancellationTrigger::is_cancelled of the given trigger is true. In typical situations, you don’t use this method directly, but instead use the is_cancelled macro.
check_local_cancellation
Check if the current thread-local cancellation trigger is canceled. In typical situations, you don’t use this method directly, but instead use the is_cancelled macro.
never
Run the given action by overriding current cancellation criteria with CancelNever, meaning they do not apply and the action is never canceled.
on_atomic
Run the given action, cancelling it if the provided CancelAtomic trigger is canceled by some external mechanism.
on_timeout
Run the given action, cancelling it if the provided duration of time has elapsed, measured by the CancelTimer.
on_trigger
Run the action in a context where a cancellation can be signaled using the given trigger.

Type Aliases§

Cancellable
A result of a cancellable operation.
DynamicCancellationTrigger
A dynamic boxed CancellationTrigger.