web-dev-qa-db-fra.com

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

[~ # ~] met à jour [~ # ~]

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

SomeEvent?.Invoke(this, e);

J'entends/lis souvent les conseils suivants:

Faites toujours une copie d'un événement avant de le rechercher null et de le déclencher. Cela éliminera un problème potentiel de thread où l'événement devient null à l'emplacement situé entre l'emplacement où vous recherchez la valeur null et celui 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: Après avoir lu sur les optimisations, je pensais que cela pourrait aussi rendre le membre de l'événement instable, mais Jon Skeet a déclaré dans sa réponse que le CLR n'optimisait pas la copie.

Mais en attendant, pour que ce problème se produise, un autre fil 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 est que OnTheEvent s'exécute une fois que l'auteur s'est désabonné, et pourtant, il l'a simplement désinscrit spécifiquement pour éviter que cela ne se produise. Il faut sûrement une implémentation d’événements personnalisés avec une synchronisation appropriée dans les accesseurs add et remove. Et en plus, il y a le problème des blocages possibles si un verrou est maintenu pendant qu'un événement est déclenché.

Alors, est-ce Cargo Cult Programming ? Cela semble être le cas - 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 d'attention que cela avant de pouvoir être utilisés dans le cadre d'une conception multithread . Par conséquent, les personnes qui ne prennent pas ce soin supplémentaire peuvent aussi 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 volatile dans la plupart des exemples de code en ligne, le conseil peut n'avoir aucun effet.

(Et n'est-il pas beaucoup plus simple d'assigner simplement le delegate { } Vide dans la déclaration du membre afin que vous n'ayez jamais besoin de vérifier null en premier lieu?)

Mise à jour: Au cas où cela ne serait pas clair, j'ai bien saisi l'intention du conseil - éviter une exception de référence nulle en toutes circonstances. Mon point est que cette exception de référence null particulière ne peut se produire que si un autre thread est retiré de la liste 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 réalisé par cette technique. . Vous dissimuleriez une situation de concurrence critique - il serait préférable de la révéler! Cette exception nulle aide à 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 émettre une exception si un autre thread tente d'interagir directement avec votre composant. Sinon, implémentez un composant vraiment thread-safe (tâche pas facile).

Je soutiens donc que le simple fait de faire cette copie/vérification est une programmation culte du fret, qui ajoute du désordre et du bruit à votre code. Protéger réellement contre d'autres threads nécessite beaucoup plus de travail.

Mise à jour en réponse aux publications du blog d'Eric Lippert:

Il y a donc une chose importante qui m'avait manqué dans les gestionnaires d'événements: "Les gestionnaires d'événements doivent être robustes face aux appels, même après la désinscription de l'événement", et il est donc évident que nous devons uniquement nous soucier de la possibilité de l'événement. délégué étant null. Cette exigence sur les gestionnaires d'événements est-elle documentée quelque part?

Et ainsi: "Il existe d'autres moyens de résoudre ce problème, par exemple, initialiser le gestionnaire pour avoir une action vide qui n'est jamais supprimée. Mais le contrôle standard est le test standard."

Le fragment restant de ma question est donc, pourquoi explicite-null-check le "motif standard"? L’alternative, assigner le délégué vide, nécessite seulement que = delegate {} Soit ajouté à la déclaration de l'événement, ce qui élimine ces petites piles de cérémonie puante de chaque lieu où l'événement est organisé. Il serait facile de s’assurer que le délégué vide est peu coûteux à instancier. Ou est-ce que je manque encore quelque chose?

Comme l’a suggéré Jon Skeet, c’est sûrement un conseil .NET 1.x qui n’a pas disparu, comme il aurait dû le faire en 2005?

229
Daniel Earwicker

JIT n'est pas autorisé à effectuer l'optimisation dont vous parlez dans la première partie, à cause de la condition. Je sais que cela a été évoqué comme un spectre il y a quelque temps, mais ce n'est pas valide. (Je l'ai vérifié avec Joe Duffy ou Vance Morrison il y a un moment; 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 causera pas un NullReferenceException.

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

TheEvent(this, EventArgs.Empty);

Supposons maintenant que la liste d’invocation de ce délégué comporte 1 000 entrées. Il est parfaitement possible que l'action au début de la liste ait été exécutée avant qu'un autre thread ne désabonne un gestionnaire proche de la fin de la liste. Cependant, ce gestionnaire sera toujours exécuté car ce sera 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 certes le contrôle de nullité, mais ne résout pas la situation de concurrence critique. Cela ne garantit pas non plus que vous "voyez" toujours la dernière valeur de la variable.

98
Jon Skeet

Je vois beaucoup de gens se tourner vers la méthode d'extension de faire cela ...

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 puisqu'elle est capturée au moment de l'appel de la méthode.

50
JP Alioto

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

Je soupçonne que la raison en est peut-être que la vérification à zéro est plus performante.

Si vous souscrivez 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 la construction du délégué vide.
  • Coût de la construction d'une chaîne de délégués pour la contenir.
  • Coût d'invocation du 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. Devoir créer un abonné factice pour chaque événement, puis l'invoquer, ce serait probablement un problème de performances significatif.)

J'ai effectué des tests de performance rapides pour voir l'impact de l'approche subscribe-empty-delegate (souscription-vide-délégué), 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é (commun pour les contrôles d'interface utilisateur, où les événements sont nombreux), l'événement pré-initialisé avec un délégué vide est considérablement plus lent (plus de 50 millions d'itérations ...).

Pour plus d’informations et le code source, visitez ce billet de blog sur sécurité du thread d’appel. NET que j’ai publié la veille du jour où cette question a été posée (!)

(La configuration de mon test peut être défectueuse, alors n'hésitez pas à télécharger le code source et à l'inspecter vous-même. Tous les commentaires sont très appréciés.)

32
Daniel Fortunov

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 résoudre ce problème dans le compilateur? Je sais qu'il y a des personnes atteintes de sclérose en plaques qui lisent ces articles, alors s'il vous plaît, n'enflammez pas cela!

1 - le problème Null) Pourquoi ne pas faire en sorte que les événements soient .Empty au lieu de null en premier lieu? Combien de lignes de code seraient sauvegardées pour un contrôle nul ou pour avoir à coller un = delegate {} sur la déclaration? Laissez le compilateur gérer le cas vide, IE ne faites rien! Si tout compte pour le créateur de l'événement, il peut vérifier .Empty et en faire ce qu'il veut! Sinon, tout ce qui est nul vérifie/ajoute délégué sont des hacks autour du problème!

Honnêtement, je suis fatigué de devoir faire cela avec chaque événement - aka code standard!

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 condition de concurrence critique) Je lis le blog de Eric, je suis d'accord pour que le gestionnaire H (gestionnaire) gère le cas où il se déréférence, mais l'événement ne peut-il pas être rendu immuable/thread sécurisé? IE, définir un indicateur de verrouillage sur sa création, de sorte que chaque fois qu'il est appelé, il verrouille tous les abonnements et les désabonnements lors de son exécution?

Conclusion,

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

11
Chuck Savage

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

// 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 qu'il force une copie de référence. Pour plus d'informations, voir sa section Event dans le livre.

5
alyx

J'utilise ce modèle de conception pour m'assurer que les gestionnaires d'événements ne sont pas exécutés après leur désinscription. Cela fonctionne plutôt bien jusqu'à présent, même si je n'ai encore essayé aucun profil de performance.

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 aimer cela lorsque vous essayez de mettre à jour une vue après que son activité a été envoyé à l'arrière-plan.

4
Ash

Avec C # 6 et supérieur, le code pourrait être simplifié en utilisant le nouveau .? operator Comme dans:

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

Référence: https://docs.Microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-conditional-operators

3
Phil1970

Donc, je suis un peu en retard à la fête ici. :)

En ce qui concerne l'utilisation de null plutôt que du modèle d'objet null pour représenter des événements sans abonné, considérons ce scénario. Vous devez appeler un événement, mais la construction de l'objet (EventArgs) n'est pas triviale et, dans le cas habituel, votre événement ne compte aucun abonné. Il serait avantageux pour vous d'optimiser votre code pour vérifier si vous aviez des abonnés du tout avant de vous engager dans des efforts de traitement pour construire les 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 de null 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. Vous ne réaliseriez donc cette opération coûteuse que si myDelegate.Count> 0. L’utilisation d’une propriété Count est un modèle plutôt sympa qui résout le problème initial. de permettre l'optimisation, et il a aussi la propriété de Nice de pouvoir être invoqué sans provoquer une exception NullReferenceException.

Gardez toutefois à l'esprit que, comme les délégués sont des types de référence, ils sont autorisés à être nuls. Peut-être n'y avait-il simplement aucun moyen de cacher ce fait sous les couvertures et de ne prendre en charge que le modèle d'objet nul pour les événements; l'alternative a peut-être obligé les développeurs à vérifier à la fois les abonnés nulles et nuls. Ce serait encore plus laid que la situation actuelle.

Note: Ceci est pure spéculation. Je ne suis pas impliqué dans les langages .NET ou CLR.

1
Levi

Cette pratique ne consiste pas à appliquer 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 qui s’intéressent à l’exception de référence nulle et non à la situation d’aléatoire exigerait des recherches psychologiques approfondies. Je pense que cela a quelque chose à voir avec le fait que résoudre le problème de la référence nulle est beaucoup plus facile. Une fois que cela est corrigé, 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 pour indiquer si le gestionnaire doit être exécuté.

1
dss539

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

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

  • Possibilité de sous/désabonner de n'importe quel fil pendant une relance (condition de concurrence supprimée)
  • Les surcharges de l'opérateur pour + = et - = au niveau de la classe.
  • Délégué générique défini par l'appelant
0
crokusek

Câblez tous vos événements à la construction et laissez-les tranquilles. La conception de la classe Delegate ne peut éventuellement traiter aucune autre utilisation correctement, comme je l'expliquerai dans le dernier paragraphe de ce post.

Tout d’abord, il ne sert à rien d’essayer d’intercepter un événement notification lorsque votre événement les gestionnaires doivent déjà prendre une décision synchronisée pour savoir si/comment répondre à la notification.

Tout ce qui peut être notifié doit être notifié. Si vos gestionnaires d’événements traitent correctement les notifications (c’est-à-dire qu’ils ont accès à un état faisant autorité et ne répondent que lorsque cela est approprié), il sera alors correct de les avertir à tout moment et d’avoir confiance de répondre correctement.

Le seul moment où un gestionnaire ne doit pas être averti qu'un événement s'est produit est si l'événement n'a pas eu lieu! Donc, si vous ne voulez pas qu'un gestionnaire soit notifié, arrêtez de générer les événements (c.-à-d. Désactivez le contrôle ou tout ce qui est responsable de la détection et de la création de l'événement en premier lieu).

Honnêtement, je pense que la classe des délégués est invendable. La fusion/transition vers un MulticastDelegate était une grave erreur, car elle a effectivement modifié la définition (utile) d'un événement, passant de quelque chose se produisant à un instant donné à quelque chose se déroulant sur une période donnée. Un tel changement nécessite un mécanisme de synchronisation capable de le replier logiquement en un seul instant, mais le MulticastDelegate ne possède aucun mécanisme de ce type. La synchronisation doit englober toute la durée ou l'instant de l'événement, afin qu'une fois que l'application prend la décision synchronisée de commencer à gérer un événement, elle le termine complètement (de manière transactionnelle). Avec la boîte noire qui est la classe hybride MulticastDelegate/Delegate, ceci est presque impossible, donc adhérez à l’utilisation d’un seul abonné et/ou implémentez votre propre type de MulticastDelegate doté d’un descripteur de synchronisation pouvant être retiré tout en la chaîne de gestionnaires est utilisée/modifiée. Je le recommande, car l’alternative serait d’implémenter de manière redondante la synchronisation/intégrité transactionnelle dans tous vos gestionnaires, ce qui serait ridiculement/inutilement complexe.

0
Triynko

S'il vous plaît jeter un oeil ici: http://www.danielfortunov.com/software/%24daniel_fortunovs_adventures_in_software_development/2009/04/23/net_event_invocation_thread_safety Ceci est la solution correcte et devrait toujours être utilisé à la place de toutes les autres solutions de contournement .

"Vous pouvez vous assurer que la liste d'appels interne a toujours au moins un membre en l'initialisant avec une méthode anonyme" ne rien faire ". Etant donné qu'aucun tiers externe ne peut avoir de référence à la méthode anonyme, aucun tiers externe ne peut supprimer la méthode, le délégué ne sera donc jamais nul "- Programming .NET Components, 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);  
}  
0
Eli

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

Est-ce que je me trompe?

0
Greg D

Merci pour une discussion utile. Je travaillais sur ce problème récemment et ai fait la classe suivante qui est un peu plus lente, mais permet d’éviter les appels à des objets éliminés.

Le point principal ici est que la liste d’invocation peut être modifiée même si un é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'usage est:

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

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

Test

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), je m'abonne à SomeEvent (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;
    }

Aussi, j'ai quelques fils qui soulèvent l'événement dans une boucle.

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

S'il y avait une situation de concurrence, 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. Je peux donc en conclure qu'il est possible d'implémenter un événement thread-safe en c #.

Qu'est-ce que tu penses?

0
Tony

pour les applications à un seul fil, vous êtes correc, 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. Dans ce cas, vous devez vous préparer au pire.

L'utilisation du délégué vide résout le problème, mais provoque également une baisse de performance à chaque appel de 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’ils dépassent la copie temporaire, prenez en compte le message déjà en transit.

Si vous n'utilisez pas la variable temporaire, ni 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. Je pense donc que le coût en vaut la peine.

0
Jason Coyne