Search code examples
c#linqparallel.foreachcancellation-token

Why threads continue to run after a cancel has been called?


Consider this simple example code:

var cts = new CancellationTokenSource();
var items = Enumerable.Range(1, 20);

var results = items.AsParallel().WithCancellation(cts.Token).Select(i =>
{
    double result = Math.Log10(i);                
    return result;
});

try
{
    foreach (var result in results)
    {
        if (result > 1)
            cts.Cancel();
        Console.WriteLine($"result = {result}");
    }
}
catch (OperationCanceledException e)
{
    if (cts.IsCancellationRequested)
        Console.WriteLine($"Canceled");
}

Foreach of the results in the parallel results it prints the results until result > 1

This code output is something like:

result = 0.9030899869919435
result = 0.8450980400142568
result = 0.7781512503836436
result = 0
result = 0.6020599913279624
result = 0.47712125471966244
result = 0.3010299956639812
result = 0.6989700043360189
result = 0.9542425094393249
result = 1
result = 1.0413926851582251 <-- This is normal
result = 1.2041199826559248 <-- Why it prints this value (and below)
result = 1.0791812460476249
result = 1.2304489213782739
result = 1.1139433523068367
result = 1.255272505103306
result = 1.146128035678238
result = 1.2787536009528289
result = 1.1760912590556813
result = 1.3010299956639813
Canceled

My question is why it continue printing values over 1? I had expected that it the Cancel() token will be terminate the process.


Update 1

@mike-s's answer suggested:

It's also useful to check a cancellation token inside a loop (as a means to abort the loop) or before a long operation.

I've tried adding a check

foreach (var result in results)
{
    if (result > 1)
        cts.Cancel();

    if (!cts.IsCancellationRequested) //<----Check the cancellation token before printing
        Console.WriteLine($"result = {result}");
}

It still gives the same result's output.


Solution

  • My question is why it continue printing values over 1?

    Imagine you hired a hundred pilots to fly a hundred planes from a hundred airports. A bunch of them take off, and then you send a message saying "cancel all the flights". Well, there are a bunch of planes on the runway at takeoff speed when you send that message, and the message arrives after they are in the air. Those flights will not be cancelled!

    You are discovering the most important thing to know about multithreaded programming. You have to reason as though every possible ordering of things happening might occur. That includes messages arriving later than you think they should.

    In particular, your problem is a result of your abuse of the parallelization mechanisms, which are designed to parallelize long work. You've created a bunch of tasks that take less time to run than it takes to send the message stopping them. It should not be a surprise in that case that some of the tasks complete after they've been told to stop.

    I expected that calling Cancel() on the token would terminate the process.

    Your expectation is completely, totally wrong. Stop expecting that, since that expectation in no way conforms to reality. A cancellation token is a request to cancel an operation as soon as it is convenient to do so. It's not terminating a thread or a process.

    However, even if you did terminate the threads, you would still observe this behaviour. Thread termination is an event like any other, and that event is not instantaneous. It takes time to execute, and other threads can continue their work while that thread termination is executing.

    what do you mean by "convenient" in "a request to cancel an operation as soon as it is convenient to do so"?

    Let's take a step back.

    If the work to be done is extremely short, then there is no need to represent it as a task. Just do the work! In general if work takes less than about 30ms, just do the work.

    Therefore, let's assume that every task takes a long time.

    Now, why might a task take a long time? There are generally two reasons:

    • We're waiting for another system to complete some task. We're waiting for a network packet or a disk read or some such thing.

    • We have a huge amount of computation, and the CPU is saturated.

    Suppose we are in the first situation. Does parallelizing help? No. If you are waiting for a package in the mail, hiring one, two, ten or a hundred people to wait does not make the package come faster.

    But that does help for the second case; if we have an extra CPU in the machine we can dedicate two CPUs to solve the problem in about half the time.

    Therefore we can assume that if we are parallelizing a task, it is because the CPU is doing a lot of work.

    Great. Now, what is the nature of "CPU does a lot of work?" It almost always involves a loop somewhere.

    So then, how do we cancel a task? We do not cancel a task by terminating the thread. We ask the task to cancel itself. A well-designed task will take a cancellation token, and in its loop will check to see if the cancellation token is indicating that the task is cancelled. Cancellation is cooperative. The task has to cooperate and decide when it checks to see if it is cancelled.

    Notice that checking to see if you are cancelled is work, and that is work that takes time away from the real task. If you spend half your time checking to see if you are cancelled, your task takes twice as long as it could. And remember, the point of parallelizing the task is to make it take half as long, so doubling the amount of time it takes to do the task is a non-starter.

    Therefore most tasks do not check every time through the loop if they are cancelled. A well-designed task will check every few milliseconds, not every few nanoseconds.

    That's what I mean by "a cancellation is a request to stop when it is convenient". The task, if it was written correctly, should know what a good time to check for cancellation is so that it balances responsiveness against performance.