Événements C # et sécurité des threads

237

METTRE À JOUR

Depuis C # 6, la réponse à cette question est:

SomeEvent?.Invoke(this, e);

J'entends / lis fréquemment les conseils suivants:

Faites toujours une copie d'un événement avant de le vérifier nullet de le déclencher. Cela éliminera un problème potentiel avec le filetage où l'événement se situe nullà l'emplacement exact entre l'endroit où vous vérifiez la valeur null et l'endroit où vous déclenchez l'événement:

// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;

if (copy != null)
    copy(this, EventArgs.Empty); // Call any handlers on the copied list

Mise à jour : J'ai pensé à la lecture des optimisations que cela pourrait également nécessiter la volatilité du membre de l'événement, mais Jon Skeet déclare dans sa réponse que le CLR n'optimise pas la copie.

Mais en attendant, pour que ce problème se produise, un autre thread doit avoir fait quelque chose comme ceci:

// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;
// Good, now we can be certain that OnTheEvent will not run...

La séquence réelle pourrait être ce mélange:

// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;

// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;    
// Good, now we can be certain that OnTheEvent will not run...

if (copy != null)
    copy(this, EventArgs.Empty); // Call any handlers on the copied list

Le fait étant que OnTheEventl'auteur s'exécute après sa désinscription, et pourtant ils se sont simplement désabonnés spécifiquement pour éviter que cela ne se produise. Ce qui est vraiment nécessaire, c'est une implémentation d'événements personnalisée avec une synchronisation appropriée dans les accesseurs addet remove. De plus, il y a le problème des interblocages possibles si un verrou est maintenu pendant le déclenchement d'un événement.

Alors est - ce Programming Cargo Cult ? Il semble que de cette façon - beaucoup de gens doivent prendre cette mesure pour protéger leur code de plusieurs threads, alors qu'en réalité il me semble que les événements nécessitent beaucoup plus de soin que cela avant de pouvoir être utilisés dans le cadre d'une conception multi-thread . Par conséquent, les personnes qui ne prennent pas ces précautions supplémentaires pourraient tout aussi bien ignorer ce conseil - ce n'est tout simplement pas un problème pour les programmes à un seul thread, et en fait, étant donné l'absence de la volatileplupart des exemples de code en ligne, le conseil peut ne pas avoir effet du tout.

(Et n'est-il pas beaucoup plus simple d'affecter simplement le vide delegate { }sur la déclaration de membre afin que vous n'ayez jamais besoin de vérifier nullen premier lieu?)

Actualisé:Au cas où ce ne serait pas clair, j'ai bien compris l'intention de l'avis - éviter une exception de référence nulle en toutes circonstances. Mon point est que cette exception de référence nulle particulière ne peut se produire que si un autre thread est retiré de l'événement, et la seule raison de le faire est de s'assurer qu'aucun autre appel ne sera reçu via cet événement, ce qui n'est clairement PAS atteint par cette technique. . Vous cacheriez une condition de course - il vaudrait mieux la révéler! Cette exception nulle permet de détecter un abus de votre composant. Si vous souhaitez que votre composant soit protégé contre les abus, vous pouvez suivre l'exemple de WPF - stocker l'ID de thread dans votre constructeur, puis lever une exception si un autre thread tente d'interagir directement avec votre composant. Ou bien implémentez un composant vraiment thread-safe (pas une tâche facile).

Je soutiens donc que le simple fait de faire cet idiome de copie / vérification est une programmation culte, ajoutant du désordre et du bruit à votre code. Pour se protéger contre d'autres threads, il faut beaucoup plus de travail.

Mise à jour en réponse aux articles de blog d'Eric Lippert:

Il y a donc une chose importante que j'ai manquée à propos des gestionnaires d'événements: "les gestionnaires d'événements doivent être robustes face à être appelés même après que l'événement a été désabonné", et évidemment, nous n'avons donc qu'à nous soucier de la possibilité de l'événement être délégué null. Cette exigence sur les gestionnaires d'événements est-elle documentée n'importe où?

Et donc: "Il existe d'autres façons de résoudre ce problème; par exemple, initialiser le gestionnaire pour avoir une action vide qui n'est jamais supprimée. Mais faire une vérification nulle est le modèle standard."

Donc, le dernier fragment de ma question est, pourquoi est-null-check explicite le "modèle standard"? L'alternative, attribuer le délégué vide, ne nécessite que = delegate {}d'être ajoutée à la déclaration de l'événement, ce qui élimine ces petits tas de cérémonie puante de chaque endroit où l'événement est déclenché. Il serait facile de s'assurer que le délégué vide est bon marché à instancier. Ou est-ce que je manque encore quelque chose?

Il doit sûrement s'agir (comme l'a suggéré Jon Skeet) d'un conseil .NET 1.x qui n'a pas disparu, comme il aurait dû le faire en 2005?

Daniel Earwicker
la source
4
Cette question a été soulevée lors d'une discussion interne il y a quelque temps; J'ai l'intention de bloguer cela depuis un certain temps maintenant. Mon article sur le sujet est ici: Evénements et courses
Eric Lippert
3
Stephen Cleary a un article CodeProject qui examine ce problème, et il conclut qu'il n'existe pas de solution «thread-safe» à usage général. Fondamentalement, il appartient à l'appelant d'événement de s'assurer que le délégué n'est pas nul, et au gestionnaire d'événement de pouvoir gérer l'invocation après sa désinscription.
rkagerer
3
@rkagerer - en fait, le deuxième problème doit parfois être traité par le gestionnaire d'événements même si les threads ne sont pas impliqués. Peut se produire si un gestionnaire d'événements demande à un autre gestionnaire de se désabonner de l'événement en cours de traitement, mais que le deuxième abonné recevra quand même l'événement (car il s'est désabonné pendant la gestion).
Daniel Earwicker
3
L'ajout d'un abonnement à un événement avec zéro abonné, la suppression du seul abonnement pour l'événement, l'appel d'un événement avec zéro abonné et l'appel d'un événement avec exactement un abonné, sont tous des opérations beaucoup plus rapides que l'ajout / suppression / appel de scénarios impliquant d'autres nombres de les abonnés. L'ajout d'un délégué fictif ralentit le cas commun. Le vrai problème avec C # est que ses créateurs ont décidé d' EventName(arguments)invoquer le délégué de l'événement sans condition, plutôt que de ne l'invoquer que s'il n'est pas nul (ne rien faire s'il est nul).
supercat

Réponses:

100

Le JIT n'est pas autorisé à effectuer l'optimisation dont vous parlez dans la première partie, en raison de la condition. Je sais que cela a été soulevé comme un spectre il y a quelque temps, mais ce n'est pas valable. (Je l'ai vérifié avec Joe Duffy ou Vance Morrison il y a quelque temps; je ne me souviens plus lequel.)

Sans le modificateur volatile, il est possible que la copie locale prise soit obsolète, mais c'est tout. Cela ne provoquera pas de NullReferenceException.

Et oui, il y a certainement une condition de course - mais il y en aura toujours. Supposons que nous changions simplement le code en:

TheEvent(this, EventArgs.Empty);

Supposons maintenant que la liste d'appels de ce délégué comporte 1 000 entrées. Il est parfaitement possible que l'action au début de la liste se soit exécutée avant qu'un autre thread ne désabonne un gestionnaire vers la fin de la liste. Cependant, ce gestionnaire sera toujours exécuté car il s'agira d'une nouvelle liste. (Les délégués sont immuables.) Autant que je sache, cela est inévitable.

L'utilisation d'un délégué vide évite certainement le contrôle de nullité, mais ne résout pas la condition de concurrence. Cela ne garantit pas non plus que vous "voyez" toujours la dernière valeur de la variable.

Jon Skeet
la source
4
«Programmation simultanée sous Windows» de Joe Duffy couvre l'optimisation JIT et l'aspect du modèle de mémoire de la question; voir code.logos.com/blog/2008/11/events_and_threads_part_4.html
Bradley Grainger
2
J'ai accepté cela sur la base du commentaire selon lequel le conseil "standard" était pré-C # 2, et je n'entends personne le contredire. À moins qu'il ne soit vraiment coûteux d'instancier vos arguments d'événement, mettez simplement '= delegate {}' à la fin de votre déclaration d'événement, puis appelez directement vos événements comme s'il s'agissait de méthodes; ne leur attribuez jamais de valeur nulle. (Les autres éléments que j'ai apportés pour garantir qu'un gestionnaire n'est pas appelé après la radiation, cela n'était pas pertinent et sont impossibles à garantir, même pour le code à thread unique, par exemple si le gestionnaire 1 demande au gestionnaire 2 de se retirer, le gestionnaire 2 sera toujours appelé suivant.)
Daniel Earwicker
2
Le seul cas problématique (comme toujours) est celui des structures, où vous ne pouvez pas vous assurer qu'elles seront instanciées avec autre chose que des valeurs nulles dans leurs membres. Mais les structures sont nulles.
Daniel Earwicker
1
À propos du délégué vide, voir aussi cette question: stackoverflow.com/questions/170907/… .
Vladimir
2
@Tony: Il y a toujours fondamentalement une condition de concurrence entre un abonnement / désabonnement et l'exécution du délégué. Votre code (après l'avoir parcouru brièvement) réduit cette condition de concurrence en permettant à l'abonnement / au désabonnement de prendre effet pendant qu'il est levé, mais je soupçonne que dans la plupart des cas où le comportement normal n'est pas assez bon, ce n'est pas non plus.
Jon Skeet
52

Je vois beaucoup de gens se diriger vers la méthode d'extension pour ce faire ...

public static class Extensions   
{   
  public static void Raise<T>(this EventHandler<T> handler, 
    object sender, T args) where T : EventArgs   
  {   
    if (handler != null) handler(sender, args);   
  }   
}

Cela vous donne une syntaxe plus agréable pour déclencher l'événement ...

MyEvent.Raise( this, new MyEventArgs() );

Et supprime également la copie locale car elle est capturée au moment de l'appel de méthode.

JP Alioto
la source
9
J'aime la syntaxe, mais soyons clairs ... cela ne résout pas le problème avec un gestionnaire périmé qui est invoqué même après avoir été désenregistré. Cela ne résout que le problème de déréférencement nul. Bien que j'aime la syntaxe, je me demande si elle est vraiment meilleure que: public event EventHandler <T> MyEvent = delete {}; ... MyEvent (ceci, nouveau MyEventArgs ()); C'est aussi une solution à très faible frottement que j'aime pour sa simplicité.
Simon Gillbee
@Simon Je vois différentes personnes dire des choses différentes à ce sujet. Je l'ai testé et ce que j'ai fait m'indique que cela gère le problème du gestionnaire nul. Même si le récepteur d'origine se désinscrit de l'événement après la vérification du gestionnaire! = Null, l'événement est toujours déclenché et aucune exception n'est levée.
JP Alioto
yup, cf cette question: stackoverflow.com/questions/192980/…
Benjol
1
+1. Je viens d'écrire cette méthode moi-même, en commençant à penser à la sécurité des threads, j'ai fait des recherches et suis tombé sur cette question.
Niels van der Rest
Comment cela peut-il être appelé depuis VB.NET? Ou bien «RaiseEvent» répond-il déjà aux scénarios multi-threading?
35

"Pourquoi explicit-null-check le 'modèle standard'?"

Je soupçonne que cela pourrait être dû au fait que le contrôle nul est plus performant.

Si vous abonnez toujours un délégué vide à vos événements lors de leur création, il y aura des frais généraux:

  • Coût de construction du délégué vide.
  • Coût de construction d'une chaîne de délégués pour la contenir.
  • Coût d'invoquer le délégué inutile à chaque fois que l'événement est déclenché.

(Notez que les contrôles d'interface utilisateur comportent souvent un grand nombre d'événements, dont la plupart ne sont jamais abonnés. Le fait de créer un abonné factice pour chaque événement, puis de l'invoquer serait probablement un impact significatif sur les performances.)

J'ai fait quelques tests de performances superficiels pour voir l'impact de l'approche subscribe-empty-delegate, et voici mes résultats:

Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      432ms
OnClassicNullCheckedEvent took: 490ms
OnPreInitializedEvent took:     614ms <--
Subscribing an empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      674ms
OnClassicNullCheckedEvent took: 674ms
OnPreInitializedEvent took:     2041ms <--
Subscribing another empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      2011ms
OnClassicNullCheckedEvent took: 2061ms
OnPreInitializedEvent took:     2246ms <--
Done

Notez que pour le cas de zéro ou un abonné (courant pour les contrôles UI, où les événements sont nombreux), l'événement pré-initialisé avec un délégué vide est notamment plus lent (plus de 50 millions d'itérations ...)

Pour plus d'informations et le code source, visitez cet article de blog sur la sécurité des threads d'invocation d'événements .NET que j'ai publié la veille de la question (!)

(Ma configuration de test peut être défectueuse, alors n'hésitez pas à télécharger le code source et à l'inspecter vous-même. Tout commentaire est très apprécié.)

Daniel Fortunov
la source
8
Je pense que vous faites valoir le point clé de votre article de blog: il n'y a pas lieu de s'inquiéter des implications en termes de performances jusqu'à ce qu'il s'agisse d'un goulot d'étranglement. Pourquoi laisser la voie laide être la voie recommandée? Si nous voulions une optimisation prématurée au lieu de la clarté, nous utiliserions l'assembleur - donc ma question reste, et je pense que la réponse probable est que les conseils sont antérieurs aux délégués anonymes et que la culture humaine met beaucoup de temps à changer les anciens conseils, comme dans la célèbre "histoire du rôti en pot".
Daniel Earwicker
13
Et vos chiffres le prouvent très bien: les frais généraux se réduisent à seulement deux et demi NANOSECONDS (!!!) par événement levé (pré-init vs classic-null). Cela serait indétectable dans presque toutes les applications avec un vrai travail à faire, mais étant donné que la grande majorité de l'utilisation des événements se fait dans des cadres d'interface graphique, vous devrez comparer cela avec le coût de la peinture de parties d'un écran dans Winforms, etc., donc il devient encore plus invisible dans le déluge du vrai travail CPU et dans l'attente des ressources. Vous obtenez +1 de moi pour le travail acharné, de toute façon. :)
Daniel Earwicker
1
@DanielEarwicker l'a bien dit, vous m'avez incité à croire à l'événement public WrapperDoneHandler OnWrapperDone = (x, y) => {}; modèle.
Mickey Perlstein
1
Il peut également être judicieux de chronométrer une paire Delegate.Combine/ Delegate.Removedans les cas où l'événement a zéro, un ou deux abonnés; si l'on ajoute et supprime à plusieurs reprises la même instance de délégué, les différences de coût entre les cas seront particulièrement prononcées car Combinea un comportement de cas spécial rapide lorsque l'un des arguments est null(il suffit de renvoyer l'autre), et Removeest très rapide lorsque les deux arguments sont égal (il suffit de retourner null).
supercat
12

J'ai vraiment apprécié cette lecture - pas! Même si j'en ai besoin pour travailler avec la fonctionnalité C # appelée événements!

Pourquoi ne pas corriger cela dans le compilateur? Je sais qu'il y a des personnes atteintes de sclérose en plaques qui lisent ces messages, alors s'il vous plaît, ne flambez pas!

1 - le problème Null ) Pourquoi ne pas faire en sorte que les événements soient .Empty au lieu de null en premier? Combien de lignes de code seraient enregistrées pour une vérification nulle ou pour coller un = delegate {}sur la déclaration? Laissez le compilateur gérer le cas vide, IE ne fait rien! Si tout cela est important pour le créateur de l'événement, ils peuvent vérifier .Vider et faire ce qu'ils veulent avec! Sinon, toutes les vérifications nulles / ajouts de délégués sont des hacks autour du problème!

Honnêtement, je suis fatigué d'avoir à le faire avec chaque événement - aka code passe-partout!

public event Action<thisClass, string> Some;
protected virtual void DoSomeEvent(string someValue)
{
  var e = Some; // avoid race condition here! 
  if(null != e) // avoid null condition here! 
     e(this, someValue);
}

2 - le problème de la condition de concurrence ) J'ai lu le blog d'Eric, je suis d'accord que le H (gestionnaire) devrait gérer quand il se déréférence, mais l'événement ne peut-il pas être rendu immuable / thread-safe? IE, définir un indicateur de verrouillage lors de sa création, de sorte que chaque fois qu'il est appelé, il verrouille tous les abonnements et désabonnements pendant son exécution?

Conclusion ,

Les langues modernes ne sont-elles pas censées résoudre de tels problèmes pour nous?

Chuck Savage
la source
D'accord, il devrait y avoir un meilleur support pour cela dans le compilateur. Jusque-là, j'ai créé un aspect PostSharp qui le fait dans une étape de post-compilation . :)
Steven Jeuris
4
Il est bien pire d' empêcher le blocage des demandes d'abonnement / désabonnement des threads en attendant la fin du code extérieur arbitraire que de demander aux abonnés de recevoir des événements après l'annulation des abonnements, d'autant plus que ce dernier "problème" peut être résolu facilement en demandant simplement aux gestionnaires d'événements de vérifier un indicateur pour voir si ils sont toujours intéressés à recevoir leur événement, mais les blocages résultant de l'ancienne conception peuvent être insolubles.
supercat
@supercat. Imo, le commentaire "bien pire" dépend assez de l'application. Qui ne voudrait pas d'un verrouillage très strict sans drapeaux supplémentaires quand c'est une option? Un blocage ne devrait se produire que si le thread de gestion des événements attend encore un autre thread (c'est-à-dire abonnement / désabonnement) car les verrous sont rentrants sur le même thread et un abonnement / désabonnement dans le gestionnaire d'événements d'origine ne serait pas bloqué. S'il y a du fil croisé en attente dans le cadre d'un gestionnaire d'événements qui ferait partie de la conception, je préférerais retravailler. Je viens d'un angle d'application côté serveur qui a des modèles prévisibles.
crokusek
2
@crokusek: L'analyse requise pour prouver qu'un système est exempt de blocage est facile s'il n'y aurait pas de cycles dans un graphique dirigé reliant chaque verrou à tous les verrous qui pourraient être nécessaires pendant qu'il est maintenu [le manque de cycles prouve le système sans blocage]. Permettre au code arbitraire d'être invoqué alors qu'un verrou est maintenu créera un bord dans le graphique "peut-être nécessaire" de ce verrou à tout verrou qu'un code arbitraire pourrait acquérir (pas tout à fait tous les verrous du système, mais pas loin de lui ). L'existence conséquente de cycles n'impliquerait pas une impasse, mais ...
supercat
1
... augmenterait considérablement le niveau d'analyse nécessaire pour prouver qu'il ne le pouvait pas.
supercat
8

Avec C # 6 et supérieur, le code pourrait être simplifié en utilisant un nouvel ?.opérateur comme dans:

TheEvent?.Invoke(this, EventArgs.Empty);

Voici la documentation MSDN.

Phil1970
la source
5

Selon Jeffrey Richter dans le livre CLR via C # , la bonne méthode est:

// Copy a reference to the delegate field now into a temporary field for thread safety
EventHandler<EventArgs> temp =
Interlocked.CompareExchange(ref NewMail, null, null);
// If any methods registered interest with our event, notify them
if (temp != null) temp(this, e);

Parce que cela force une copie de référence. Pour plus d'informations, consultez sa section Événement dans le livre.

alyx
la source
Il se peut que je manque smth, mais Interlocked.CompareExchange lève NullReferenceException si son premier argument est nul, ce qui est exactement ce que nous voulons éviter. msdn.microsoft.com/en-us/library/bb297966.aspx
Kniganapolke
1
Interlocked.CompareExchangeéchouerait s'il était en quelque sorte passé un null ref, mais ce n'est pas la même chose que d'être passé refà un emplacement de stockage (par exemple NewMail) qui existe et qui contient initialement une référence nulle.
supercat
4

J'ai utilisé ce modèle de conception pour garantir que les gestionnaires d'événements ne sont pas exécutés après leur désinscription. Cela fonctionne assez bien jusqu'à présent, même si je n'ai pas essayé de profilage des performances.

private readonly object eventMutex = new object();

private event EventHandler _onEvent = null;

public event EventHandler OnEvent
{
  add
  {
    lock(eventMutex)
    {
      _onEvent += value;
    }
  }

  remove
  {
    lock(eventMutex)
    {
      _onEvent -= value;
    }
  }

}

private void HandleEvent(EventArgs args)
{
  lock(eventMutex)
  {
    if (_onEvent != null)
      _onEvent(args);
  }
}

Je travaille principalement avec Mono pour Android ces jours-ci, et Android ne semble pas l'aimer lorsque vous essayez de mettre à jour une vue après que son activité a été envoyée en arrière-plan.

Cendre
la source
En fait, je vois que quelqu'un d'autre utilise un modèle très similaire ici: stackoverflow.com/questions/3668953/…
Ash
2

Cette pratique ne consiste pas à faire respecter un certain ordre d'opérations. Il s'agit en fait d'éviter une exception de référence nulle.

Le raisonnement derrière les personnes se souciant de l'exception de référence nulle et non de la condition de race nécessiterait une recherche psychologique approfondie. Je pense que cela a quelque chose à voir avec le fait que la résolution du problème de référence nulle est beaucoup plus facile. Une fois que cela est résolu, ils accrochent une grande bannière "Mission accomplie" sur leur code et décompressent leur combinaison de vol.

Remarque: la correction de la condition de concurrence implique probablement l'utilisation d'une piste de drapeau synchrone si le gestionnaire doit s'exécuter

dss539
la source
Je ne demande pas de solution à ce problème. Je me demande pourquoi il est largement conseillé de pulvériser un gâchis de code supplémentaire autour du déclenchement d'événements, alors qu'il n'évite une exception nulle qu'en cas de condition de concurrence difficile à détecter, qui existera toujours.
Daniel Earwicker
1
Eh bien, c'était mon point. Ils ne se soucient pas de la condition de course. Ils ne se soucient que de l'exception de référence nulle. Je vais modifier cela dans ma réponse.
dss539
Et mon point est: pourquoi serait-il logique de se soucier de l'exception de référence nulle et pourtant de ne pas se soucier de la condition de concurrence?
Daniel Earwicker
4
Un gestionnaire d'événements correctement écrit doit être prêt à gérer le fait que toute demande particulière de déclencher un événement dont le traitement pourrait chevaucher une demande d'ajout ou de suppression, peut ou non finir par déclencher l'événement qui a été ajouté ou supprimé. La raison pour laquelle les programmeurs ne se soucient pas de la condition de concurrence est que, dans un code correctement écrit , peu importe qui gagne .
supercat
2
@ dss539: Bien que l'on puisse concevoir un cadre d'événements qui bloquerait les demandes de désabonnement jusqu'à la fin des invocations d'événements en attente, une telle conception rendrait impossible pour tout événement (même quelque chose comme un Unloadévénement) d'annuler en toute sécurité les abonnements d'un objet à d'autres événements. Méchant. Mieux vaut simplement dire que les demandes de désabonnement aux événements entraîneront la désinscription des événements "éventuellement", et que les abonnés aux événements devraient vérifier lorsqu'ils sont appelés s'ils ont quelque chose d'utile à faire.
supercat
1

Je suis donc un peu en retard à la fête ici. :)

En ce qui concerne l'utilisation de null plutôt que le modèle d'objet null pour représenter des événements sans abonnés, envisagez ce scénario. Vous devez appeler un événement, mais la construction de l'objet (EventArgs) n'est pas anodine et, dans le cas courant, votre événement n'a pas d'abonné. Il serait avantageux pour vous que vous puissiez optimiser votre code pour vérifier si vous avez des abonnés avant de vous engager à traiter la construction des arguments et à invoquer l'événement.

Dans cet esprit, une solution consiste à dire "eh bien, zéro abonné est représenté par null". Ensuite, effectuez simplement la vérification nulle avant d'effectuer votre opération coûteuse. Je suppose qu'une autre façon de procéder aurait été d'avoir une propriété Count sur le type Delegate, donc vous ne feriez que l'opération coûteuse si myDelegate.Count> 0. L'utilisation d'une propriété Count est un modèle assez sympa qui résout le problème d'origine d'autoriser l'optimisation, et il a également la belle propriété de pouvoir être invoqué sans provoquer une exception NullReferenceException.

Gardez à l'esprit, cependant, que puisque les délégués sont des types de référence, ils peuvent être nuls. Peut-être qu'il n'y avait tout simplement pas de bon moyen de cacher ce fait sous les couvertures et de ne prendre en charge que le modèle d'objet null pour les événements, donc l'alternative pourrait avoir forcé les développeurs à vérifier à la fois pour les abonnés null et pour zéro. Ce serait encore plus laid que la situation actuelle.

Remarque: Il s'agit de pure spéculation. Je ne suis pas impliqué avec les langages .NET ou CLR.

Levi
la source
Je suppose que vous voulez dire "l'utilisation d'un délégué vide plutôt que ..." Vous pouvez déjà faire ce que vous suggérez, avec un événement initialisé au délégué vide. Le test (MyEvent.GetInvocationList (). Length == 1) sera vrai si le délégué vide initial est la seule chose sur la liste. Il ne serait toujours pas nécessaire de faire une copie en premier. Bien que je pense que le cas que vous décrivez serait de toute façon extrêmement rare.
Daniel Earwicker,
Je pense que nous confondons les idées des délégués et des événements ici. Si j'ai un événement Foo sur ma classe, alors lorsque des utilisateurs externes appellent MyType.Foo + = / - =, ils appellent en fait les méthodes add_Foo () et remove_Foo (). Cependant, lorsque je référence Foo à partir de la classe où il est défini, je fais référence directement au délégué sous-jacent, pas aux méthodes add_Foo () et remove_Foo (). Et avec l'existence de types comme EventHandlerList, rien n'oblige le délégué et l'événement à être au même endroit. C'est ce que je voulais dire par le paragraphe "Gardez à l'esprit" dans ma réponse.
Levi
(suite) J'avoue que c'est une conception déroutante, mais l'alternative a peut-être été pire. Puisqu'en fin de compte, tout ce que vous avez est un délégué - vous pouvez référencer directement le délégué sous-jacent, vous pouvez l'obtenir à partir d'une collection, vous pouvez l'instancier à la volée - il peut être techniquement impossible de prendre en charge autre chose que la «vérification de null "modèle.
Levi
Comme nous parlons de déclencher l'événement, je ne vois pas pourquoi les accesseurs d'ajout / suppression sont importants ici.
Daniel Earwicker
@Levi: Je n'aime vraiment pas la façon dont C # gère les événements. Si j'avais mes druthers, le délégué aurait reçu un nom différent de l'événement. De l'extérieur d'une classe, les seules opérations autorisées sur un nom d'événement seraient +=et -=. Au sein de la classe, les opérations autorisées comprennent également l'invocation (avec vérification nulle intégrée), les tests par rapport à nullou la définition de null. Pour toute autre chose, il faudrait utiliser le délégué dont le nom serait le nom de l'événement avec un préfixe ou un suffixe particulier.
supercat
0

pour les applicaitons à filetage unique, vous êtes correct ce n'est pas un problème.

Cependant, si vous créez un composant qui expose des événements, rien ne garantit qu'un consommateur de votre composant n'ira pas en multithreading, auquel cas vous devez vous préparer au pire.

L'utilisation du délégué vide résout le problème, mais entraîne également un impact sur les performances à chaque appel à l'événement et peut éventuellement avoir des implications pour le GC.

Vous avez raison de dire que le consommateur essaie de se désabonner pour que cela se produise, mais s'il a dépassé la copie temporaire, alors considérez le message déjà en transit.

Si vous n'utilisez pas la variable temporaire, n'utilisez pas le délégué vide et que quelqu'un se désabonne, vous obtenez une exception de référence nulle, ce qui est fatal, donc je pense que le coût en vaut la peine.

Jason Coyne
la source
0

Je n'ai jamais vraiment considéré cela comme un problème, car je ne protège généralement que contre ce type de problème de threading potentiel dans les méthodes statiques (etc.) sur mes composants réutilisables, et je ne crée pas d'événements statiques.

Suis-je en train de mal faire?

Greg D
la source
Si vous allouez une instance d'une classe avec un état mutable (champs qui changent leurs valeurs) puis laissez plusieurs threads accéder simultanément à la même instance, sans utiliser de verrouillage pour empêcher ces champs d'être modifiés par deux threads en même temps, vous êtes le faire probablement mal. Si tous vos threads ont leurs propres instances distinctes (ne partageant rien) ou tous vos objets sont immuables (une fois alloués, les valeurs de leurs champs ne changent jamais), alors vous êtes probablement d'accord.
Daniel Earwicker le
Mon approche générale consiste à laisser la synchronisation à l'appelant, sauf dans les méthodes statiques. Si je suis l'appelant, je synchroniserai à ce niveau supérieur. (à l'exception d'un objet dont le seul but est de gérer l'accès synchronisé, bien sûr. :))
Greg D
@GregD qui dépend de la complexité de la méthode et des données qu'elle utilise. si cela affecte les membres internes, et que vous décidez de vous lancer dans un état thread / Tasked, vous êtes très blessé
Mickey Perlstein
0

Câblez tous vos événements à la construction et laissez-les tranquilles. La conception de la classe Delegate ne peut pas gérer correctement toute autre utilisation correctement, comme je l'expliquerai dans le dernier paragraphe de cet article.

Tout d'abord, il est inutile d'essayer d' intercepter une notification d' événement lorsque vos gestionnaires d' événements doivent déjà prendre une décision synchronisée quant à savoir / comment répondre à la notification .

Tout ce qui peut être notifié doit être notifié. Si vos gestionnaires d'événements gèrent correctement les notifications (c'est-à-dire qu'ils ont accès à un état d'application faisant autorité et ne répondent que lorsque cela est approprié), alors il sera bon de les informer à tout moment et de croire qu'ils répondront correctement.

La seule fois où un gestionnaire ne doit pas être averti qu'un événement s'est produit, c'est si l'événement en fait ne s'est pas produit! Donc, si vous ne voulez pas qu'un gestionnaire soit averti, arrêtez de générer les événements (c'est-à-dire désactivez le contrôle ou tout ce qui est responsable de la détection et de la mise en place de l'événement en premier lieu).

Honnêtement, je pense que la classe Délégué est irréversible. La fusion / transition vers un MulticastDelegate était une énorme erreur, car elle a effectivement changé la définition (utile) d'un événement de quelque chose qui se produit à un seul instant dans le temps, à quelque chose qui se produit sur une période de temps. Un tel changement nécessite un mécanisme de synchronisation qui peut logiquement le réduire en un seul instant, mais le MulticastDelegate ne dispose d'aucun de ces mécanismes. La synchronisation doit englober l'intégralité de la période ou l'instant où l'événement se produit, de sorte qu'une fois qu'une application prend la décision synchronisée de commencer à gérer un événement, elle termine de le gérer complètement (de manière transactionnelle). Avec la boîte noire qui est la classe hybride MulticastDelegate / Delegate, cela est presque impossible, doncadhérer à l'utilisation d'un seul abonné et / ou implémenter votre propre type de MulticastDelegate qui a une poignée de synchronisation qui peut être supprimée pendant que la chaîne de gestionnaire est utilisée / modifiée . Je recommande cela, car l'alternative serait d'implémenter la synchronisation / l'intégrité transactionnelle de manière redondante dans tous vos gestionnaires, ce qui serait ridiculement / inutilement complexe.

Triynko
la source
[1] Il n'y a aucun gestionnaire d'événements utile qui se produit à "un seul instant". Toutes les opérations ont une durée. Tout gestionnaire unique peut avoir une séquence d'étapes non triviale à effectuer. La prise en charge d'une liste de gestionnaires ne change rien.
Daniel Earwicker
[2] Tenir une serrure pendant le déclenchement d'un événement est une folie totale. Cela conduit inévitablement à une impasse. La source retire le verrou A, déclenche l'événement, le puits retire le verrou B, maintenant deux verrous sont maintenus. Que faire si une opération dans un autre thread entraîne la suppression des verrous dans l'ordre inverse? Comment de telles combinaisons mortelles peuvent-elles être exclues lorsque la responsabilité du verrouillage est répartie entre des composants conçus / testés séparément (ce qui est le point central des événements)?
Daniel Earwicker
[3] Aucun de ces problèmes ne réduit en aucune façon l'utilité omniprésente des délégués / événements de multidiffusion normaux dans la composition monothread des composants, en particulier dans les cadres d'interface graphique. Ce cas d'utilisation couvre la grande majorité des utilisations d'événements. L'utilisation d'événements de façon libre est d'une valeur discutable; cela n'invalide en rien leur conception ou leur utilité évidente dans les contextes où elles ont un sens.
Daniel Earwicker,
[4] Threads + synchronous events est essentiellement un hareng rouge. La communication asynchrone en file d'attente est la voie à suivre.
Daniel Earwicker,
[1] Je ne parlais pas de temps mesuré ... Je parlais d'opérations atomiques, qui se produisent logiquement instantanément ... et j'entends par là que rien d'autre impliquant les mêmes ressources qu'elles utilisent ne peut changer pendant que l'événement se produit car il est sérialisé avec un verrou.
Triynko
0

Veuillez jeter un œil ici: http://www.danielfortunov.com/software/%24daniel_fortunovs_adventures_in_software_development/2009/04/23/net_event_invocation_thread_safety Il s'agit de la bonne solution et doit toujours être utilisée à la place de toutes les autres solutions.

«Vous pouvez vous assurer que la liste d'invocation interne a toujours au moins un membre en l'initialisant avec une méthode anonyme de ne rien faire. Puisqu'aucune partie externe ne peut avoir de référence à la méthode anonyme, aucune partie externe ne peut supprimer la méthode, donc le délégué ne sera jamais nul »- Programmation des composants .NET, 2e édition, par Juval Löwy

public static event EventHandler<EventArgs> PreInitializedEvent = delegate { };  

public static void OnPreInitializedEvent(EventArgs e)  
{  
    // No check required - event will never be null because  
    // we have subscribed an empty anonymous delegate which  
    // can never be unsubscribed. (But causes some overhead.)  
    PreInitializedEvent(null, e);  
}  
Eli
la source
0

Je ne crois pas que la question soit limitée au type "événement" c #. Supprimer cette restriction, pourquoi ne pas réinventer un peu la roue et faire quelque chose dans ce sens?

Soulever le fil de l'événement en toute sécurité - meilleure pratique

  • Possibilité de se souscrire / se désinscrire de n'importe quel thread pendant une relance (condition de course supprimée)
  • Surcharge de l'opérateur pour + = et - = au niveau de la classe.
  • Délégué générique défini par l'appelant
crokusek
la source
0

Merci pour une discussion utile. Je travaillais sur ce problème récemment et j'ai créé la classe suivante qui est un peu plus lente, mais permet d'éviter les appels aux objets supprimés.

Le point principal ici est que la liste d'invocation peut être modifiée même si l'événement est déclenché.

/// <summary>
/// Thread safe event invoker
/// </summary>
public sealed class ThreadSafeEventInvoker
{
    /// <summary>
    /// Dictionary of delegates
    /// </summary>
    readonly ConcurrentDictionary<Delegate, DelegateHolder> delegates = new ConcurrentDictionary<Delegate, DelegateHolder>();

    /// <summary>
    /// List of delegates to be called, we need it because it is relatevely easy to implement a loop with list
    /// modification inside of it
    /// </summary>
    readonly LinkedList<DelegateHolder> delegatesList = new LinkedList<DelegateHolder>();

    /// <summary>
    /// locker for delegates list
    /// </summary>
    private readonly ReaderWriterLockSlim listLocker = new ReaderWriterLockSlim();

    /// <summary>
    /// Add delegate to list
    /// </summary>
    /// <param name="value"></param>
    public void Add(Delegate value)
    {
        var holder = new DelegateHolder(value);
        if (!delegates.TryAdd(value, holder)) return;

        listLocker.EnterWriteLock();
        delegatesList.AddLast(holder);
        listLocker.ExitWriteLock();
    }

    /// <summary>
    /// Remove delegate from list
    /// </summary>
    /// <param name="value"></param>
    public void Remove(Delegate value)
    {
        DelegateHolder holder;
        if (!delegates.TryRemove(value, out holder)) return;

        Monitor.Enter(holder);
        holder.IsDeleted = true;
        Monitor.Exit(holder);
    }

    /// <summary>
    /// Raise an event
    /// </summary>
    /// <param name="args"></param>
    public void Raise(params object[] args)
    {
        DelegateHolder holder = null;

        try
        {
            // get root element
            listLocker.EnterReadLock();
            var cursor = delegatesList.First;
            listLocker.ExitReadLock();

            while (cursor != null)
            {
                // get its value and a next node
                listLocker.EnterReadLock();
                holder = cursor.Value;
                var next = cursor.Next;
                listLocker.ExitReadLock();

                // lock holder and invoke if it is not removed
                Monitor.Enter(holder);
                if (!holder.IsDeleted)
                    holder.Action.DynamicInvoke(args);
                else if (!holder.IsDeletedFromList)
                {
                    listLocker.EnterWriteLock();
                    delegatesList.Remove(cursor);
                    holder.IsDeletedFromList = true;
                    listLocker.ExitWriteLock();
                }
                Monitor.Exit(holder);

                cursor = next;
            }
        }
        catch
        {
            // clean up
            if (listLocker.IsReadLockHeld)
                listLocker.ExitReadLock();
            if (listLocker.IsWriteLockHeld)
                listLocker.ExitWriteLock();
            if (holder != null && Monitor.IsEntered(holder))
                Monitor.Exit(holder);

            throw;
        }
    }

    /// <summary>
    /// helper class
    /// </summary>
    class DelegateHolder
    {
        /// <summary>
        /// delegate to call
        /// </summary>
        public Delegate Action { get; private set; }

        /// <summary>
        /// flag shows if this delegate removed from list of calls
        /// </summary>
        public bool IsDeleted { get; set; }

        /// <summary>
        /// flag shows if this instance was removed from all lists
        /// </summary>
        public bool IsDeletedFromList { get; set; }

        /// <summary>
        /// Constuctor
        /// </summary>
        /// <param name="d"></param>
        public DelegateHolder(Delegate d)
        {
            Action = d;
        }
    }
}

Et l'utilisation est:

    private readonly ThreadSafeEventInvoker someEventWrapper = new ThreadSafeEventInvoker();
    public event Action SomeEvent
    {
        add { someEventWrapper.Add(value); }
        remove { someEventWrapper.Remove(value); }
    }

    public void RaiseSomeEvent()
    {
        someEventWrapper.Raise();
    }

Tester

Je l'ai testé de la manière suivante. J'ai un fil qui crée et détruit des objets comme celui-ci:

var objects = Enumerable.Range(0, 1000).Select(x => new Bar(foo)).ToList();
Thread.Sleep(10);
objects.ForEach(x => x.Dispose());

Dans un constructeur Bar(un objet écouteur) SomeEventauquel je m'abonne (qui est implémenté comme indiqué ci-dessus) et me désabonne dans Dispose:

    public Bar(Foo foo)
    {
        this.foo = foo;
        foo.SomeEvent += Handler;
    }

    public void Handler()
    {
        if (disposed)
            Console.WriteLine("Handler is called after object was disposed!");
    }

    public void Dispose()
    {
        foo.SomeEvent -= Handler;
        disposed = true;
    }

J'ai aussi quelques threads qui déclenchent un événement en boucle.

Toutes ces actions sont effectuées simultanément: de nombreux auditeurs sont créés et détruits et l'événement est déclenché en même temps.

S'il y avait des conditions de course, je devrais voir un message dans une console, mais il est vide. Mais si j'utilise les événements clr comme d'habitude, je le vois plein de messages d'avertissement. Donc, je peux conclure qu'il est possible d'implémenter des événements thread-safe en c #.

Qu'est-ce que tu penses?

Tony
la source
Ça me semble assez bien. Bien que je pense qu'il pourrait être possible (en théorie) de disposed = truese produire avant foo.SomeEvent -= Handlerdans votre application de test, produisant un faux positif. Mais à part cela, il y a quelques choses que vous voudrez peut-être changer. Vous voulez vraiment utiliser try ... finallypour les verrous - cela vous aidera à rendre cela non seulement thread-safe, mais aussi abort-safe. Sans oublier que vous pourriez alors vous débarrasser de cette idiote try catch. Et vous ne vérifiez pas que le délégué a passé Add/ Remove- cela pourrait l'être null(vous devriez jeter immédiatement dans Add/ Remove).
Luaan