Ik heb een .NET applicatie onder Windows 7 met user-interface waarvoor het zeer belangrijk is dat de functionele integriteit van de applicatie zo goed als mogelijk gegarandeerd kan worden. Denk hierbij onder andere aan data integriteit en de volgordelijkheid van acties van de gebruiker. Performance, waaronder zelfs de responsiveness van de UI, is veel minder belangrijk. Omdat de applicatie zo belangrijk is is er een uitgebreide automatische testomgeving.
De applicatie is relatief complex, bevat meerdere lagen en is opgezet volgens het “Model View ViewModel” pattern (Wikipedia: Model View ViewModel). Hierdoor is de business-logic netjes gescheiden van de UI. De applicatie doet geen langdurige acties vanaf de UI (de duurste actie kost 10-15 ms) dus hierdoor is het mogelijk om de hele applicatie single-threaded te maken. Door de applicatie op deze manier op te zetten, kunnen race-conditions en andere threading gerelateerde bugs zo goed als uitgesloten worden.
Echter, in een vrij laat stadium komen er allerlei problemen aan het licht die data corruptie, crashes, en deadlocks veroorzaken. Deze problemen zijn in een automatische test eigenlijk niet zichtbaar, maar bij handmatige tests komen ze heel af en toe voor. Ondanks dat ze niet heel vaak voorkomen, zijn de gevolgen vaak wel ernstig en bovendien erg onvoorspelbaar. Hierdoor zijn de problemen die zich voordoen zogenaamde show-stoppers voor de applicatie.
Na enig onderzoek blijkt onderstaande de root-cause van alle problemen die zich voordoen:
De UI-thread van een .NET UI applicatie is een zogenaamde COM STA thread. Dit betekent dat wanneer deze thread op een synchronisatie object moet wachten (bijvoorbeeld een Monitor, AutoResetEvent, of een Mutex), messages in de message queue afgehandeld kunnen worden. Zie ook: http://blogs.msdn.com/cbrumme/archive/2004/02/02/66219.aspx
De applicatie gebruikt zelf geen threads, en dus ook geen synchronisatie objecten. Echter verschillende (.NET) componenten die de applicatie gebruikt hebben intern wel worker-threads en dus ook synchronisatie objecten. Hierdoor kan het impliciet toch gebeuren dat er vanaf de UI-thread kortstondig op een synchronisatie object moet worden gewacht.
De consequentie is bijvoorbeeld dat een functie die via een BeginInvoke op de UI-thread uitgevoerd wordt, halverwege onderbroken wordt door een OnPaint omdat er toevallig (deep-down ergens) op een synchronisatie object moet worden gewacht. Hierdoor kan het gebeuren dat de applicatie zich niet in een consistente state bevind op het moment dat de OnPaint wordt uitgevoerd. Of andersom, de state van de applicatie kan veranderen op ieder moment dat er op een synchronisatie object moet worden gewacht doordat de OnPaint de state van de applicatie aanpast.
Dit kan eenvoudig gereproduceerd worden met onderstaand voorbeeld:
Start de applicatie en druk snel achter elkaar een aantal keer op de “Add” knop. Na een paar seconden zal de lijst gevuld zijn met items. Als alles goed is gegaan zouden er alleen items mogen staan die beginnen met een “1”. Echter wanneer deze test op Windows Vista of nieuwer wordt uitgevoerd zullen er regelmatig items tussen zitten die met een “2” beginnen, bijvoorbeeld:
1 OnButtonClick Enter 8
2 OnPaint Enter 9
2 OnPaint Leave 9
1 OnButtonClick Leave 8
Hieruit kan afgeleid worden dat terwijl OnButtonClick wacht op een synchronisatie object OnPaint al uitgevoerd wordt.
Dit probleem wordt erger op het moment dat de applicatie andere stimuli van buitenaf krijgt. Probeer bijvoorbeeld eens heel vaak op de “Add” knop te drukken en dan terwijl de lijst vult met de muis boven de knop in de taakbalk hangen en dan rechts klikken op de popup van de taakbalk. Nu zullen er in de lijst soms zelfs items zitten die met een “3” beginnen, bijvoorbeeld:
1 OnButtonClick Enter 41
2 OnPaint Enter 42
3 OnBeginInvoke Enter 43
3 OnBeginInvoke Leave 43
3 OnPaint Enter 44
3 OnPaint Leave 44
2 OnPaint Leave 42
1 OnButtonClick Leave 41
Hieruit kan afgeleid worden dat OnButtonClick is onderbroken door een OnPaint, maar vervolgens de OnPaint is onderbroken door een OnBeginInvoke gevolgd door een OnPaint. Het is ook goed om te beseffen dat de “lock (this)” statements hier niet tegen beschermen omdat alles vanaf de dezelfde thread wordt aangeroepen.
Let op: dit is geen bug, dit is by-design (zie het bovengenoemde MSDN blog). Het is dus de bedoeling dat hier in het ontwerp van een applicatie rekening mee gehouden wordt. Dat is in dit geval dus niet gebeurd.
Dit probleem kan gereduceerd worden door zo veel mogelijk events op een worker-thread af te handelen. Echter alle acties op de UI, clipboard, shell, enz. zullen dan weer op de UI-thread gezet moeten worden waardoor de applicatie alsnog een complexe multi-threaded applicatie wordt met alle aanverwante threading problemen. Bovendien lost dit het probleem niet op en moet er heel goed opgelet worden hoe de synchronisatie tussen de worker-thread en de UI-thread gedaan wordt, aangezien dit alsnog voor problemen kan zorgen.
Daarom de volgende vragen:
Is bovengenoemde probleem bekend? Zo ja, hoe wordt er mee omgegaan en wat is de beste manier om het in het design mee te nemen?
Bovenstaand probleem beschouwende, is het eigenlijk wel verstandig om een mission-critical applicatie op basis van .NET te maken? Zo niet, wat is dan het (beste) alternatief?
De applicatie is relatief complex, bevat meerdere lagen en is opgezet volgens het “Model View ViewModel” pattern (Wikipedia: Model View ViewModel). Hierdoor is de business-logic netjes gescheiden van de UI. De applicatie doet geen langdurige acties vanaf de UI (de duurste actie kost 10-15 ms) dus hierdoor is het mogelijk om de hele applicatie single-threaded te maken. Door de applicatie op deze manier op te zetten, kunnen race-conditions en andere threading gerelateerde bugs zo goed als uitgesloten worden.
Echter, in een vrij laat stadium komen er allerlei problemen aan het licht die data corruptie, crashes, en deadlocks veroorzaken. Deze problemen zijn in een automatische test eigenlijk niet zichtbaar, maar bij handmatige tests komen ze heel af en toe voor. Ondanks dat ze niet heel vaak voorkomen, zijn de gevolgen vaak wel ernstig en bovendien erg onvoorspelbaar. Hierdoor zijn de problemen die zich voordoen zogenaamde show-stoppers voor de applicatie.
Na enig onderzoek blijkt onderstaande de root-cause van alle problemen die zich voordoen:
De UI-thread van een .NET UI applicatie is een zogenaamde COM STA thread. Dit betekent dat wanneer deze thread op een synchronisatie object moet wachten (bijvoorbeeld een Monitor, AutoResetEvent, of een Mutex), messages in de message queue afgehandeld kunnen worden. Zie ook: http://blogs.msdn.com/cbrumme/archive/2004/02/02/66219.aspx
De applicatie gebruikt zelf geen threads, en dus ook geen synchronisatie objecten. Echter verschillende (.NET) componenten die de applicatie gebruikt hebben intern wel worker-threads en dus ook synchronisatie objecten. Hierdoor kan het impliciet toch gebeuren dat er vanaf de UI-thread kortstondig op een synchronisatie object moet worden gewacht.
De consequentie is bijvoorbeeld dat een functie die via een BeginInvoke op de UI-thread uitgevoerd wordt, halverwege onderbroken wordt door een OnPaint omdat er toevallig (deep-down ergens) op een synchronisatie object moet worden gewacht. Hierdoor kan het gebeuren dat de applicatie zich niet in een consistente state bevind op het moment dat de OnPaint wordt uitgevoerd. Of andersom, de state van de applicatie kan veranderen op ieder moment dat er op een synchronisatie object moet worden gewacht doordat de OnPaint de state van de applicatie aanpast.
Dit kan eenvoudig gereproduceerd worden met onderstaand voorbeeld:
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
| using System; using System.Drawing; using System.Threading; using System.Windows.Forms; using System.Windows.Threading; namespace Test { public class Test : Form { [STAThread] static void Main() { Application.Run(new Test()); } public Test() { messageList.Location = new Point(13, 13); messageList.Size = new Size(267, 225); Controls.Add(this.messageList); addButton.Location = new Point(205, 241); addButton.Size = new Size(75, 23); addButton.Text = "Add"; addButton.Click += OnButtonClick; Controls.Add(this.addButton); ClientSize = new Size(292, 273); Name = Text = "Test"; FormClosed += OnFormClosed; running = true; workerThread = new Thread(WorkerThread); workerThread.Start(); } private void OnFormClosed(object sender, FormClosedEventArgs e) { running = false; workerThread.Join(); } private void OnButtonClick(object sender, EventArgs e) { Dispatcher.CurrentDispatcher.BeginInvoke(new Action(OnBeginInvoke)); Invalidate(); lock (this) { enterCount++; int n = eventCount++; messageList.Items.Add(enterCount + " OnButtonClick Enter " + n); lock (synchronizationObject) { /* ... */ } messageList.Items.Add(enterCount + " OnButtonClick Leave " + n); enterCount--; } } private void OnBeginInvoke() { lock (this) { enterCount++; int n = eventCount++; messageList.Items.Add(enterCount + " OnBeginInvoke Enter " + n); lock (synchronizationObject) { /* ... */ } messageList.Items.Add(enterCount + " OnBeginInvoke Leave " + n); Invalidate(); enterCount--; } } protected override void OnPaint(PaintEventArgs e) { lock (this) { enterCount++; int n = eventCount++; messageList.Items.Add(enterCount + " OnPaint Enter " + n); lock (synchronizationObject) { base.OnPaint(e); } messageList.Items.Add(enterCount + " OnPaint Leave " + n); enterCount--; } } private void WorkerThread() { while (running) { lock (synchronizationObject) { Thread.Sleep(250); } } } private int eventCount, enterCount; private object synchronizationObject = new object(); private ListBox messageList = new ListBox(); private Button addButton = new Button(); private volatile bool running; private Thread workerThread; } } |
Start de applicatie en druk snel achter elkaar een aantal keer op de “Add” knop. Na een paar seconden zal de lijst gevuld zijn met items. Als alles goed is gegaan zouden er alleen items mogen staan die beginnen met een “1”. Echter wanneer deze test op Windows Vista of nieuwer wordt uitgevoerd zullen er regelmatig items tussen zitten die met een “2” beginnen, bijvoorbeeld:
1 OnButtonClick Enter 8
2 OnPaint Enter 9
2 OnPaint Leave 9
1 OnButtonClick Leave 8
Hieruit kan afgeleid worden dat terwijl OnButtonClick wacht op een synchronisatie object OnPaint al uitgevoerd wordt.
Dit probleem wordt erger op het moment dat de applicatie andere stimuli van buitenaf krijgt. Probeer bijvoorbeeld eens heel vaak op de “Add” knop te drukken en dan terwijl de lijst vult met de muis boven de knop in de taakbalk hangen en dan rechts klikken op de popup van de taakbalk. Nu zullen er in de lijst soms zelfs items zitten die met een “3” beginnen, bijvoorbeeld:
1 OnButtonClick Enter 41
2 OnPaint Enter 42
3 OnBeginInvoke Enter 43
3 OnBeginInvoke Leave 43
3 OnPaint Enter 44
3 OnPaint Leave 44
2 OnPaint Leave 42
1 OnButtonClick Leave 41
Hieruit kan afgeleid worden dat OnButtonClick is onderbroken door een OnPaint, maar vervolgens de OnPaint is onderbroken door een OnBeginInvoke gevolgd door een OnPaint. Het is ook goed om te beseffen dat de “lock (this)” statements hier niet tegen beschermen omdat alles vanaf de dezelfde thread wordt aangeroepen.
Let op: dit is geen bug, dit is by-design (zie het bovengenoemde MSDN blog). Het is dus de bedoeling dat hier in het ontwerp van een applicatie rekening mee gehouden wordt. Dat is in dit geval dus niet gebeurd.
Dit probleem kan gereduceerd worden door zo veel mogelijk events op een worker-thread af te handelen. Echter alle acties op de UI, clipboard, shell, enz. zullen dan weer op de UI-thread gezet moeten worden waardoor de applicatie alsnog een complexe multi-threaded applicatie wordt met alle aanverwante threading problemen. Bovendien lost dit het probleem niet op en moet er heel goed opgelet worden hoe de synchronisatie tussen de worker-thread en de UI-thread gedaan wordt, aangezien dit alsnog voor problemen kan zorgen.
Daarom de volgende vragen:
Is bovengenoemde probleem bekend? Zo ja, hoe wordt er mee omgegaan en wat is de beste manier om het in het design mee te nemen?
Bovenstaand probleem beschouwende, is het eigenlijk wel verstandig om een mission-critical applicatie op basis van .NET te maken? Zo niet, wat is dan het (beste) alternatief?
Too many people, making too many problems