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.Dispose
method 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
linkedCts
andCts
- 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
usingor
try... 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.)