cancel-this 0.2.0

A user-friendly cooperative cancellation and liveness monitoring library.
Documentation

Crates.io Api Docs Continuous integration Benchmarks Coverage GitHub issues GitHub last commit Crates.io

cancel_this (Rust co-op cancellation)

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), 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 below).

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 liveness enabled, you can register a per-thread handler invoked once the thread becomes unresponsive (i.e. cancellation is not checked periodically withing the desired interval).
  • Practically no overhead in cancellable code when cancellation is not enabled.
  • Very small overhead for "atomic-based" cancellation triggers, acceptable overhead for 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 cancelled by a one-second timeout. More complex examples (including liveness monitoring and multi-threaded usage) are provided in the documentation.

use std::time::Duration;
use cancel_this::{Cancellable, is_cancelled};

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

fn main() {
   let one_s = Duration::from_secs(1);
   let result: Cancellable<()> = cancel_this::on_timeout(one_s, || {
      cancellable_counter(5)?;
      cancellable_counter(10)?;
      cancellable_counter(100)?;
      Ok(())
   });
    
   assert!(result.is_err());   
}

Performance

The overall overhead of adding cancellation checks will heavily depend on how often they are performed. Under ideal conditions, you don't want to run them too often. However, delaying cancellation too much can make your code seem unresponsive. In ./benches, we provide a benchmark to illustrate the impact of cancellation on simple code. Here, we intentionally use cancellation checks too often to gain significant overhead. In your own code, it is typically sufficient to run cancellation every few milliseconds.

Caching cancellation triggers

If you need to check cancellation repeatedly in a performance sensitive piece of code, you might want to sacrifice some ergonomics of cancel_this for reduced overhead. In such cases, you can use cancel_this::active_triggers to store a "local copy" of all active triggers. You can then pass such triggers directly to is_cancelled! to avoid a (relatively) costly thread-local variable access.

Sample results

Benchmarks with liveness=true are running with liveness monitoring (this adds additional overhead). The synchronous benchmark is a baseline without any cancellation support. The async::tokio benchmark implements cancellation using async functions. The cancellable::none benchmark implements cancellation using cancel_this, but with no trigger registered. Benchmarks marked as cached use a local variable the cache the active triggers. Remaining benchmarks test different "cancellation triggers" implemented in cancel_this.

These results were obtained on a M2 Max Macbook Pro using cargo bench (the exact output is simplified for brevity). Latest results from a more stable desktop environment are also available on bencher.dev or in the relevant CI run.

hash::synchronous;                                    4.0006 µs

hash::async::tokio;                                   17.076 µs

hash::cancellable::none; (liveness=false)             4.0369 µs
hash::cancellable::none; (liveness=true)              7.6464 µs
hash::cancellable::none::cached; (liveness=false)     4.0020 µs
hash::cancellable::none::cached; (liveness=true)      4.0214 µs

hash::cancellable::atomic; (liveness=false)          4.9599 µs
hash::cancellable::atomic; (liveness=true)           7.6691 µs
hash::cancellable::atomic::cached; (liveness=false)  4.0318 µs
hash::cancellable::atomic::cached; (liveness=true)   4.0614 µs

hash::cancellable::timeout; (liveness=false)         4.9626 µs
hash::cancellable::timeout; (liveness=true)          7.7143 µs

hash::cancellable::sigint; (liveness=false)          4.9717 µs
hash::cancellable::sigint; (liveness=true)           7.7038 µs

hash::cancellable::python; (liveness=false)          79.738 µs
hash::cancellable::python; (liveness=true)           82.695 µs

To run the benchmarks locally, simply use cargo bench --all-features (with liveness turned on) or cargo bench --features=ctrlc --features=pyo3 (liveness turned off).