Keeping Things Straight with GCD
This is the sixth and final post in a series about Grand Central Dispatch.
Long time users of GCD will tell you: it’s easy to forget where you are. Which queue am I on? Should I dispatch_sync
to a queue that protects my variables, or has my caller taken care of that?
In this post I will describe a simple naming convention that has kept me sane over the years. Follow it, and you shouldn’t ever deadlock or forget to synchronize access to your member variables again.
Designing thread-safe libraries
When designing thread-safe code, it helps to have a library mindset. You should distinguish between the external or public interface (API), and the internal or private interface. The public API is presented in public headers, and the private interface is presented in private headers, used only by the library’s developers.
The ideal thread-safe public API does not expose threading or queueing at all (unless, of course, thread or queue management is the point of your library’s utility). It should simply not be possible to induce a race condition or deadlock when using your library. Let’s take a look at this classic example:
// Public header
#import <Foundation/Foundation.h>
// Thread-safe
@interface Account: NSObject
@property (nonatomic, readonly, getter=isClosed) BOOL closed;
@property (nonatomic, readonly) double balance;
- (void)addMoney:(double)amount;
- (void)subtractMoney:(double)amount;
- (double)closeAccount; // Returns balance
@end
@interface Bank: NSObject
@property (nonatomic, copy) NSArray<Account *> *accounts;
@property (nonatomic, readonly) double totalBalanceInAllAccounts;
- (void)transferMoneyAmount:(double)amount
fromAccount:(Account *)fromAccount
toAccount:(Account *)toAccount;
@end
If it weren’t for the helpful comment, you wouldn’t be able to tell from this header that the library is thread-safe. In other words, you’ve defined thread-safety as an implementation detail.
Three Simple Rules
In your implementation, define a serial queue on which all member access can be serialized. In my experience, it’s usually enough to define a simple serial queue for an entire area of functionality, which may later be replaced by a concurrent queue if needed for performance.
// Bank_Private.h
dispatch_queue_t memberQueue();
// Bank.m
#import "Bank_Private.h"
dispatch_queue_t memberQueue() {
static dispatch_queue_t queue;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
queue = dispatch_queue_create("member queue", DISPATCH_QUEUE_SERIAL);
});
return queue;
}
And now, we introduce a naming convention as Rule Number One: Every function and variable that must be serialized on a queue must be prefixed by the queue’s name.
All of the properties of our Account
class, for example, must be serialized, so their ivars need to be
prefixed. One convenient way to do this is to introduce a private class extension:
// Bank_Private.h
@interface Account()
@property (nonatomic, getter=memberQueue_isClosed) BOOL memberQueue_closed;
@property (nonatomic) double memberQueue_balance;
@end
This class extension should appear in the class’s private header.
We’ve changed balance
to a readwrite
property in the private header, so we can easily change its value from within our library.
Because Objective-C automatically synthesizes ivars and accessors for all properties, we now end up with duplicate ivars: one for the public properties, and one for the private, member-queue-protected ones. One way to prevent autosynthesis for the public properties is to declare them @dynamic
in the class’s implementation.
// Bank.m
@implementation Account
@dynamic closed, balance;
@end
We manually provide accessors for the public properties:
// Bank.m
@implementation Account
// ...
- (BOOL)isClosed {
__block BOOL retval;
dispatch_sync(memberQueue(), ^{
retval = self.memberQueue_isClosed;
});
return retval;
}
- (double)balance {
__block double retval;
dispatch_sync(memberQueue(), ^{
retval = self.memberQueue_balance;
});
return retval;
}
@end
Yes, you can defeat authosynthesis by providing a getter and (for readwrite
properties) a setter. But I prefer to state explicitly that I don’t want an ivar, getter, or setter created for me, ever, by using @dynamic
. Better to crash during testing due to an unimplemented selector than to have a latent error in released code.
See the pattern? That brings us to Rule Number Two: Only access queue-prefixed variables and functions from blocks enqueued on that queue.
Now let’s implement addMoney:
, subtractMoney:
and closeAccount
to round out the Account
class. We’re actually
going to write two versions of each function: one that assumes we are not on the member queue, and one that does.
Here we go:
// Bank.m
@implementation Account
//...
- (void)addMoney:(double)amount {
dispatch_sync(memberQueue(), ^{
[self memberQueue_addMoney:amount];
});
}
- (void)memberQueue_addMoney:(double)amount {
self.memberQueue_balance += amount;
}
- (void)subtractMoney:(double)amount {
dispatch_sync(memberQueue(), ^{
[self memberQueue_subtractMoney:amount];
});
}
- (void)memberQueue_subtractMoney:(double)amount {
self.memberQueue_balance -= amount;
}
- (double)closeAccount {
__block double retval;
dispatch_sync(memberQueue(), ^{
retval = [self memberQueue_closeAccount];
});
return retval;
}
- (double)memberQueue_closeAccount {
self.memberQueue_closed = YES;
double balance = self.memberQueue_balance;
self.memberQueue_balance = 0.0;
return balance;
}
@end
We also publish the prefixed versions of these functions in our private header:
// Bank_Private.h
@interface Account()
//...
- (void)memberQueue_addMoney:(double)amount;
- (void)memberQueue_subtractMoney:(double)amount;
- (double)memberQueue_closeAccount;
This brings us to Rule Number Three: Code inside a prefixed function must only touch other functions and variables that are prefixed by the same queue’s name.
These three rules keep us sane: You know exactly what known queue (if any) you are on, and once you are on that queue, you only access functions and member variables that already assume you are on that queue.
Note how we can modify both memberQueue_closed
and memberQueue_balance
atomically inside memberQueue_closeAccount
, knowing that the function is only called when we are serialized on memberQueue
. The increment and decrement operators inside memberQueue_addMoney:
and memberQueue_subtractMoney:
, too, can safely perform read-modify-write operations without fear of race conditions.
One more time
We can now use objects of the Account
class from any thread we want. Now let’s make Bank
objects thread-safe as well. Because we are using a single memberQueue
for both Bank
and Account
objects, our job is relatively easy.
Let’s review the Three Rules:
- The name of every function and variable that must be accessed only on a queue must be prefixed by the queue name.
- Only access queue-prefixed variables and functions from blocks enqueued on that queue.
- Code inside a prefixed function must only touch other functions and variables that are prefixed by the same queue’s name.
First, we declare queue-prefixed properties and methods in our private header:
// Bank_Private.h
@interface Bank()
@property (nonatomic, copy) NSArray<Account *> *memberQueue_accounts;
@property (nonatomic, readonly) double memberQueue_totalBalanceInAllAccounts;
- (void)memberQueue_transferMoneyAmount:(double)amount
fromAccount:(Account *)fromAccount
toAccount:(Account *)toAccount;
@end
We suppress autosynthesis by declaring public properties @dynamic
:
// Bank.m
@implementation Bank
@dynamic accounts, totalBalanceInAllAccounts;
@end
We define our member functions:
// Bank.m
@implementation Bank
//...
- (NSArray<Account *> *)accounts {
__block NSArray<Account *> *retval;
dispatch_sync(memberQueue(), ^{
retval = self.memberQueue_accounts;
});
return retval;
}
- (void)setAccounts:(NSArray<Account *> *)accounts {
dispatch_sync(memberQueue(), ^{
self.memberQueue_accounts = accounts;
});
}
- (double)totalBalanceInAllAccounts {
__block double retval;
dispatch_sync(memberQueue(), ^{
retval = self.memberQueue_totalBalanceInAllAccounts;
});
return retval;
}
- (double)memberQueue_totalBalanceInAllAccounts {
__block double retval = 0.0;
for (Account *account in self.memberQueue_accounts) {
retval += account.memberQueue_balance;
}
return retval;
}
- (void)transferMoneyAmount:(double)amount
fromAccount:(Account *)fromAccount
toAccount:(Account *)toAccount {
dispatch_sync(memberQueue(), ^{
[self memberQueue_transferMoneyAmount:amount
fromAccount:fromAccount
toAccount:toAccount];
});
}
- (void)memberQueue_transferMoneyAmount:(double)amount
fromAccount:(Account *)fromAccount
toAccount:(Account *)toAccount {
fromAccount.memberQueue_balance -= amount;
toAccount.memberQueue_balance += amount;
}
And we’re done. The naming convention makes it abundantly clear which operations are performed safely on the serialization queue, and which are not.
Single queue simplicity
This naming pattern has worked very well for me, but it does have limitations. Generally, the system works great if you only have a single serial queue. Luckily, I haven’t found many occasions to use anything else.
Avoid premature optimization. Begin coding with a single serial queue to synchronize access in a cluster of related classes. Change this only when you see performance bottlenecks.
Readers-writer lock
To support concurrent reader-writer queues, you’d need two prefixes for your functions: memberQueue_
and memberQueueMutating_
. Non-mutating functions must only read from protected variables, and must only call other non-mutating functions. Mutating functions may read from or write to protected variables, and may call both mutating and non-mutating functions. Use dispatch_sync
or dispatch_async
to schedule non-mutating function calls, and use dispatch_barrier_sync
or dispatch_barrier_async
to schedule mutating functions.
Just say no to multiple, nested queues
If you ever find yourself adding awareness of more than one synchronization queue to your class, you’ve probably goofed your architecture.
You normally find yourself needing to deal with two queues when some part of your app accesses a class that uses an “outer” queue (for instance, Bank
may have its own queue), but other parts of your app also directly use a class that uses an “inner” queue (calling Account
directly). For certain methods like -[Bank transferMoney:...]
, it may be necessary to serialize on both queues to prevent direct changes to Account
objects while Bank
is transferring money. This is a sure sign of a design error.
In my experience, it’s never been worth having multiple synchronization queues in a class collection that implement a single function. For performance tuning, turning the serial queue into a concurrent one is usually sufficient.
Exercises for the reader
- Have
-[Bank transferMoney:...]
perform checks to prevent withdrawal from a closed account, or overdrafting. How would you change the public and private interfaces to communicate such errors? - Implement broadcasting an “account changed” notification using
NSNotificationCenter
. How would you implement this without risking deadlock? - Assume the bank has millions of accounts. Reimplement
totalBalanceInAllAccounts
to be asynchronous, taking a completion block. What performance challenges might you come across? On what queue should you call the completion block to prevent deadlock?
Prologue
I hope this simple technique will keep your code clean and maintainable, and keep you out of threading trouble. It’s certainly done that for me.
This is the last post in my series on Grand Central Dispatch. I hope you’ve learned something from it! If you like it, please spread the word.