CS110: Principles of Computer Systems

Spring 2021
Instructors Roz Cyrus and Jerry Cain

PDF

Reviewing the Ticket Agents Example

  • The ticketAgent thread routine accepts an id number (used for logging purposes) and a reference to the remainingTickets.
  • It continually polls remainingTickets to see if any tickets remain, and if so, answers the phone, sells a ticket, and publishes a little note about the ticket sale to cout.
  • handleCall, shouldTakeBreak, and takeBreak each introduce short, random delays and guarantee each test run is different than prior ones. Full program: right here.
static void ticketAgent(size_t id, size_t& remainingTickets) {
  while (remainingTickets > 0) {
    handleCall(); // sleep for a small amount of time to emulate conversation time.
    remainingTickets--;
    cout << oslock << "Agent #" << id << " sold a ticket! (" << remainingTickets 
         << " more to be sold)." << endl << osunlock;
    if (shouldTakeBreak()) // flip a biased coin
      takeBreak();         // if comes up heads, sleep for a random time to take a break
  }
  cout << oslock << "Agent #" << id << " notices all tickets are sold, and goes home!" 
       << endl << osunlock;
}

int main(int argc, const char *argv[]) {
  thread agents[10];
  size_t remainingTickets = 250;
  for (size_t i = 0; i < 10; i++)
    agents[i] = thread(ticketAgent, 101 + i, ref(remainingTickets));
  for (thread& agent: agents) agent.join();
  cout << "End of Business Day!" << endl;
  return 0;
}

Review of Ticket Agents Example

  • Presented below right is the abbreviated output of a confused-ticket-agents run.
  • In its current state, the program suffers from a serious race condition.
  • Why? Because remainingTickets > 0 and remainingTickets-- aren't guaranteed to execute within the same time slice.
  • If a thread evaluates remainingTickets > 0 to be true and commits to selling a ticket, the ticket might not be there by the time it executes the decrement. That's because the thread may be swapped off the
    CPU after the decision to sell
    but before the sale, and during
    the dead time, other threads—
    perhaps the nine others—all
    might get the CPU and do
    precisely the same thing.
  • The solution? Ensure the
    decision to sell and the sale
    itself are executed without
    competition.
poohbear@myth61:$ ./confused-ticket-agents 
Agent #110 sold a ticket! (249 more to be sold).
Agent #104 sold a ticket! (248 more to be sold).
Agent #106 sold a ticket! (247 more to be sold).
// some 245 lines omitted for brevity 
Agent #107 sold a ticket! (1 more to be sold).
Agent #103 sold a ticket! (0 more to be sold).
Agent #105 notices all tickets are sold, and goes home!
Agent #104 notices all tickets are sold, and goes home!
Agent #108 sold a ticket! (4294967295 more to be sold).
Agent #106 sold a ticket! (4294967294 more to be sold).
Agent #102 sold a ticket! (4294967293 more to be sold).
Agent #101 sold a ticket! (4294967292 more to be sold).
// carries on for a very, very, very long time

Analysis of Ticket Agents Example

  • Before we solve this problem, we should really understand why remainingTickets-- itself isn't even thread-safe.
    • C++ statements aren't inherently atomic. Virtually all C++ statements—even ones as simple as remainingTickets--—compile to multiple assembly code instructions.
    • Assembly code instructions are atomic, but C++ statements are not.
    • g++ on the myths compiles remainingTickets-- to five assembly code instructions, as with:




       
    • The first two lines drill through the ticketsRemaining reference to load a copy of the ticketsRemaining held in main into %rax. The third line decrements that copy, and the last two write the decremented copy back to the ticketsRemaining variable held in main.
0x0000000000401a9b <+36>:    mov    -0x20(%rbp),%rax
0x0000000000401a9f <+40>:    mov    (%rax),%eax
0x0000000000401aa1 <+42>:    lea    -0x1(%rax),%edx
0x0000000000401aa4 <+45>:    mov    -0x20(%rbp),%rax
0x0000000000401aa8 <+49>:    mov    %edx,(%rax)

Improvements to Ticket Agents Example

  • We need to guarantee that the code that tests for remaining tickets, the code that sells a ticket, and everything in between are executed as part of one large transaction, without interference from other threads. Restated, we must guarantee that at no other threads are permitted to even examine the value of ticketsRemaining if another thread is staged to modify it.
  • One solution: provide a directive that allows a thread to ask that it not be swapped off the CPU while it's within a block of code that should be executed transactionally.
    • That, however, is not an option, and shouldn't be.
    • That would grant too much power to threads, which could abuse the option and block other threads from running for an indeterminate amount of time.
  • The other option is to rely on a concurrency directive that can be used to prevent more than one thread from being anywhere in the same critical region at one time. That concurrency directive is the mutex, and in C++ it looks like this:
class mutex {
public:
  mutex();        // constructs the mutex to be in an unlocked state
  void lock();    // acquires the lock on the mutex, blocking until it's unlocked
  void unlock();  // releases the lock and wakes up another threads trying to lock it
};

Improvements to Ticket Agents Example

  • The name mutex is a contraction of the words mutual and exclusion. It's so named because its primary use it to mark the boundaries of a critical region—that is, a stretch of code where at most one thread is permitted to be at any one moment.
    • Restated, a thread executing code within a critical region enjoys exclusive access.
  • The constructor initializes the mutex to be in an unlocked state.
  • The lock method will eventually acquire a lock on the mutex.
    • If the mutex is in an unlocked state, lock will lock it and return immediately.
    • If the mutex is in a locked state (presumably because another thread called lock but has yet to unlock), lock will pull the calling thread off the CPU and render it ineligible for processor time until it's notified the lock was released.
  • The unlock method will release the lock on a mutex. The only thread qualified to release the lock on the mutex is the one that holds it.
class mutex {
public:
  mutex();        // constructs the mutex to be in an unlocked state
  void lock();    // acquires the lock on the mutex, blocking until it's unlocked
  void unlock();  // releases the lock and wakes up another threads trying to lock it
};

Final Solution to Ticket Agents Example

  • We can declare a single mutex beside the declaration of remainingTickets in main, and we can use that mutex to mark the boundaries of the critical region.
  • This requires the mutex also be shared by reference with the ticketAgent thread routine so that all child threads compete to acquire the same lock.
  • The new ticketAgent thread routine looks like this:










     
  • When do you need a mutex?
    • When there are multiple threads writing to a variable.

    • When there is a thread writing and one or more threads reading

    • Why do you not need a mutex when there are no writers (only readers)?

static void ticketAgent(size_t id, size_t& remainingTickets, mutex& ticketsLock) {
  while (true) {
    ticketsLock.lock();
    if (remainingTickets == 0) break;
    handleCall();
    remainingTickets--;
    cout << oslock << "Agent #" << id << " sold a ticket! (" << remainingTickets 
         << " more to be sold)." << endl << osunlock;
    ticketsLock.unlock();
    if (shouldTakeBreak()) 
      takeBreak();
  }
  ticketsLock.unlock();
  cout << oslock << "Agent #" << id << " notices all tickets are sold, and goes home!" 
       << endl << osunlock;
}

critical region

Multithreading and Dining Philosophers

  • The Dining Philosophers Problem
    • This is a canonical multithreading example used to illustrate the potential for deadlock and how to avoid it.
      • Five philosophers sit around a table, each in front of a big plate of spaghetti.
      • A single fork (utensil, not system call) is placed between neighboring philosophers.
      • Each philosopher comes to the table to think, eat, think, eat, think, and eat. That's three square meals of spaghetti after three extended think sessions.
      • Each philosopher keeps to himself as he thinks. Sometime he thinks for a long time, and sometimes he barely thinks at all.
      • After each philosopher has thought for a while, he proceeds to eat one of his three daily meals. In order to eat, he must grab hold of two forks—one on his left, then one on his right. With two forks in hand, he chows on spaghetti to nourish his big, philosophizing brain. When he's full, he puts down the forks in the same order he picked them up and returns to thinking for a while.
    • The next two slides present the core of our first stab at the program that codes to this problem description. (The full program is right here.)

Multithreading and Dining Philosophers

  • The Dining Philosophers Problem
    • The program models each of the forks as a mutex, and each philosopher either holds a fork or doesn't. By modeling the fork as a mutex, we can rely on mutex::lock to model a thread-safe grab and mutex::unlock to model a thread-safe release.
static void philosopher(size_t id, mutex& left, mutex& right) {
  for (size_t i = 0; i < 3; i++) {
    think(id);
    eat(id, left, right);
  }
}

int main(int argc, const char *argv[]) {
  mutex forks[5];
  thread philosophers[5];
  for (size_t i = 0; i < 5; i++) {
    mutex& left = forks[i], & right = forks[(i + 1) % 5];
    philosophers[i] = thread(philosopher, i, ref(left), ref(right));
  }
  for (thread& p: philosophers) p.join();
  return 0;
}

images courtesy of Roz Cyrus

Multithreading and Dining Philosophers

  • The Dining Philosophers Problem
    • The implementation of think is straightforward. It's designed to emulate the time a philosopher spends thinking without interacting with forks or other philosophers.
    • The implementation of eat is almost as straightforward, provided you understand the thread subroutine is being fed references to the two forks he needs to acquire if he's permitted to eat.
static void think(size_t id) {
  cout << oslock << id << " starts thinking." << endl << osunlock;
  sleep_for(getThinkTime());
  cout << oslock << id << " all done thinking. " << endl << osunlock;
}

static void eat(size_t id, mutex& left, mutex& right) {
  left.lock();
  right.lock();
  cout << oslock << id << " starts eating om nom nom nom." << endl << osunlock;
  sleep_for(getEatTime());
  cout << oslock << id << " all done eating." << endl << osunlock;
  left.unlock();
  right.unlock();
}

Multithreading, Dining Philosophers, Deadlock

  • The program appears to work well (we'll run it several times), but it doesn't guard against this: each philosopher emerges from deep thought, successfully grabs the fork to his left, and is then forced off the processor because his time slice is up.
  • If all five philosopher threads are subjected to the same scheduling pattern, each would be stuck waiting for a second fork to become available.
    That's a real deadlock threat.
  • Deadlock is more or less guaranteed if we insert a sleep_for call
    in between the two calls to lock, as we have in the version of eat
    presented below.
    • We should be able to insert a sleep_for call anywhere in a
      thread routine. If it surfaces an concurrency issue, then you have a larger problem to be solved.
static void eat(size_t id, mutex& left, mutex& right) {
  left.lock();
  sleep_for(5000);  // artificially force off the processor
  right.lock();
  cout << oslock << id << " starts eating om nom nom nom." << endl << osunlock;
  sleep_for(getEatTime());
  cout << oslock << id << " all done eating." << endl << osunlock;
  left.unlock();
  right.unlock();
}

Multithreading, Dining Philosophers, Deadlock

  • When coding with threads, you need to ensure that:
    • there are no race conditions, even if they rarely cause problems, and
    • there's zero threat of deadlock, lest a subset of threads forever starve for processor time.
  • mutexes are generally the solution to race conditions, as we've seen with the ticket agent example. We can use them to mark the boundaries of critical regions and limit the number of threads present within them to be at most one.
  • Deadlock can be programmatically prevented by implanting directives to limit the number of threads competing for a shared resource, like, you know, utensils.
    • We could, for instance, recognize that it's impossible for three philosophers to be eating at the same time. That means we could limit the number of philosophers who have permission to grab forks to a mere 2.
    • We could also argue it's okay to let four—though certainly not all five—philosophers grab forks, knowing that at least one will successfully grab both.
      • My personal preference? Impose a limit of four.
      • My rationale? Implant the minimal amount of bottlenecking needed to remove the threat of deadlock, and trust the OS and thread manager to otherwise make good choices.

Lecture 10: Threads and Race Conditions

By Jerry Cain

Lecture 10: Threads and Race Conditions

  • 1,697