On playing with disposing CancellationTokenSource objects and linked CTS to see how it affects memory, I found that disposing the CTS also releases the objects from a linked CTS which was created from the CancellationTokenSource.Token. Why is that?
Background
I was investigating a memory leak, caused by CancellationTokenSource not being disposed, similar as to what has been reported here. The memory dump of the application revealed a huge amount of CancellationTokenSource and related objects. The application (console, C#, .Net 4.7.2, Windows Server) runs several jobs from a queue. The class to which I could root many of the objects in memory, creates many CancellationTokenSource but does not dispose them.
Looking for info about CancellationTokenSource and memory leaks, I came across this really informative question here on SO: When to dispose CancellationTokenSource?.
For me, not (yet :-)) a tasks expert, there are some contradictory statements in it. The Microsoft docs are pretty clear about disposing of CancellationTokenSource (reference)
The CancellationTokenSource class implements the IDisposable interface. You should be sure to call the
CancellationTokenSource.Disposemethod when you have finished using the cancellation token source to free any unmanaged resources it holds.
Still, there seems to be cases where disposing can bring you in trouble, especially when the CTS gets cancelled while you await an operation. Also there is an answer quoting Stephen Toub:
It depends. In .NET 4, CTS.Dispose served two primary purposes. If the CancellationToken's WaitHandle had been accessed (thus lazily allocating it), Dispose will dispose of that handle. Additionally, if the CTS was created via the CreateLinkedTokenSource method, Dispose will unlink the CTS from the tokens it was linked to. In .NET 4.5, Dispose has an additional purpose, which is if the CTS uses a Timer under the covers (e.g. CancelAfter was called), the Timer will be Disposed.
It's very rare for CancellationToken.WaitHandle to be used, so cleaning up after it typically isn't a great reason to use Dispose. If, however, you're creating your CTS with CreateLinkedTokenSource, or if you're using the CTS' timer functionality, it can be more impactful to use Dispose.
Probably I just didn't fully understand his point, but this could be interpreted like "you can, but must not dispose".
Tests
To get more clarity for myself, if what I have found in the app can be the reason for the memory leak, I made up a test. The test code is simplified, but uses the same "pattern" as the application. Being able to compare results from different tests, the queue is filled with 200 jobs which are then processed by the worker. In the real app, jobs keep coming in and get processed in an infinite loop.
The full code can be found here: https://dotnetfiddle.net/8AMXgf
For every job, there are 3 tokens at play:
- "global" CancellationToken, to cancel the whole process when app is stopped (
ct) - job specific CancellationTokenSource, to cancel after 5 mins (
Cts) - linked CancellationTokenSource, links 1 + 2 (
linkedCts)
The tests are:
- Dispose only
linkedCts - Dispose
linkedCtsandCts - Dispose only
Cts
"Benchmark"
The "benchmark" I compared the results to, basically mirrors the current state of the real app, which is not disposing anything. The memory snapshots shown, were taken right after the 200 jobs have been processed.
Here is the code part which creates tasks and adds them to a process queue.
foreach (var job in jobsToRun)
{
var runProcess = new ProcessDescriptor()
{
Descriptor = job.Value,
Cts = new CancellationTokenSource(TimeSpan.FromMinutes(5))
};
// link cts with "global" token (ct)
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, runProcess.Cts.Token);
runProcess.Task = RunJob1(job.Value, linkedCts.Token);
_processQueue.Add(runProcess);
}
When the job is done, it's removed from the queue, but not disposed.
foreach (var process in _processQueue.ToList())
{
if (process.Task.Status == TaskStatus.RanToCompletion)
{
// use dispose() for test 2 & test 3
// process.CancellationTokenSource.Dispose();
_processQueue.Remove(process);
}
}
After 200 jobs have been processed. The heap looks like this. 200 * Cts and 200 * linkedCts = 400 CancellationTokenSource objects.
Even after a forced GC.Collect the CancellationTokenSource objects still remain in memory.
Test 1: Dispose only linked CancellationTokenSource
This goes back to the comment from Stephen Toub (reference)
If, however, you're creating your CTS with CreateLinkedTokenSource, or if you're using the CTS' timer functionality, it can be more impactful to use Dispose.
For this I chose to pass linkedCts cts one level down, where it is disposed when the job is done. (It's probably not best practice to do so. But I can't await here, so usingortry... catch... finallyisn't an option. As an alternative.ContinueWith()` could probably be better. But I don't know. That would be another question...)
// pass on the CTS instead of the token (probably not best practice), which will be disposed when job is done - use this line for test 1 & test 2
runProcess.Task = RunJob2(job.Value, linkedCts);
The result shows, that half of the CancellationTokenSource objects have been released from memory. Still 200 objects remain.
Test 2: Dispose linked CancellationTokenSource and CancellationTokenSource
Here both Cts and linkedCts get disposed when a job is done. The result shows, that all CancellationTokenSource objects (besides the 2 global instances) have been released from memory.
Test 3:
This time, only the Cts gets disposed. The linkedCts, which is created from Cts and the global ct, is not being disposed. I'd expect the same result as with test 1 (half of the objects being released).
The result shows the exakt same picture as with test 2 where both cts and linked cts have been disposed. This confuses me.
Why does disposing of Cts only, show the same result as disposing Cts and linkedCts? To me it seems that disposing Cts frees resources of linkedCts to - even though linkedCts is also linked to ct which is still "alive"?
(Sorry for the lengthy post. I wanted to put all info in. Feel free to edit, if you feel it improves the question.)





