Multithreading in Java #

Ich habe eine Swing-Applikation, die, nachdem ich immer mehr Funktionen eingebaut habe, etwas langsam in der Reaktion wurde. Gleichzeitig wusste ich natürlich, daß so manche Berechnung oder so mancher Datenbank-Zugriff auch parallel ablaufen könnte. Also habe ich beschlossen, meine ganze Applikation und die zugrundeliegenden Bibliotheken multithreading-fähig zu machen. Dazu habe ich mir eine Menge Dinge angelesen und so langsam die Angst vor der ganzen Sache verloren. Die dabei gewonnenen Erkenntnisse möchte ich hier notieren.

Einige der von mir gezogenen Schlüsse und Regeln stehen so nirgendwo geschrieben. Ich bitte also darum, daß der geneigte Leser sich selber Gedanken macht und Denkfehler bitte direkt ändert oder kommentiert!

Problemfelder #

Bei der Implementierung von Multithreading in Java (und übrigens auch in anderen Sprachen) gilt es, mehrere Probleme zu lösen:

Synchronisierung #

Als erstes müssen alle parallel laufenden Methoden synchronisiert werden. Das heisst, es muss dafür gesorgt werden, daß sie sich nicht gegenseitig stören. Die Synchronisation geschieht in der einfachen Variante über das Schlüsselwort "synchronized" und bei komplexeren Szenarien über Lock-Objekte. Hat man zwei oder mehr verschiedene Objekte, an denen man synchronisiert, ergibt sich die Gefahr von Deadlocks - hat man nur eines, ergibt sich im Zweifel keine echte parallele Abarbeitung, weil die Tasks ständig in der Warteschlange des einen Objektes stehen. Es gibt "natürliche" Lock-Objekte wie eine Datenbank-Verbindung oder ein Speicherbereich (Ein solches natürliches Lock-Objekt ist übrigens auch die GUI, was u.a. ein Grund dafür ist, daß Swing gar nicht erst multithreading-fähig ist).

Aufruf von Threads #

Statt dem normalen Thread.start() sollte man einen Executor benutzen. Das hat vor allem den Vorteil, daß man über die Auswahl des ExecutorService im Nachhinein noch an der Multithreading-Strategie basteln kann, ohne daß man das an allen Stellen im Code ändern muss, wo man Threads startet. Wer eine Webapplikation schreibt, wird z.B. einen ExecutorService mögen, der die Anzahl der erzeugten Threads nach oben hin begrenzt (und die restlichen dann solange in eine Warteschlange stellt). Wer eine Desktop-Applikation schreibt, wird im Notfall (beim Auftreten seltsamer Fehler) einen Executor mögen, der gar keinen neuen Thread startet, sondern das Runnable sofort im aktuellen Thread ausführt. Für Resourcen, die es nur einmal gibt (wenn ich z.B. im Hintergrund meinen Scanner ansprechen will), gibt es auch einen Executor, der alle Aufrufe zwar in einem eigenen Thread, aber immer im selben und damit hintereinander ausführt.

Kommunikation zwischen Threads #

Mit diesem Thema habe ich mich noch nicht so ausführlich beschäftigt, weil das in meinem Projekt nicht so wichtig war. Es gibt aber auch hierzu eine ganze Fülle von hilfreichen Klassen in der Java Concurrency API.

In meinem Projekt (und wohl auch in vielen anderen Swing-Applikationen) erschöpft sich das Thema, indem ein Hintergrund-Thread am Ende seines Lebens ein Ergebnis hat und dann eine Methode, die dieses Ergebnis benutzt und etwas damit macht, im Swing Event Dispatcher Thread ausführt. Genau dies übernimmt am besten der SwingWorker.

Besonderheiten bei Swing #

Alle Swing-Komponenten müssen immer im Swing Event Dispatcher Thread ausgeführt werden. Das gilt natürlich auch für Listener und ähnliches. Jeder Aufruf, der evtl. aus einem anderen Thread heraus erfolgen könnte, sollte also ggf. als Event in den Dispatcher eingestellt werden. Alle fire*-Methoden müssen daher durch so etwas wie in der folgenden Überladung benutzt gekapselt werden:

	public void firePropertyChange(final PropertyChangeEvent e) {
		if (SwingUtilities.isEventDispatchThread()) {
			super.firePropertyChange(e);
		} else {
			Runnable doFirePropertyChange = new Runnable() {
				public void run() {
					firePropertyChange(e);
				}
			};
			SwingUtilities.invokeLater(doFirePropertyChange);
		}
	}

Wo werde ich schlauer? #

Auf jeden Fall sollte man sich erstmal in Ruhe in das Thema einlesen. Viele vor allem ältere Einführungen zum Thema beschäftigen sich mit den Java-Grundbefehlen wie synchronized, Object.wait(), etc. Diese taugen aber zumeist nur für einfachste Anwendungen. Wer ernsthafte Bibliotheken schreiben will, sollte die sog. High Level Concurrency Klassen in java.util.concurrent benutzen. Diese bieten modernere Schnittstellen und mehr Möglichkeiten.

Konkretes Vorgehen in Einzelschritten #

Ich habe in meinem SwingingBeans-Projekt eine Bibliothek, die sowohl auf eine Datenbank zugreift als auch Swing-Wodgets erzeugt. Daneben gibt es natürlich auch noch diverse Applikations-Methoden, die ganz andere Dinge treiben, die auch ggf. in einen eigenen Thread gehören. Ziel ist, die Antwortzeiten der GUI zu optimieren.

So muss ich vorgehen, um meine Bibliothek multithreading-sicher zu machen:

Welche Methoden müssen synchronisiert werden? #

Als erstes sollten alle Klassen identifiziert werden, die theoretisch in mehreren Threads ablaufen können. Hier fallen insbesondere Swing-Klassen weg, weil die sowieso immer im Swing Event Thread ausgeführt werden.

Alle public-Methoden sind jetzt Kandidaten für Synchronisation. Sollte eine solche kein Kandidat hierfür sein (z.B. weil Sie nur einen Delegator aufruft), dann müssen stattdessen alle von dieser aufgerufenen Methoden synchronisiert sein.

Eine Methode, die trivial aussieht, ist nur dann nicht zu synchronisieren, wenn sie entweder auf gar keine Felder der Klasse zugreift oder wenn es nur ein einziges Feld ist und dieses entweder final oder volatile ist.

Das ist jetzt eine gute Gelegenheit, die API zu überprüfen, ob vielleicht die eine oder andere public Methode doch ein Fall für package visibility ist (also ohne public deklariert wird). Auch die Aufteilung der Klassen in Pakete kann bei dieser Gelegenheit überdacht werden.

Genau nachdenken, wenn eine Methode andere Methoden anderer Klassen aufruft. Das führt potentiell zu Deadlocks. Insbesondere fire*-Methoden sind da gefährlich. Im Zweifelsfall (bei komplexen Listener-Struktoren) sollte man den Aufruf von Listenern jeweils in einen eigenen Task packen, damit sie ausgeführt werden, wenn mein Lock wieder freigegeben wird.

Wie implementiert man Synchronisation? #

Vorab muss man sich fragen, ob man die Synchronisation, die ja auch ein Stück Performance verschlingt und den Code etwas unübersichtlicher macht, in die eigentliche Klasse packt oder eine abgeleitete Variante erzeugt und die nicht-synchronisierte Version bestehen lässt. Hat man mehrere Klassen, die sich alle auf ein einheitliches Lock-Objekt zurückführen lassen (wie bei meiner Datenbank-Bobliothek), so fällt die Ableitungs-Variante eigentlich aus, wenn man nicht wirklich sicherstellen kann, daß nicht doch einmal irgendwo ein unsynchronisiertes Objekt erzeugt wird und dann "durchrutscht". Das Performance-Problem kann man ggf. durch ein Flag beheben, das in lock() abgefragt wird und ggf. auf die Synchronisation verzichtet.

Die beste Art der Synchronisation ist mit ReentrantLock (im Normalfall) oder ReadWriteLock (wenn es sinnvoll ist, mehrere Reader gleichzeitig, aber nur einen Writer zuzulassen) zu arbeiten.

Triviale Daten (primitive Typen oder einfache Klassen, die nur aus solchen bestehen) können mit volatile synchronisiert werden.

Der Aufruf der Synchronisation sollte über zwei eigene Methoden gehen. So kann die Synchronisation auch alternativ ganz weggelassen werden (durch überladen dieser Methoden oder ein dort abgefragtes Flag). Ein gelockter Block sollte in etwa so aussehen:

	lock();
     try {
		// ...
     } finally {
         unlock();
     }

Die lock() und unlock()-Methode kann dann z.B. so aussehen:

	void lock() throws InterruptedException {
		final int lockTimeout = 60000; // in ms, d.h. 60 Sekunden
		final int infoTimeout = 500; // in ms, d.h. 1/2 Sekunde
		long startzeit = System.currentTimeMillis();
		if (!lock.tryLock(lockTimeout, TimeUnit.MILLISECONDS)) {
			throw new DeadLockException("Deadlock nach " + lockTimeout / 1000f
					+ " Sekunden.");
		}
		long endzeit = System.currentTimeMillis();
		if (endzeit - startzeit > infoTimeout) {
			log.info("Synchronisation: Wartezeit auf Lock "
					+ (endzeit - startzeit) / 1000f + " Sekunden.");
		}
	}
	
	void unlock() {
		lock.unlock();
	}

Man kann diese Funktionalität und noch mehr hübsche Dinge, wie z.B. eine Analyse der oben gefundenen Deadlocks in eine eigene von ReentrantLock abgeleitete Klasse packen. Ich habe dies gemacht und meine Klasse TimeoutLock genannt. Wer Interesse hat, kann dort gerne mal reinschauen.

An welchem Objekt synchronisiert man? #

In den lock/unlock-Methoden kann das Lock-Objekt eines anderen, übergeordneten Objektes benutzt werden. So ist es z.B. sinnvoll, in mehreren Klassen, die sich alle mit einer Datenbank beschäftigen, ein Lock-Objekt pro JDBC-Connection zu benutzen. Die Reduzierung auf weniger Lock-Objekte (oder nur eines) reduziert auch die Möglichkeit von Deadlocks.

Was ist mit Unterbrechungen #

Grundsätzlich bin ich der Meinung, daß jeder Thread unterbrechbar sein sollte. Eine entsprechende Abfrage kann z.B. in lock() erfolgen. Das bedeutet aber, daß man sich überall Gedanken um die dabei erzeugte InterruptedException machen muss. Kann sowieso nicht schaden, mal viel öfter bei der Benutzung irgendwelcher Resourcen über <code>try{...}finally{...}</code> nachzudenken.

Swing-Schnittstellen finden #

Ich muss alle Stellen suchen, an denen aus dem Multithreading-Code heraus Swing-Methoden aufgerufen werden. Insbesondere habe ich alle fire*-Methoden herausgesucht und mit obigem Code dafür gesorgt, daß die Listener immer im EDT (Event Dispatcher Thread) ausgeführt werden.

Starten von Threads #

Im Normalfall übergibt man sein Runnable einem Executor und fertig. Will man jedoch ein Feedback in einer Swing-GUI haben, so empfiehlt sich, als Runnable einen SwingWorker zu nehmen (diese Klasse implementiert nämlich Runnable) und diesen dann seinem Executor zu übergeben.

Starten von Threads im Swing Application Framework

Wenn eine @Action-Methode des "Swing Application Framework" ein Task-Objekt zurückgibt, wird diese auch automatisch als eigener Thread gestartet. Task ist von SwingWorker abgeleitet.

Zum Starten benutzt das SAF einen sog. TaskService mit dem Namen "default", den man im ApplicationContext seines Programmes ersetzen kann (nachdem man den alten gleichnamigen gelöscht hat). Es handelt sich eigentlich nur um einen Wrapper um ein Executor-Objekt, man kann also hier einen eigenen Executor einbinden, wenn man auf dessen besondere Fähigkeiten angewiesen ist.

Zugriff auf natürlich eingeschränkte Resourcen #

Ich möchte in meinem Programm einen Scanner bedienen. Der recht lange Vorgang des Scannens bietet sich natürlich an, um im Hintergrund abzulaufen. Nun habe ich eine ganze Weile überlegt, wie man das synchronisiert, zumal ich ja auch schon auf meine Datenbank-Connection hin synchronisiere und grossen Respekt vor Deadlocks habe. Die richtige Lösung für solch eine natürlich auf ein Stück begrenzte Ressource ist jedoch eine andere (so wird es übrigens auch in Swing gemacht). Man erzeugt einen SingleThreadExecutor und stellt dort dann seine Aufgaben als Runnable ein. Sie werden dann ordentlich hintereinander ausgeführt, ohne untereinander synchronisiert werden zu müssen.

Was ich nicht synchronisieren kann, kann ich vielleicht klonen #

Wenn ich eine Datenbank-Operation in einem eigenen Thread ausführen möchte, kann es durch die Synchronisation trotzdem dazu kommen, daß meine Swing-GUI eine ganze Weile nicht mehr reagiert, weil sie ja auf das gleiche Datenbank-Objekt zugreift. Deshalb kann es sinnvoll sein, meinen Datenbank-Zugriff komplett so zu klonen, daß ich eine zweite Datenbank-Abfrage habe, die jedoch eine andere Connection benutzt.

Sonstiges / Weitere Fragen #

Deadlock-Erkennung #

Meine selbstimplementierten Lock-Objekte erkennen einen Deadlock selber, wenn sie länger als eine bestimmte Timeout-Zeit keinen Zugriff erhalten und lösen dann eine entsprechende Exception aus (statt sich wortlos aufzuhängen).

Progress Indicator #

Es wäre für manche Aufgaben schön, wenn man eine Anzeige über den Fortgang des Prozesses bekommen könnte.

-- ThomasBayen

Tags:  Java

Add new attachment

Only authorized users are allowed to upload new attachments.
« This page (revision-4) was last changed on 19-Feb-2008 21:43 by PeterHormanns