- Introduction
- Multithreaded Programming in Object-Oriented Languages
- Doomed If You Do, and Doomed If You Don't
- Locks as Permits
- Whose Lock Is It, Anyway?
- Improving External Locking
- Conclusion
Doomed If You Do, and Doomed If You Don't
When designing a class susceptible to sharing by multiple threads, one inevitable question occurs: Should that class use external, caller-provided, or internal locking?
External locking is easiest to support because it puts the entire burden on the user of the class. External locking means that all synchronization occurs in client code. You only guarantee that separate threads can manipulate distinct instances of your class concurrently. All you have to do to support external locking is to make sure that your class doesn't use static/global dataor if it does, that it uses proper synchronization. (Recently, the C++ community began using the term thread-neutral to describe such classes.)
The BankAccount class above uses internal locking. Basically, a class that uses internal locking guarantees that any concurrent calls to its public member functions don't corrupt an instance of that class. This is typically ensured by having each public member function acquire a lock on the object upon entry. This way, for any given object of that class, there can be only one member function call active at any moment, so the operations are nicely serialized.
This approach is reasonably easy to implement and has an attractive simplicity. Unfortunately, "simple" might sometimes morph into "simplistic."
Internal locking is insufficient for many real-world synchronization tasks. Imagine that you want to implement an ATM withdrawal transaction with the BankAccount class. The requirements are simple. The ATM transaction consists of two withdrawalsone for the actual money and one for the $2 commission. The two withdrawals must appear in strict sequence; that is, no other transaction can exist between them.
The obvious implementation is erratic:
void ATMWithdrawal(BankAccount& acct, int sum) { acct.Withdraw(sum); acct.Withdraw(2); }
The problem is that between the two calls above, another thread can perform another operation on the account, thus breaking the second design requirement.
In an attempt to solve this problem, let's lock the account from the outside during the two operations:
void ATMWithdrawal(BankAccount& acct, int sum) { Lock<BankAccount> guard(acct); acct.Withdraw(sum); acct.Withdraw(2); }
Notice that now acct is being locked by Withdraw after it has already been locked by guard. When running such code, one of two things happens.
Your mutex implementation might support the so-called recursive mutex semantics. This means that the same thread can lock the same mutex several times successfully. In this case, the implementation works but has a performance overhead due to unnecessary locking. (The locking/unlocking sequence in the two Withdraw calls is not needed but performed anywayand that costs time.)
Your mutex implementation might not support recursive locking, which means that as soon as you try to acquire it the second time, it blocksso the ATMWithdrawal function enters the dreaded deadlock.
The caller-ensured locking approach is more flexible and the most efficient, but very dangerous. In an implementation using caller-ensured locking, BankAccount still holds a mutex, but its member functions don't manipulate it at all. Deposit and Withdraw are not thread-safe anymore. Instead, the client code is responsible for locking BankAccount properly.
Obviously, the caller-ensured locking approach has a safety problem. BankAccount's implementation code is finite, and easy to reach and maintain, but there's an unbounded amount of client code that manipulates BankAccount objects. In designing applications, it's important to differentiate between requirements imposed on bounded code and unbounded code. If your class makes undue requirements on unbounded code, that's usually a sign that encapsulation is out the window.
To conclude, if in designing a multithreaded class you settle on internal locking, you expose yourself to inefficiency or deadlocks. On the other hand, if you rely on caller-provided locking, you make your class error-prone and difficult to use. Finally, external locking completely avoids the issue by leaving it all to the client code.
Note, however, that classes supporting external locking are not the bottom of the pile. Some classes are even less friendly to threads. For example:
class Counted { static unsigned int instances_; public: Counted() { ++instances_; } Counted(const Counted&) { ++instances_; } ~Counted() { --instances_; } }; unsigned int Counted::instances_ = 0;
The purpose of Counted is to tally its own number of instances at any moment. If integer arithmetic is not atomic on your system (and it isn't on many), you need to serialize (with a global mutex) the creation and destruction of all Counted objects throughout the system. This is feasible but often impractical.