-
Notifications
You must be signed in to change notification settings - Fork 363
Thread Safety
YapDatabase was designed with concurrency in mind. But that doesn't mean its impossible to shoot yourself in the foot. Arm yourself with knowledge so you never have any "accidents".
One of the powerful features of the YapDatabase architecture is that connections are thread-safe. That means that you can share a single YapDatabaseConnection amongst multiple threads.
All connections have an internal serial DispatchQueue. And all operations on a connection (such as executing a transaction) go through this internal serial queue. So its rather easy to conceptualize the nature of the thread-safety within a single connection: All transactions executed on connectionA will execute in a serial fashion.
The main thing to watch out for is executing a transaction within a transaction. This is not allowed, and will result in DEADLOCK :
func deadlock1() {
dbConnection.read {(transaction) in
id deadlock = self.deadlock2()
}
}
func deadlock2() {
var uh_oh: String? = nil
dbConnection.read {(transaction) in
uh_oh = transaction.object(forKey: "dead", inCollection: "lock") as? String
} // transaction within transaction == deadlock !!!
return uh_oh
}
This is the most common reason for deadlock reports. Now that you understand the problem, I'm sure you can come up with multiple solutions. We'll present one such solution here. Not because its the best solution (you might like yours better), but rather because its one that tends to be thought of the least.
func estimatedCosts(forAddress addr: Address) -> Float {
var costs: Float = 0.0
dbConnection.read {(transaction) in
costs += self.propertyTaxes(forAddress: addr, transaction: transaction)
costs += self.hoaFees(forAddress: addr, transaction: transaction)
}
return taxes
}
func propertyTaxes(forAddress addr: Address, transaction: YapDatabaseReadTransaction) -> Float {
let key = String(addr.zip)
let result = transaction.object(forKey: key, inCollection: "propertyTaxes") as? Float
return result ?? 0.0
}
Passing a transaction parameter is recommended for helper methods like this.
Remember:
- This is just one example of a solution.
- A transaction instance should never be saved as an ivar. That won't work.
As a developer, you're familiar with the concept of mutable vs immutable. It's visible from the moment you start coding in Swift:
let immutable = "You cannot change the value of this"
var mutable = "You CAN change the value of this"
In addition to this, Swift gives us both Structs and Classes. And Structs are value types:
A value type is a type whose value is copied when it’s assigned to a variable or constant, or when it’s passed to a function.
This means that Structs are incredibly safe to use. On the other hand, Classes come with increased flexibility. And passing instances by reference can be MUCH faster, especially for larger types.
It's important to keep in mind that, although YapDatabase is thread-safe, the objects you're fetching from the database may not be. For example, consider the following code:
func someFunctionOnMainThread() {
var person: Person? = nil // Person is a Class, not a Struct
dbConnection.read {(transaction) in
person = transaction.object(forKey: personId, inCollection: "persons") as? Person
}
// Accessing Person.children array on main thread...
for child in (person?.children ?? []) {
self.addView(forChild: child)
}
}
func someFunctionOnBackgroundThread(_ newChild: Child) {
dbConnection.read {(transaction) in
if let person = transaction.object(forKey: personId, inCollection: "persons") as? Person {
// Modifying Person.children array on background thread...
person.children.append(newChild) // <= This could be a bug !!!
}
}
}
OK, so what's wrong with this ?
- On the main thread, we fetch our Person instance from dbConnection
- On a background thread, we fetch our Person instance from dbConnection
But are we referring to the exact same person object? Or is each a different copy?
Since Person is a Class, and not a Struct, this becomes a possibility.
Recall that every connection has a cache. The cache is important for performance, and drastically reduces both trips to the disk, and the overhead of deserializing an object. Thus its highly likely that both the main thread and background thread are fetching the exact same Person instance.
(Notice that both threads are using the same YapDatabaseConnection instance.)
And so the background thread may be modifying the object while the main thread is simultaneously attempting to use it.
If the objects you put in the database are mutable you must follow all the same rules & guidelines that you would for any other mutable object.
From Apple's Threading Programming Guide:
Immutable objects are generally thread-safe. Once you create them, you can safely pass these objects to and from threads. On the other hand, mutable objects are generally not thread-safe. To use mutable objects in a threaded application, the application must synchronize appropriately.
The recommended practice is:
-
Follow Apple's recommendations: Choosing Between Structures and Classes
-
This means you'll be using Structs often, and these threading problems disappear
-
When your database objects are Classes, then make copies of them before modifying them.
For example:
func someFunctionOnMainThread() {
var person: Person? = nil
dbConnection.read {(transaction) in
person = transaction.object(forKey: personId, inCollection: "persons") as? Person
}
for child in (person?.children ?? []) {
self.addView(forChild: child)
}
}
func someFunctionOnBackgroundThread(_ newChild: Child) {
dbConnection.readWrite {(transaction) in
if var person = transaction.object(forKey: personId, inCollection: "persons") as? Person {
person = person.copy() as! Person // Safety badge achieved !
person.children.append(newChild)
transaction.setObject(person, forKey: personId, inCollection: "persons")
}
}
}
If you follow this simple guideline, you generally won't have to worry about thread-safety. Even when you're using mutable class instances.
YapDatabase makes it easy to access & update your database using async operations. And one of the most common mistakes I see people make is this one:
func downloadImage(forPost originalPost: Post) {
ImageDownloader.asyncDownloadImage(originalPost.imageURL) {
(dowloadedImageFileURL: URL) in
bgConnection.asyncReadWrite {(transaction) in
// Update post
let updatedPost = originalPost.copy() as! Post // <= BUG !!!!!!!!
updatedPost.downloadedImageURL = downloadedImageFileURL
transaction.setObject(updatedPost, forKey: updatedPost.uuid, inCollection: "posts")
}
}
}
What's wrong with this code? It's following the recommended practice stated above: make copies of class instances before modifying them.
The code is very nearly perfect. We just forgot to put our "async thinking hat" on.
When you fetch an item from the database, you should think of that item as a snapshot in time. The object represents the state of that object at a particular commit. And you should keep in mind that there may be other commits happening in the background that might just store an updated version of that object to the database. For example, imagine the class has another method like this:
func markPostAsRead(_ postId: String) {
bgConnection.asyncReadWrite {(transaction) in
if let post = transaction.object(forKey: postId, inCollection: "posts") as? Post {
post = post.copy() as! Post
post.isRead = true
transaction.setObject(post, forKey: postId, inCollection: "posts")
}
}
}
So what will happen when we do this?:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
var post: Post? = nil
dbConnection.read {(transaction) in
if let viewTransaction = transaction.ext("order") as? YapDatabaseViewTransaction {
post = viewTransaction.object(atIndex: indexPath.row, inGroup: "postsFromFriends") as? Post
}
}
// create and configure cell using post...
// since we're display the post, we can mark it as read now
if (!post.isRead) {
self.markPostAsRead(post.uuid)
}
// download the image if needed
if (post.imageURL != nil) && (post.downloadedImageURL == nil) {
self.downloadImage(forPost: post)
}
return cell;
}
- The first async commit will set post.isRead to true
- And the second async commit will accidentally undo the first, by using an OLD VERSION of the post object !!!
Luckily the fix is easy.
Before editing an object in the database, be sure to grab the LATEST REVISION of the object within the read-write transaction.
(Recall that there can only be a single read-write transaction at any one time. So if you follow this guideline, you'll always be sure to update the most recent revision of the object.)
func downloadImage(forPost originalPost: Post) {
ImageDownloader.asyncDownloadImage(originalPost.imageURL) {
(dowloadedImageFileURL: URL) in
bgConnection.asyncReadWrite {(transaction) in
// Update post (be sure to grab latest revision of object)
if var post = transaction.object(forKey: originalPost.postId, inCollection: "posts") as? Post {
post = post.copy() as! Post
post.downloadedImageURL = dowloadedImageFileURL
transaction.setObject(post, forKey: post.uuid, inCollection: "posts")
}
}
}