Swift ist eine stark auf Sicherheit ausgelegte Programmiersprache. Für uns Entwickler hat das in der Regel viele Vorteile, da wir uns um bestimmte Belange der Speicherverwaltung und des Speicherzugriffs keine größeren Gedanken mehr machen müssen; es funktioniert einfach. So können beispielsweise Variablen erst genutzt werden, nachdem sie initialisiert wurden und auf bereits freigegebenen Speicher wird nicht mehr zugegriffen.

Diese Sicherheit gilt auch beim Thema Memory Safety. Hierbei soll sichergestellt werden, dass nicht parallel mehrere Zugriffe auf ein und dieselbe Speicherstelle erfolgen. Zugriff bezieht sich in diesem Kontext sowohl auf Schreib- wie auch auf Lesezugriffe innerhalb des Speichers.

Konflikte der Memory Safety verstehen

Doch warum sollte das überhaupt ein Problem sein? Was genau ist kritisch an einem multiplen Zugriff auf eine bestimmte Speicherstelle? Am einfachsten lässt sich das mithilfe eines kleinen Beispiels erläutern, so wie es in Bild 1 zu sehen ist. Es stellt den programmatischen Ablauf eines Warenkorbs dar, wie er in einer Vielzahl von Anwendungen zum Einsatz kommt. Zunächst ist da der Before-Zustand, der als Ausgangsbasis dient; der Warenkorb enthält einige Elemente und daraus ergibt sich ein Gesamtpreis. Am Ende steht der After-Zustand, der erreicht wurde, nachdem dem Warenkorb neue Elemente hinzugefügt wurden, was – absolut logisch – natürlich auch zu einer Anpassung des Gesamtpreises führt.

Aber dann ist da der Zwischenstand – During. Aus Programmsicht ist das der Moment, wo dem Warenkorb die neuen Elemente hinzugefügt werden, der Gesamtpreis aber noch nicht aktualisiert ist; dieser Zwischenschritt, dieser kleine Prozess wurde somit noch nicht ausgeführt.

Genau dieser During-Zwischenstand ist es, der in Swift in Sachen Memory Safety eine wichtige Rolle spielt. Denn stellen Sie sich einmal folgendes vor: Was passiert, wenn Sie auf den Gesamtpreis des Warenkorbs zugreifen, während diesem neue Elemente hinzugefügt werden und der Gesamtpreis somit noch nicht final aktualisiert ist? Sie erhalten dann zwar den finalen Stand, aber einen falschen Preis. Hier gilt es also, Obacht walten zu lassen.

Übrigens: Die in diesem Artikel beschriebenen Konflikte bei der Memory Safety haben nichts mit Code zu tun, der parallel auf mehreren Threads ausgeführt wird; da gibt es ja naturgemäß derartige Probleme. Doch es kann auch bei Befehlen, die auf einem einzigen gemeinsamen Thread ausgeführt werden, zu derartigen Konflikten kommen.

Eigenschaften des Konflikts eines Speicherzugriffs

Doch wie genau kommt nun ein solcher Konflikt in Swift-Code zustande? Dazu müssen insgesamt drei Bedingungen erfüllt sein:

  • Es findet mindestens eine Schreibaktion auf eine Speicherstelle statt.
  • Es wird von wenigstens zwei Stellen dieselbe Speicherstelle angefragt.
  • Die verschiedenen Zugriffszeitpunkte überschneiden sich.

Des Weiteren unterscheidet man begrifflich zwischen sogenannten instantaneous und long-term Zugriffen auf den Speicher. Instantaneous bezeichnet Befehle, die auf einem Thread immer nacheinander ausgeführt werden und es technisch gar keine Chance gibt, dass ein paralleler Zugriff erfolgt, der Konflikte in Sachen Memory Safety erzeugen kann. Ein Beispiel hierzu sehen Sie im folgenden Listing. Alle darin aufgeführten Befehle werden nacheinander ausgeführt, ein paralleler Zugriff auf einzelne Speicherbereiche (wie beispielsweise die Variable myNumber) ist nicht möglich.

func addOne(toNumber number: Int) -> Int {
    return number + 1
}
var myNumber = 1
myNumber = addOne(toNumber: myNumber)
print(myNumber)
// "2"

Dem gegenüber stehen die long-term Zugriffe. Die Problematik, die bei dieser Form von Speicherzugriffen auftreten kann, ist, dass auf eine Speicherstelle zugegriffen wird, während eine Operation darauf noch nicht abgeschlossen ist. Das wird auch als sogenannter Overlap bezeichnet. Und genau diese Problematik betrachten wir im Folgenden im Detail.

Memory Safety und In-Out-Parameter

In-Out-Parameter einer Funktion sind ein typisches Beispiel für einen long-term Zugriff. Einer übergebenen Variable wird nicht einfach nur direkt ein neuer Wert zugewiesen, sondern sie durchläuft eine beliebig lange Liste an Befehlen innerhalb der entsprechenden Funktion und kann darin angepasst werden. Und genau hier kann es zu sich überschneidenden Lese- und Schreibzugriffen für eine spezifische Speicherstelle kommen.

Ein Beispiel für das Potential eines solchen Konflikts finden Sie im folgenden Listing. Es zeigt eine Funktion namens balanceScores(_:_:), die zwei übergebene Score-Werte einander angleicht indem sie den Durchschnitt der beiden Parameter ermittelt und ihnen dieses Ergebnis anschließend wieder zuweist. Um die Funktion zu testen, werden im Anschluss zwei passende Score-Variablen erzeugt und die Funktion mit ihnen aufgerufen.

func balanceScores(_ firstScore: inout Int, _ secondScore: inout Int) {
    let scoreSum = firstScore + secondScore
    firstScore = scoreSum / 2
    secondScore = scoreSum - firstScore
}
var firstPlayerScore = 19
var secondPlayerScore = 99
balanceScores(&firstPlayerScore, &secondPlayerScore)
// playerOneScore = 59
// playerTwoScore = 59

An diesem Punkt ist noch alles okay, da es zu keinem Zeitpunkt einen parallelen Lese- und Schreibzugriff auf eine der beiden Variablen firstPlayerScore und secondPlayerScore gibt. Anders sieht das aber im Aufruf von balanceScores(_:_:) im folgenden Listing aus. Dort werden nicht zwei verschiedene Variablen, sondern zwei Mal ein und dieselbe als Parameter der Funktion übergeben. Das führt in der letzten Zeile der Funktion zu einem entsprechenden Speicherkonflikt. Da die Parameter firstScore und secondStore auf dieselbe Speicherstelle verweisen, werden sie im letzten Befehl gleichzeitig gelesen und verändert, es erfolgt also ein paralleler Zugriff auf ein und dieselbe Stelle im Speicher.

balanceScores(&firstPlayerScore, &firstPlayerScore)
// error: inout arguments are not allowed to alias each other

Eine typische Möglichkeit, diese Form von Speicherkonflikt zu umgehen, besteht im Erzeugen einer Kopie für einen Parameter, so wie im folgenden Listing gezeigt. Das Original und die Kopie verweisen damit – trotz desselben Werts – auf zwei verschiedene Stellen im Speicher und die Zugriffe innerhalb der balanceScores(_:_:)-Funktion ist kein Problem.

var copyOfFirstPlayerScore = firstPlayerScore
balanceScores(&firstPlayerScore, &copyOfFirstPlayerScore)

Memory Safety und Mutating Functions

Einen ähnlichen Speicherkonflikt können auch Mutating Functions von Structures auslösen. Dazu zeigt das folgende Listing die Deklaration einer entsprechenden Structure namens Player, die einen einzelnen Spieler abbildet und mit den zwei Attributen name und health versieht. Über eine Methode namens shareHealth(with:) können Spieler ihre Lebensenergie mithilfe der zuvor vorgestellten balanceScores(_:_:)-Funktion einander angleichen.

struct Player {
    var name: String
    var health: Int
    mutating func shareHealth(with teammate: inout Player) {
        balanceScores(&teammate.health, &health)
    }
}

Der Clou der shareHealth(with:)-Methode ist also der, dass sie sowohl den Aufrufer wie auch den übergebenen Parameter gleichermaßen verändert und deren Scores angleicht. Solange zwei unterschiedliche Player-Instanzen bei diesem Zusammenspiel zum Einsatz kommen, gibt es auch kein Problem, wie das folgende Listing zeigt.

var thomas = Player(name: "Thomas", health: 10)
var ela = Player(name: "Ela", health: 4)
thomas.shareHealth(with: &ela)
// thomas.health = 7
// ela.health = 7

Doch sobald der Aufrufer und der übergebene Parameter identisch sind, kommt es erneut zum Speicherkonflikt, da dann parallel ein Lese- und Schreibzugriff auf dieselbe Instanz und somit dieselbe Stelle im Speicher erfolgt:

thomas.shareHealth(with: &thomas)
// error: inout arguments are not allowed to alias each other

Fazit

Speicherkonflikte können in Swift auch bei Code auftreten, der auf einem einzigen Thread ausgeführt wird. Erfreulicherweise weist der Compiler in solchen Fällen in der Regel bereits vor Ausführung des entsprechenden Codes auf ein derartiges Problem hin. Zum besseren Verständnis solcher Probleme und um diese Form von Konflikten möglichst von vornherein zu vermeiden, ist es aber wichtig, ein grundlegendes Verständnis für diese Thematik und die Memory Safety von Swift zu besitzen.