Programmieren von Java-Threads in der realen Welt, Teil 1

Alle Java-Programme außer einfachen konsolenbasierten Anwendungen sind Multithread-Programme, ob Sie möchten oder nicht. Das Problem besteht darin, dass das Abstract Windowing Toolkit (AWT) Betriebssystemereignisse in einem eigenen Thread verarbeitet, sodass Ihre Listener-Methoden tatsächlich auf dem AWT-Thread ausgeführt werden. Dieselben Listener-Methoden greifen normalerweise auf Objekte zu, auf die auch über den Hauptthread zugegriffen wird. An diesem Punkt mag es verlockend sein, den Kopf in den Sand zu stecken und so zu tun, als müssten Sie sich keine Gedanken über Fadenprobleme machen, aber normalerweise kommen Sie damit nicht durch. Und leider befasst sich praktisch keines der Bücher über Java mit Threading-Problemen in ausreichender Tiefe. (Eine Liste hilfreicher Bücher zum Thema finden Sie unter Ressourcen.)

Dieser Artikel ist der erste einer Reihe, der reale Lösungen für die Probleme beim Programmieren von Java in einer Multithread-Umgebung vorstellt. Es richtet sich an Java-Programmierer, die die Sprachkenntnisse (das synchronizedSchlüsselwort und die verschiedenen Funktionen der ThreadKlasse) verstehen , aber lernen möchten, wie diese Sprachfunktionen effektiv genutzt werden.

Plattformabhängigkeit

Leider fällt Javas Versprechen der Plattformunabhängigkeit in der Thread-Arena ins Wanken. Obwohl es möglich ist, ein plattformunabhängiges Multithread-Java-Programm zu schreiben, müssen Sie dies mit offenen Augen tun. Dies ist nicht wirklich Javas Schuld; Es ist fast unmöglich, ein wirklich plattformunabhängiges Threading-System zu schreiben. (Doug Schmidts ACE-Framework (Adaptive Communication Environment) ist ein guter, wenn auch komplexer Versuch. Einen Link zu seinem Programm finden Sie unter Ressourcen.) Bevor ich in späteren Abschnitten über Kernprobleme der Java-Programmierung sprechen kann, muss ich dies tun Erläutern Sie die Schwierigkeiten, die durch die Plattformen verursacht werden, auf denen die Java Virtual Machine (JVM) ausgeführt werden kann.

Atomenergie

Das erste Konzept auf Betriebssystemebene, das zu verstehen ist, ist die Atomizität. Eine atomare Operation kann nicht durch einen anderen Thread unterbrochen werden. Java definiert mindestens einige atomare Operationen. Insbesondere die Zuordnung zu Variablen jeglichen Typs außer longoder doubleist atomar. Sie müssen sich keine Sorgen machen, dass ein Thread eine Methode in der Mitte der Zuweisung verhindert. In der Praxis bedeutet dies , dass Sie nie ein Verfahren zu synchronisieren, die nichts tut , sondern geben den Wert von (oder einen Wert zuzuweisen) eine booleanoder intInstanzvariable. In ähnlicher Weise müsste eine Methode, die viele Berechnungen nur mit lokalen Variablen und Argumenten durchgeführt und die Ergebnisse dieser Berechnung als letztes einer Instanzvariablen zugewiesen hat, nicht synchronisiert werden. Zum Beispiel:

Klasse some_class {int some_field; void f (some_class arg) // absichtlich nicht synchronisiert {// Hier viele Dinge tun, die lokale Variablen // und Methodenargumente verwenden, aber nicht auf // Felder der Klasse zugreifen (oder Methoden aufrufen //, die auf irgendwelche zugreifen) Felder der Klasse). // ... some_field = new_value; // mach das zuletzt. }}

Auf der anderen Seite könnten Sie bei der Ausführung von x=++yoder x+=ynach dem Inkrement, aber vor der Zuweisung ausgeschlossen werden. Um in dieser Situation Atomizität zu erhalten, müssen Sie das Schlüsselwort verwenden synchronized.

All dies ist wichtig, da der Aufwand für die Synchronisierung nicht trivial sein kann und von Betriebssystem zu Betriebssystem variieren kann. Das folgende Programm zeigt das Problem. Jede Schleife ruft wiederholt eine Methode auf, die dieselben Operationen ausführt, aber eine der Methoden ( locking()) ist synchronisiert und die andere ( not_locking()) nicht. Unter Verwendung der JDK-VM "Performance-Pack", die unter Windows NT 4 ausgeführt wird, meldet das Programm einen Laufzeitunterschied von 1,2 Sekunden zwischen den beiden Schleifen oder etwa 1,2 Mikrosekunden pro Aufruf. Dieser Unterschied scheint nicht viel zu sein, bedeutet jedoch eine Verlängerung der Anrufzeit um 7,25 Prozent. Natürlich sinkt der prozentuale Anstieg, wenn die Methode mehr Arbeit leistet, aber eine erhebliche Anzahl von Methoden - zumindest in meinen Programmen - bestehen nur aus wenigen Codezeilen.

import java.util. *; Klassensynchronisation { synchronisierte int-Sperrung (int a, int b) {Rückgabe a + b;} int not_locking (int a, int b) {Rückgabe a + b;} private statische endgültige int ITERATIONEN = 1000000; statisch public void main (String [] args) {Synchronisationstester = neue Synchronisation (); doppelter Start = neues Datum (). getTime (); für (lang i = ITERATIONEN; --i> = 0;) tester.locking (0,0); double end = new Date (). getTime (); double locking_time = end - start; start = new Date (). getTime (); für (lang i = ITERATIONEN; --i> = 0;) tester.not_locking (0,0);end = new Date (). getTime (); double not_locking_time = end - start; double time_in_synchronization = locking_time - not_locking_time; System.out.println ("Zeitverlust durch Synchronisation (Millis.):" + Time_in_synchronization); System.out.println ("Overhead pro Anruf sperren:" + (time_in_synchronization / ITERATIONS)); System.out.println (not_locking_time / locking_time * 100.0 + "% erhöhen"); }}

Obwohl die HotSpot-VM das Problem des Synchronisationsaufwands lösen soll, ist HotSpot kein Freebee - Sie müssen es kaufen. Wenn Sie HotSpot nicht mit Ihrer App lizenzieren und ausliefern, ist nicht abzusehen, welche VM sich auf der Zielplattform befindet, und Sie möchten natürlich, dass die Ausführungsgeschwindigkeit Ihres Programms so wenig wie möglich von der VM abhängt, die es ausführt. Selbst wenn es keine Deadlock-Probleme gab (die ich im nächsten Teil dieser Serie besprechen werde), ist die Vorstellung, dass Sie "alles synchronisieren" sollten, einfach falsch.

Parallelität versus Parallelität

Das nächste Problem im Zusammenhang mit dem Betriebssystem (und das Hauptproblem beim Schreiben von plattformunabhängigem Java) hat mit den Begriffen Parallelität und Parallelität zu tun . Gleichzeitige Multithreading-Systeme lassen mehrere Aufgaben gleichzeitig ausgeführt werden. Diese Aufgaben werden jedoch in Blöcke aufgeteilt, die den Prozessor mit Blöcken anderer Aufgaben teilen. Die folgende Abbildung zeigt die Probleme. In parallelen Systemen werden tatsächlich zwei Aufgaben gleichzeitig ausgeführt. Parallelität erfordert ein System mit mehreren CPUs.

Wenn Sie nicht viel Zeit damit verbringen, blockiert zu werden und auf den Abschluss von E / A-Vorgängen zu warten, wird ein Programm, das mehrere gleichzeitige Threads verwendet, häufig langsamer ausgeführt als ein gleichwertiges Single-Thread-Programm, obwohl es häufig besser organisiert ist als das entsprechende Single-Thread-Programm -Thread-Version. Ein Programm, das mehrere Threads verwendet, die parallel auf mehreren Prozessoren ausgeführt werden, wird viel schneller ausgeführt.

Obwohl Java zumindest theoretisch die vollständige Implementierung von Threading in der VM ermöglicht, würde dieser Ansatz jegliche Parallelität in Ihrer Anwendung ausschließen. Wenn keine Threads auf Betriebssystemebene verwendet würden, würde das Betriebssystem die VM-Instanz als Single-Thread-Anwendung betrachten, die höchstwahrscheinlich für einen einzelnen Prozessor geplant wäre. Das Nettoergebnis wäre, dass keine zwei Java-Threads, die unter derselben VM-Instanz ausgeführt werden, jemals parallel ausgeführt würden, selbst wenn Sie mehrere CPUs hätten und Ihre VM der einzige aktive Prozess wäre. Natürlich könnten zwei Instanzen der VM, auf denen separate Anwendungen ausgeführt werden, parallel ausgeführt werden, aber ich möchte es besser machen. Um Parallelität zu erhalten, muss die VMJava-Threads OS-Threads zuordnen; Sie können es sich also nicht leisten, die Unterschiede zwischen den verschiedenen Threading-Modellen zu ignorieren, wenn die Plattformunabhängigkeit wichtig ist.

Machen Sie Ihre Prioritäten klar

Ich werde zeigen, wie sich die soeben diskutierten Probleme auf Ihre Programme auswirken können, indem ich zwei Betriebssysteme vergleiche: Solaris und Windows NT.

Zumindest theoretisch bietet Java zehn Prioritätsstufen für Threads. (Wenn zwei oder mehr Threads darauf warten, ausgeführt zu werden, wird der Thread mit der höchsten Prioritätsstufe ausgeführt.) In Solaris, das 231 Prioritätsstufen unterstützt, ist dies kein Problem (obwohl die Verwendung von Solaris-Prioritäten schwierig sein kann - mehr dazu in einem Moment). Für NT hingegen stehen sieben Prioritätsstufen zur Verfügung, die auf die zehn von Java abgebildet werden müssen. Diese Zuordnung ist undefiniert, daher bieten sich viele Möglichkeiten. (Beispielsweise können die Java-Prioritätsstufen 1 und 2 beide der NT-Prioritätsstufe 1 und die Java-Prioritätsstufen 8, 9 und 10 der NT-Stufe 7 zugeordnet werden.)

Der Mangel an Prioritätsstufen bei NT ist ein Problem, wenn Sie die Priorität zur Steuerung der Zeitplanung verwenden möchten. Erschwerend kommt hinzu, dass die Prioritätsstufen nicht festgelegt sind. NT bietet einen Mechanismus namens Prioritätserhöhung, den Sie mit einem C-Systemaufruf deaktivieren können, jedoch nicht über Java. Wenn die Prioritätserhöhung aktiviert ist, erhöht NT die Priorität eines Threads jedes Mal, wenn bestimmte E / A-bezogene Systemaufrufe ausgeführt werden, für einen unbestimmten Zeitraum um einen unbestimmten Betrag. In der Praxis bedeutet dies, dass die Prioritätsstufe eines Threads höher sein kann als Sie denken, da dieser Thread zu einem ungünstigen Zeitpunkt eine E / A-Operation ausgeführt hat.

Der Zweck der Prioritätserhöhung besteht darin, zu verhindern, dass Threads, die Hintergrundverarbeitung ausführen, die offensichtliche Reaktionsfähigkeit von UI-lastigen Aufgaben beeinträchtigen. Andere Betriebssysteme verfügen über komplexere Algorithmen, die normalerweise die Priorität von Hintergrundprozessen senken. Der Nachteil dieses Schemas, insbesondere wenn es auf Thread- und nicht auf Prozessebene implementiert wird, besteht darin, dass es sehr schwierig ist, anhand der Priorität zu bestimmen, wann ein bestimmter Thread ausgeführt wird.

Es wird schlimmer.

In Solaris haben Prozesse, wie in allen Unix-Systemen, sowohl Priorität als auch Threads. Die Threads von Prozessen mit hoher Priorität können nicht durch die Threads von Prozessen mit niedriger Priorität unterbrochen werden. Darüber hinaus kann die Prioritätsstufe eines bestimmten Prozesses von einem Systemadministrator begrenzt werden, damit ein Benutzerprozess kritische Betriebssystemprozesse nicht unterbricht. NT unterstützt nichts davon. Ein NT-Prozess ist nur ein Adressraum. Es hat an sich keine Priorität und ist nicht geplant. Das System plant Threads. Wenn dann ein bestimmter Thread unter einem Prozess ausgeführt wird, der sich nicht im Speicher befindet, wird der Prozess ausgetauscht. NT-Thread-Prioritäten fallen in verschiedene "Prioritätsklassen", die über ein Kontinuum tatsächlicher Prioritäten verteilt sind. Das System sieht folgendermaßen aus:

Die Spalten sind tatsächliche Prioritätsstufen, von denen nur 22 von allen Anwendungen gemeinsam genutzt werden müssen. (Die anderen werden von NT selbst verwendet.) Die Zeilen sind Prioritätsklassen. Die Threads, die in einem Prozess ausgeführt werden, der an die Leerlaufprioritätsklasse gebunden ist, werden abhängig von der zugewiesenen logischen Prioritätsstufe auf den Ebenen 1 bis 6 und 15 ausgeführt. Die Threads eines Prozesses, der als normale Prioritätsklasse festgelegt ist, werden auf den Ebenen 1, 6 bis 10 oder 15 ausgeführt, wenn der Prozess nicht über den Eingabefokus verfügt. Wenn der Eingabefokus vorhanden ist, werden die Threads auf den Ebenen 1, 7 bis 11 oder 15 ausgeführt. Dies bedeutet, dass ein Thread mit hoher Priorität eines Leerlaufprioritätsklassenprozesses einen Thread mit niedriger Priorität eines normalen Prioritätsklassenprozesses verhindern kann. aber nur, wenn dieser Prozess im Hintergrund läuft. Beachten Sie, dass ein Prozess im "High" ausgeführt wird.Der Prioritätsklasse stehen nur sechs Prioritätsstufen zur Verfügung. Die anderen Klassen haben sieben.

NT bietet keine Möglichkeit, die Prioritätsklasse eines Prozesses einzuschränken. Jeder Thread in einem Prozess auf der Maschine kann jederzeit die Kontrolle über die Box übernehmen, indem er seine eigene Prioritätsklasse erhöht. Es gibt keine Verteidigung dagegen.

Der Fachbegriff, mit dem ich die Priorität von NT beschreibe, ist unheiliges Durcheinander. In der Praxis ist Priorität unter NT praktisch wertlos.

Was kann ein Programmierer tun? Zwischen der begrenzten Anzahl von Prioritätsstufen von NT und der unkontrollierbaren Erhöhung der Priorität gibt es für ein Java-Programm keine absolut sichere Möglichkeit, Prioritätsstufen für die Planung zu verwenden. Ein gangbarer Kompromiss ist zu beschränken , sich Thread.MAX_PRIORITY, Thread.MIN_PRIORITYund Thread.NORM_PRIORITYwenn Sie anrufen setPriority(). Diese Einschränkung vermeidet zumindest das Problem der 10-Ebenen-Zuordnung zu 7-Ebenen. Ich nehme an, Sie könnten die os.nameSystemeigenschaft verwenden, um NT zu erkennen, und dann eine native Methode aufrufen, um die Prioritätserhöhung zu deaktivieren. Dies funktioniert jedoch nicht, wenn Ihre App unter Internet Explorer ausgeführt wird, es sei denn, Sie verwenden auch das VM-Plug-In von Sun. (Die VM von Microsoft verwendet eine nicht standardmäßige Implementierung mit nativen Methoden.) Auf jeden Fall hasse ich es, native Methoden zu verwenden.Normalerweise vermeide ich das Problem so weit wie möglich, indem ich die meisten Threads anlegeNORM_PRIORITYund Verwenden anderer Planungsmechanismen als der Priorität. (Ich werde einige davon in zukünftigen Abschnitten dieser Serie besprechen.)

Kooperieren!

Es gibt normalerweise zwei Threading-Modelle, die von Betriebssystemen unterstützt werden: kooperativ und präventiv.

Das kooperative Multithreading-Modell

In einem kooperativen System behält ein Thread die Kontrolle über seinen Prozessor, bis er beschließt, ihn aufzugeben (was möglicherweise niemals der Fall ist). Die verschiedenen Threads müssen miteinander kooperieren oder alle bis auf einen der Threads werden "ausgehungert" (was bedeutet, dass sie nie die Chance haben, ausgeführt zu werden). Die Planung in den meisten kooperativen Systemen erfolgt streng nach Prioritätsstufe. Wenn der aktuelle Thread die Kontrolle aufgibt, erhält der wartende Thread mit der höchsten Priorität die Kontrolle. (Eine Ausnahme von dieser Regel ist Windows 3.x, das ein kooperatives Modell verwendet, aber keinen großen Scheduler hat. Das Fenster mit dem Fokus wird gesteuert.)