Threads: Merkwürdiges Lock-Acquire ?

Hey, ich wiederhole grade ein bisschen was zum Thema Multithreading.

Habe mir als Test eine Klasse geschrieben mit einer consume und einer produce -Methode, die jeweils Integer aus einer ArrayList nehmen, bzw einfügen.

import java.util.Random;

public class Factory {

	ArrayList<Integer> list = new ArrayList<Integer>();
	Random r = new Random();

	public synchronized void produce() throws InterruptedException {
		while (list.size() == 10) {
			wait();
		}
		int number = r.nextInt(200);
		list.add(number);
		System.out.println("Added int: " + number + "	 Queue-size: "
				+ list.size());
		notify();
		
	}

	public synchronized void consume() throws InterruptedException {
		while (list.size() == 0) {
			wait();
		}
		int number = list.remove(0);
		System.out.println("Acquired int: " + number + "	 Queue-size: "
				+ list.size());

		notify();

	}

	public static void main(String[] args) {
		Factory p = new Factory();
		Thread producer = new Thread(new Runnable() {

			@Override
			public void run() {
				while (true) {
					try {
						p.produce();
					} catch (InterruptedException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					}

				}
			}
		});

		Thread consumer = new Thread(new Runnable() {

			@Override
			public void run() {
				while (true) {
					try {
						p.consume();
					} catch (InterruptedException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					}
				}
			}
		});

		producer.start();
		consumer.start();
	}

}

Das ganze läuft zu Testzwecken in einer Endlosschleife.

Jetzt hätte ich mit folgendem Szenario gerechnet:
Einer der beiden Threads holt sich den Lock auf das Factory-Objekt. Falls es der Consumer-Thread ist, legt der sich schlafen, weil die Liste leer ist, falls es der Producer ist, fügt er ein Objekt ein und weckt den Consumer auf.

Dann sollten beide Threads versuchen den Lock zu bekommen.

Allerdings kriegt der Producer jedes mal den Lock, bis die Liste voll ist(in meinem Beispiel auf 10 begrenzt), und sich dann schlafen legt.
Danach darf der Consumer ran und kann sich ein Objekt rausnehmen, und ruft wieder notify.
Auch hier hätte ich damit gerechnet, dass wieder beide Threads versuchen den Lock zu kriegen(was ja wahrscheinlich auch passiert).

Aber auch hier kriegt der Consumer-Thread jedes Mal den Lock bis die Liste leer ist und er ist sich wieder schlafen legt.

Hatte es schon mit ein paar Sleeps probiert, weil ich dachte, das der Thread vllt etwas braucht um “aufzuwachen” und dann den Lock nicht mehr kriegen kann, aber das änderte nichts.
Aber das wäre ja eig. auch keine Erklärung. Wenn in der Liste 5Elemente sind, schläft keiner der beiden Threads und trotzdem kriegt einer der Threads jedes mal den Lock, bis die Liste voll bzw leer ist.

Wie kommt es, dass die Lock-Vergabe so ist? Dh erstmal 10x produce, dann 10x consume, dann 10x produce und so weiter. Es kommt im Prinzip nie zu einer 50/50 Vergabe, womit ich eigentlich gerechnet hätte.

Hoffe mich kann da jemand aufklären

Grüße

beim Programm wie gepostet (wobei Factory final sein muss damit in anonymen Klassen zu nutzen?) gibt es bei mir einen ständigen Wechsel, am Anfang zwischen size 0 und 1,
geht selten auch mal hoch, wieder viele Wechsel, und auch wieder runter, alles dabei

am Anfang könnte es so sein, dass schlicht der Producer-Thread schneller ist, das notify() bewirkt nichts wenn noch kein anderer Thread wartet,
aber danach sollte es spätestens anfangen, richtig

kommt mir in diesem Beispiel auch ganz ok vor, ich wäre gerade glücklicher mit deinem Verlauf als mit meinem, jedenfalls mögliche Vorteile:
ein Thread-Wechsel ist ein immenser Aufwand, besser nicht bei jedem notify() -> 10000x pro Sekunde (oder wieviel überhaupt technisch möglich ist),
sondern jeden Thread wenigstens die üblichen x ms arbeiten lassen oder was auch immer,

wenn in der Zeit noch mehr zu schaffen ist, was spricht dagegen, 10x einfügen und 10x herausnehmen in Reihenfolge ist effizienter als im direkten Wechsel,
solange der jeweils andere Thread nicht ewig verzögert wird sondern innerhalb normaler Randbedingungen drankommt, dann doch ok

erhöhe mal testweise die maximale Anzahl, wie lange braucht der Producer bis 1000, 1 Mio. oder Maximum 2 Mrd. befüllt?
neben reiner Arbeit dann auch (zumindest am Anfang) Frage der Speicherreservierung, Umfüllprozesse in ArrayList usw.,

kommt dann irgendwann der Consumer auch vorher dran?


es gibt auch Thread.yield() um explizit einen anderen Thread dranzulassen,
bestimmt erst in den Runnable außerhalb der synchronized empfehlenswert,

bei mir kein sichtbarer Unterschied, bei dir wer weiß…


[quote=Tarrew]Hatte es schon mit ein paar Sleeps probiert, weil ich dachte, das der Thread vllt etwas braucht um “aufzuwachen” und dann den Lock nicht mehr kriegen kann, aber das änderte nichts.
[/quote]
wo hast du sleeps mit welchen Zeiten getestet?
hilft nicht dagegen dass du dir wünschst dass es ohne geht, aber bewirken sollte es schon etwas!

zumindest sleeps in den Runnable-Methoden außerhalb von produce()/ consume(), dann muss ja zwingend genug Zeit sein dass abwechselnder Zugriff garantiert ist, oder bei dir nicht?

sleep nur in den synchronized-Methoden, ok, da ist vorstellbar dass sleep nichts ändert,
der kurzen Zeitsprung zwischen Ende eines Aufrufs über Schleife zu neuen Eintritt kann theoretisch immer überwunden werden


direkt verwertbares Wissen zu deiner Frage kann ich nicht anbieten,
aber für den Fall dass nicht bekannt Hinweis, dass eine Suche mit ‘java wait notify fairness’ und ähnliches Massen an Links bietet

edit: ach ja, Standardweisheit, die auch in den Links zur Genüge steht:
lieber gar nichts erwarten, nicht darauf verlassen dass der Scheduler besonders fair ist,
sondern die Programme robust schreiben,
jeder Thread darf solange arbeiten bis evtl. blockiert, dann sollte ein anderer freie Bahn haben und informiert sein, usw.,
dein Programm funktioniert ja durchaus

Hey, danke für die ausführliche Antwort.

Ich habe die maximale Größe mal auf 100erhöht und dann gibt es immerhin etwas mehr Abwechslung. Das heißt, der Producer schreibt nur etwa ~40Einträge, danach liest der Consumer wieder etwa ~40Einträge.
Vllt optimiert die JVM im Hintergrund da irgendwas, da ein Threadwechsel einiges an Aufwand bedeutet, wie du ja schon gesagt hattest.

Das ein Thread schläft, bzw aufgewecket wird sollte ja praktisch nur an den Extrembedingungen stattfinden, also Liste voll bzw Liste leer. Anders müssten ja beide um den Lock “kämpfen”, wobei ein Thread den Kampf immer zu gewinnen scheint.

Ich hab das mal zum Test umgeschrieben, also alles ohne wait() und notify();

        if(list.size() == 100) {
            
        }else{
        int number = r.nextInt(89)+10;
        list.add(number);
        System.out.println("Added int: " + number + "	 Queue-size: "
                + list.size());
        }
    }
 
    public synchronized void consume() throws InterruptedException {
       if(list.size() == 0) {
        
        }else{
        int number = list.remove(0);
        System.out.println("Acquired int: " + number + "	 Queue-size: "
                + list.size());
        }
      
 
    }

Aber jetzt ist es so, dass der Producer Thread die Liste komplett füllt, und der Consumer dann die komplette Liste leert. Da kriegt der jeweils andere Thread auch nach ~40 Durchgängen nicht den Lock sondern wartet bis die Liste ganz voll bzw ganz leer ist.

Mit den Sleeps hattest du Recht. Hatte es in den synchronized-Methoden drin. Wenn ich in der run-Methode den Consumer-Thread mit Thread.sleep(1) schlafen lasse nach jedem consume() dann ist die Liste so gut wie immer voll, beim sleep() im Producer-Thread fast immer leer. Mit Thread.yield() kriege ich dann wieder eine relative Gleichverteilung in Blöcken hin. Dh einmal kriegt der eine Thread 20x den Lock, dann der andere Thread.

Werde sofort nochmal zu der Thread-fairness googeln, danke für den Hinweis.

Macht es überhaupt Sinn generell mit wait(), notify() zu arbeiten. Dh ist das zweite Beispiel eventuell garnicht so viel Rechenintensiver, weil das “Versuchen den Lock zu bekommen”, eventuell so gut wie nichts kostet?

Grüße

synchronized ohne notify hatte ich gerade nicht im Blick, richtig, für sich auch noch irgendwelche Regeln bestimmt…

wenn in humanen Maßstäben, nur maximal paar Mal pro Sekunde, dann ginge das, wobei nur begrenzt sinnvoll,

auf keinen Fall darf das aber in einer Schleife ohne Pause potentiell 1 Mio. Mal pro Sekunde wiederholt werden, CPU auf 100%,
egal ob bei Synchronisiertung oder in anderen Fällen

schlimm genug wenn regulär soviel zu produzieren usw. (wie das ursprüngliche Programm), dann aber wenigestens eine Aufgabe,
leeres Warten lieber auf <1% verringern, durch wait()/ sleep()

Okay in meinem Fall macht es jetzt so gut wie keinen Unterschied. Solange die Liste nicht leer ist und vom Producer gefüllt wird, versucht der Consumer den Lock zu kriegen. Fragt sich nur wie rechenintensiv dieser Versuch ist. Manuell leg ich ihn ja nur schlafen, bei leerer bzw voller Liste, was praktisch nie der Fall ist, bzw der andere Thread wird direkt wieder aufgeweckt im Prinzip.

Wie läuft das denn intern ab? Wird ein Thread - wenn er einen Lock nicht kriegt - intern schlafen gelegt um keine CPU-Leistung zu verbrauchen, und wenn ein andere Thread den Lock abgibt wieder aufgeweckt? Oder blockiert der Thread intern irgendwie anders und versucht permanent den Lock zu kriegen bis er ihn hat?

Wie schon gesagt (bzw. spekuliert) wurde: Zwischen verschiedenen Threads hin- und her zu schalten ist vergleichsweise teuer. Die JVM wird das nicht auf so feingranularer Ebene machen. Wenn man die Kapazität auf 10000 oder so erhöht, sieht man ja auch, dass er dann irgendwann anfängt umzuschalten, aber die genauen Mechanismen dahinter sind natürlich Schwarze Magie, und sind so tief in der JVM und ihrer Verbandelung mit dem Betriebssystem versteckt, dass man nur mit enormem Aufwand wirklich definitive Aussagen dazu machen könnte.

Nebnebei: Die Funktionalität gibt es ja auch ohne manuelles synchronized/wait/notifyAll, nämlich im concurrent-Package, speziell in den BlockingQueue-Implementierungen. Z.B.

import java.util.Random;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;

public class FactoryNew {

    BlockingDeque<Integer> list = new LinkedBlockingDeque<Integer>(10);
    Random r = new Random();

    public void produce() throws InterruptedException {
        int number = r.nextInt(200);
        list.put(number);
        System.out.printf("Added   int: %3d Queue-size: %3d
", number, list.size());
    }

    public void consume() throws InterruptedException {
        int number = list.take();
        System.out.printf("Removed int: %3d Queue-size: %3d
", number, list.size());
    }

    public static void main(String[] args) {
        FactoryNew p = new FactoryNew();
        Thread producer = new Thread(new Runnable() {

            @Override
            public void run() {
                while (true) {
                    try {
                        p.produce();
                    } catch (InterruptedException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }
                }
            }
        });

        Thread consumer = new Thread(new Runnable() {

            @Override
            public void run() {
                while (true) {
                    try {
                        p.consume();
                    } catch (InterruptedException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }
                }
            }
        });

        producer.start();
        consumer.start();
    }

}

Die LinkedblockingQueue verwendet intern ein ReentrantLock, bei dem man explizit im Konstruktor angeben kann, ob er „fair“ sein soll:

The constructor for this class accepts an optional fairness parameter. When set true, under contention, locks favor granting access to the longest-waiting thread. Otherwise this lock does not guarantee any particular access order.

Leider wird dieser Parameter nicht durch die LinkedBlockingQueue geschleift - die ist also leider immer UN-fair. Aber man könnte zum testen mal dein ursprüngliches Programm umschreiben, so dass es einen Lock und zwei Conditions verwendet (wie die LinkedBlockingQueue), und würde dann vermutlich sehen, dass „fair“ und „unfair“ einen Unterschied macht, im Hinblick auf die Frage, wie oft umgeschaltet wird. (Man würde annehmen, dass bei „fair“ wirklich IMMER nach jeden einzelnen Element hin-und-her geschaltet wird…)

Danke, die werd ich mir nochmal angucken.

Nochmal eine letzte Sache. Wisst ihr, wie der Lock intern umgesetzt ist. Dh wenn ein Thread einen Lock nicht kriegen kann, macht er dann eventuell ein wait() und ein Thread der ein synchronized-Block verkässt und seinen Lock abgibt, macht ein notify() oder Ähnliches?

Oder gibt es irgendeine rechenarme Operation intern, sodass die Threads irgendwie warten ohne direkt komplett einzuschlafen?

diese Frage kann man eigentlich auch zur Implementierung von sleep() und wait() stellen,
was genau passiert da mit dem Thread, wie genau wird er zum Ende/ Abbruch informiert?

grundsätzlich oder oberflächlich muss man ein wenig vertrauen, dass die JVM ein paar Dinge alleine (oder mit Betriebssystem, eh anderen Programmiersprachen) ordentlich kann

Synchronization under the hood, and why Java 5 improves it
hilft vielleicht, insbesondere auf der zweiten Seite auch fettgedruckt “We’re generally not going to sit in the loop burning CPU”
(Suche war ‘java synchronized implementation’, gibt wie immer auch mehr Ergebnisse)

Sowas wie Java theory and practice: Synchronization optimizations in Mustang könnte da relevant sein. Die JVM ist ziemlich clever, und kann ggf. Locks auch komplett weglassen, wenn sie merkt, dass sie überflüssig sind, bzw. sie zusammenfassen, wenn nichts dagegen spricht (Stichworte z.B. “Lock Elision”).

Kleine Ergänzung nochmal. Bin grade nochmal die Vorlesungsfolien durchgegangen und habe auch gelesen:

  • „Lokale Variablen sind in anonymen Klassen nicht sichtbar“.
    und
  • „Damit auf //Variable aus Beispiel// zugegriffen werden kann muss die lokale Variable als final deklariert werden!“
    so ähnlich wie SlaterB es schon gesagt hatte:

Mein Programmcode geht aber problemlos durch, ohne das die Factory final ist. Betrifft das eventuell nur Datenprimitive (im Beispiel wars ein double) und Objekte sind davon nicht betroffen, oder sind lokale Variablen in Java8 eventuell auch im Scope von anonymen Klassen?

[QUOTE=Tarrew]
Mein Programmcode geht aber problemlos durch, ohne das die Factory final ist. Betrifft das eventuell nur Datenprimitive (im Beispiel wars ein double) und Objekte sind davon nicht betroffen, oder sind lokale Variablen in Java8 eventuell auch im Scope von anonymen Klassen?[/QUOTE]

Ja, mit Java 8 geht das. Dort wurde das Konzept von “effectively final” Variablen eingeführt (siehe auch Local Classes (The Java ). D.h. dass man eine Variable meistens (Details muss ich mir auch noch anlesen) nicht mehr als final deklarieren muss, sondern sie (GROB gesagt) “automatisch als ‘final’ angenommen wird, wenn sie nicht verändert wird”. Wenn du als letzte Zeile deiner main schreiben würdest
p = null;
sollte er sich beschweren.