Nach einigen Abstürzen aufgrund von Zugriffen auf einen NSManagedObjectContext (Kontext) durch mehrere Threads haben wir nach einer Lösung gesucht, die unseren Code robuster macht. Das durch nutzlose Fehlermeldungen mühsame und zeitaufwändige Debugging solcher Fehler wollten wir unbedingt vermeiden.
Die Grundregeln bei dem Umgang mit Core Data Multithreading lesen sich einfach:
- Jeder Kontext darf nur von dem Thread aus verwendet werden, auf dem er erstellt wurde.
- NSManagedObjects (Objekte) sind fest an ihren Kontext gebunden. Nur ihre IDs dürfen Kontext- und damit Threadgrenzen passieren.
Unser Projekt enthält zahlreiche NSOperations (Operationen), die vom System auf einem Hintergrund-Thread ausgeführt werden. Zur Einhaltung der oben genannten Regeln wird am Anfang jeder Operation ein neuer Kontext erstellt. Dieser Kontext wird nun über mehrere Hierarchien an Hilfsobjekte weitergereicht, um beispielsweise Objekte über NSFetchRequests zu holen.
Folgende Probleme ergeben sich dadurch:
- Die Signaturen vieler Methoden werden durch einen Kontext-Parameter aufgebläht.
- Eine Hilfsmethode, die intern den Standard-Kontext verwendet, wird von einem zweiten Entwickler in gutem Glauben aus einer Operation heraus aufgerufen.
- Die Übergabe von nil als Kontext an Hilfsmethoden ist zum Synonym für den Main-Thread-Kontext (Standard-Kontext) geworden. Dieser Automatismus kann aber für Fehler sorgen, wenn man vergisst den korrekten Kontext mitzugeben.
Magical Record
Offensichtlich gibt es einen engen Zusammenhang zwischen Kontext und Thread. Also warum wird der Kontext nicht einfach an den Thread gebunden und passend zum aktuellen Thread automatisch gewählt. Mit diesem Gedanken stößt man bei der Recherche auf Github schnell auf MagicalRecord.
Inspiriert von der Active Record Implementierung in Ruby on Rails bietet MagicalRecord zum einen Methoden zum Importieren von Daten und zum anderen die kompakte Erstellung von Fetch-Requests wie im folgenden Beispiel:
Ohne MagicalRecord wäre die Erstellung des gleichen Fetch-Requests mindestens fünf Zeilen lang.
Unterliegend verwendet MagicalRecord sogenannte Nested-Contexts, die Apple mit iOS 5 eingeführt hat. Dabei hat ein Kontext keinen NSPersistentStore zum Speichern, sondern einen Eltern-Kontext. Beim Aufruf von „save:“ werden die Änderungen in den Eltern-Kontext und nicht direkt in den NSPersistentStore geschrieben.
Das Einbinden von MagicalRecord gestaltete sich in unserem Fall unter iOS 6 erstaunlich einfach. Nach der Initialisierung des defaultContext mit Hilfe des eigenen NSPersistentStoreCoordinators, erstellt MagicalRecord alle weiteren Kontexte automatisch.
Diese bekommt man sehr einfach mit der statischen Methode contextForCurrentThread der NSManagedObjectContext-Klasse geliefert. Gibt es für den aktuellen Thread keinen Kontext, wird einer erstellt.
In unserem Projekt haben wir nun alle Zugriffe auf den Standard-Kontext durch den aktuellen Thread-Kontext ersetzt. Beim Zugriff vom Main-Thread entspricht dies dem defaultContext.
Der erstellte Thread-Kontext hat automatisch den defaultContext als Eltern-Kontext. Dies entspricht in etwa dem häufigen Fall (ohne Nested-Contexts), dass der Standard-Kontext den Thread-Kontext observiert und alle Änderungen abspeichert.
Es hat sich bewährt, am Anfang und am Ende jeder Operation den contextForCurrentThread mit reset zurück zu setzen, damit nicht versehentlich alte Änderungen im Kontext verbleiben, denn der contextForCurrentThread wird wiederverwendet, solange der Thread nicht vom System abgeräumt wurde.
Zum Speichern bietet MagicalRecord verschiedene Methoden an. Bei Umstellung von einem bestehenden Projekt ohne Nested-Contexts sollte man alle Kontext save: Methoden durch saveToPersistentStoreAndWait ersetzen, da bei beiden Methoden alle späteren Aufrufe davon ausgehen können, dass alle Daten in den NSPersistentStore gespeichert wurden.
Will man den aktuellen Thread nicht blockieren, so kann man auch asynchron mit saveToPersistentStoreWithCompletion: speichern. Aufwändiger wird es, wenn man bisher den Erfolg der save: Methode ausgewertet hat. In diesem Fall muss man alles was nach dem Speichern passieren soll manuell in den completionBlock von saveToPersistentStoreWithCompletion: verlegen.
[context saveToPersistentStoreWithCompletion:^(BOOL success, NSError *error) {
if (success) ...
}];
Ergebnis iOS 6
Ein Abgleich des contextForCurrentThread mit unseren manuell durchgereichten Kontexten hat einige Fehler in unserem alten Code aufgespürt. Alle drei am Anfang aufgezählten Probleme sind aufgetreten und durch threadgebundene Kontexte gelöst worden.
Man muss natürlich weiterhin selbst darauf achten, dass man NSManagedObjects nicht über Threadgrenzen hinweg verwendet.
Will man explizit einen anderen Kontext verwenden, so kann man eine Version des persistierten Objekts durch die Methode inContext: im gewünschten Kontext bekommen.
Desaster auf iOS 5
Nach den Umstellungen lief die App unter iOS 6 problemlos. Ein Test auf iOS 5 war dagegen ein Desaster. Die App ist nicht einmal mehr gestartet.
Innerhalb von performFetch: eines NSFetchedResultsControllers ist es reproduzierbar zu Deadlocks gekommen. Sinnvolle Fehlermeldungen gab es nicht. Aber das war nicht das einzige Problem: Nested-Contexts auf iOS 5 führen zu Deadlocks, fehlerhaften Suchergebnissen, falschen Sortierungen und Abstürzen.
Eines wird daraus klar: Nested-Contexts wurden zwar unter iOS 5 eingeführt, aber sind so fehleranfällig, dass man sie erst ab iOS 6 in einer produktiven Umgebung einsetzen sollte. Apple hat an dieser Stelle leider geschlafen.
Dieser Blog liefert eine Beschreibung der oben genannten Probleme.
Magical Record ohne Nested-Contexts
Da wir MagicalRecords Fetching, Logging, ErrorHandling, etc. weiter verwenden wollten, haben wir alle Abhängigkeiten zu Nested-Contexts aus dem Framework entfernt und lassen den defaultContext andere Kontexte observieren.
Das Erstellen von Kontexten, Speichern und alle blockbasierten Methoden mussten dazu angepasst oder entfernt werden.
Automatische Tests
Beim automatischen Testen eines Projekts ersetzen wir vor jedem Test MagicalRecords defaultContext durch einen neuen Kontext der einen NSInMemoryStore verwendet. Da alle anderen Kontexte vom defaultContext abgeleitet werden und daher den selben NSPersistentStore verwenden, muss man ansonsten nichts weiter beachten.
Fazit
Trotz der wichtigen Lehre, dass Nested-Contexts unter iOS 5 unbrauchbar sind und des damit verbundenen zusätzlichen Aufwands hat sich die Arbeit gelohnt. Dank MagicalRecord und etwas Handarbeit ist der Code jetzt zentralisierter, kürzer und robuster geworden. Kontexte werden passend zum Thread automatisch gewählt, Fehler werden vermieden, Abstürze wurden beseitigt und als Entwickler kann man wieder ruhig schlafen.














