Vor kurzem habe ich in einem Code Review (meines eigenen Codes) eine Klasse gezeigt, die einen “seltsamen” statischen Konstruktor enthielt. Das sah ungefähr so aus:
public class AType
{
private static readonly CType c;
private static readonly ReaderWriterLock locker = new ReaderWriterLock();
static AType()
{
locker.AquireWriterLock(Timeout.Infinite);
try
{
c = BType.CreateCType();
}
finally
{
locker.ReleaseWriterLock();
}
}
}
Kaum hatte ich den Konstruktor auf dem Schirm gezeigt, kam unisono ein Kommentar der Reviewer: “Das Locking im statischen Konstruktor ist überflüssig, der statische Konstruktor gewährleistet die threadsichere Ausführung schon durch implizites Locking”.
Tatsächlich ist es so, das im obigen Beispiel das Locking im Typinitialisierer (statischer Konstruktor) über den ReaderWriterLock quasi überflüssig ist, denn das .NET Framework (genauer die CLR) gewährleistet, dass
- Typinitialisierungen nur ein einziges mal pro Typ aufgerufen werden, und
- Typinitialisierungen innerhalb eines Threads bis zur Vollständigkeit ausgeführt werden.
Konsequenterweise lässt sich das obige Beispiel damit auch wesentlich einfacher formulieren:
public class AType
{
private static readonly CType c;
static AType()
{
c = BType.CreateCType();
}
}
Im Code Review musste ich zugeben, das mein initialer Code etwas zuviel des Guten gewesen ist. Ein Physiker, der auch am Code Review teilnahm, kommentierte den Code folgendermaßen: “Es ist eigentlich unnötig, aber schaden kann es nicht”. Diese Aussage hat mich zum Grübeln gebracht – und damit zu der Analyse, ob das Locking innerhalb des statischen Konstruktors wirklich überflüssig ist oder nicht.
Test 1: Typinitialisierung wird nur einmal und in einem einzigen Thread ausgeführt
Zunächst einmal wollte ich überprüfen, ob es tatsächlich so ist, das der statische Konstruktor nur einmalig von einem Thread aus aufgerufen wird. Dazu habe ich ein einfaches Testprogramm geschrieben:
public class Foo
{
static Foo()
{
Console.WriteLine("Begin Foo .cctor on thread {0}", Thread.CurrentThread.Name);
Thread.Sleep(5000);
Console.WriteLine("Exit Foo .cctor on thread {0}", Thread.CurrentThread.Name);
}
public static void Message()
{
Console.WriteLine("Foo on thread {0}", Thread.CurrentThread.Name);
}
}
public class Program
{
static void Main(string[] args)
{
ThreadStart ts = new ThreadStart(() => Foo.Message());
for (int i = 0; i < 10; i++)
{
Thread t = new Thread(ts);
t.Name = String.Format("Thread {0}", i);
t.Start();
}
Console.ReadKey();
}
}
Im statischen Konstruktor der Klasse Foo wird 5 Sekunden gewartet, bevor die Initialisierung fertiggestellt wird. Die Message()-Methode von Foo wird von 10 separaten Threads aufgerufen - also 10 Threads, die potenziell eine Typinitialisierung durchführen könnten. Führt man das Testprogramm aus, so stellt man in der Tat fest, das der statische Konstruktor nur ein einziges Mal aufgerufen wird. Alle anderen Threads, die den Typ benötigen, sind so lange blockiert, bis die Ausführung des Konstruktors beendet wurde.
Fazit Test 1: Der Typinitialisierer (statische Konstruktor) wird nur ein einziges Mal von einem einzigen Thread pro Typ ausgeführt.
(Anmerkung: Das gilt natürlich nur "pro AppDomain", denn die Deklarationskontexte von zwei AppDomains sind voneinander getrennt.)
Test 2: Typinitialisierung von generischen Typen
Als nächstes war es für mich von Interesse zu untersuchen, wie sich die Typinitialisierung bei speziellen Typen, nämlich den generischen Typen verhält. Auch dafür habe ich ein kleines Testprogramm geschrieben:
public class Foo<T>
{
static Foo()
{
Console.WriteLine("Begin Foo .cctor on thread {0}", Thread.CurrentThread.Name);
Thread.Sleep(5000);
Console.WriteLine("Exit Foo .cctor on thread {0}", Thread.CurrentThread.Name);
}
public static void Message()
{
Console.WriteLine("Foo on thread {0}", Thread.CurrentThread.Name);
}
}
public class Program
{
static void Main(string[] args)
{
ThreadStart ts1 = new ThreadStart(() => Foo<string>.Message());
ThreadStart ts2 = new ThreadStart(() => Foo<int>.Message());
ThreadStart ts3 = new ThreadStart(() => Foo<double>.Message());
ThreadStart[] tsx = new ThreadStart[] { ts1, ts2, ts3 };
for (int i = 0; i < 10; i++)
{
Thread t = new Thread(tsx[i%3]);
t.Name = String.Format("Thread {0}", i);
t.Start();
}
Console.ReadKey();
}
}
Im Endeffekt ist das obige Testprogramm nur eine kleine Variation des ersten Testprogrammes. Statt der Klasse Foo gibt es nun eine Klasse Foo<T>. Wieder werden 10 Threads zum Aufruf der Message()-Methode angeworfen, allerdings mit 3 unterschiedlichen Typinstanzen, also Foo<string>, Foo<int> und Foo<double>.
Führt man dieses Programm nun aus, stellt man fest, dass der statische Konstruktor des generischen Typs Foo<T> 3 Mal aufgerufen wird, und das sogar von 3 unterschiedlichen Threads! Wieso?
Die Erklärung liegt in der "Implementierung" (besser der Definition) von generischen Typen. Eine Typinstanz Foo<AnyType> wird vom Compiler als eigenständiger Typ emittiert und damit von der CLR als eigenständiger Typ verstanden. Demzufolge ist es durchaus valide, das der statische Konstruktor des generischen Typs 3 Mal (von 3 verschiedenen Threads) aufgerufen wird, denn es sind ja schlußendlich 3 verschiedene Typinstanzen des generischen Typs, die verwendet werden.
Das ist in vielen Fällen kein besonderes Problem, kann aber in besonderen Fällen doch problematisch werden. Ein Beispiel wäre eine Initialisierung oder ein Zugriff eines statischen Feldes einer Oberklasse (Basistyps) von Foo<T>.
Fazit Test 2: Der Typinitialisierer (statischer Konstruktor) von generischen Typen kann mehrfach von unterschiedlichen Threads aufgerufen werden (wobei gilt: pro Typinstanz nur einmal) und ist damit mit besonderer Sorgfalt zu implementieren.
Test 3: Typinitialisierung bei Deadlock-Szenario (gegenseitige Blockade)
Nach dem zweiten Test war mir klar geworden, dass statische Konstruktoren mit Vorsicht zu genießen sind. Diese Erkenntnis führte mich zum dritten Test. Ich wollte untersuchen, wie die Typinitialisierung reagiert, wenn es zu einem Deadlock-Szenario kommt. Was passiert also, wenn zwei Threads zwei unterschiedliche Typen initialisieren, wobei sich deren Initialisierung untereinander bedingt? Ein weiteres Testprogramm soll diese Frage klären:
public class Foo
{
private static string text = "blank Foo";
static Foo()
{
Console.WriteLine("Begin Foo .cctor on thread {0}", Thread.CurrentThread.Name);
Thread.Sleep(5000);
Console.WriteLine("Foo needs message from Bar: \"{0}\"", Bar.Text);
text = "Hello From Foo";
Console.WriteLine("Exit Foo .cctor on thread {0}", Thread.CurrentThread.Name);
}
public static void Message()
{
Console.WriteLine("Foo got message from Bar: \"{0}\" on thread {1}", Bar.Text, Thread.CurrentThread.Name);
}
public static string Text
{
get { return text; }
}
}
public class Bar
{
private static string text = "empty Bar";
static Bar()
{
Console.WriteLine("Begin Bar .cctor on thread {0}", Thread.CurrentThread.Name);
Thread.Sleep(5000);
Console.WriteLine("Bar needs message from Foo: \"{0}\"", Foo.Text);
text = "Hello From Bar";
Console.WriteLine("Exit Bar .cctor on thread {0}", Thread.CurrentThread.Name);
}
public static void Message()
{
Console.WriteLine("Bar got message from Foo: \"{0}\" on thread {1}", Foo.Text, Thread.CurrentThread.Name);
}
public static string Text
{
get { return text; }
}
}
public class Program
{
static void Main(string[] args)
{
Thread t1 = new Thread(new ThreadStart(() => Foo.Message()));
t1.Name = "Thread 1";
Thread t2 = new Thread(new ThreadStart(() => Bar.Message()));
t2.Name = "Thread 2";
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.ReadKey();
}
}
Im Programm werden zwei Typen durch die Klassen Foo und Bar definiert. Deren statische Konstruktoren enthalten einen Aufruf zum anderen Typ - Foo greift auf Bar.Text zu und Bar auf Foo.Text. Durch die eingebaute Wartezeit (Thread.Sleep) werden beide Threads für die Initialisierung reichlich Zeit haben. In letzter Konsequenz wird also der erste Thread darauf warten, das der zweite mit der Konstruktion des zweiten Typs fertig ist. Dieser aber bedingt, das der erste Typ konstruiert ist, welches ja beim ersten Thread noch auf Vervollständigung wartet. Beide Konstruktionen bedingen sich, es entsteht eine gegenseitige Blockade - der Deadlock.
Beim Ausführen des Testprogrammes kann es sein, das man seinen Augen kaum trauen wird. Denn beide Typen werden erfolgreich initialisiert und das Programm komplett ausgeführt - es kommt zu keinem Deadlock, obwohl es doch theoretisch zu einem Deadlock kommen muss. Wieso?
Die Antwort liegt in den Tiefen der CLI - denn zur Laufzeit des Programmes kommt es tatsächlich zu einem Deadlock. Jedoch ist die CLI so klever und erkennt das. Nach der Erkennung des Deadlocks löst die CLI eigenständig das Locking eines der beiden Threads auf, um somit die Vervollständigung der Typinitialisierung zu ermöglichen. Die Runtime macht das nicht aus Spass, sondern weil es eine Durchführung der Typinitialisierung garantieren muss.
Das Resultat einer solchen Garantie ist eine mögliche Fehlinformation innerhalb des statischen Konstruktors - und damit auch eine mögliche Fehlfunktion. Um diese potenzielle Fehlerquelle zu verhindern, müsste man tatsächlich innerhalb des statischen Konstruktors nochmals ein explizites Locking durchführen. Damit würde man den Deadlock regelrecht erzwingen. Ob man das möchte, hängt davon ab, welche Situation erstrebenswerter ist: Entweder man akzeptiert das geringe Risiko das bei der gegenseitigen (meist indirekten) Abhängigkeit seltene "komische Verhaltensweisen" des Programms auftreten, oder man möchte bei einer gegenseitigen Blockade, dass das Programm komplett einfriert.
Fazit Test 3: Voneinander abhängige statische Konstruktoren führen nicht zu einem Deadlock, können aber Fehlkonstruktion und Fehlverhalten nach sich ziehen.
Schlußfolgerungen und Konsequenzen
Die Ergebnisse der Tests sind (zumindest für mich) sehr aufschlußreich. Zu erkennen ist, das die Typinitialisierung in .NET einen ganz besonderen Stellenwert einnimmt. Auf Grund der Besonderheiten bei der Typinitialisierung ist es unbedingt erforderlich, auf die Umgebung und die Laufzeit-Bedingungen näher einzugehen, bevor man die Implementierung eines statischen Konstruktors für eine Klasse durchführt.
Die Tests haben gezeigt, das prinzipiell eine Typinitialisierung nur einmal von einem einzigen Thread pro Typ ausgeführt wird. Es gilt weiterhin, das die Ausführung des statischen Konstruktors threadsicher ist und auf jeden Fall vervollständigt wird - sogar bei gegenseitiger Blockade zweier Typinitialisierungs-Routinen.
Es ist zudem feststellbar, das es unter bestimmten Voraussetzungen dennoch zu einer mehrfachen Ausführung der statischen Initialisierungsroutine kommt, mitunter sogar von unterschiedlichen Threads zu beinaher gleichen Zeit. Dies ist im Besonderen bei statischer Konstruktion von generischen Typen der Fall.
Für mein Eingangs erwähntes Beispiel (der seltsame statische Konstruktor im Code Review) haben diese Erkenntnisse insofern eine Bedeutung, als das die Aussage des Physikers im Code Review bestätigt werden kann: Das explizite Locking im statischen Konstruktor ist eigentlich unnötig, schaden kann es dennoch nicht. Wäre die gezeigte Klasse eine generische gewesen, so wäre das explizite Locking u.U. vertretbar und notwendig gewesen. Da dem nicht so ist, ist es der Korrektheit halber wohl besser, das unnötige Locking rauszunehmen, was ich wohl auch tun werde.
Fazit: Die Typinitialisierung in .NET ist mit einigen Besonderheiten ausgestattet, die man in unter gegebenen Umständen (generische Typen, Multithreading) besonders beachten muss. Für die meisten Anwendungsfälle ist dies jedoch nicht nötig, da die CLR viele interne Maßnahmen ergreift, um eine sichere Typinitialisierung zu gewährleisten - in diesem Sinne: ziemlich de Luxe!
Anbei: Source-Code der Testprogramme (VS 2008, .NET 3.5, ZIP, 12kB)
byRobertonOctober 2nd 2008Danke sehr interessant