CSE 374, Lecture 25: More Concurrency

Where we left off

Last Friday, we were talking about concurrency and the problems that arise with executing code on multiple "threads" of execution at once when they access shared memory. We had a BankAccount class and figured out that in order to prevent problems, we should use "locks" to allow only one thread at a time to use/modify the data.

All of this locking/unlocking is tricky, and it is easy to forget. As an alternative, C++ provides something called a "lock guard" which simplifies the act of using a mutex:

    void deposit(double amount) {
      std::lock_guard<std::mutex> lock(m_);       // locks mutex m_ in the lock_guard constructor
      // mutex is now locked
      setBalanceWithLock(getBalance() + amount);
      // When deposit() returns, the stack-allocated lock_guard will be deleted,
      // calling the destructor and releasing the mutex.
    }

A "lock guard" is a special type of object that locks the mutex in the constructor, and unlocks the mutex in the destructor. If we allocate the lock guard on the stack, then as soon as it is created, we can guarantee that we have locked the mutex, and the destructor will automatically be called when the lock guard goes out of scope. We don't have to remember to unlock in all cases! This even works for the exception case.

Race conditions

A "race condition" happens when the result of a computation depends upon scheduling of multiple threads, ie the order in which the processor executes instructions. But we've seen two types of race conditions:

std::atomic

What about the static accountCount_ variable that we are using to generate account IDs? This is also a problem! What if two accounts are created at the same time? The ++ operation is not necessarily safe in a multi-thread setting - this is an example of a data race!. We could fix this by adding a static mutex to protect the static count:

    static std::mutex accountCountMutex_;
    static int accountCount_;

and then lock it in the constructor before we set the account id:

    BankAccount::BankAccount() {
      accountCountMutex_.lock();
      accountId_ = ++accountCount_;
      accountCountMutex_.unlock();
      balance_ = 0;
    }

This is kind of a pain, but luckily there is a simple tool in the standard libraries to facilitate this use case! We just want a lock around all of the read/modifications of the integer. The std::atomic wrapper object does just this! It makes any operations on the integer "atomic". The ++ operation will be done all in one, so we can guarantee that no one else gets the same value of accountCount_.

    // In h:
    static std::atomic<int> accountCount_;

    // In cpp:
    BankAccount::BankAccount() {
      accountId_ = ++accountCount_;
      balance_ = 0;
    }

Deadlocks

How about if we want to write a transferTo function to transfer an amount from account A to account B?

    void transferTo(double amount, BankAccount& other) {
      m_.lock();
      other.m_.lock();

      setBalanceInternal(getBalance() - amount);
      other.setBalanceInternal(other.getBalance() + amount);

      other.m_.unlock();
      m_.unlock();
    }

Since now we are dealing with two different accounts, we will have to lock the mutexes of both accounts before doing any balance transfer. So first we call lock() on the current object's mutex, then we call lock() on the other object's mutex.

Unfortunately, this logic can produce a deadlock. Why? Consider that we have account A and account B, and on thread T1, we are trying to transfer $50 from A to B and on thread T2, we are trying to transfer $20 from B to A. We can model this with a bad interleaving:

    Thread T1: A.transferTo(50, B);                    Thread T2: B.transferTo(20, A);

    m_.lock();  // Locks A's mutex
                                                       m_.lock();  // Lock's B's mutex

    other.m_.lock();  // Waits for B's mutex
                                                       other.m_.lock()  // Waits for A's mutex

Object A locks A's mutex, while Object B locks B's mutex. Then each of them wait for the other object's mutex to become available - which will never happen because they are both waiting for the other object to be finished! We have a DEADLOCK.

Solutions to this situation:

Other types of synchronization primitives

There are other types of locks and primitives that are useful, besides the regular mutex, lock guard, and std::atomic:

Wisdom

For every memory location, you should obey at least one of the following:

When it comes to synchronization, there are several guidelines to follow:

  1. No data races. Never allow two threads to read/write or write/write a location at the same time. In C, a program with a data race is almost always wrong.
  2. Think of what operations need to be atomic. Consider atomicity first, then figure out how to implement it with locks).
  3. Consistent locking. For each location that should be synchronized, have a lock that is ALWAYS locked when reading or writing that location. The same lock may (and often should) be used to guard multiple locations/pieces of memory. Clearly document with comments the mutex that guards a particular piece of memory.
  4. Start with coarse-grained locking; move to finer-grained locking only if blocking for locks becomes an issue. Coarse-grained locking is the practice of having fewer locks: one for the whole data structure, or one for all bank accounts. It is simpler to implement, but performance can be bad (fewer operations can be done at the same time). But if there isn't a lot of concurrent access, then coarse locking is probably fine. Fine-grained locking is the practice of having more locks, each guarding less data: one lock per data element, or one lock per field in the bank account. Fine-grained locking is trickier to get correct, requires more programming, and has more overhead (more locks to lock), but it can be more performant if there is lots of concurrent access, since we can do more things at once. Move to fine-grained locks if performance is a problem.
  5. Don't do expensive computations or I/O in critical sections, but also don't introduce race conditions. This balances performance with correctness.
  6. Use built-in libraries whenever possible. Concurrency is extremely tricky and difficult to get right; experts have spent countless hours building tools for you to use to make your code safe.