48 votes

Éviter les problèmes liés à Invoke/BeginInvoke dans la gestion des événements WinForm multithread ?

Je suis toujours embêté par le threading en arrière-plan dans une interface utilisateur WinForm. Pourquoi ? Voici quelques-uns des problèmes :

  1. De toute évidence, le problème le plus important est que je ne peux pas modifier un contrôle à moins d'être exécuté sur le même thread que celui qui l'a créé.
  2. Comme vous le savez, Invoke, BeginInvoke, etc. ne sont pas disponibles avant la création d'un contrôle.
  3. Même si RequiresInvoke renvoie vrai, BeginInvoke peut toujours lancer ObjectDisposed et même s'il ne le fait pas, il peut ne jamais exécuter le code si le contrôle est détruit.
  4. Même après le retour de RequiresInvoke, Invoke peut attendre indéfiniment l'exécution par un contrôle qui a été disposé en même temps que l'appel à Invoke.

Je suis à la recherche d'une solution élégante à ce problème, mais avant d'entrer dans les détails de ce que je recherche, je pensais clarifier le problème. Il s'agit de prendre le problème générique et de lui donner un exemple plus concret. Dans cet exemple, disons que nous transférons de grandes quantités de données sur Internet. L'interface utilisateur doit être capable de montrer un dialogue de progression pour le transfert en cours. La boîte de dialogue de progression doit se mettre à jour constamment et rapidement (5 à 20 mises à jour par seconde). L'utilisateur peut rejeter la boîte de dialogue de progression à tout moment et la rappeler si nécessaire. En outre, supposons que si la boîte de dialogue est visible, elle doit traiter chaque événement de progression. L'utilisateur peut cliquer sur Annuler dans la boîte de dialogue de progression et, en modifiant les arguments de l'événement, annuler l'opération.

J'ai maintenant besoin d'une solution qui s'inscrive dans le cadre des contraintes suivantes :

  1. Permettre à un fil de travail d'appeler une méthode sur un contrôle/forme et bloquer/attendre jusqu'à ce que l'exécution soit terminée.
  2. Permettre à la boîte de dialogue elle-même d'appeler cette même méthode lors de l'initialisation ou autre (et donc ne pas utiliser invoke).
  3. Ne faites pas peser la charge de la mise en œuvre sur la méthode de traitement ou l'événement appelant, la solution ne doit modifier que l'abonnement à l'événement lui-même.
  4. Gérer de manière appropriée les appels bloquants à une boîte de dialogue qui pourrait être en cours d'élimination. Malheureusement, cela n'est pas aussi simple que de vérifier IsDisposed.
  5. Doit pouvoir être utilisé avec tout type d'événement (supposez un délégué de type EventHandler)
  6. Ne doit pas traduire les exceptions en TargetInvocationException.
  7. La solution doit fonctionner avec .Net 2.0 et plus.

Alors, est-ce que cela peut être résolu compte tenu des contraintes ci-dessus ? J'ai cherché et creusé dans d'innombrables blogs et discussions, mais hélas je n'ai toujours rien trouvé.

Mise à jour : Je réalise que cette question n'a pas de réponse facile. Je ne suis sur ce site que depuis quelques jours et j'ai vu des personnes ayant beaucoup d'expérience répondre à des questions. J'espère que l'une de ces personnes a résolu le problème suffisamment pour que je n'aie pas à consacrer la semaine ou plus qu'il faudra pour construire une solution raisonnable.

Mise à jour n°2 : Ok, je vais essayer de décrire le problème de manière un peu plus détaillée et voir ce qui se passe (si quelque chose se passe). Les propriétés suivantes, qui nous permettent de déterminer l'état de l'objet, ont quelques points d'inquiétude...

  1. Control.InvokeRequired = Documenté pour retourner false si l'exécution se fait sur le thread actuel ou si IsHandleCreated retourne false pour tous les parents. Je suis troublé par l'implémentation de InvokeRequired qui a le potentiel de lancer ObjectDisposedException ou même de recréer le handle de l'objet. Et comme InvokeRequired peut renvoyer true lorsque nous ne sommes pas en mesure d'invoquer (Dispose en cours) et qu'il peut renvoyer false même si nous avons besoin d'utiliser invoke (Create en cours), on ne peut tout simplement pas lui faire confiance dans tous les cas. Le seul cas que je vois où nous pouvons faire confiance à InvokeRequired pour retourner false est lorsque IsHandleCreated retourne true à la fois avant et après l'appel (BTW les docs MSDN pour InvokeRequired mentionnent la vérification de IsHandleCreated).

  2. Control.IsHandleCreated = Renvoie true si un handle a été attribué au contrôle ; sinon, false. Bien que IsHandleCreated soit un appel sûr, il peut tomber en panne si le contrôle est en train de recréer son handle. Ce problème potentiel semble pouvoir être résolu en effectuant un lock(control) pendant l'accès à IsHandleCreated et InvokeRequired.

  3. Control.Disposing = Renvoie un message de vérité si le contrôle est en train d'être éliminé.

  4. Control.IsDisposed = Renvoie true si le contrôle a été éliminé. J'envisage de souscrire à l'événement Disposed et de vérifier la propriété IsDisposed pour déterminer si le BeginInvoke se terminera un jour. Le gros problème ici est l'absence d'un verrou de synchronisation pendant la transition Disposing -> Disposed. Il est possible que si vous souscrivez à l'événement Disposed et que vous vérifiez ensuite que Disposing == false && IsDisposed == false, vous ne verrez jamais l'événement Disposed se déclencher. Cela est dû au fait que l'implémentation de Dispose définit Disposing = false, puis définit Disposed = true. Cela vous donne une opportunité (même minime) de lire à la fois Disposing et IsDisposed comme faux sur un contrôle disposé.

... j'ai mal à la tête :( J'espère que les informations ci-dessus apporteront un peu plus de lumière à tous ceux qui ont ces problèmes. J'apprécie vos cycles de réflexion sur ce sujet.

On se rapproche du problème... Ce qui suit est la dernière moitié de la méthode Control.DestroyHandle() :

if (!this.RecreatingHandle && (this.threadCallbackList != null))
{
    lock (this.threadCallbackList)
    {
        Exception exception = new ObjectDisposedException(base.GetType().Name);
        while (this.threadCallbackList.Count > 0)
        {
            ThreadMethodEntry entry = (ThreadMethodEntry) this.threadCallbackList.Dequeue();
            entry.exception = exception;
            entry.Complete();
        }
    }
}
if ((0x40 & ((int) ((long) UnsafeNativeMethods.GetWindowLong(new HandleRef(this.window, this.InternalHandle), -20)))) != 0)
{
    UnsafeNativeMethods.DefMDIChildProc(this.InternalHandle, 0x10, IntPtr.Zero, IntPtr.Zero);
}
else
{
    this.window.DestroyHandle();
}

Vous remarquerez que l'exception ObjectDisposedException est envoyée à toutes les invocations inter-filières en attente. Peu de temps après, l'appel à this.window.DestroyHandle() détruit la fenêtre et définit la référence de son handle à IntPtr.Zero, empêchant ainsi tout nouvel appel à la méthode BeginInvoke (ou plus précisément MarshaledInvoke qui gère à la fois BeginInvoke et Invoke). Le problème ici est qu'après que le verrou se soit libéré sur threadCallbackList, une nouvelle entrée peut être insérée avant que le thread du contrôle ne remette à zéro le handle de la fenêtre. Il semble que ce soit le cas que je rencontre, bien que peu fréquent, assez souvent pour arrêter une libération.

Mise à jour n°4 :

Désolé de faire traîner les choses en longueur, mais j'ai pensé que cela valait la peine d'être documenté ici. J'ai réussi à résoudre la plupart des problèmes ci-dessus et je me rapproche d'une solution qui fonctionne. J'ai rencontré un autre problème qui me préoccupait, mais que je n'avais pas encore vu dans la nature.

Ce problème a à voir avec le génie qui a écrit la propriété Control.Handle :

    public IntPtr get_Handle()
    {
        if ((checkForIllegalCrossThreadCalls && !inCrossThreadSafeCall) && this.InvokeRequired)
        {
            throw new InvalidOperationException(SR.GetString("IllegalCrossThreadCall", new object[] { this.Name }));
        }
        if (!this.IsHandleCreated)
        {
            this.CreateHandle();
        }
        return this.HandleInternal;
    }

En soi, ce n'est pas si mal (quelles que soient mes opinions sur les modifications get { }) ; cependant, lorsqu'il est combiné avec la propriété InvokeRequired ou la méthode Invoke/BeginInvoke, c'est mauvais. Voici le flux de base de la méthode Invoke :

if( !this.IsHandleCreated )
    throw;
... do more stuff
PostMessage( this.Handle, ... );

Le problème est qu'à partir d'un autre thread, je peux réussir à passer la première instruction if, après quoi le handle est détruit par le thread du contrôle, ce qui fait que le get de la propriété Handle recrée le handle de la fenêtre sur mon thread. Cela peut ensuite provoquer la levée d'une exception sur le thread du contrôle d'origine. Cette situation me laisse vraiment perplexe car il n'y a aucun moyen de s'en prémunir. S'ils avaient utilisé uniquement la propriété InternalHandle et testé le résultat de IntPtr.Zero, ce ne serait pas un problème.

22voto

Pavel Minaev Points 60647

Votre scénario, tel qu'il est décrit, correspond parfaitement BackgroundWorker - pourquoi ne pas simplement utiliser ça ? Vos exigences en matière de solution sont beaucoup trop génériques et plutôt déraisonnables - je doute qu'il existe une solution qui puisse toutes les satisfaire.

8voto

JaredPar Points 333733

J'ai rencontré ce problème il y a quelque temps et j'ai trouvé une solution impliquant des contextes de synchronisation. La solution consiste à ajouter une méthode d'extension au SynchronizationContext qui lie un délégué particulier au thread auquel le SynchronizationContext est lié. Cette méthode génère un nouveau délégué qui, lorsqu'il est invoqué, transfère l'appel au thread approprié, puis appelle le délégué original. Cela rend presque impossible pour les consommateurs du délégué de l'appeler dans le mauvais contexte.

Article de blog sur le sujet :

7voto

csharptest.net Points 16556

Ok, quelques jours plus tard, j'ai fini de créer une solution. Elle résout toutes les contraintes et tous les objectifs énumérés dans le post initial. L'utilisation est simple et directe :

myWorker.SomeEvent += new EventHandlerForControl<EventArgs>(this, myWorker_SomeEvent).EventHandler;

Lorsque le fil de travail appelle cet événement, il gère l'invocation nécessaire au fil de contrôle. Il s'assure qu'il ne sera pas suspendu indéfiniment et lancera systématiquement une ObjectDisposedException s'il n'est pas en mesure de s'exécuter sur le thread de contrôle. J'ai créé d'autres dérivations de la classe, une pour ignorer l'erreur, et une autre pour appeler directement le délégué si le contrôle n'est pas disponible. Cela semble bien fonctionner et passe parfaitement les différents tests qui reproduisent les problèmes ci-dessus. Il n'y a qu'un seul problème avec la solution que je ne peux pas empêcher sans violer la contrainte n° 3 ci-dessus. Il s'agit de la dernière (mise à jour n°4) de la description du problème, à savoir les problèmes de threading dans get Handle. Cela peut provoquer un comportement inattendu sur le thread de contrôle d'origine, et j'ai régulièrement vu des InvalidOperationException() lancées lors de l'appel de Dispose() puisque le handle était en train d'être créé sur mon thread. Pour faire face à ce problème, j'ai mis en place un verrou autour de l'accès aux fonctions qui utilisent la propriété Control.Handle. Cela permet à un formulaire de surcharger la méthode DestroyHandle et de verrouiller avant d'appeler l'implémentation de base. Si cela est fait, cette classe devrait être entièrement thread-safe (au mieux de mes connaissances).

public class Form : System.Windows.Forms.Form
{
    protected override void DestroyHandle()
    {
        lock (this) base.DestroyHandle();
    }
}

Vous remarquerez peut-être que l'aspect central de la résolution de l'impasse est devenu une boucle d'interrogation. À l'origine, j'avais réussi à résoudre les cas de test en traitant les événements Disposed et HandleDestroyed du contrôle et en utilisant plusieurs gestionnaires d'attente. Après un examen plus approfondi, j'ai constaté que l'inscription/désinscription à partir de ces événements n'est pas thread-safe. J'ai donc choisi d'interroger l'événement IsHandleCreated à la place afin de ne pas créer de conflit inutile sur les événements du thread et d'éviter ainsi la possibilité de produire un état de blocage.

Bref, voici la solution que j'ai trouvée :

/// <summary>
/// Provies a wrapper type around event handlers for a control that are safe to be
/// used from events on another thread.  If the control is not valid at the time the
/// delegate is called an exception of type ObjectDisposedExcpetion will be raised.
/// </summary>
[System.Diagnostics.DebuggerNonUserCode]
public class EventHandlerForControl<TEventArgs> where TEventArgs : EventArgs
{
    /// <summary> The control who's thread we will use for the invoke </summary>
    protected readonly Control _control;
    /// <summary> The delegate to invoke on the control </summary>
    protected readonly EventHandler<TEventArgs> _delegate;

    /// <summary>
    /// Constructs an EventHandler for the specified method on the given control instance.
    /// </summary>
    public EventHandlerForControl(Control control, EventHandler<TEventArgs> handler)
    {
        if (control == null) throw new ArgumentNullException("control");
        _control = control.TopLevelControl;
        if (handler == null) throw new ArgumentNullException("handler");
        _delegate = handler;
    }

    /// <summary>
    /// Constructs an EventHandler for the specified delegate converting it to the expected
    /// EventHandler&lt;TEventArgs> delegate type.
    /// </summary>
    public EventHandlerForControl(Control control, Delegate handler)
    {
        if (control == null) throw new ArgumentNullException("control");
        _control = control.TopLevelControl;
        if (handler == null) throw new ArgumentNullException("handler");

        //_delegate = handler.Convert<EventHandler<TEventArgs>>();
        _delegate = handler as EventHandler<TEventArgs>;
        if (_delegate == null)
        {
            foreach (Delegate d in handler.GetInvocationList())
            {
                _delegate = (EventHandler<TEventArgs>) Delegate.Combine(_delegate,
                    Delegate.CreateDelegate(typeof(EventHandler<TEventArgs>), d.Target, d.Method, true)
                );
            }
        }
        if (_delegate == null) throw new ArgumentNullException("_delegate");
    }

    /// <summary>
    /// Used to handle the condition that a control's handle is not currently available.  This
    /// can either be before construction or after being disposed.
    /// </summary>
    protected virtual void OnControlDisposed(object sender, TEventArgs args)
    {
        throw new ObjectDisposedException(_control.GetType().Name);
    }

    /// <summary>
    /// This object will allow an implicit cast to the EventHandler&lt;T> type for easier use.
    /// </summary>
    public static implicit operator EventHandler<TEventArgs>(EventHandlerForControl<TEventArgs> instance)
    { return instance.EventHandler; }

    /// <summary>
    /// Handles the 'magic' of safely invoking the delegate on the control without producing
    /// a dead-lock.
    /// </summary>
    public void EventHandler(object sender, TEventArgs args)
    {
        bool requiresInvoke = false, hasHandle = false;
        try
        {
            lock (_control) // locked to avoid conflicts with RecreateHandle and DestroyHandle
            {
                if (true == (hasHandle = _control.IsHandleCreated))
                {
                    requiresInvoke = _control.InvokeRequired;
                    // must remain true for InvokeRequired to be dependable
                    hasHandle &= _control.IsHandleCreated;
                }
            }
        }
        catch (ObjectDisposedException)
        {
            requiresInvoke = hasHandle = false;
        }

        if (!requiresInvoke && hasHandle) // control is from the current thread
        {
            _delegate(sender, args);
            return;
        }
        else if (hasHandle) // control invoke *might* work
        {
            MethodInvokerImpl invocation = new MethodInvokerImpl(_delegate, sender, args);
            IAsyncResult result = null;
            try
            {
                lock (_control)// locked to avoid conflicts with RecreateHandle and DestroyHandle
                    result = _control.BeginInvoke(invocation.Invoker);
            }
            catch (InvalidOperationException)
            { }

            try
            {
                if (result != null)
                {
                    WaitHandle handle = result.AsyncWaitHandle;
                    TimeSpan interval = TimeSpan.FromSeconds(1);
                    bool complete = false;

                    while (!complete && (invocation.MethodRunning || _control.IsHandleCreated))
                    {
                        if (invocation.MethodRunning)
                            complete = handle.WaitOne();//no need to continue polling once running
                        else
                            complete = handle.WaitOne(interval);
                    }

                    if (complete)
                    {
                        _control.EndInvoke(result);
                        return;
                    }
                }
            }
            catch (ObjectDisposedException ode)
            {
                if (ode.ObjectName != _control.GetType().Name)
                    throw;// *likely* from some other source...
            }
        }

        OnControlDisposed(sender, args);
    }

    /// <summary>
    /// The class is used to take advantage of a special-case in the Control.InvokeMarshaledCallbackDo()
    /// implementation that allows us to preserve the exception types that are thrown rather than doing
    /// a delegate.DynamicInvoke();
    /// </summary>
    [System.Diagnostics.DebuggerNonUserCode]
    private class MethodInvokerImpl
    {
        readonly EventHandler<TEventArgs> _handler;
        readonly object _sender;
        readonly TEventArgs _args;
        private bool _received;

        public MethodInvokerImpl(EventHandler<TEventArgs> handler, object sender, TEventArgs args)
        {
            _received = false;
            _handler = handler;
            _sender = sender;
            _args = args;
        }

        public MethodInvoker Invoker { get { return this.Invoke; } }
        private void Invoke() { _received = true; _handler(_sender, _args); }

        public bool MethodRunning { get { return _received; } }
    }
}

Si vous voyez quelque chose d'erroné ici, faites-le moi savoir.

2voto

Adam Robinson Points 88472

Je ne vais pas écrire une solution exhaustive pour vous qui réponde à toutes vos exigences, mais je vais vous offrir une perspective. Dans l'ensemble, cependant, je pense que vous visez la lune avec ces exigences.

El Invoke / BeginInvoke exécute simplement un délégué fourni sur le thread de l'interface utilisateur du contrôle en lui envoyant un message Windows et la boucle de messages elle-même exécute le délégué. Le fonctionnement spécifique de cette architecture n'est pas pertinent, mais le fait est qu'il n'y a aucune raison particulière d'utiliser cette architecture pour la synchronisation avec le thread de l'interface utilisateur. Tout ce dont vous avez besoin, c'est d'une autre boucle en cours d'exécution, par exemple dans une boucle Forms.Timer ou quelque chose comme ça, qui surveille un Queue pour que les délégués s'exécutent et le font. Il serait assez simple d'implémenter le vôtre, bien que je ne sache pas ce qu'il obtiendrait spécifiquement pour vous qui Invoke y BeginInvoke ne fournissent pas.

1voto

Filip Navara Points 3679

Il ne s'agit pas vraiment d'une réponse à la deuxième partie de la question, mais je vais l'inclure juste pour la référence :

private delegate object SafeInvokeCallback(Control control, Delegate method, params object[] parameters);
public static object SafeInvoke(this Control control, Delegate method, params object[] parameters)
{
    if (control == null)
        throw new ArgumentNullException("control");
    if (control.InvokeRequired)
    {
        IAsyncResult result = null;
        try { result = control.BeginInvoke(new SafeInvokeCallback(SafeInvoke), control, method, parameters); }
        catch (InvalidOperationException) { /* This control has not been created or was already (more likely) closed. */ }
        if (result != null)
            return control.EndInvoke(result);
    }
    else
    {
        if (!control.IsDisposed)
            return method.DynamicInvoke(parameters);
    }
    return null;
}

Ce code devrait éviter les pièges les plus courants avec Invoke/BeginInvoke et il est facile à utiliser. Il suffit de tourner

if (control.InvokeRequired)
    control.Invoke(...)
else
    ...

en

control.SafeInvoke(...)

Une construction similaire est possible pour BeginInvoke.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X