Synchronize() oder: Wo beginnt der Main-Thread ?

      Synchronize() oder: Wo beginnt der Main-Thread ?

      Hallo,

      folgendes Verständnisproblem:

      Angenommen, ich habe ein selbstdefiniertes Objekt TEinheit, welches diverse Zustände annehmen kann
      und einen Überwachungsthread beinhaltet: THorcher = class(TThread)

      Der Horchthread hat die übliche while Self.running - Schleife und horcht eine serielle Schnittstelle ab.

      Habe ich ein Datenpaket erkannt, muß ich die Daten in den Hauptthread schaffen, und hier kommts:

      Kann ich das Datenpaket (sagen wir einen String) gleich irgendwie in das umhüllende Objekt schreiben (denn eigentlich verändere ich an der GUI ja nichts) oder muß ich die Daten mittels einer Synchronize( @Transportroutine ) nach "aussen" schaffen ?
      Und weiter: Unter bestimmten Bedingungen soll dann das Objekt TEinheit eine Aktion ausführen. Auch grafisch.
      Geht das dann einfach so über einen eigenen Event in TEinheit ?
      Oder vielleicht besser formuliert: Sorgt das Auslösen eines selbstdefinierten Events im umhülleden Objekt, welches dann natürlich den Eventhandler aufruft für die Entkopplung von Mainthread und Horchthread, die sonst durch Synchronize() erreicht wird ?
      Nebenbei gesagt funktioniert es in Ansätzen, kommt mir aber etwas durch die Hüfte geschossen vor.
      Morgen ist Heute schon Gestern
      Moin... 8o
      Prinzipiell:
      Wenn du Daten nach außerhalb vom Thread schaffst, ob in ein Objekt (in deinem Falle THorcher TEinheit) oder einen anderen Thread wie die GUI, geschieht das immert über Synchronize. Der Thread THorcher gibt die "Daten" über ein synchonisiertes Event an THorcher TEinheit weiter. Dann kann THorcher TEinheit wiederum die "Daten" über ein normales Event and die GUI übergeben... fertsch. :)

      Nachtrag:
      Wo beginnt der Main-Thread ?

      ...prinzipiell direkt außerhalb vom Thread. Deshalb gehört auch das Synchronize in den Thread. 8) Stelle dir das Synchronize als "Schnittstelle" vom Thread nach außen vor.

      Dieser Beitrag wurde bereits 4 mal editiert, zuletzt von „haentschman“ ()

      Hej hej,

      danke für die Antwort.
      Also nochmal gaaanz langsam gefragt:
      Nehmen wir an, ich habe 2 Threads. Thread A läuft wie beschrieben im Kreis und sammelt Daten, woher auch immer.
      Thread B läuft auch, wartend auf Daten (von A ), im Kreis.
      Dann muß ich also die Übergabe in A mit einem Synchronize(@Bla) erledigen ?
      Ich dachte, Synchronize() führt die aufgerufene Prozedur nur im Kontext des Mainthreads aus ?

      Schönes WE,
      ism :help:
      Morgen ist Heute schon Gestern
      Tschuldigung, ich hatte THorcher mit TEinheit verwechselt. :/ Schaust du bitte in deinen Thread. Da habe das korrigiert.
      Ich dachte, Synchronize() führt die aufgerufene Prozedur nur im Kontext des Mainthreads aus ?

      TEinheit ist instanziert im MainThread. Oder?

      Dieser Beitrag wurde bereits 1 mal editiert, zuletzt von „haentschman“ ()

      ismirschlecht schrieb:

      Nehmen wir an, ich habe 2 Threads. Thread A läuft wie beschrieben im Kreis und sammelt Daten, woher auch immer.
      Thread B läuft auch, wartend auf Daten (von A ), im Kreis.
      Dann muß ich also die Übergabe in A mit einem Synchronize(@Bla) erledigen ?
      Ich dachte, Synchronize() führt die aufgerufene Prozedur nur im Kontext des Mainthreads aus ?


      Um zu verstehen, was wirklich notwendig ist, müssen wir etwas tiefer einsteigen. [Vorsicht - das Lesen könnte ungefähr so lange dauern wie das Schreiben...; und ich hatte keine Muße mehr, das noch einmal Kontrollzulesen. Schreibfehler bitte zu entschuldigen, wenn etwas nicht verständlich formuliert ist, bitte nachfragen.] Hier ein Modell von dem, was intern geschieht. Das ist relativ zutreffend, auch wenn ich hier der Einfachheit halber immer mal wieder Absolutheiten verwende (niemands, keine Möglichkeit, ...) und tatsächlich nichts absolut ist - mit dem nötigen Aufwand lässt sich alles irgendwie hinbiegen.
      • Du hast auf deinem Rechner eine gewisse Menge an physikalischem, adressierbarem Hauptspeicher zur Verfügung. Der wird vom Betriebssystem verwaltet und du kannst ihn dir im Prinzip wie einen Bandspeicher vorstellen.
      • Beim Start eines Prozesses erzeugt das Betriebssystem virtuellen Speicher. Es wird also quasi dafür gesorgt, dass der Prozess so handeln kann als wäre er der einzige auf dem Rechner und hätte den gesamten, kontinuierlichen Hauptspeicher zur Verfügung. Das stimmt natürlich nicht; intern besitzt das Betriebssystem also ein Mapping virtuelle Adresse <> physikalische Adresse. Sämtliche nicht-Kernel Anwendungen sehen lediglich die virtuellen Adressen und haben somit keine Möglichkeit, außerhalb ihres Speicherbereits irgendetwas zu schreiben oder zu lesen - ja, sie wissen ja nicht einmal, dass es da etwas anderes gibt.
      • Nun kann ein Prozess mehrere Threads haben. Threads werden häufig als „leichtgewichtige Prozesse“ bezeichnet. Aber vielleicht ist ein anderes Bild hilfreicher: Stelle dir einen Prozess lediglich als Container vor, der überhaupt nicht ausgeführt wird. Ein Prozess ist lediglich ein Verbund mehrerer Threads, die sich denselben RAM (und noch verschiedene andere Dinge) teilen. Tatsächlich ausgeführt wird nur der Thread.
      • Dann gibt es einen Hauptthread, der häufig stellvertretend für den Prozess an sich steht. Aber im Grund genommen ist das auch nichts weiter als eben der Thread, der ganz am Anfang gestartet wird und der dann dafür verantwortlich ist, andere Threads zu starten (oder auch nicht).
      • Nun zur Synchronisation - oder besser gesagt, zur Thread-Kooperation. Problemstellung wie bei dir: Thread A will irgendeine Aufgabe erledigt haben. Er startet also Thread B und sagt ihm: Tue dies und das und schreibe das Ergebnis an eine gewisse Stelle im Speicher. Sag Bescheid, wenn du fertig bist (oder, wie bei dir: Ich schau von Zeit zu Zeit vorbei und hole mir deine letzten Ausgaben). Das ist quasi ein Mini-Protokoll, auf das sich die beiden Threads geeinigt haben, was in dieser Form sinnvoll ist. Es gäbe aber z.B. auch die Möglichkeit, dass Thread A nicht vorgibt, wohin die Ergebnisse kommen sollen, sondern Thread B mitteilt, wohin er sie geschrieben hat. Das sind einfach Sachen des Protokolls, die müssen halt irgendwie geklärt werden. Prinzipiell ist egal wie, aber es gibt gewisse Denkmuster und Code Patterns, die einem sagen, was konventionell „besser“ ist als andere Dinge. Z.B. das Prinzip dass derjenige, der Speicher allokiert auch dafür verantwortlich ist, diesen wieder freizugeben.
      • Das Mini-Protokoll was ich bisher formuliert habe, verbirgt bisher noch geschickt die Problematik. Grundsätzlich kann jeder Thread in den „Speicherbereich des anderen Threads“ schreiben - einfach, weil das der gleiche Speicherbereich ist, nämlich der des gemeinsamen Prozesses. Also ist nichts von dem, was ich gesagt habe unmöglich.
        Es gibt nur ein Problem: Die Threads arbeiten ja unabhängig voneinander. Würden sie sequentiell ausgeführt, wäre es sinnlos, Threads zu verwenden. Das bedeutet, dass Thread A nicht weiß, was Thread B gerade tut. Insbesondere könnte es sein, dass A genau in dem Moment neue Daten abholen will, wie B dabei ist, zusätzlich Daten an die Speicheradresse zu hinterlegen. Und das kann - muss aber nicht! - zu Problemen führen.
        Bevor ich diese Probleme genauer beschreibe, kommen wir zur Lösung: Die beiden Threads müssen miteinander kommunizieren, d.h. sicherstellen, dass derartige gleichzeitige Ressourcenzugriffe durch gegenseitige Kooperation vermieden werden. Natürlich ist das Problem damit nur verschoben: Denn was ist, wenn beide Threads gleichzeitig das Signal verschicken: Jetzt bin ich dran, warte, bis ich fertig bin! Tatsächlich ist es nicht möglich, softwareseitig Threadsynchronisation zu erreichen. Die einzige Möglichkeit ist es, dass die Hardware selbst eine Möglichkeit bereitstellt, wie ursprünglich parallele Signale doch sequentiell (dann eben in willkürlicher Reihenfolge) abgearbeitet werden. Und genau das ist der Fall, und das Betriebssystem stellt verschiedene Funktionen bereit, die in der Lage sind, die Hardware entsprechend anzusteuern. Auf diese Art und Weise ist es möglich, etwa
        • einen Wartebereich zu implementieren: Bevor auf eine gemeinsame Ressource zugegriffen wird, muss der Thread über eine hardwareseitig gesicherte Routine anfragen, ob schon jemand auf diese Ressource zugreift. Wenn nicht, muss er warten. Es gibt das „aktive“ Warten, d.h. dass der Thread immer wieder nachfragt, ob jetzt endlich frei ist; und es gibt das „passive“ Warten, bei dem der Thread dem Betriebssystem sagt, dass er erst wieder neue Rechenzeit zugewiesen bekommen will, wenn der Bereich frei ist und sich solange schlafen legt. „Passives“ Warten ist natürlich zu bevorzugen; es gibt auch die Möglichkeit, eine gewisse Maximaldauer anzugeben (Stichwort WaitForSingleObject; Mutex; Semaphore; CriticalSection; Event).
        • die Message-Queue zu nutzen: Man kann für ein Handle einen Nachrichtenhandler implementieren. Das heißt, Thread A erzeugt ein Handle mit einer WndProc. Das ist die Callback-Funktion, die im Kontext von Thread A aufgerufen wird, um Nachrichten zu verwalten, die an dieses Handle geschickt werden. Thread B kann nun eine Nachricht verschicken - er fügt also ein Element an die Queue von Thread A an. An sich liegt die Queue auch wieder in einen Speicherbereich, der nicht Thread B gehört, aber das Betriebssystem kümmert sich hier darum, dass alles funktioniert. Irgendwann bequemt sich dann mal Thread A in seiner WndProc, die Nachrichten abzufragen und sieht, dass er irgendetwas tun soll.
        • die „Nachricht“ von Thread B nur aus einer einzigen, atomaren Operation. Dann kann Thread B direkt in den vorgesehenen Speicherbereich schreiben, falls das Datenabruf in Thread A entsprechend umsichtig gestaltet wurde. Das ist mit Abstand das Schnellste. Bleiben Sie dran - mehr zu atomaren Operation nach der Werbepause...
      Master of the EDH ;)
      Weiter geht's mit einem neuen maximalen Zeichenlimit pro Post...
      • Nun also zu den Problemen beim gemeinsamen Zugriff und zu atomaren Operationen etc. Grundlage ist der Gedanke, dass jedem Thread eine Zeitscheibe zugewiesen wird. Das ist einfach eine gewisse Menge an CPU-Zyklen während der das Betriebssystem dem Thread gestattet, etwas zu tun. Den Rest der Zeit „schläft“ der Thread einfach, ohne etwas davon mitzubekommen. Und grundsätzlich kann es jederzeit, mitten in irgendeiner Funktion, Schleife, was auch immer passieren, dass das Betriebssystem den Thread einfach „abdreht“ und einen anderen aktiviert. Schließlich magst du zwar vier oder vierundsechzig physikalische Kerne haben, aber es auf dem Computer laufen immer noch weit mehr Threads, die alle irgendwann mal auf die CPU zugreifen wollen. Das ist aber auf jeden Fall der Grund dafür, weshalb selbst bei Single-Core CPUs die Probleme genauso existieren wie bei Multi-Cores. Um es anschaulicher zu machen, beschreibe ich deshalb im Folgenden einen Single-Core-Rechner; zusätzlich ist bei Multi-Core das Problem, dass es hier auch echte Gleichzeitigkeit gibt.
        Dieses dramatische Szenario ermöglicht es, folgendes Beispiel zu konstruieren:
        • Thread A erzeuge ein dynamisches Array. Das bedeutet, dass Thread A eine Speicheradresse zur Verfügung stellt, an der gespeichert wird, wie viele Elemente gerade vorhanden sind und wo das erste dieser aufeinanderfolgenden Elemente zu finden ist.
        • Thread B bekommt diese Adresse mit der Anweisung, dort seine Daten zu speichern und ggf. das Array zu vergrößen.
        • Nun passiert es, dass Thread B neue Daten hat. Eine mögliche Implementierung ist nun:
          (1) SetLength wird aufgerufen, um das Array zu vergrößern
          (2) Das letzte, gerade neu eingefügte Element wird mit tatsächlichen Daten befüllt. Sagen wird, der Thread berechnet Näherungen von Pi als rationale Zahlen, und ein Array-Element enthält also der Einfachheit halber zwei Integer: Zähler und Nenner
        • Thread A fragt regelmäßig die Daten ab, und sein Job ist es, Zähler / Nenner mit der FPU zu berechnen und das Ergebnis als Double-Näherung auszugeben. Wann bekommt dieser Thread nun eine Zeitscheibe? Wir unterscheiden drei Möglichkeiten:
          • vor (1) oder nach (2). Alles ist in Ordnung.
          • nach (1). Das Array wurde gerade um ein Element erweitert. Standardmäßig initialisiert der Speichermanager das neu hinzugekommene Element mit Nullen. Thread A berechnet 0 / 0 und gibt NaN aus. Falsches Ergebnis - könnte in anderen Fällen aber auch zu einer Exception führen und Thread A ist ggf. nicht mehr in der Lage, sinnvoll weiterzuarbeiten.
          • zwischen (2). Der Zähler ist schon gespeichert, der Nenner noch nicht. Thread A berechnet ? / 0 und gibt +-Inf aus. Oder eben in anderen Fällen eine Exception.
        • Das ist denke ich relativ gut verständlich. Aber eine ganz üble Möglichkeit habe ich noch nicht erwähnt: Während (1). Denn was tut SetLength? Erst einmal muss geschaut werden, was für eine größe das Array hat, ob es größer oder kleiner wird. Dann muss der Speichermanager kucken, ob er an der aktuellen Stelle genug Platz hat. Wenn ja, wird der allokierte Speicherplatz einfach erweitert. Wenn nein, muss an einer neuen Stelle der Speicher allokiert werden, das Array wird komplett kopiert, das alte Array wird zerstört und der Zeiger des Arrays aktualiersiert. Das sind eine ganze Menge Operationen, und während jeder dieser Teiloperationen (die wiederum zahlreiche Assembler-Instruktionen beinhalten) kann der Thread unterbrochen werden. Wenn es blöd läuft, ist der Speicher in einem inkonsistenten Zustand. Stell dir vor, das alte Array wurde gerade zerstört, der Zeiger aber noch nicht aktualisiert. Das wäre eine AccessViolation - und nicht unbedingt eine der netten Sorte, die von Delphi generiert wird.
        • Kein Problem wäre übrigens aufgetaucht, wenn zuerst das Array vergrößert wird, ohne dass die Länge angepasst wird. Falls neuer Speicher benötigt wird, darf das nicht in die geteilte Variable zurückgeschrieben werden. Dann werden die neuen Daten gespeichert und danach wird entweder die Array-Länge inkrementiert oder der Pointer auf die neue Adresse gebogen. Das funktioniert aber nur, wenn es nur einen einzigen Thread gibt, der schreibt und alle anderen lediglich lesen.
      • Nun zum letzten Teil, den atomaren Operationen. Ich habe gerade von Assembler-Instruktionen gesprochen. Heißt das also, dass eine Assembler-Instruktion eine atomare Operation ist? Damit liegt man nicht verkehrt. Nur will man nicht immer in Assembler etwas tun. Was ist, wenn du einfach nur einen Zähler mitlaufen lassen willst, den die Threads verändern und so aufeinander reagieren? Dann musst du den Inhalt einer Speicheradresse in ein Register laden, dieses Register kannst du inkrementieren und das dann wieder zurückschreiben. In Delphi ist das die einzelne Anweisung Inc(X), die in Assembler in (mindestens) drei atomare Anweisungen runtergebrochen wird. Wenn nun nach dem Laden in ein Register ein anderer Thread an die Reihe kommt, der den Inhalt verändert, wird das Zurückschreiben des inkrementierten Wertes nicht mit dem Zustand des anderen Threads konsistent sein.
      • Dennoch gibt es gewisse Anweisung - du kannst sie im Disassembler am vorangestellten „lock“ erkennen, bei denen gewährleistet ist, dass sie am Stück ausgeführt werden, obwohl sie Dinge tun, die „eigentlich“ nur über den beschriebenen drei-Anweisungs-Umweg gehen. Z.B. kannst du die Delphi-Funktion InterlockedIncrement oder in neueren Versionen AtomicIncrement verwenden. Diese Funktion wird intern so übersetzt, dass sie ein solches hardwareseitiges sequentielles Feature nutzt, von dem ich vorher gesprochen habe. Denn der Inhalt des Speichers wird über einen Bus ausgelesen, und dieser Bus kann, egal wie viele Threads du hast, nur eine Operation gleichzeitig durchführen. Und mit dieser Anweisung wird dem Bus gesagt: Schau auf den Inhalt dieser Speicherzelle, addiere eins dazu und gib den neuen Wert an die CPU. Und es ist gewährleistet, dass das während dies passiert zwar eine ganze Menge in anderen Kernen in der CPU vor sich gehen kann - aber kein anderer Kern kann währenddessen auf den Speicher zugreifen. Das funktioniert aber nur, weil der Speicher-Bus tatsächlich (in sehr eingeschränktem Umfang) rechnen kann. Wenn du es also schaffst, dass du einen Status in nur einer einzigen Integer-Variable übermittelst, dann kannst du sicher sein, dass damit kein inkonsistenter Zustand im Speicher geschaffen wird. Das ist vergleichsweise langsam, aber viel schneller, als das Inkrementieren in einen Wartebereich zu legen - und wesentlich einfacher zu implementieren.
      • Trotzdem muss Thread A immer noch vorsichtig designed sein. Denn stelle dir etwa folgende Situation vor:

        Delphi-Code

        1. Var Data: Integer; // der Einfachheit halber globale Variable - wenn man weiß, was man tut, ist das nicht per se böse
        2. Procedure ThreadATimer;
        3. Begin
        4. If Data = 0 Then
        5. Assert(Data = 0)
        6. Else
        7. Assert(Data <> 0);
        8. End;

        Das wäre unvorsichtig. Angenommen, Thread A stellt fest, dass Data Null ist; Thread B erhöht Data mittels InterlockedIncrement; Thread A ruft nun Assert(Data = 0) auf.
        Was ist nun Data? Keine Ahnung.
        Denn es kann sein, dass der Compiler intern optimiert und Data bei der If-Abfrage in ein Register lädt. Dann hat sich dieses Register nicht verändert, und 0 = 0 liefert True, Assertion klappt. Dann wäre der Prozedurablauf konsistent mit sich selbst, aber inkonsistent mit dem Zustand des Prozesses. Denn eigentlich ist Data inzwischen etwas anderes. Und wenn statt der Assertion etwas komplizierteres geschieht und etwas eine Prozedur aufgerufen wird, die wiederum Data verwendet, kann das übel ausgehen.
        Es kann aber auch sein (und seit Delphi Berlin kann man das mit dem Compiler-Attribut [volatile] erzwingen), dass der Compiler nicht optimiert. Dann wird Data wieder neu aus dem Speicher gelesen. 0 = 1 liefert False, die Assertion schmeißt eine Exception. Dann wäre der Prozedurablauf inkonsistent mit sich selbst, aber konsistent mit dem anderen Thread (natürlich nicht konsistent mit dem Prozess, denn wer mit sich selbst schon inkonsistent ist, kann nicht mit etwas konsistent sein, was sich selbst enthält).
        Wie kann man das vermeiden? Ganz einfach: Lade die synchronisierte Variable einmal in eine lokale Variable und verwende danach nur noch diese lokale Variable. Stelle sicher, dass keine Funktionsaufrufe wieder auf die Speicheradresse zugreifen.
      Master of the EDH ;)
      Weiter geht's mit einem neuen maximalen Zeichenlimit pro Post...

      Kurze Zusammenfassung:
      • Synchronisation braucht Zeit. (Und dazu gehört auch TThread.Synchronize, was intern mit kritischen Sektionen und Events arbeitet.) Wer synchronisiert, geht auf die Nummer sicher und muss bei späteren Codeanpassungen nicht unbedingt so sehr darauf achten, wie er Zugriffe vornimmt. Andererseits muss dann eben alles immer in einen synchronisierten Block und wird langsamer.
      • „Nicht-Synchronisation“ trotz Zugriffs mehrer Thread erfordert einige Gedanken im Vorhinein, zahlt sich aber aus (Nachdenken ist aber auch bei Synchronisation zu empfehlen ;) ). Grundsätzlich sollten solche Optimierungen immer möglich sein, wenn nur ein Thread schreibt und alle anderen Lesen.
      • Wer synchronisiert, sollte darauf achten, den geschützten Bereich möglichst kurz zu halten: Wirklich nur das entscheidende Ändern der gespeicherten Variable in einen Warteblock packen, nicht aber in diesem Block berechnen, was denn der neue Wert sein soll. Denn: Die anderen Threads wollen ja vielleicht auch gerade etwas an dieser Variable tun, und die müssen dann warten. Wenn natürlich die ganze Rechnung obsolet werden würde, falls ein anderer Thread etwas täte, .... Das sind Einzelfallentscheidungen.
      • TThread.Synchronize ist nicht unbedigt der performanteste Weg, um etwas zu synchronisieren, er ist nur einfach, weil es ein Einzeiler ist. Eine selbstgemachte CriticalSection sollte aber auch in insgesamt unter 10 Zeilen (inkl. Speicherschutzblöcke) geschrieben.
      • Bei alldem nicht die Messages vergessen. Das Abarbeiten von Messages geschieht, wenn das System gerade nichts Besseres zu tun hat. Also ist es ideal, Message-Handler zu verwenden, falls die Daten nicht unbedingt jetzt und sofort (was sowieso nur mit Event gescheit ginge) verarbeitet werden sollen. Stichwort PostMessage. Wenn es darauf ankommt, dass ein semantischer Kontext gewahrt bleibt und Thread A an Thread B zurückgibt, wenn er die Daten abgeholt hat - Stichwort SendMessage.
      Wenn ich noch Klarheiten beseitigen kann, gerne :rolleyes: .
      Master of the EDH ;)