Comment rendre une méthode annulable sans devenir moche?

Je suis actuellement en train de moderniser nos méthodes de longue durée pour qu’elles soient annulables. Je prévois d’utiliser System.Threading.Tasks.CancellationToken pour l’implémenter.

Nos méthodes effectuent généralement quelques étapes longues (envoi de commandes puis attente du matériel principalement), par exemple

void Run() { Step1(); Step2(); Step3(); } 

Ma première pensée (peut-être stupide) sur l’annulation transformerait cela en

 bool Run(CancellationToken cancellationToken) { Step1(cancellationToken); if (cancellationToken.IsCancellationRequested) return false; Step2(cancellationToken); if (cancellationToken.IsCancellationRequested) return false; Step3(cancellationToken); if (cancellationToken.IsCancellationRequested) return false; return true; } 

ce qui est franchement horrible. Ce “pattern” continuerait aussi à l’intérieur des étapes simples (et elles sont déjà assez longues). Cela rendrait Thread.Abort () plutôt sexy, bien que je sache que ce n’est pas recommandé.

Y a-t-il un modèle plus propre pour y parvenir qui ne cache pas la logique de l’application sous beaucoup de code passe-partout?

modifier

Comme exemple de la nature des étapes, la méthode Run peut lire

 void Run() { GiantRobotor.MoveToBase(); Oven.ThrowBaguetteTowardsBase(); GiantRobotor.CatchBaguette(); // ... } 

Nous contrôlons différentes unités matérielles qui doivent être synchronisées pour fonctionner ensemble.

Si les étapes sont indépendantes en ce qui concerne le stream de données dans la méthode, mais ne peuvent pas être exécutées en parallèle, l’approche suivante peut être plus lisible:

 void Run() { // list of actions, defines the order of execution var actions = new List>() { ct => Step1(ct), ct => Step2(ct), ct => Step3(ct) }; // execute actions and check for cancellation token foreach(var action in actions) { action(cancellationToken); if (cancellationToken.IsCancellationRequested) return false; } return true; } 

Si les étapes ne nécessitent pas le jeton d’annulation, car vous pouvez les diviser en minuscules unités, vous pouvez même écrire une définition de liste plus petite:

 var actions = new List() { Step1, Step2, Step3 }; 

Qu’en est-il de la continuation?

 var t = Task.Factory.StartNew(() => Step1(cancellationToken), cancellationToken) .ContinueWith(task => Step2(cancellationToken), cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current) .ContinueWith(task => Step3(cancellationToken), cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current); 

J’avoue que ce n’est pas joli, mais le conseil est soit de faire ce que vous avez fait:

 if (cancellationToken.IsCancellationRequested) { /* Stop */ } 

… ou légèrement plus court:

 cancellationToken.ThrowIfCancellationRequested() 

En règle générale, si vous pouvez transmettre le jeton d’annulation aux différentes étapes, vous pouvez répartir les chèques de manière à ne pas saturer le code. Vous pouvez également choisir de ne pas vérifier les annulations en permanence ; Si les opérations que vous effectuez sont idempotentes et ne nécessitent pas beaucoup de ressources, vous ne devez pas nécessairement vérifier les annulations à chaque étape. Le moment le plus important pour vérifier est avant de retourner un résultat.

Si vous transmettez le jeton à toutes vos étapes, vous pouvez faire quelque chose comme ceci:

 public static CancellationToken VerifyNotCancelled(this CancellationToken t) { t.ThrowIfCancellationRequested(); return t; } ... Step1(token.VerifyNotCancelled()); Step2(token.VerifyNotCancelled()); Step3(token.VerifyNotCancelled()); 

Quand j’ai dû faire quelque chose comme ça, j’ai créé un délégué pour le faire:

 bool Run(CancellationToken cancellationToken) { var DoIt = new Func,bool>((f) => { f(cancellationToken); return cancellationToken.IsCancellationRequested; }); if (!DoIt(Step1)) return false; if (!DoIt(Step2)) return false; if (!DoIt(Step3)) return false; return true; } 

Ou, s’il n’y a jamais de code entre les étapes, vous pouvez écrire:

 return DoIt(Step1) && DoIt(Step2) && DoIt(Step3); 

Version courte:

Utilisez lock() pour synchroniser l’appel Thread.Abort() avec les sections critiques.


Laissez-moi vous expliquer la version:

En règle générale, lorsque vous abandonnez un thread, vous devez considérer deux types de code:

  • Un long code de fonctionnement que vous ne vous souciez pas vraiment s’il se termine
  • Code qui doit absolument suivre son cours

Le premier type est le type qui ne nous intéresse pas si l’utilisateur a demandé l’abandon. Peut-être comptions-nous une centaine de milliards et il s’en moque maintenant?

Si nous utilisions des sentinelles telles que CancellationToken , nous les testerions difficilement à chaque itération du code sans importance?

 for(long i = 0; i < bajillion; i++){ if(cancellationToken.IsCancellationRequested) return false; counter++; } 

Tellement moche. Donc, pour ces cas, Thread.Abort() est une aubaine.

Malheureusement, comme certains disent, vous ne pouvez pas utiliser Thread.Abort() cause du code atomique qui doit absolument s'exécuter! Le code a déjà soustrait de l'argent de votre compte, il doit maintenant effectuer la transaction et transférer l'argent sur le compte cible. Personne n'aime l'argent qui disparaît.

Heureusement, nous avons une exclusion mutuelle pour nous aider avec ce genre de choses. Et C # le rend joli:

 //unimportant long task code lock(_lock) { //atomic task code } 

Et ailleurs

 lock(_lock) //same lock { _thatThread.Abort(); } 

Le nombre de verrous sera toujours <= nombre de sentinelles, car vous voudrez aussi des sentinelles sur le code sans importance (pour le rendre plus rapide). Cela rend le code légèrement plus joli que la version sentinelle, mais il permet aussi de mieux en finir, car il n’est pas nécessaire d’attendre des choses sans importance.


À noter également que ThreadAbortException pourrait être ThreadAbortException n'importe où, même dans les blocs finally . Cette imprévisibilité rend Thread.Abort() si controversé.

Avec les verrous, vous éviterez cela en verrouillant simplement le bloc try-catch-finally . Si le nettoyage est essentiel, le bloc entier peut être verrouillé. try blocs d’ try sont généralement conçus pour être aussi courts que possible, une ligne de préférence, nous ne bloquons donc pas de code inutile.

Cela rend Thread.Abort() , comme vous le dites, un peu moins mal. Vous ne voudrez peut-être pas l'appeler sur le thread d'interface utilisateur, car vous le verrouillez maintenant.

Si un refactor est autorisé, vous pouvez refactoriser les méthodes Step de telle sorte qu’il y ait une méthode Step(int number) .

Vous pouvez ensuite passer de 1 à N et vérifier si le jeton d’annulation n’est demandé qu’une seule fois.

 bool Run(CancellationToken cancellationToken) { for (int i = 1; i < 3 && !cancellationToken.IsCancellationRequested; i++) Step(i, cancellationToken); return !cancellationToken.IsCancellationRequested; } 

Ou, de manière équivalente: (selon votre préférence)

 bool Run(CancellationToken cancellationToken) { for (int i = 1; i < 3; i++) { Step(i, cancellationToken); if (cancellationToken.IsCancellationRequested) return false; } return true; } 

Vous voudrez peut-être examiner le modèle NSOperation d’Apple comme exemple. C’est plus compliqué que de simplement annuler une seule méthode, mais c’est plutôt robuste.

Je suis un peu étonné que personne n’ait proposé le moyen standard de gérer cela:

 bool Run(CancellationToken cancellationToken) { //cancellationToke.ThrowIfCancellationRequested(); try { Step1(cancellationToken); Step2(cancellationToken); Step3(cancellationToken); } catch(OperationCanceledException ex) { return false; } return true; } void Step1(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ... } 

Bien que normalement vous ne vouliez pas dépendre des contrôles à des niveaux plus profonds, dans ce cas, les étapes acceptent déjà un CancellationToken et devraient quand même avoir la vérification (comme toute méthode non sortingviale acceptant un CancellationToken).

Cela vous permet également d’être aussi granulaire ou non granulaire que nécessaire avec les contrôles, c’est-à-dire avant les opérations de longue durée / intensives.