Quand éliminer CancellationTokenSource?

163

La classe CancellationTokenSourceest jetable. Un rapide coup d'œil dans Reflector prouve l'utilisation d' KernelEventune ressource (très probablement) non gérée. Puisque CancellationTokenSourcen'a pas de finaliseur, si nous ne le supprimons pas, le GC ne le fera pas.

En revanche, si vous examinez les exemples répertoriés dans l'article MSDN Annulation dans les threads gérés , un seul extrait de code supprime le jeton.

Quelle est la bonne façon de s'en débarrasser dans le code?

  1. Vous ne pouvez pas encapsuler le code commençant votre tâche parallèle avec usingsi vous ne l'attendez pas. Et il est logique d'avoir une annulation uniquement si vous n'attendez pas.
  2. Bien sûr, vous pouvez ajouter une ContinueWithtâche avec un Disposeappel, mais est-ce la voie à suivre?
  3. Qu'en est-il des requêtes PLINQ annulables, qui ne se synchronisent pas, mais font simplement quelque chose à la fin? Disons .ForAll(x => Console.Write(x))?
  4. Est-ce réutilisable? Le même jeton peut-il être utilisé pour plusieurs appels, puis le disposer avec le composant hôte, disons le contrôle de l'interface utilisateur?

Parce qu'il n'a pas quelque chose comme une Resetméthode de nettoyage IsCancelRequestedet de Tokenchamp, je suppose qu'il n'est pas réutilisable, donc chaque fois que vous démarrez une tâche (ou une requête PLINQ), vous devez en créer une nouvelle. Est-ce vrai? Si oui, ma question est de savoir quelle est la stratégie correcte et recommandée pour traiter Disposeces nombreux CancellationTokenSourcecas?

George Mamaladze
la source

Réponses:

82

En parlant de savoir s'il est vraiment nécessaire d'appeler Dispose CancellationTokenSource... J'ai eu une fuite de mémoire dans mon projet et il s'est avéré que CancellationTokenSourcec'était le problème.

Mon projet a un service, qui lit constamment la base de données et déclenche différentes tâches, et je passais des jetons d'annulation liés à mes travailleurs, donc même après avoir terminé le traitement des données, les jetons d'annulation n'étaient pas supprimés, ce qui a provoqué une fuite de mémoire.

L' annulation MSDN dans les threads gérés l' indique clairement:

Notez que vous devez appeler Disposela source du jeton lié lorsque vous en avez terminé. Pour obtenir un exemple plus complet, consultez Comment: écouter plusieurs demandes d'annulation .

J'ai utilisé ContinueWithdans ma mise en œuvre.

Gruzilkin
la source
14
Il s'agit d'une omission importante dans la réponse actuellement acceptée par Bryan Crosby - si vous créez un CTS lié , vous risquez des fuites de mémoire. Le scénario est très similaire aux gestionnaires d'événements qui ne sont jamais désinscrits.
Søren Boisen
5
J'ai eu une fuite à cause de ce même problème. En utilisant un profileur, je pouvais voir des enregistrements de rappel contenant des références aux instances CTS liées. L'examen du code pour l'implémentation de CTS Dispose ici a été très perspicace, et souligne la comparaison @ SørenBoisen avec les fuites d'enregistrement du gestionnaire d'événements.
BitMask777
Les commentaires ci-dessus reflètent l'état de la discussion dans lequel l'autre réponse de @Bryan Crosby a été acceptée.
George Mamaladze
La documentation en 2020 dit clairement: Important: 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.- docs.microsoft.com/en-us/dotnet/standard/threading
...
44

Je ne pense pas qu'aucune des réponses actuelles ne soit satisfaisante. Après des recherches, j'ai trouvé cette réponse de Stephen Toub ( référence ):

Ça dépend. Dans .NET 4, CTS.Dispose avait deux objectifs principaux. Si le WaitHandle du CancellationToken a été accédé (donc en l'allouant paresseusement), Dispose supprimera ce handle. En outre, si le CTS a été créé via la méthode CreateLinkedTokenSource, Dispose dissociera le CTS des jetons auxquels il était lié. Dans .NET 4.5, Dispose a un objectif supplémentaire, qui est que si le CTS utilise un Timer sous les couvertures (par exemple CancelAfter a été appelé), le Timer sera éliminé.

Il est très rare que CancellationToken.WaitHandle soit utilisé, donc nettoyer après ce n'est généralement pas une bonne raison d'utiliser Dispose. Si, cependant, vous créez votre CTS avec CreateLinkedTokenSource, ou si vous utilisez la fonctionnalité de minuterie du CTS, il peut être plus efficace d'utiliser Dispose.

La partie audacieuse, je pense, est la partie importante. Il utilise «plus percutant» ce qui laisse un peu flou. Je l'interprète comme signifiant que l'appel Disposedans ces situations doit être fait, sinon l'utilisation Disposen'est pas nécessaire.

Jesse Good
la source
10
Plus percutant signifie que le CTS enfant est ajouté à celui du parent. Si vous ne disposez pas de l'enfant, il y aura une fuite si le parent vit depuis longtemps. Il est donc essentiel de supprimer les éléments liés.
Grigory le
26

J'ai jeté un coup d'œil dans ILSpy pour le CancellationTokenSourcemais je ne peux trouver que m_KernelEventce qui est en fait une ManualResetEvent, qui est une classe wrapper pour un WaitHandleobjet. Cela doit être géré correctement par le GC.

Bryan Crosby
la source
7
J'ai le même sentiment que GC nettoiera tout cela. J'essaierai de le vérifier. Pourquoi Microsoft mis en œuvre dispose-t-il dans ce cas? Pour se débarrasser des rappels d'événements et éviter probablement la propagation vers le GC de deuxième génération. Dans ce cas, l'appel de Dispose est facultatif - appelez-le si vous le pouvez, sinon ignorez-le. Pas de la meilleure manière que je pense.
George Mamaladze
4
J'ai enquêté sur ce problème. CancellationTokenSource récupère les déchets. Vous pourriez aider à disposer pour le faire dans GEN 1 GC. Accepté.
George Mamaladze
1
J'ai fait cette même enquête de manière indépendante et suis arrivé à la même conclusion: éliminez-vous si vous le pouvez facilement, mais ne vous inquiétez pas d'essayer de le faire dans les cas rares mais pas inconnus où vous avez envoyé un CancellationToken dans les boondocks et ne voulez pas attendre qu'ils écrivent une carte postale vous disant qu'ils en ont fini avec. Cela se produira de temps en temps en raison de la nature de l'utilisation de CancellationToken, et c'est vraiment OK, je le promets.
Joe Amenta
6
Mon commentaire ci-dessus ne s'applique pas aux sources de jetons liées; Je ne pouvais pas prouver qu'il était correct de les laisser non éliminés, et la sagesse de ce fil et de MSDN suggère que ce n'est peut-être pas le cas.
Joe Amenta
23

Vous devez toujours vous débarrasser CancellationTokenSource.

La manière de s'en débarrasser dépend exactement du scénario. Vous proposez plusieurs scénarios différents.

  1. usingne fonctionne que lorsque vous utilisez CancellationTokenSourceun travail parallèle que vous attendez. Si c'est votre senario, alors tant mieux, c'est la méthode la plus simple.

  2. Lorsque vous utilisez des tâches, utilisez une ContinueWithtâche comme vous l'avez indiqué pour vous en débarrasser CancellationTokenSource.

  3. Pour plinq, vous pouvez l'utiliser usingcar vous l'exécutez en parallèle mais attendez que tous les nœuds de calcul exécutés en parallèle aient terminé.

  4. Pour l'interface utilisateur, vous pouvez créer un nouveau CancellationTokenSourcepour chaque opération annulable qui n'est pas liée à un seul déclencheur d'annulation. Gérez List<IDisposable>et ajoutez chaque source à la liste, en les éliminant toutes lorsque votre composant est supprimé.

  5. Pour les threads, créez un nouveau thread qui joint tous les threads de travail et ferme la source unique lorsque tous les threads de travail sont terminés. Voir CancellationTokenSource, Quand en disposer?

Il y a toujours un moyen. IDisposableles instances doivent toujours être supprimées. Les échantillons ne le font souvent pas parce qu'ils sont soit des échantillons rapides pour montrer l'utilisation du noyau, soit parce que l'ajout de tous les aspects de la classe démontrée serait trop complexe pour un échantillon. L'échantillon n'est qu'un échantillon, pas nécessairement (ou même généralement) un code de qualité de production. Tous les échantillons ne peuvent pas être copiés tels quels dans le code de production.

Samuel Neff
la source
pour le point 2, une raison pour laquelle vous ne pourriez pas utiliser awaitsur la tâche et disposer le CancellationTokenSource dans le code qui vient après l'attente?
stijn le
14
Il y a des mises en garde. Si le CTS est annulé pendant awaitune opération, vous pouvez reprendre en raison d'un OperationCanceledException. Vous pourriez alors appeler Dispose(). Mais s'il y a des opérations toujours en cours d'exécution et utilisant le correspondant CancellationToken, ce jeton signale toujours CanBeCanceledcomme étant truemême si la source est supprimée. S'ils tentent d'enregistrer un rappel d'annulation, BOOM! , ObjectDisposedException. Il est suffisamment sûr d'appeler Dispose()après la réussite des opérations. Cela devient vraiment délicat lorsque vous devez annuler quelque chose.
Mike Strobel
8
Évité pour les raisons données par Mike Strobel - forcer une règle à toujours appeler Dispose peut vous mettre dans des situations difficiles lorsque vous traitez avec CTS et Task en raison de leur nature asynchrone. La règle devrait plutôt être: toujours disposer des sources de jetons liées .
Søren Boisen
1
Votre lien mène à une réponse supprimée.
Trisped le
19

Cette réponse est toujours à venir dans les recherches Google, et je pense que la réponse votée ne donne pas toute l'histoire. Après avoir examiné le code source pour CancellationTokenSource(CTS) et CancellationToken(CT), je pense que pour la plupart des cas d'utilisation, la séquence de code suivante convient:

if (cancelTokenSource != null)
{
    cancelTokenSource.Cancel();
    cancelTokenSource.Dispose();
    cancelTokenSource = null;
}

Le m_kernelHandlechamp interne mentionné ci-dessus est l'objet de synchronisation qui soutient la WaitHandlepropriété dans les classes CTS et CT. Il n'est instancié que si vous accédez à cette propriété. Donc, à moins que vous n'utilisiez WaitHandlepour une synchronisation de thread à l'ancienne dans votre Taskappel, disposer n'aura aucun effet.

Bien sûr, si vous êtes l' utilisez , vous devez faire ce qui est suggéré par les autres réponses ci - dessus et les appels de retard Disposejusqu'à ce que toutes les WaitHandleopérations à l' aide de la poignée sont complètes, parce que, comme cela est décrit dans la documentation de l' API Windows pour WaitHandle , les résultats ne sont pas définis.

jlyonsmith
la source
7
L'article MSDN Annulation dans les threads gérés indique: «Les écouteurs surveillent la valeur de la IsCancellationRequestedpropriété du jeton en interrogeant, en rappelant ou en attendant la poignée.» En d'autres termes: ce n'est peut-être pas vous (c'est-à-dire celui qui fait la requête asynchrone) qui utilise le handle d'attente, ce peut être l'auditeur (c'est-à-dire celui qui répond à la requête). Ce qui signifie que vous, en tant que responsable de l'élimination, n'avez aucun contrôle sur l'utilisation ou non de la poignée d'attente.
herzbube
Selon MSDN, les rappels enregistrés qui ont fait exception entraîneront la levée de .Cancel. Votre code n'appellera pas .Dispose () si cela se produit. Les rappels doivent faire attention à ne pas le faire, mais cela peut arriver.
Joseph Lennox
11

Cela fait longtemps que je n'ai pas posé cette question et que j'ai obtenu de nombreuses réponses utiles, mais je suis tombé sur un problème intéressant lié à cela et j'ai pensé que je le publierais ici comme une autre réponse:

Vous ne devez appeler CancellationTokenSource.Dispose()que lorsque vous êtes sûr que personne ne tentera d'obtenir la Tokenpropriété du CTS . Sinon, vous ne devriez pas l' appeler, car c'est une course. Par exemple, voir ici:

https://github.com/aspnet/AspNetKatana/issues/108

Dans le correctif de ce problème, le code qui auparavant a cts.Cancel(); cts.Dispose();été modifié pour le faire simplement cts.Cancel();parce que toute personne ayant la malchance d'essayer d'obtenir le jeton d'annulation afin d'observer son état d'annulation après Dispose avoir été appelé devra malheureusement également gérer ObjectDisposedException- en plus duOperationCanceledException qu'ils prévoyaient.

Une autre observation clé liée à ce correctif est faite par Tratcher: "La suppression n'est requise que pour les jetons qui ne seront pas annulés, car l'annulation effectue le même nettoyage." c'est-à-dire qu'il suffit de faire Cancel()au lieu de se débarrasser!

Tim Lovell-Smith
la source
1

J'ai créé une classe thread-safe qui lie a CancellationTokenSourceà a Tasket garantit que le CancellationTokenSourcesera supprimé une fois son associé Taskterminé. Il utilise des verrous pour garantir que le CancellationTokenSourcene sera pas annulé pendant ou après sa mise au rebut. Cela se produit pour la conformité à la documentation , qui stipule:

La Disposeméthode ne doit être utilisée que lorsque toutes les autres opérations sur l' CancellationTokenSourceobjet sont terminées.

Et aussi :

La Disposeméthode laisse le CancellationTokenSourcedans un état inutilisable.

Voici la classe:

public class CancelableExecution
{
    private readonly bool _allowConcurrency;
    private Operation _activeOperation;

    private class Operation : IDisposable
    {
        private readonly object _locker = new object();
        private readonly CancellationTokenSource _cts;
        private readonly TaskCompletionSource<bool> _completionSource;
        private bool _disposed;

        public Task Completion => _completionSource.Task; // Never fails

        public Operation(CancellationTokenSource cts)
        {
            _cts = cts;
            _completionSource = new TaskCompletionSource<bool>(
                TaskCreationOptions.RunContinuationsAsynchronously);
        }
        public void Cancel()
        {
            lock (_locker) if (!_disposed) _cts.Cancel();
        }
        void IDisposable.Dispose() // Is called only once
        {
            try
            {
                lock (_locker) { _cts.Dispose(); _disposed = true; }
            }
            finally { _completionSource.SetResult(true); }
        }
    }

    public CancelableExecution(bool allowConcurrency)
    {
        _allowConcurrency = allowConcurrency;
    }
    public CancelableExecution() : this(false) { }

    public bool IsRunning =>
        Interlocked.CompareExchange(ref _activeOperation, null, null) != null;

    public async Task<TResult> RunAsync<TResult>(
        Func<CancellationToken, Task<TResult>> taskFactory,
        CancellationToken extraToken = default)
    {
        var cts = CancellationTokenSource.CreateLinkedTokenSource(extraToken, default);
        using (var operation = new Operation(cts))
        {
            // Set this as the active operation
            var oldOperation = Interlocked.Exchange(ref _activeOperation, operation);
            try
            {
                if (oldOperation != null && !_allowConcurrency)
                {
                    oldOperation.Cancel();
                    await oldOperation.Completion; // Continue on captured context
                }
                var task = taskFactory(cts.Token); // Run in the initial context
                return await task.ConfigureAwait(false);
            }
            finally
            {
                // If this is still the active operation, set it back to null
                Interlocked.CompareExchange(ref _activeOperation, null, operation);
            }
        }
    }

    public Task RunAsync(Func<CancellationToken, Task> taskFactory,
        CancellationToken extraToken = default)
    {
        return RunAsync<object>(async ct =>
        {
            await taskFactory(ct).ConfigureAwait(false);
            return null;
        }, extraToken);
    }

    public Task CancelAsync()
    {
        var operation = Interlocked.CompareExchange(ref _activeOperation, null, null);
        if (operation == null) return Task.CompletedTask;
        operation.Cancel();
        return operation.Completion;
    }

    public bool Cancel() => CancelAsync() != Task.CompletedTask;
}

Les principales méthodes de la CancelableExecutionclasse sont les RunAsyncet les Cancel. Par défaut, les opérations simultanées ne sont pas autorisées, ce qui signifie que l'appelRunAsync deuxième annulera silencieusement et attendra la fin de l'opération précédente (si elle est toujours en cours), avant de démarrer la nouvelle opération.

Cette classe peut être utilisée dans des applications de tout type. Son utilisation principale est cependant dans les applications d'interface utilisateur, dans des formulaires avec des boutons pour démarrer et annuler une opération asynchrone, ou avec une zone de liste qui annule et redémarre une opération à chaque fois que son élément sélectionné est modifié. Voici un exemple du premier cas:

private readonly CancelableExecution _cancelableExecution = new CancelableExecution();

private async void btnExecute_Click(object sender, EventArgs e)
{
    string result;
    try
    {
        Cursor = Cursors.WaitCursor;
        btnExecute.Enabled = false;
        btnCancel.Enabled = true;
        result = await _cancelableExecution.RunAsync(async ct =>
        {
            await Task.Delay(3000, ct); // Simulate some cancelable I/O operation
            return "Hello!";
        });
    }
    catch (OperationCanceledException)
    {
        return;
    }
    finally
    {
        btnExecute.Enabled = true;
        btnCancel.Enabled = false;
        Cursor = Cursors.Default;
    }
    this.Text += result;
}

private void btnCancel_Click(object sender, EventArgs e)
{
    _cancelableExecution.Cancel();
}

La RunAsyncméthode accepte un extra CancellationTokencomme argument, qui est lié au créé en interne CancellationTokenSource. Fournir ce jeton facultatif peut être utile dans les scénarios avancés.

Theodor Zoulias
la source