Is there a better way to cancel a token as soon as a predicate is fulfilled?

47 Views Asked by At

I have a composite asynchronous method:

CancellationTokenSource cts;
protected virtual async void Test() {
    cts = new CancellationTokenSource();
    await Attack(cts.Token);
}

protected virtual async Task Attack(CancellationToken token) {
    token.ThrowIfCancellationRequested();
    await MoveToTarget(token);
    await DoAttackAnimation(token);
    await ReturnToBase(token);
}

Now suppose that I want the attack to cancel under certain conditions dictated by this method:

protected virtual void EnsureConditions() {
    if (HP <= 0 || IsParalyzed || TargetIsDead) {
        cts?.Cancel();
        cts?.Dispose();
        cts = null;
    }
}

With this, my new attack method has looked like this:

protected virtual async Task Attack(CancellationToken token) {
    EnsureConditions();
    await MoveToTarget(token);
    EnsureConditions();
    await DoAttackAnimation(token);
    EnsureConditions();
    await ReturnToBase(token);
}

But it immediately feels like I'm doing something incorrectly:

  1. Not only do I need to inject the Ensure method inbetween awaits, but I also need to inject it in the body of Move, Animation and Return since the conditions may have changed.
  2. Lots of boilerplate in the long term.

So after reconsidering, I figured that the "intended" use of CancellationTokenSource is to be cancelled when an event happens, rather than proactively polling for conditions - something like this:

public void OnHPChanged() {
    if (HP <= 0) {
        // cancel & dispose CTS
    }
}

public void OnParalyzed() {
    // cancel & dispose CTS
}

public void OnTargerDied() {
    // cancel & dispose CTS
}

And now I don't have to do any injections which seems to be the correct approach to me.

But I would still like to know, is there a better way to execute the "polling predicate" style? My ideal scenario would be this:

protected virtual async void Test() {
    cts = new CancellationTokenSource();
    cts.AttachPredicate(EnsureConditions); // <--- magically cancels CTS as soon as predicate is true
    await Attack(cts.Token);
}

I suppose that with better understanding or control of the order in which tasks are run this could be doable (like a task that runs before all other tasks and checks the predicate) but also sounds a bit iffy.

1

There are 1 best solutions below

0
Stephen Cleary On

So after reconsidering, I figured that the "intended" use of CancellationTokenSource is to be cancelled when an event happens, rather than proactively polling for conditions

This is correct.

But I would still like to know, is there a better way to execute the "polling predicate" style?

Sure, you could do that. But it would quite literally be polling a predicate.

E.g.:

async Task WatchAsync(CancellationTokenSource cts, TimeSpan pollInterval, Func<bool> cancelPredicate)
{
  try
  {
    while (!cancelPredicate())
      await Task.Delay(pollInterval);
  }
  finally
  {
    cts.Cancel();
  }
}

But no one does that. It's hacky:

  • It depends on a polling interval which needs to be adjusted for different use cases.
  • The WatchAsync loop itself should ideally be cancellable.

Instead, everyone uses the approach you've already discovered: just cancel the CTS immediately when its cancellation trigger occurs.