Wie kann die Thread-Sicherheit durch eine Programmiersprache gewährleistet werden, die der Speichersicherheit durch Java und C # ähnelt?

10

Java und C # bieten Speichersicherheit, indem sie Array-Grenzen und Zeiger-Dereferenzen überprüfen.

Welche Mechanismen könnten in eine Programmiersprache implementiert werden, um die Möglichkeit von Rennbedingungen und Deadlocks zu verhindern?

mrpyo
quelle
3
Sie könnten interessiert sein, was Rust tut: Furchtlose Parallelität mit Rust
Vincent Savard
2
Machen Sie alles unveränderlich oder machen Sie alles asynchron mit sicheren Kanälen. Sie könnten auch an Go und Erlang interessiert sein .
Theraot
@Theraot "mach alles asynchron mit sicheren Kanälen" - wünschte, du könntest das näher erläutern.
Mrpyo
2
@mrpyo Sie würden keine Prozesse oder Threads verfügbar machen, jeder Aufruf ist ein Versprechen, alles wird gleichzeitig ausgeführt (wobei die Laufzeit ihre Ausführung plant und Systemthreads nach Bedarf hinter den Kulissen erstellt / bündelt), und die Logik, die den Status schützt, befindet sich in den Mechanismen das gibt Informationen weiter ... die Laufzeit kann automatisch durch Planung serialisiert werden, und es würde eine Standardbibliothek mit thread-sicherer Lösung für nuanciertere Verhaltensweisen geben, insbesondere Hersteller / Verbraucher und Aggregationen werden benötigt.
Theraot
2
Übrigens gibt es einen anderen möglichen Ansatz: das Transaktionsgedächtnis .
Theraot

Antworten:

14

Rennen treten auf, wenn Sie gleichzeitig ein Aliasing für ein Objekt haben und mindestens einer der Aliase mutiert.

Um Rennen zu verhindern, müssen Sie eine oder mehrere dieser Bedingungen unwahr machen.

Verschiedene Ansätze befassen sich mit verschiedenen Aspekten. Die funktionale Programmierung betont die Unveränderlichkeit, wodurch die Veränderlichkeit beseitigt wird. Locking / Atomics entfernen die Gleichzeitigkeit. Affine Typen entfernen das Aliasing (Rust entfernt veränderbares Aliasing). Schauspieler-Modelle entfernen normalerweise Aliasing.

Sie können die Objekte einschränken, die als Alias ​​verwendet werden können, um sicherzustellen, dass die oben genannten Bedingungen vermieden werden. Hier kommen Kanäle und / oder Nachrichtenübermittlungsstile ins Spiel. Sie können keinen Alias ​​für einen beliebigen Speicher verwenden, sondern nur das Ende eines Kanals oder einer Warteschlange, die so angeordnet ist, dass sie rennfrei sind. In der Regel durch Vermeidung von Gleichzeitigkeit, dh Sperren oder Atomics.

Der Nachteil dieser verschiedenen Mechanismen ist, dass sie die Programme einschränken, die Sie schreiben können. Je stumpfer die Einschränkung, desto weniger Programme. Also kein Aliasing oder keine Veränderbarkeitsarbeit, und sie sind leicht zu begründen, aber sehr einschränkend.

Deshalb sorgt Rust für so viel Aufsehen. Es ist eine Ingenieursprache (im Gegensatz zur akademischen), die Aliasing und Veränderlichkeit unterstützt, aber vom Compiler überprüfen lässt, ob sie nicht gleichzeitig auftreten. Obwohl dies nicht das Ideal ist, kann eine größere Klasse von Programmen sicher geschrieben werden als viele seiner Vorgänger.

Alex
quelle
11

Java und C # bieten Speichersicherheit, indem sie Array-Grenzen und Zeiger-Dereferenzen überprüfen.

Es ist wichtig, zuerst darüber nachzudenken, wie C # und Java dies tun. Dazu konvertieren sie undefiniertes Verhalten in C oder C ++ in definiertes Verhalten: Absturz des Programms . Null-Dereferenzen und Array-Index-Ausnahmen sollten niemals in einem korrekten C # - oder Java-Programm abgefangen werden. Sie sollten nicht in erster Linie auftreten, da das Programm diesen Fehler nicht haben sollte.

Aber das ist meiner Meinung nach nicht das, was du mit deiner Frage meinst! Wir könnten ganz einfach eine "Deadlock-sichere" Laufzeit schreiben, die regelmäßig überprüft, ob n Threads aufeinander warten, und das Programm beenden, wenn dies passiert, aber ich denke nicht, dass Sie das zufriedenstellen würde.

Welche Mechanismen könnten in eine Programmiersprache implementiert werden, um die Möglichkeit von Rennbedingungen und Deadlocks zu verhindern?

Das nächste Problem, mit dem wir bei Ihrer Frage konfrontiert sind, ist, dass "Rennbedingungen" im Gegensatz zu Deadlocks schwer zu erkennen sind. Denken Sie daran, dass wir bei der Thread-Sicherheit keine Rennen eliminieren wollen . Wir wollen das Programm korrigieren, egal wer das Rennen gewinnt ! Das Problem mit den Rennbedingungen ist nicht, dass zwei Threads in einer undefinierten Reihenfolge ausgeführt werden und wir nicht wissen, wer zuerst fertig wird. Das Problem mit den Rennbedingungen ist, dass Entwickler vergessen, dass einige Reihenfolgen der Thread-Fertigstellung möglich sind, und diese Möglichkeit nicht berücksichtigen.

Ihre Frage lautet also im Wesentlichen: "Gibt es eine Möglichkeit, mit der eine Programmiersprache sicherstellen kann, dass mein Programm korrekt ist?" und die Antwort auf diese Frage lautet in der Praxis nein.

Bisher habe ich nur Ihre Frage kritisiert. Lassen Sie mich hier versuchen, den Gang zu wechseln und den Geist Ihrer Frage anzusprechen. Gibt es Entscheidungen, die Sprachdesigner treffen könnten, um die schreckliche Situation, in der wir uns mit Multithreading befinden, zu mildern?

Die Situation ist wirklich schrecklich! Es ist sehr, sehr schwierig, Multithread-Code korrekt zu machen, insbesondere bei Architekturen mit schwachen Speichermodellen. Es ist lehrreich darüber nachzudenken, warum es schwierig ist:

  • Mehrere Kontrollthreads in einem Prozess sind schwer zu begründen. Ein Thread ist hart genug!
  • Abstraktionen werden in einer Multithread-Welt extrem undicht. In der Single-Threaded-Welt ist garantiert, dass sich Programme so verhalten, als würden sie in der richtigen Reihenfolge ausgeführt, auch wenn sie nicht in der richtigen Reihenfolge ausgeführt werden. In der Multithread-Welt ist dies nicht mehr der Fall. Optimierungen, die für einen einzelnen Thread unsichtbar wären, werden sichtbar, und jetzt muss der Entwickler diese möglichen Optimierungen verstehen.
  • Aber es wird schlimmer. Die C # -Spezifikation besagt, dass eine Implementierung KEINE konsistente Reihenfolge von Lese- und Schreibvorgängen benötigt, die von allen Threads vereinbart werden kann . Die Vorstellung, dass es überhaupt "Rennen" gibt und dass es einen klaren Sieger gibt, ist eigentlich nicht wahr! Stellen Sie sich eine Situation vor, in der einige Variablen in vielen Threads zwei Schreib- und zwei Lesevorgänge ausführen. In einer vernünftigen Welt könnten wir denken: "Nun, wir können nicht wissen, wer die Rennen gewinnen wird, aber zumindest wird es ein Rennen geben und jemand wird gewinnen." Wir sind nicht in dieser vernünftigen Welt. Mit C # können sich mehrere Threads nicht über die Reihenfolge einig sein, in der Lese- und Schreibvorgänge ausgeführt werden. Es gibt nicht unbedingt eine konsistente Welt, die jeder beobachtet.

Es ist also offensichtlich, dass Sprachdesigner die Dinge verbessern können. Geben Sie die Leistungsgewinne moderner Prozessoren auf . Stellen Sie sicher, dass alle Programme, auch Multithread-Programme, ein extrem starkes Speichermodell haben. Dadurch werden Multithread-Programme um ein Vielfaches langsamer, was direkt dem Grund entgegenwirkt, dass Multithread-Programme überhaupt vorhanden sind: für eine verbesserte Leistung.

Selbst wenn man das Speichermodell außer Acht lässt, gibt es noch andere Gründe, warum Multithreading schwierig ist:

  • Das Verhindern von Deadlocks erfordert eine Analyse des gesamten Programms. Sie müssen die globale Reihenfolge kennen, in der Sperren aufgehoben werden können, und diese Reihenfolge im gesamten Programm durchsetzen, auch wenn das Programm aus Komponenten besteht, die zu unterschiedlichen Zeiten von verschiedenen Organisationen geschrieben wurden.
  • Das wichtigste Werkzeug, mit dem Sie Multithreading zähmen können, ist die Sperre, aber Sperren können nicht zusammengesetzt werden .

Dieser letzte Punkt muss weiter erläutert werden. Mit "zusammensetzbar" meine ich Folgendes:

Angenommen, wir möchten ein int mit einem Double berechnen. Wir schreiben eine korrekte Implementierung der Berechnung:

int F(double x) { correct implementation here }

Angenommen, wir möchten eine Zeichenfolge mit einem int berechnen:

string G(int y) { correct implementation here }

Wenn wir nun eine Zeichenfolge mit einem Double berechnen möchten:

double d = whatever;
string r = G(F(d));

G und F können zusammengesetzt in eine richtige Lösung für das komplexere Problem.

Sperren haben diese Eigenschaft jedoch aufgrund von Deadlocks nicht. Eine korrekte Methode M1, die Sperren in der Reihenfolge L1, L2 akzeptiert, und eine korrekte Methode M2, die Sperren in der Reihenfolge L2, L1 akzeptiert, können nicht beide im selben Programm verwendet werden, ohne ein falsches Programm zu erstellen. Durch Sperren können Sie nicht sagen, dass "jede einzelne Methode korrekt ist, also ist das Ganze korrekt".

Was können wir als Sprachdesigner tun?

Gehen Sie zuerst nicht dorthin. Mehrere Steuerungs-Threads in einem Programm sind eine schlechte Idee, und das Teilen von Speicher zwischen Threads ist eine schlechte Idee. Setzen Sie ihn also nicht in die Sprache oder Laufzeit.

Dies ist anscheinend ein Nichtstarter.

Wenden wir uns dann der grundlegenderen Frage zu: Warum haben wir überhaupt mehrere Threads? Es gibt zwei Hauptgründe, und sie werden häufig zu derselben Sache zusammengeführt, obwohl sie sehr unterschiedlich sind. Sie werden zusammengeführt, weil es bei beiden um die Verwaltung der Latenz geht.

  • Wir erstellen fälschlicherweise Threads, um die E / A-Latenz zu verwalten. Sie müssen eine große Datei schreiben, auf eine entfernte Datenbank zugreifen, einen Arbeitsthread erstellen, anstatt Ihren UI-Thread zu sperren.

Schlechte Idee. Verwenden Sie stattdessen Single-Threaded-Asynchronität über Coroutinen. C # macht das wunderbar. Java, nicht so gut. Aber dies ist der wichtigste Weg , dass die aktuelle Ernte von Sprachdesignern helfen , das Einfädeln Problem zu lösen. Der awaitOperator in C # (inspiriert von asynchronen F # -Workflows und anderem Stand der Technik) wird in immer mehr Sprachen integriert.

  • Wir erstellen Threads richtig, um inaktive CPUs mit rechenintensiver Arbeit zu sättigen. Grundsätzlich verwenden wir Threads als einfache Prozesse.

Sprachdesigner können helfen, indem sie Sprachfunktionen erstellen, die gut mit Parallelität funktionieren. Denken Sie beispielsweise darüber nach, wie LINQ so natürlich auf PLINQ erweitert wird. Wenn Sie eine vernünftige Person sind und Ihre TPL-Operationen auf CPU-gebundene Operationen beschränken, die sehr parallel sind und keinen gemeinsamen Speicher haben, können Sie hier große Gewinne erzielen.

Was können wir sonst noch tun?

  • Lassen Sie den Compiler die Fehler ohne Kopf erkennen und sie in Warnungen oder Fehler umwandeln.

Mit C # können Sie nicht in einem Schloss warten, da dies ein Rezept für Deadlocks ist. Mit C # können Sie keinen Werttyp sperren, da dies immer falsch ist. Sie sperren die Box, nicht den Wert. C # warnt Sie, wenn Sie einen Alias ​​als flüchtig verwenden, da der Alias ​​keine Erfassungs- / Freigabesemantik auferlegt. Es gibt viel mehr Möglichkeiten, wie der Compiler häufig auftretende Probleme erkennen und verhindern kann.

  • Entwerfen Sie "Qualitätsgruben" -Funktionen, bei denen der natürlichste Weg, dies zu tun, auch der korrekteste Weg ist.

C # und Java haben einen großen Entwurfsfehler gemacht, indem Sie jedes Referenzobjekt als Monitor verwenden konnten. Dies fördert alle Arten von schlechten Praktiken, die es schwieriger machen, Deadlocks aufzuspüren und sie statisch zu verhindern. Und es verschwendet Bytes in jedem Objektheader. Monitore sollten von einer Monitorklasse abgeleitet sein müssen.

  • Ein großer Teil der Zeit und Mühe von Microsoft Research wurde in den Versuch gesteckt, einer C # -ähnlichen Sprache Software-Transaktionsspeicher hinzuzufügen, und sie haben nie eine ausreichende Leistung erzielt, um sie in die Hauptsprache zu integrieren.

STM ist eine schöne Idee, und ich habe in Haskell mit Spielzeugimplementierungen herumgespielt. Sie können damit korrektere Lösungen aus korrekten Teilen viel eleganter zusammenstellen als Lösungen auf Schlossbasis. Ich weiß jedoch nicht genug über die Details, um zu sagen, warum es nicht möglich war, im Maßstab zu arbeiten. Fragen Sie Joe Duffy, wenn Sie ihn das nächste Mal sehen.

  • Eine andere Antwort erwähnte bereits die Unveränderlichkeit. Wenn Sie Unveränderlichkeit mit effizienten Coroutinen kombiniert haben, können Sie Funktionen wie das Darstellermodell direkt in Ihre Sprache integrieren. denke zum Beispiel an Erlang.

Es wurde viel über prozesskalkülbasierte Sprachen geforscht, und ich verstehe diesen Raum nicht sehr gut. Lesen Sie selbst ein paar Artikel darüber und prüfen Sie, ob Sie Einblicke erhalten.

  • Machen Sie es Dritten leicht, gute Analysegeräte zu schreiben

Nachdem ich bei Microsoft an Roslyn gearbeitet hatte, arbeitete ich bei Coverity, und eines der Dinge, die ich tat, war, das Analysator-Frontend mit Roslyn zu bekommen. Durch eine genaue lexikalische, syntaktische und semantische Analyse von Microsoft könnten wir uns dann auf die harte Arbeit konzentrieren, Detektoren zu schreiben, bei denen häufig auftretende Multithreading-Probleme auftreten.

  • Erhöhen Sie die Abstraktionsebene

Ein grundlegender Grund, warum wir Rennen und Deadlocks haben und all das Zeug ist, dass wir Programme schreiben, die sagen, was zu tun ist , und es stellt sich heraus, dass wir alle Mist darin sind, imperative Programme zu schreiben. Der Computer tut, was Sie ihm sagen, und wir sagen ihm, dass er die falschen Dinge tun soll. In vielen modernen Programmiersprachen geht es immer mehr um deklarative Programmierung: Sagen Sie, welche Ergebnisse Sie möchten, und lassen Sie den Compiler herausfinden, wie Sie dieses Ergebnis effizient, sicher und korrekt erzielen können. Denken Sie wieder an LINQ. Wir möchten, dass Sie sagen from c in customers select c.FirstName, was eine Absicht ausdrückt . Lassen Sie den Compiler herausfinden, wie der Code geschrieben wird.

  • Verwenden Sie Computer, um Computerprobleme zu lösen.

Algorithmen für maschinelles Lernen sind bei einigen Aufgaben viel besser als handcodierte Algorithmen, obwohl es natürlich viele Kompromisse gibt, einschließlich Korrektheit, Zeitaufwand für das Training, Verzerrungen durch schlechtes Training und so weiter. Es ist jedoch wahrscheinlich, dass eine Vielzahl von Aufgaben, die wir derzeit "von Hand" codieren, bald maschinengenerierten Lösungen zugänglich sein werden. Wenn Menschen den Code nicht schreiben, schreiben sie keine Fehler.

Tut mir leid, dass ich dort ein bisschen herumgeschlendert bin. Dies ist ein großes und schwieriges Thema, und in den 20 Jahren, in denen ich Fortschritte in diesem Problembereich verfolgt habe, hat sich in der PL-Community kein klarer Konsens herausgebildet.

Eric Lippert
quelle
"Ihre Frage läuft also im Grunde auf" Gibt es eine Möglichkeit, wie eine Programmiersprache sicherstellen kann, dass mein Programm korrekt ist? "Und die Antwort auf diese Frage lautet in der Praxis" Nein ". - eigentlich ist es durchaus möglich - es wird als formale Überprüfung bezeichnet, und obwohl es unpraktisch ist, bin ich mir ziemlich sicher, dass es routinemäßig mit kritischer Software durchgeführt wird, sodass ich es nicht als unpraktisch bezeichnen würde. Aber Sie als Sprachdesigner wissen das wahrscheinlich ...
Mrpyo
6
@ Mrpyo: Mir ist klar. Es gibt viele Probleme. Erstens: Ich habe einmal an einer offiziellen Verifizierungskonferenz teilgenommen, bei der ein MSFT-Forschungsteam ein aufregendes neues Ergebnis vorstellte: Sie konnten ihre Technik erweitern, um Multithread-Programme mit einer Länge von bis zu zwanzig Zeilen zu verifizieren, und den Verifizierer in weniger als einer Woche ausführen lassen. Dies war eine interessante Präsentation, die mir aber nichts half. Ich hatte ein 20-Millionen-Linienprogramm zu analysieren.
Eric Lippert
@mrpyo: Zweitens ist, wie bereits erwähnt, ein großes Problem bei Sperren, dass ein Programm aus thread-sicheren Methoden nicht unbedingt ein thread-sicheres Programm ist. Die formelle Überprüfung einzelner Methoden hilft nicht unbedingt, und die Analyse des gesamten Programms ist für nicht triviale Programme schwierig.
Eric Lippert
6
@mrpyo: Drittens ist das große Problem bei der formalen Analyse, was wir grundsätzlich tun. Wir präsentieren eine Spezifikation der Vor- und Nachbedingungen und überprüfen dann, ob das Programm dieser Spezifikation entspricht. Groß; theoretisch ist das völlig machbar. In welcher Sprache ist die Spezifikation geschrieben? Wenn es eine eindeutige, überprüfbare Spezifikationssprache gibt, schreiben wir einfach alle unsere Programme in dieser Sprache und kompilieren diese . Warum machen wir das nicht? Weil sich herausstellt, dass es wirklich schwierig ist, korrekte Programme auch in der Spezifikationssprache zu schreiben!
Eric Lippert
2
Die Analyse eines Antrags auf Richtigkeit unter Verwendung von Vor- / Nachbedingungen ist möglich (z. B. unter Verwendung von Codierungsverträgen). Eine solche Analyse ist jedoch nur unter der Bedingung möglich, dass die Bedingungen zusammensetzbar sind, was bei Sperren nicht der Fall ist. Ich werde auch bemerken, dass das Schreiben eines Programms auf eine Weise, die eine Analyse ermöglicht, sorgfältige Disziplin erfordert. Beispielsweise widersetzen sich Anwendungen, die sich nicht strikt an das Liskov-Substitutionsprinzip halten, der Analyse.
Brian