(InvokeRequired + Invoke) < SynchronizationContext
Tous les développeurs savent qu’il faut être prudent lorsqu’on est en Corée du Nord ou lorsqu’on s’amuse avec les Winforms dans un contexte multi-threads. On ne doit pas, plus précisément, accéder à un contrôle graphique à partir d’un thread différent de celui dans lequel il a été créé. Ceux qui ont essayé (tous, je crois) ont découvert qu’une InvalidOperationException
était levée :
Opération inter-threads non valide : le contrôle ‘MyControl’ a fait l’objet d’un accès à partir d’un thread autre que celui sur lequel il a été créé.
Enfin, à partir de VS2005 et en debug mode uniquement. Par exemple, dans une Form
le code suivant affecté à l’évènement Click
d’un bouton suffit à provoquer une telle exception :
private void myButton_Click(object sender, EventArgs e) { var thread = new Thread((ThreadStart) delegate { this.Text = "Yeah"; }); thread.Start(); }
L’accès à la propriété Text
de la fenêtre (qui est un contrôle) s’effectue dans un thread différent du thread d’affichage principal.
Si je vous demande comment arranger ça, vous allez me répondre naturellement qu’il faut écrire quelque chose comme :
private void myButton_Click(object sender, EventArgs e) { var thread = new Thread(ChangeFormCaption); thread.Start(); } private void ChangeFormCaption() { if (this.InvokeRequired) { this.Invoke((MethodInvoker) ChangeFormCaption); } else { this.Text = "Yeah"; } }
On teste via la propriété InvokeRequired
si on est dans le thread ayant créé la fenêtre. Si ce n’est pas le cas, alors on utilise la méthode Invoke
pour exécuter le délégué passé en paramètre (ChangeFormCaption
) dans le bon thread : celui à partir duquel la fenêtre a été créée.
Ca fonctionne, hourra.
Maintenant, un autre exemple :
public partial class MyForm : Form { public MyForm() { this.InitializeComponent(); // New background thread started from the form constructor var thread = new Thread(DoSomething); thread.Start(); Thread.Sleep(100); } public void DoSomething() { if (this.InvokeRequired) { this.Invoke((ThreadStart) this.DoSomething); } else { Thread.Sleep(100); // Access to the control (the form itself, actually) // from the background thread this.Text = "Yeah"; } } }
A priori, ça fonctionne, puisqu’on prend bien garde d’utiliser la méthode Invoke
lorsque nécessaire. Mais en fait – et c’est bien là tout l’intérêt de ce billet si tant est qu’il y en ait un – ça ne fonctionne pas toujours. On continue d’obtenir plus ou moins aléatoirement l’InvalidOperationException
liée à l’interaction cross-thread condamnée plus tôt.
Mais pourquoi ?
Un nord-coréen répondrait sans doute « parce que », mais je ne suis pas nord-coréen. La MSDN non plus, faut croire, puisqu’après avoir halluciné quelque temps j’y ai trouvé une ligne spécifiant le comportement de la propriété InvokeRequired
lorsque le handle du contrôle sur lequel elle est appelée n’est pas encore créé : elle retourne false
.
C’est fâcheux, et c’est aussi ce qu’illustre l’exemple précédent. Dans certains cas, en lançant le background thread durant la construction de la fenêtre, le handle de cette dernière peut ne pas avoir été créé lors de l’accès à la propriété InvokeRequired
. Cela entraine logiquement la modification de la fenêtre directement à partir du background thread, et l’exception est levée. Notez qu’effectuer la modification en appelant systématiquement la méthode Invoke
n’arrange rien : cette dernière nécessite également que le handle du contrôle soit créé, et lèvera une autre InvalidOperationException
si ce n’est pas le cas.
Le SynchronizationContext
permet de régler ça. En fait, c’est une solution disponible à partir du framework 2.0 qui peut complètement remplacer le pattern if(InvokeRequired) Invoke(...)
, et qui gère les handles correctement. C’est ce qu’utilise le BackgroundWorker
, pour info. Voilà ce que ça donne à l’utilisation :
public partial class MyForm : Form { private readonly SynchronizationContext syncContext; public MyForm() { this.InitializeComponent(); // Getting the synchronization context from the // form constructor this.syncContext = SynchronizationContext.Current; // New background thread started from the form constructor var thread = new Thread(this.DoSomething); thread.Start(); Thread.Sleep(100); } public void DoSomething() { this.syncContext.Post(delegate { Thread.Sleep(100); // Access to the control from the // synchronization context this.Text = "Yeah"; }, null); } }
Lors de la construction de la fenêtre, avant tout, on récupère le contexte de synchronisation correspondant au thread courant, et on conserve sa référence. Dans le background thread (méthode DoSomething
) on accède à la fenêtre via la méthode Post
du SynchronizationContext
. Et voilà.
Je vous laisse lire la documentation pour en apprendre plus, car il y a plus à apprendre. Je voulais surtout mettre en avant aujourd’hui le comportement éventuellement troublant de la propriété InvokeRequired
, qui peut facilement faire perdre du temps dans ce scénario bien particulier.
Rhaaa, je savais bien que je n’étais pas le seul à avoir rencontré ce problème :
[…] après avoir halluciné quelque temps j’y ai trouvé une ligne spécifiant le comportement de la propriété InvokeRequired lorsque le handle du contrôle sur lequel elle est appelée n’est pas encore créé : elle retourne false.
Après avoir passé et repassé le code au debug pas à pas, j’ai également fini par trouver cette information sur la MSDN. Mais lorsque je suis parvenu jusqu’au SynchronizeContext, j’ai lâché l’affaire car j’ai trouvé les explications trop obscures pour moi…
Je vais tester l’astuce asap (à mon retour de congés ^^), merci !
Ça c’est cool comme tips!! Et ça fait un petit rappelle sur les dangers des threads en Winforms.
Bonjour,
Merci beaucoup pour cette astuce, la syntaxe est beaucoup plus simple et ça fonctionne.
Pour info, pour éviter ce bug, je ne faisait jamais rien dans mes contructeurs de winform, je faisais tout dans le OnLoad.
Manitra.
Dans le post il dit que ça fonctionne pas toujours si tu « lances le thread dans le constructeur de la Form », sauf que dans le constructeur de la Form t’es pas sensé lancer de threads….
Imagine tu fais :
Form f = new Form() ;
=> Ta pas encore affiché ta Form que déjà ta lancé des threads….. je vois pas bien à quoi ça sert
Normalement si tu veux lancer des threads a l’ouverture la Form tu les mets dans le Form_Load avec l’allocation des ressources utilisées par le formulaire.
Excellente démarche d’explication et recherche