Synchronization, Transactional Memories

January 24 2007

Leftovers

Finish going over some practice problems from last week:

  1. What are the text segment, data segment, heap and stack? Why do we need all these segments? How do I use these segments in a C program?
  2. Nachos' Thread::Fork() calls Thread::StackAllocate() to set up the new thread's stack. The comment for Thread::StackAllocate() says:
    //----------------------------------------------------------------------
    // Thread::StackAllocate
    //      Allocate and initialize an execution stack.  The stack is
    //      initialized with an initial stack frame for ThreadRoot, which:
    //              enables interrupts
    //              calls (*func)(arg)
    //              calls Thread::Finish
    //----------------------------------------------------------------------
    
    This means that when a new thread starts executing, the very first thing it does is enable interrupts. Why? Who turned off interrupts?

Project 1

You've started, right? :)

A quick reminder. Here's a good recipe for Nachos:

  1. Write pseudocode
  2. Verify correctness of pseudocode. ("will this work in all cases?")
  3. Translate pseudocode to C++
  4. Debug C++. "Debug" means "remove bugs", not "figure out how to handle this case we didn't think about"

You'll be spending most of your time on steps 2 and 4.

In step 2, you'll need to convince yourself that your code will work in all cases. This means you'll need to think about all possible thread orderings, and all possible points where context switches could occur. Step 2 will make your head hurt.

In step 4, you'll need to figure out why your C++ code isn't doing what you think it should be doing. Debugging multithreaded code is hard. In addition to that, you're adding your code and your partner's code to an existing big pile of code that neither of you wrote. I can pretty much guarantee that sometime this quarter, you will run into crazy bugs that will take many hours to track down. Step 4 will make your head hurt.

If you crank out C++ code from the start, you will end up doing steps 2 and 4, the most difficult and time consuming steps, at the same time. This will make your head explode. Not recommended.

Right. Questions on Project 1?

Lecture Review

  1. Threads
  2. Synchronization

Questions

  1. What's the difference between a thread and a process?
  2. Locked withdraw example from lecture:
    withdraw(account, amount) {
      acquire(lock);
      balance = get_balance(account);
      balance = balance - amount;
      put_balance(account, balance);
      release(lock);
    
      return balance;
    }
    
    return is outside the critical section. What does the return value of withdraw() tell the caller about the account?
  3. Lock acquire was defined in lecture as:
    void acquire (lock) {
      Disable interrupts;
    
      while (lock->held) {
        put current thread on lock Q;
        block current thread;
      }
    
      lock->held = 1;
    
      Enable interrupts;
    }
    
    Why do we need the while loop? What happens if we change the while to an if?
  4. What do you think a "thread pool" is? Why would I want one?

Transactional Memories

Issues with Locks

Suppose I have a multithreaded web crawler scouring the web for low, low prices. The program has a global variable called globalMinPrice, and a bunch of threads each with a local variable localPrice. The threads all run the following code:

if(localPrice < globalMinPrice) {
  globalMinPrice = localPrice;
}

Do we need to worry about synchronization? Yes, because this code won't work properly if globalMinPrice changes between the comparison and the write. We can fix this by creating a lock and using it like this:

lock->Acquire();
if(localPrice < globalMinPrice) {
  globalMinPrice = localPrice;
}
lock->Release();

The lock ensures that only one thread can update globalMinPrice at a time. Good - our code will run correctly. But what if globalMinPrice is 2, and I have a bunch of threads running with localPrice 5, 10, 15, and 20? Well, none of these threads will actually change globalMinPrice, so we could run all these threads in parallel without any problems. Yet the lock forces each thread to go through the critical section one at a time.

In this case, our use of a lock is unnecessarily restrictive. We are creating a critical section and forcing the threads to go through the critical section one at a time, even when the threads could have safely run through the critical section in parallel.

Can you think of other cases where locks are unnecessarily restrictive?

Transactional Memory to the Rescue

What we really want is a magic synchronization primitive that:

  1. Knows when threads must march through a critical section one at a time for safety
  2. Knows when threads can barge through a critical section in parallel without breaking anything

This is what transactional memories do. How do we use transactions?

BeginTransaction();
if(localPrice < globalMinPrice) {
  globalMinPrice = localPrice;
}
EndTransaction();

All we do is replace Acquire() with BeginTransaction(), and Release() with EndTransaction(). Note that Acquire() and Release() are invoked on a lock object, while BeginTransaction() and EndTransaction() are not associated with an object.

Why? A typical program multithreaded program will use a lock to protect each piece of data from concurrent modification. In Java, for example, every Object comes with a lock built in. Many locks are used so different threads can safely manipulate different pieces of data in parallel. Going back to our globalMinPrice example, if we were also tracking globalMaxPrice, we'd protect that with a different lock.

But with transactions, we don't need to distinguish which piece of data is being protected. The magical properties of transactions automatically ensure that no two transactions can modify the same data at the same time.

When multiple locks are involved, deadlocks are a common problem. Consider the following code:

void Transfer(Account* from, Account* to, int amount) {
  from->lock->Acquire();
  to->lock->Acquire();

  from->balance -= amount;
  to->balance += amount;

  to->lock->Release();
  from->lock->Release();
}

The code above can deadlock (How? And how do you fix it?). This is what the code looks like with transactions:

void Transfer(Account* from, Account* to, int amount) {
  BeginTransaction();

  from->balance -= amount;
  to->balance += amount;

  EndTransaction();
}

Transactions haven't really made problem disappear... They have merely changed who must solve it - now the transactional memory system must prevent deadlock, instead of the user.

The Aforementioned Magic

Transactions may seem magical, but the concepts behind them are actually pretty simple (most magic is like this :). The idea is to check if any of a transaction's data dependencies have been violated by another transaction. If so, we abort the transaction, undoing everything it's done, and restart the transaction.

Here's the idea again, in more detail:

So how are transactions really implemented? You could implement them in software, but you'd have to copy a lot of data around and check for data dependency violations in software. It'd be pretty slow.

Instead, transactional memory systems typically depend on hardware support. The trick is to use each processor's cache to buffer writes, and to listen in on cache coherence messages between processors to detect interference. I'm not going to go into much detail here for two reasons: one, the details are mostly computer architecture related (and this is an operating systems course), and two, the computer architects haven't figured out all the details yet. :) Transactional memories are a hot topic in computer architecture research right now - it will be a while before they're ready for use by the general public.

Transactional memories provide a form of speculation. Speculation is a big word that roughly translates to "I'm going to go ahead and do this, even though it might be the wrong thing to do. If it really is the wrong thing to do, I'll make it look like it never happened." This is a simple, yet very powerful concept.