Dette indlæg bygger på Chris Burrows’ nylige indlæg (her og her) om, hvordan håndtering af events er blevet ændret i den kommende version af C#-compileren. Læs endelig begge indlæg! Og læs dem så lige igen. Savner du yderligere forklaring omkring problemet, kan dette indlæg forhåbentlig besvare de resterende spørgsmål.
Problemerne
Der er to problemer med den nuværende compilers måde at generere kode for håndtering af events. Det ene problem er uheldigt, og kan desværre lede til deadlocks i koden. Heldigvis er dette problem forholdsvis ligetil at forstå.
Det andet problem er derimod mere subtilt, men humlen er, at compileren i visse situationer vil generere kode, der ikke er thread safe. Konsekvenserne af dette kan udarte sig på mange forskellige måder, og det kan derfor være svært, at finde sammenhængen.
Lad os se på det første problem.
Som jeg tidligere har været inde på, behandler compileren events specielt. Så når vi erklærer en event som følger
public event EventHandler SomeEvent;
genererer compileren et privat felt af typen EventHandler samt metoderne add_SomeEvent og remove_SomeEvent. Implementeringen af disse metoder er dekoreret med MethodImplOptions.Synchronized, hvilket vil sige, at de kalder lock(this) for at sikre udelt adgang til den underliggende delegate-liste. Det er en dårlig ide, at bruge en offentlig tilgængelig reference til låsning. Da this i sagens natur ikke er privat for instansen, kan dette føre til deadlocks.
Det er der ikke så meget at gøre ved på nuværende tidspunkt. Følger man anbefalingen om at altid låse via en dedikeret, privat instans af object er risikoen for problemer i denne sammenhæng begrænset.
Heldigvis er kodegenereringen ændret i den kommende version af compileren, så ikke alene er deadlock-problematikken håndteret, men da implementeringen nu undgår låse, kan vi også regne med en lille hastighedsforbedring. add_SomeEvent ser nu ud som følger:
public void add_SomeEvent(EventHandler value)
{
EventHandler handler2;
EventHandler someEvent = this.SomeEvent;
do
{
handler2 = someEvent;
EventHandler handler3 = (EventHandler) Delegate.Combine(handler2, value);
someEvent = Interlocked.CompareExchange<EventHandler>(ref this.SomeEvent, handler3, handler2);
}
while (someEvent != handler2);
}
Det andet problem
Det næste problem er langt mere underfundigt. Betragt nedenstående type:
public sealed class SomeType {
public event EventHandler SomeEvent;
private int Count = 0;
public void AddInternalEvent(object o) {
SomeEvent += SomeEventHandler;
Interlocked.Increment(ref Count);
}
private void SomeEventHandler(object sender, EventArgs args) { }
public void ReportNumberOfListeners() {
Console.WriteLine("Listeners {0}", SomeEvent.GetInvocationList().Length);
Console.WriteLine("AddInternalEvent called {0} times", Count);
}
}
Typen er ikke videre interessant, men den implementerer en event, der kan modificeres via AddInternalEvent(). Læg også mærke til, at den holder styr på, hvor mange gange denne metode er blevet kaldt for hver instans. Det er egentlig ikke nødvendig, men det fjerner eventuel usikkerhed omkring timing af de enkelte tråde.
Hvad sker der, hvis vi kalder AddInternalEvent() fra mange samtidige tråde? Her er et eksempel, hvor vi sætter 100 kald over via .NETs thread pool:
var st = new SomeType();
for (int i = 0; i < 100; i++) {
ThreadPool.QueueUserWorkItem(st.AddInternalEvent);
}
Thread.Sleep(1000);
st.ReportNumberOfListeners();
Formålet med denne kode er blot at illustrere problemet. Jeg er klar over, at man ikke ville skrive kode som ovenfor, men hvis mere end en tråd kalder AddInternalEvent samtidigt, kan problemet opstå. Ved at kalde den mange gange som her, øger vi sandsynligheden for, at vi vil se effekten af problemet.
Ovenstående skulle gerne udskrive, at metoden er blevet kaldt 100 gange, samt at vi derfor har 100 listeners. Desværre er det ikke altid tilfældet. Vores tæller viser, at metoden bliver kaldt 100 gange som forventet, men alligevel har vi ikke altid 100 listeners.
Vi så jo lige, at add_SomeEvent() sørger for at kalde lock(this), hvilket godt nok kan føre til deadlocks, men trods alt burde synkronisere adgangen til event-listen. Hvordan kan det så gå galt?
Problemet er, at add_SomeEvent() ikke bliver kaldt i dette tilfælde!
Hvis en type specificerer en event og selv manipulerer denne via +=, genererer compileren ikke et kald til add_SomeEvent(), men går i stedet direkte på det underliggende delegate field og kalder Combine() på dette. (Det tilsvarende gør sig naturligvis gældende for -=). Her er vores kode til AddInternalEvent(), som den ser ud i .NET Reflector:
public void AddInternalEvent(object o)
{
this.SomeEvent = (EventHandler) Delegate.Combine(this.SomeEvent, new EventHandler(this.SomeEventHandler));
Interlocked.Increment(ref this.Count);
}
Som det fremgår af ovenstående, kaldes Delegate.Combine() direkte, og der er således ingen synkronisering. Derfor er koden i dette tilfælde ikke thread safe. Jeg er overbevist om, at Chris Burrows har ret, når han skriver, at størstedelen af C#-udviklere næppe er opmærksomme på denne subtile, men vigtige forskel.
Som nævnt indtræffer dette kun under visse omstændigheder. Såvel event-typen som brugen af den skal være defineret i samme type. Sagt på en anden måde: Hvis SomeEvent kaldes udefra, eller hvis eventen er defineret på en baseklasse (hvilket er tilfældet for mange GUI-komponenter), genererer compileren kald til add_SomeEvent() som forventet.
Heldigvis er dette problem også adresseret i den kommende version af compileren. Compileren genererer nu også kald til add_SomeEvent i dette tilfælde, hvilket vi kan verificere ved at se på den genererede IL.
.method public hidebysig instance void AddInternalEvent(object o) cil managed
{
.maxstack 8
L_0000: ldarg.0
L_0001: ldarg.0
L_0002: ldftn instance void TestApp.SomeType::SomeEventHandler(object, class [mscorlib]System.EventArgs)
L_0008: newobj instance void [mscorlib]System.EventHandler::.ctor(object, native int)
L_000d: call instance void TestApp.SomeType::add_SomeEvent(class [mscorlib]System.EventHandler)
L_0012: ldarg.0
L_0013: ldflda int32 TestApp.SomeType::Count
L_0018: call int32 [mscorlib]System.Threading.Interlocked::Increment(int32&amp;amp;amp;amp;)
L_001d: pop
L_001e: ret
}