Consider the following .Net 6 console program:
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
var semaphore = new SemaphoreSlim(1, 1);
var cts = new CancellationTokenSource();
var tasks = Enumerable.Range(0, 10).Select(WaitingTask);
var t = Task.Run(async () =>
{
await Task.WhenAll(tasks);
Console.WriteLine("Tasks complete");
});
await Task.Delay(500);
Console.WriteLine("Press any key to Cancel waiting tasks, then immediately release semaphore");
Console.ReadKey();
cts.Cancel();
semaphore.Release();
await t;
async Task WaitingTask(int i)
{
try
{
Console.WriteLine($"{i} Waiting");
await semaphore.WaitAsync(cts.Token);
Console.WriteLine($"{i} Aquired");
await Task.Delay(50);
Console.WriteLine($"{i} Complete");
}
catch (OperationCanceledException)
{
Console.WriteLine($"{i} Cancelled");
}
}
It creates 10 tasks that try to acquire a lock on a semaphore that only allows 1 entry at a time.
After the first task has reported completion, and the other nine tasks report that they are waiting for the semaphore, I wish to cancel the token passed to the waiting tasks, and then immediately release the lock on the semaphore.
Expected: the remaining 9 tasks throw and handle OperationCanceledException, and report "Canceled".
Actual: 8 of the remaining tasks do this, but 1 of them will sucessfully enter the semaphore and complete normally. I.e. you cannot reliably cancel calls to WaitAsync(CancellationToken)
Commenting out the line semaphore.Release();
results in all 9 tasks reporting canceled as expected.
I'm assuming a race condition somewhere but my question is: Am I wrong to expect my stated behaviour, and if so why?
Many thanks.
Example output:
Hello, World!
0 Waiting
0 Aquired
1 Waiting
2 Waiting
3 Waiting
4 Waiting
5 Waiting
6 Waiting
7 Waiting
8 Waiting
9 Waiting
0 Complete
Press any key to Cancel waiting tasks, then immediately release semaphore
1 Aquired
4 Cancelled
8 Cancelled
5 Cancelled
9 Cancelled
6 Cancelled
3 Cancelled
2 Cancelled
7 Cancelled
1 Complete
Tasks complete
CancellationToken
uses a "cooperative" model of cancellation, meaning it is non-blocking and dependent on the consumer of the token to cancel, which you are not doing in each of your task-bound methods.
As a result, if there is a delay in responding to the cancellation request, it is possible to experience the type of race condition that you've described. You have created this by calling .Release()
prior to ensuring that the Task.WhenAll()
call is completed, meaning that it is possible for the following to occur:
- cancellation is requested
- One task successfully completes
semaphore.WaitAsync()
but is then held up by Task.Delay- Release of the semaphore is called
- The majority of the tasks cancel (those that did not successfully complete entry past
semaphore.WaitAsync()
.
The only reason this is possible in the first place is because you add an artificial delay before calling release. Removing await Task.Delay(500)
results in an exception.
If you want to avoid this sort of behavior with what you have, you can change your call order to the following:
cts.Cancel();
await t;
semaphore.Release();
This prevents the semaphore from releasing prior to all tasks completing, allowing each task to cooperatively cancel, even though the work of one task will still have completed. It gives the following output:
Hello, World!
0 Waiting
0 Aquired
1 Waiting
2 Waiting
3 Waiting
4 Waiting
5 Waiting
6 Waiting
7 Waiting
8 Waiting
9 Waiting
0 Complete
Press any key to Cancel waiting tasks, then immediately release semaphore
9 Cancelled
3 Cancelled
1 Cancelled
7 Cancelled
8 Cancelled
4 Cancelled
5 Cancelled
6 Cancelled
2 Cancelled
Tasks complete
Finally, note that in the real world, you shouldn't write code like this. Each task that completes work should release the semaphore after it is completed to avoid the myriad of race conditions that you have created.