Alan Cox sagte einmal : "Ein Computer ist eine Zustandsmaschine. Threads sind für Leute, die keine Zustandsmaschinen programmieren können."
Da es für mich keine Option ist, Alan direkt zu fragen, möchte ich hier lieber fragen: Wie erreicht man Multithreading-Funktionalität in Hochsprachen wie Java mit nur einem Thread und einer Zustandsmaschine? Was ist zum Beispiel, wenn zwei Aktivitäten ausgeführt werden müssen (Berechnungen ausführen und E / A ausführen) und eine Aktivität blockieren kann?
Ist die Verwendung von "state-machine only" eine sinnvolle Alternative zum Multithreading in Hochsprachen?
multithreading
finite-state-machine
Victor Sorokin
quelle
quelle
Antworten:
Alles, was ein Thread tut, sind Verschachtelungsoperationen, sodass Teile des Prozesses sich zeitlich zu überlappen scheinen. Ein Single-Core-Rechner mit mehreren Threads springt nur herum: Er führt kleine Codestücke von einem Thread aus und wechselt dann zu einem anderen Thread. Ein einfacher Scheduler entscheidet, welcher Thread die höchste Priorität hat und tatsächlich im Core ausgeführt wird.
Auf einem Single-Core-Computer passiert eigentlich nichts "zur gleichen Zeit". Es ist alles nur verschachtelte Ausführung.
Es gibt viele, viele Möglichkeiten, Interleaving zu erreichen. Viele.
Angenommen, Sie haben einen einfachen Prozess mit zwei Threads, der eine einfache Sperre verwendet, sodass beide Threads in eine gemeinsame Variable schreiben können. Sie haben sechs Codeblöcke.
[Dies kann in einer Schleife sein oder mehrere Sperren haben oder was auch immer. Es wird nur länger, nicht komplexer.]
Die Schritte von T1 müssen der Reihe nach ablaufen (T1-vor, T1-mit, T1-nach) und die Schritte von T2 müssen der Reihe nach ablaufen (T2-vor, T2-mit, T2-nach).
Abgesehen von der "In-Order" -Einschränkung können diese in beliebiger Weise verschachtelt werden. Sowieso. Sie können wie oben aufgeführt ausgeführt werden. Eine andere gültige Reihenfolge ist (T1-vorher, T2-vorher, T2-sperren, T1-sperren, T2-nachher, T1-nachher). Es gibt viele gültige Bestellungen.
Warten.
Dies ist nur eine Zustandsmaschine mit sechs Zuständen.
Es handelt sich um nicht deterministische Automaten mit endlichen Zuständen. Die Reihenfolge von T1-xxx-Zuständen mit T2-xxx-Zuständen ist unbestimmt und spielt keine Rolle. Es gibt also Orte, an denen der "nächste Staat" ein Münzwurf ist.
Wenn der FSM beispielsweise gestartet wird, sind T1-vor oder T2-vor legitime erste Zustände. Wirf eine Münze.
Nehmen wir an, es ist T1-vorgekommen. TU das. Wenn dies erledigt ist, besteht die Wahl zwischen T1-with und T2-before. Wirf eine Münze.
Bei jedem Schritt in der FSM gibt es zwei Auswahlmöglichkeiten (zwei Threads - zwei Auswahlmöglichkeiten) und ein Münzwurf kann bestimmen, welchem spezifischen Zustand gefolgt wird.
quelle
Das Schreiben von Sperrfunktionen richtet sich an Personen, die keine Zustandsautomaten erstellen können;)
Themen sind nützlich, wenn Sie nicht um das Blockieren herumkommen können. Keine grundlegende Computeraktivität blockiert wirklich, es ist nur so, dass viele von ihnen so implementiert sind, um die Bedienung zu vereinfachen. Anstatt ein Zeichen zurückzugeben oder "Lesen fehlgeschlagen", wird eine Lesefunktion blockiert, bis der gesamte Puffer gelesen ist. Anstatt in einer Warteschlange nach Rückkehrnachrichten zu suchen und zurückzukehren, wenn keine gefunden werden, wartet eine Verbindungsfunktion auf eine Antwort.
Sie können in einer Zustandsmaschine keine Sperrfunktionen verwenden (mindestens eine, die nicht "einfrieren" darf).
Und ja, die Verwendung von Zustandsautomaten ist eine praktikable Alternative. In Echtzeitsystemen ist dies die einzige Option, bei der das System ein Framework für die Maschine bereitstellt. Die Verwendung von Threads und Sperrfunktionen ist nur "der einfache Ausweg", da normalerweise ein Aufruf einer Sperrfunktion etwa 3-4 Zustände in der Zustandsmaschine ersetzt.
quelle
Was Sie beschreiben, wird als kooperatives Multitasking bezeichnet , bei dem der CPU Aufgaben zugewiesen werden und diese nach einer selbstbestimmten Zeit oder Aktivität freiwillig freigegeben werden sollen. Eine Aufgabe, die nicht zusammenarbeitet, indem sie die CPU weiter nutzt oder das gesamte Werk blockiert und keinen Hardware-Watchdog-Timer hat, kann der Code, der die Aufgaben überwacht, nichts dagegen tun.
Was Sie in modernen Systemen sehen, wird als präemptives Multitasking bezeichnet. Dort müssen Aufgaben die CPU nicht freigeben, da der Supervisor dies für sie erledigt, wenn ein von der Hardware erzeugter Interrupt eintrifft. Die Interrupt-Serviceroutine im Supervisor speichert den Zustand der CPU und stellt ihn wieder her, wenn die Aufgabe das nächste Mal als zeitaufwändig eingestuft wird. Anschließend stellt sie den Zustand der nächsten auszuführenden Aufgabe wieder her und springt zurück, als wäre nichts geschehen . Diese Aktion wird als Kontextwechsel bezeichnet und kann teuer sein.
Lebensfähig? Sicher. Gesund? Manchmal. Ob Sie Threads oder eine Form von selbst gebrautem kooperativem Multitasking (z. B. Zustandsautomaten) verwenden, hängt von den Kompromissen ab, die Sie eingehen möchten.
Threads vereinfachen das Task-Design bis zu dem Punkt, an dem Sie jedes Programm als sein eigenes behandeln können, das zufällig den Datenraum mit anderen teilt. Dies gibt Ihnen die Freiheit, sich auf den jeweiligen Job zu konzentrieren, und nicht auf die gesamte Verwaltung und das gesamte Housekeeping, die erforderlich sind, damit der Job immer wieder ausgeführt wird. Da jedoch keine gute Tat ungestraft bleibt, zahlen Sie für all diese Bequemlichkeit bei Kontextwechseln. Viele Threads, die die CPU nach minimaler Arbeit auslasten (freiwillig oder durch blockierende Aktionen wie E / A), können beim Kontextwechsel viel Prozessorzeit in Anspruch nehmen. Dies gilt insbesondere dann, wenn Ihre Blockierungsvorgänge selten sehr lange blockieren.
Es gibt Situationen, in denen der kooperative Weg sinnvoller ist. Ich musste einmal eine Userland-Software für eine Hardware schreiben, die viele Datenkanäle über eine speicherabgebildete Schnittstelle übertrug, für die ein Abruf erforderlich war. Jeder Kanal war ein Objekt, das so aufgebaut war, dass ich es entweder als Thread ausführen oder wiederholt einen einzelnen Abfragezyklus ausführen konnte.
Die Leistung der Multithread-Version war aus genau dem Grund, den ich oben dargelegt habe, überhaupt nicht gut: Jeder Thread erledigte nur minimale Arbeit und gab dann die CPU frei, sodass die anderen Kanäle etwas Zeit haben konnten und viele Kontextwechsel verursachten. Das Freigeben der Threads bis zur Freigabe half beim Durchsatz, führte jedoch dazu, dass einige Kanäle nicht gewartet wurden, bevor die Hardware einen Pufferüberlauf erlebte, da sie nicht schnell genug eine Zeitscheibe erhielten.
Die Single-Threaded-Version, die sogar Iterationen jedes Kanals ausführte, lief wie ein verbrühter Affe, und die Last auf dem System fiel wie ein Stein. Die Strafe, die ich für die zusätzliche Leistung gezahlt habe, bestand darin, die Aufgaben selbst zu jonglieren. In diesem Fall war der Code, der dafür benötigt wurde, so einfach, dass die Kosten für die Entwicklung und Wartung die Leistungsverbesserung durchaus wert waren. Ich denke, das ist wirklich das Endergebnis. Wären meine Threads solche gewesen, die darauf gewartet hätten, dass ein Systemaufruf zurückkommt, hätte sich die Übung wahrscheinlich nicht gelohnt.
Das bringt mich zu Cox 'Kommentar: Threads sind nicht ausschließlich für Leute gedacht, die keine Zustandsautomaten schreiben können. Einige Leute sind durchaus in der Lage, dies zu tun, entscheiden sich jedoch für die Verwendung einer Dosen-Zustandsmaschine (dh eines Threads), um die Arbeit schneller oder mit geringerer Komplexität zu erledigen.
quelle
Nun, ich kann mir ehrlich gesagt nicht vorstellen, wie ich mit blockierenden E / A ohne Threads umgehen soll. Es heißt Blocking Afterall, nur weil Code, der es aufruft, es muss
wait
.Laut meiner Lektüre der Original-E-Mail von Cox (siehe unten) weist er darauf hin, dass das Threading nicht gut skaliert. Ich meine, was ist, wenn es 100 E / A-Anforderungen gibt? 1000? 10000? Cox weist darauf hin, dass eine große Anzahl von Threads zu schwerwiegenden Problemen führen kann:
Quelle: Re: Interessante Analyse von Linux-Kernel-Threading durch IBM (Linux-Kernel-Mailinglisten-Archive)
quelle
Theoretisch stimmt das. Im wirklichen Leben sind Threads nur eine effiziente Abstraktion, mit der eine solche Zustandsmaschine programmiert wird. Sie sind so effizient, dass sie auch zum Programmieren von Zustandsdiagrammen und Petri-Netzen verwendet werden können (dh parallele Verhaltensweisen, bei denen Zustandsautomaten im Grunde sequentiell sind).
Das Problem bei Zustandsautomaten ist die kombinatorische Explosion. Die Anzahl der Zustände eines Computers mit 4G RAM beträgt 2 ^ (2 ^ 32) Zustände (ohne 2T-Plattenlaufwerk).
Für einen Mann, dessen einziges Werkzeug ein Hammer ist, sieht jedes Problem aus wie ein Nagel.
quelle
Threads sind die einzige Option in zwei Fällen:
Der zweite Grund ist, warum die meisten Leute denken, dass Threads für die IO- oder Netzwerkprogrammierung unvermeidbar sind, aber dies liegt normalerweise daran, dass sie nicht wissen, dass ihr Betriebssystem eine fortgeschrittenere API hat (oder nicht damit kämpfen möchten).
Aus Gründen der Benutzerfreundlichkeit und Lesbarkeit gibt es immer Ereignisschleifen (wie libev oder EventMachine ), die das Programmieren einer Zustandsmaschine fast so einfach machen wie das Ausführen von Threads, aber genügend Kontrolle bieten, um Synchronisierungsprobleme zu vergessen.
quelle
Ein guter Weg, um die Interaktion zwischen Zustandsautomaten und Multithreading zu verstehen, ist der Blick auf GUI-Ereignishandler. Viele GUI-Anwendungen / Frameworks verwenden einen einzelnen GUI-Thread, der die möglichen Eingabequellen abfragt und für jede empfangene Eingabe eine Funktion aufruft. Im Wesentlichen könnte dies als ein riesiger Schalter geschrieben werden:
Jetzt wird schnell klar, dass die Kontrolle auf hoher Ebene in diesem Konstrukt nicht hoch sein kann: Der Handler für ButtonPressed muss ohne Benutzerinteraktion beendet werden und zur Hauptschleife zurückkehren, da sonst keine weiteren Benutzerereignisse stattfinden verarbeitet werden kann. Wenn ein Status gespeichert werden soll, muss dieser Status in globalen oder statischen Variablen gespeichert werden, jedoch nicht im Stapel. Das heißt, der normale Kontrollfluss in einer imperativen Sprache ist begrenzt. Sie sind im Wesentlichen auf einen Zustandsautomaten beschränkt.
Dies kann ziemlich chaotisch werden, wenn Sie verschachtelte Unterprogramme haben, die beispielsweise eine Rekursionsstufe speichern müssen. Oder Sie sind gerade dabei, eine Datei zu lesen, aber die Datei ist momentan nicht verfügbar. Oder sind nur in einer langen Berechnung. In all diesen Fällen ist es wünschenswert, den Status der aktuellen Ausführung zu speichern und zur Hauptschleife zurückzukehren, und dies ist Multithreading . Nicht mehr, nicht weniger.
Das Ganze wurde mit der Einführung von präemptivem Multithreading (dh dem Betriebssystem, das entscheidet, wann Threads die Kontrolle übernehmen sollen) etwas komplizierter, weshalb die Verbindung heute nicht sofort klar ist.
Um die letzte Frage zu beantworten: Ja, die Zustandsmaschine ist eine Alternative. Die meisten GUIs arbeiten auf diese Weise mit dem GUI-Thread. Schieben Sie die Zustandsmaschine einfach nicht zu weit, sie wird sehr schnell unerreichbar.
quelle
Die Frage, ob die Verwendung einer Zustandsmaschine in einer höheren Sprache sinnvoll ist, entspricht in etwa der Frage, ob das Schreiben in Assembler eine sinnvolle Alternative zur Verwendung einer höheren Sprache darstellt. Sie haben beide ihren Platz, wenn die richtige Situation gegeben ist.
Die Abstraktion der Verwendung von Threading erleichtert die Implementierung komplexerer Parallelsysteme, letztendlich haben jedoch alle Parallelsysteme die gleichen Probleme. Klassische Probleme wie Deadlock / Livelock und Prioritätsumkehr sind bei Systemen mit Zustandsautomaten genauso möglich wie bei Systemen mit gemeinsam genutztem Speicher parallel , NUMA oder sogar CSP , wenn sie komplex genug sind.
quelle
Ich glaube nicht, dass Zustandsautomaten ein sehr „elegantes“ Computerkonzept sind, aber wie Sie sagen, sie sind ziemlich kompliziert. Und komplizierte Dinge sind schwer in Ordnung zu bringen. Und Dinge, die nicht stimmen, sind einfach kaputt. Wenn Sie also nicht ein Genie von Alan Cox 'mutmaßlicher Statur sind, bleiben Sie bei dem, was Sie wissen, dass es funktioniert.
Sie können feststellen, wann jemand den vergeblichen Versuch unternommen hat, dies zu tun, da Sie (vorausgesetzt, es funktioniert gut) feststellen, dass die Aufgabe nahezu unmöglich ist, wenn es darum geht, sie zu warten. Das ursprüngliche "Genie" wird Sie mit dem Klumpen von schwer verständlichem Code zurücklassen (da diese Art von Entwicklern nicht allzu viele Kommentare hinterlassen, geschweige denn technische Dokumentationen).
In einigen Fällen ist eine Zustandsmaschine die bessere Wahl. Ich denke jetzt an eingebettete Typen, in denen einige Zustandsmaschinenmuster verwendet werden und die wiederholt und formalisiert verwendet werden (dh ordnungsgemäßes Engineering :)).
Es kann schwierig sein, das Threading richtig zu machen, aber es gibt Muster, die Sie dabei unterstützen - vor allem, wenn Sie weniger Daten zwischen den Threads austauschen müssen.
Der letzte Punkt dabei ist, dass moderne Computer sowieso auf vielen Kernen laufen, sodass eine Zustandsmaschine die verfügbaren Ressourcen nicht wirklich ausnutzt. Threading kann hier einen besseren Job machen.
quelle
Gutes Beispiel für eine ordnungsgemäße Verwendung von Zustandsmaschinen anstelle von Threads: nginx vs apache2. Im Allgemeinen können Sie davon ausgehen, dass Nginx alle Verbindungen in einem Thread verarbeitet. Apache2 erstellt einen Thread pro Verbindung.
Für mich ist die Verwendung von Zustandsautomaten und Threads mit der Verwendung von perfekt handgefertigtem asm vs. viele andere Programmierer. Wenn Sie also derjenige sind, der einen schnellen Webserver erstellen möchte, verwenden Sie Statusmaschinen und asynchrone E / A. Wenn Sie das Projekt schreiben (nicht die Bibliothek, die überall verwendet werden soll), verwenden Sie Threads.
quelle