Dies ist der zweite Teil der Artikelserie Fearless Protection. Im ersten haben wir über Speichersicherheit gesprochenModerne Anwendungen haben mehrere Threads: Anstatt Aufgaben nacheinander auszuführen, verwendet das Programm Threads, um mehrere Aufgaben gleichzeitig auszuführen. Wir alle beobachten jeden Tag
gleichzeitige Arbeit und
Parallelität :
- Websites werden von mehreren Benutzern gleichzeitig bereitgestellt.
- Die Benutzeroberfläche führt Hintergrundarbeiten aus, die den Benutzer nicht stören (stellen Sie sich vor, dass die Anwendung jedes Mal einfriert, wenn Sie ein Zeichen eingeben, um die Rechtschreibung zu überprüfen).
- Ein Computer kann mehrere Anwendungen gleichzeitig ausführen.
Parallele Streams beschleunigen die Arbeit, führen jedoch zu einer Reihe von Synchronisationsproblemen, nämlich Deadlocks und Rennbedingungen. Warum ist uns aus Sicherheitsgründen die Thread-Sicherheit wichtig? Weil die Sicherheit von Speicher und Threads ein und dasselbe Hauptproblem hat: unangemessene Verwendung von Ressourcen. Angriffe haben hier die gleichen Auswirkungen wie Speicherangriffe, einschließlich der Eskalation von Berechtigungen, der Ausführung von willkürlichem Code (ACE) und der Umgehung von Sicherheitsüberprüfungen.
Parallelitätsfehler hängen ebenso wie Implementierungsfehler eng mit der Programmkorrektheit zusammen. Während Speicherschwachstellen fast immer gefährlich sind, weisen Implementierungs- / Logikfehler nicht immer auf ein Sicherheitsproblem hin, wenn sie nicht in dem Teil des Codes auftreten, der sich auf die Einhaltung von Sicherheitsverträgen bezieht (z. B. die Erlaubnis, eine Sicherheitsüberprüfung zu umgehen). Parallelitätsfehler haben jedoch eine Besonderheit. Wenn Sicherheitsprobleme aufgrund logischer Fehler häufig neben dem entsprechenden Code auftreten, treten Parallelitätsfehler häufig
in anderen Funktionen auf und nicht in der, in der der Fehler direkt aufgetreten ist , was es schwierig macht, sie zu verfolgen und zu beseitigen. Eine weitere Schwierigkeit ist eine gewisse Überlappung zwischen unsachgemäßer Speicherverarbeitung und Parallelitätsfehlern, die wir bei Datenrennen sehen.
Programmiersprachen haben verschiedene Parallelitätsstrategien entwickelt, um Entwicklern bei der Verwaltung der Leistungs- und Sicherheitsprobleme von Multithread-Anwendungen zu helfen.
Parallelitätsprobleme
Es ist allgemein anerkannt, dass die parallele Programmierung schwieriger als gewöhnlich ist: Unser Gehirn ist besser an sequentielles Denken angepasst. Paralleler Code kann unerwartete und unerwünschte Interaktionen zwischen Threads aufweisen, einschließlich Deadlocks, Konflikten und Datenrennen.
Ein Deadlock tritt auf, wenn mehrere Threads erwarten, dass bestimmte Aktionen ausgeführt werden, um weiter zu arbeiten. Obwohl dieses unerwünschte Verhalten einen Denial-of-Service-Angriff verursachen kann, werden keine Schwachstellen wie ACE verursacht.
Eine Rennbedingung ist eine Situation, in der die Zeit oder Reihenfolge der Aufgaben die Richtigkeit eines Programms beeinflussen kann. Datenrennen treten auf, wenn mehrere Streams mit mindestens einem Schreibversuch gleichzeitig versuchen, auf denselben Speicherort zuzugreifen. Es kommt vor, dass eine Rennbedingung und ein
Datenrennen unabhängig voneinander auftreten.
Datenrennen sind jedoch immer gefährlich .
Mögliche Folgen von Parallelitätsfehlern
- Deadlock
- Informationsverlust: Ein anderer Thread überschreibt Informationen
- Integritätsverlust: Informationen aus mehreren Streams sind miteinander verwoben
- Rentabilitätsverlust: Leistungsprobleme aufgrund ungleichmäßigen Zugriffs auf gemeinsam genutzte Ressourcen
Die bekannteste Art von Parallelitätsangriff heißt
TOCTOU (Zeitpunkt der Überprüfung bis zum Zeitpunkt der Verwendung): Im Wesentlichen liegt der Status eines Rennens zwischen der Überprüfung der Bedingungen (z. B. Sicherheitsanmeldeinformationen) und der Verwendung der Ergebnisse. Ein TOCTOU-Angriff führt zu einem Integritätsverlust.
Gegenseitige Sperren und Verlust der Überlebensfähigkeit werden als Leistungsprobleme und nicht als Sicherheitsprobleme betrachtet, während Informationsverlust und Integritätsverlust wahrscheinlich mit Sicherheit zusammenhängen. Ein
Red Balloon Security-Artikel befasst sich mit einigen möglichen Exploits. Ein Beispiel ist eine Zeigerbeschädigung, gefolgt von einer Eskalation von Berechtigungen oder der Ausführung von Remotecode. Im Exploit initiiert eine Funktion, die die gemeinsam genutzte Bibliothek ELF (Executable and Linkable Format) lädt, ein Semaphor erst beim ersten Aufruf korrekt und begrenzt dann die Anzahl der Threads falsch, was zu einer Beschädigung des Kernelspeichers führt. Dieser Angriff ist ein Beispiel für Informationsverlust.
Der schwierigste Teil der parallelen Programmierung ist das Testen und Debuggen, da Parallelitätsfehler schwer zu reproduzieren sind. Timing von Ereignissen, Entscheidungen des Betriebssystems, Netzwerkverkehr und andere Faktoren ... all dies ändert das Verhalten des Programms bei jedem Start.
Manchmal ist es wirklich einfacher, das gesamte Programm zu entfernen, als nach einem Fehler zu suchen. HeisenbugsDas Verhalten ändert sich nicht nur bei jedem Start, sondern auch das Einfügen von Ausgabe- oder Debug-Anweisungen kann das Verhalten ändern. Dies führt zu „Heisenberg-Fehlern“ (nicht deterministische, schwer reproduzierbare Fehler, die für die parallele Programmierung typisch sind), die auftreten und auf mysteriöse Weise verschwinden.
Parallele Programmierung ist schwierig. Es ist schwierig vorherzusagen, wie paralleler Code mit anderem parallelen Code interagieren wird. Wenn Fehler auftreten, sind sie schwer zu finden und zu korrigieren. Anstatt sich auf Tester zu verlassen, schauen wir uns Möglichkeiten an, Programme zu entwickeln und Sprachen zu verwenden, die das Schreiben von parallelem Code erleichtern.
Zunächst formulieren wir das Konzept der "Thread-Sicherheit":
"Ein Datentyp oder eine statische Methode gilt als threadsicher, wenn sie sich beim Aufruf von mehreren Threads korrekt verhält, unabhängig davon, wie diese Threads ausgeführt werden, und keine zusätzliche Koordination durch den aufrufenden Code erfordert." MIT
Wie Programmiersprachen mit Parallelität arbeiten
In Sprachen ohne statische Thread-Sicherheit müssen Programmierer den Speicher, der für einen anderen Thread freigegeben ist, ständig überwachen und können ihn jederzeit ändern. In der sequentiellen Programmierung lernen wir, globale Variablen zu vermeiden, wenn ein anderer Teil des Codes sie leise ändert. Es ist unmöglich, Programmierer zu verpflichten, eine sichere Änderung der gemeinsam genutzten Daten sowie eine manuelle Speicherverwaltung zu gewährleisten.
"Ständige Wachsamkeit!"In der Regel sind Programmiersprachen auf zwei Ansätze beschränkt:
- Einschränkung der Veränderlichkeit oder Einschränkung des gemeinsamen Zugriffs
- Manuelle Gewindesicherheit (z. B. Schlösser, Semaphoren)
Sprachen mit Thread-Einschränkung begrenzen entweder 1 Thread für veränderbare Variablen oder erfordern, dass alle gemeinsamen Variablen unveränderlich sind. Beide Ansätze befassen sich mit dem Grundproblem des Datenrennens - falsch modifizierbare gemeinsam genutzte Daten -, aber die Einschränkungen sind zu streng. Um das Problem zu lösen, haben Sprachen Synchronisationsprimitive auf niedriger Ebene erstellt, z. B. Mutexe. Sie können verwendet werden, um threadsichere Datenstrukturen zu erstellen.
Python und globales Sperren durch Interpreter
Die Referenzimplementierung in Python und Cpython verfügt über eine Art Mutex namens Global Interpreter Lock (GIL), der alle anderen Threads blockiert, wenn ein Thread auf ein Objekt zugreift. Multithreaded Python ist bekannt für seine
Ineffizienz aufgrund der GIL-Latenz. Daher arbeiten die meisten gleichzeitigen Python-Programme in mehreren Prozessen, sodass jedes seine eigene GIL hat.
Java- und Laufzeitausnahmen
Java unterstützt die gleichzeitige Programmierung über ein Shared-Memory-Modell. Jeder Thread hat seinen eigenen Ausführungspfad, kann jedoch auf jedes Objekt im Programm zugreifen: Der Programmierer muss den Zugriff zwischen den Threads mithilfe der integrierten Java-Grundelemente synchronisieren.
Obwohl Java Bausteine zum Erstellen threadsicherer Programme enthält, wird die
Thread-Sicherheit vom Compiler
nicht garantiert (im Gegensatz zur Speichersicherheit). Wenn ein nicht synchronisierter Speicherzugriff auftritt (d. H. Ein Datenrennen), löst Java eine Laufzeitausnahme aus, aber Programmierer müssen Parallelitätsprimitive korrekt verwenden.
C ++ und das Gehirn des Programmierers
Während Python Rennbedingungen mit GIL vermeidet und Java zur Laufzeit Ausnahmen auslöst, erwartet C ++, dass der Programmierer den Speicherzugriff manuell synchronisiert. Vor C ++ 11
enthielt die Standardbibliothek
keine Parallelitätsprimitive .
Die meisten Sprachen bieten Tools zum Schreiben von thread-sicherem Code, und es gibt spezielle Methoden zum Erkennen von Datenrennen und Rennstatus. Es gibt jedoch keine Garantie für die Thread-Sicherheit und schützt nicht vor Datenrennen.
Wie kann man das Problem von Rust lösen?
Rust verfolgt einen facettenreichen Ansatz, um die Rennbedingungen mithilfe von Tenure-Regeln und sicheren Typen zu eliminieren und sich beim Kompilieren vollständig vor den Rennbedingungen zu schützen.
Im
ersten Artikel haben wir das Konzept des Eigentums eingeführt, dies ist eines der Grundkonzepte von Rust. Jede Variable hat einen eindeutigen Eigentümer, und das Eigentum kann übertragen oder ausgeliehen werden. Wenn ein anderer Thread die Ressource ändern möchte, übertragen wir den Besitz, indem wir die Variable in einen neuen Thread verschieben.
Beim Verschieben wird eine Ausnahme ausgelöst: Mehrere Threads können in denselben Speicher schreiben, jedoch niemals gleichzeitig. Was passiert, wenn ein anderer Thread eine Variable ausleiht, da der Eigentümer immer alleine ist?
In Rust haben Sie entweder eine veränderbare oder mehrere unveränderliche Anleihen. Es ist nicht möglich, veränderbare und unveränderliche Anleihen (oder mehrere veränderliche) gleichzeitig einzuführen. In der Speichersicherheit ist es wichtig, dass Ressourcen ordnungsgemäß freigegeben werden, und in der Thread-Sicherheit ist es wichtig, dass jeweils nur ein Thread das Recht hat, eine Variable zu ändern. Darüber hinaus beziehen sich in einer solchen Situation keine anderen Abläufe auf veraltete Ausleihen: Es ist entweder eine Aufzeichnung oder eine gemeinsame Nutzung möglich, jedoch nicht beides.
Das Eigentümerkonzept wurde entwickelt, um Speicherschwachstellen zu beheben. Es stellte sich heraus, dass es auch Datenrennen verhindert.
Obwohl viele Sprachen über Speichersicherheitsmethoden verfügen (z. B. Linkzählung und Speicherbereinigung), basieren sie normalerweise auf manueller Synchronisierung oder auf Verboten der gleichzeitigen Freigabe, um Datenrennen zu verhindern. Der Rust-Ansatz befasst sich mit beiden Arten von Sicherheit und versucht, das Hauptproblem der Ermittlung des akzeptablen Ressourcenverbrauchs zu lösen und diese Gültigkeit beim Kompilieren sicherzustellen.
Aber warte! Das ist noch nicht alles!
Eigentümerregeln verhindern, dass mehrere Threads Daten in denselben Speicherort schreiben, und verhindern den gleichzeitigen Datenaustausch zwischen Threads und die Veränderlichkeit. Dies bietet jedoch nicht unbedingt threadsichere Datenstrukturen. Jede Datenstruktur in Rust ist entweder threadsicher oder nicht. Dies wird über ein Typsystem an den Compiler übergeben.
"Ein gut getipptes Programm kann keinen Fehler machen." - Robin Milner, 1978
In Programmiersprachen beschreiben Typsysteme akzeptables Verhalten. Mit anderen Worten, ein gut typisiertes Programm ist gut definiert. Solange unsere Typen ausdrucksstark genug sind, um die beabsichtigte Bedeutung zu erfassen, verhält sich ein gut typisiertes Programm wie beabsichtigt.
Rust ist eine typsichere Sprache. Hier überprüft der Compiler die Konsistenz aller Typen. Der folgende Code wird beispielsweise nicht kompiliert:
let mut x = "I am a string"; x = 6;
error[E0308]: mismatched types --> src/main.rs:6:5 | 6 | x = 6;
Alle Variablen in Rust sind vom Typ, der häufig implizit ist. Wir können auch neue Typen definieren und die Fähigkeiten jedes Typs mithilfe
des Merkmalssystems beschreiben . Merkmale bieten eine Abstraktion der Schnittstelle. Zwei wichtige integrierte Merkmale sind
Send
und
Sync
, die vom Compiler standardmäßig für jeden Typ bereitgestellt werden:
Send
gibt an, dass die Struktur sicher zwischen Threads übertragen werden kann (erforderlich, um den Besitz zu übertragen).
Sync
zeigt an, dass Threads die Struktur sicher verwenden können.
Das folgende Beispiel ist eine vereinfachte Version des
Codes aus der Standardbibliothek , die Threads erzeugt:
fn spawn<Closure: Fn() + Send>(closure: Closure){ ... } let x = std::rc::Rc::new(6); spawn(|| { x; });
Die
spawn
Funktion verwendet ein einzelnes Argument, das
closure
und erfordert einen Typ für letzteres, der die
Send
und
Fn
Merkmale implementiert. Beim Versuch, einen Stream zu erstellen und den
closure
mit der Variablen
x
Compiler einen Fehler aus:
Fehler [E0277]: `std :: rc :: Rc <i32>` kann nicht sicher zwischen Threads gesendet werden
-> src / main.rs: 8: 1
|
8 | spawn (move || {x;});
| ^^^^^ `std :: rc :: Rc <i32>` kann nicht sicher zwischen Threads gesendet werden
|
= help: Innerhalb von "[schloss@src/main.rs: 8: 7: 8:21 x: std :: rc :: Rc <i32>]" ist das Merkmal "std :: marker :: Send" nicht implementiert für `std :: rc :: Rc <i32>`
= Hinweis: Erforderlich, da es im Typ "[schließung@src/main.rs: 8: 7: 8:21 x: std :: rc :: Rc <i32>]" angezeigt wird
Hinweis: Erforderlich für "Spawn"
Mit den Merkmalen Send
und
Sync
kann das System vom Typ Rust verstehen, welche Daten gemeinsam genutzt werden können. Durch die Aufnahme dieser Informationen in das Typensystem wird die Gewindesicherheit Teil der Typensicherheit. Anstelle der Dokumentation wird die
Thread-Sicherheit durch das Compiler-Gesetz implementiert .
Programmierer sehen deutlich gemeinsame Objekte zwischen Threads, und der Compiler garantiert die Zuverlässigkeit dieser Installation.
Obwohl parallele Programmiertools in vielen Sprachen verfügbar sind, ist es nicht einfach, Rennbedingungen zu verhindern. Wenn Sie von Programmierern verlangen, dass sie Anweisungen komplex abwechseln und zwischen Threads interagieren, sind Fehler unvermeidlich. Obwohl Sicherheitsverletzungen durch Threads und Speicher zu ähnlichen Konsequenzen führen, verhindern herkömmliche Speicherschutzmaßnahmen wie Linkzählung und Speicherbereinigung die Rennbedingungen nicht. Zusätzlich zur statischen Garantie der Speichersicherheit verhindert das Rust-Besitzmodell auch unsichere Datenänderungen und eine falsche gemeinsame Nutzung von Objekten zwischen Threads, während das Typsystem die Thread-Sicherheit beim Kompilieren bietet.