CS110: Principles of Computer Systems
Winter 2021-2022
Stanford University
Instructors: Nick Troccoli and Jerry Cain
https://commons.wikimedia.org/wiki/File:An_illustration_of_the_dining_philosophers_problem.png
Introduction to Threads
Mutexes and Condition Variables
Semaphores
Multithreading Patterns
assign5: implement your own multithreaded news aggregator to quickly fetch news from the web!
// Assume multiple threads share this same mutex
mutex myLock;
...
myLock.lock();
// only one thread can be executing here at a time
myLock.unlock()
When a thread calls lock():
// Assume multiple threads share this same mutex
mutex myLock;
...
myLock.lock();
// only one thread can be executing here at a time
myLock.unlock()
// Assume multiple threads share this same mutex
mutex myLock;
...
myLock.lock();
// only one thread can be executing here at a time
myLock.unlock()
static void sellTickets(size_t id, size_t& remainingTickets) {
while (true) {
if (remainingTickets == 0) break;
remainingTickets--;
sleep_for(500); // simulate "selling a ticket"
cout << oslock << "Thread #" << id << " sold a ticket (" << remainingTickets << " remain)." << endl << osunlock;
}
cout << oslock << "Thread #" << id << " sees no remaining tickets to sell and exits." << endl << osunlock;
}
int main(int argc, const char *argv[]) {
thread ticketAgents[kNumTicketAgents];
size_t remainingTickets = 250;
mutex counterLock;
for (size_t i = 0; i < kNumTicketAgents; i++) {
ticketAgents[i] = thread(sellTickets, i, ref(remainingTickets), ref(counterLock));
}
...
}
(It turns out that the mutex type can't be passed by copy in C++; since it doesn't make sense to make a copy of a mutex).
static void sellTickets(size_t id, size_t& remainingTickets, mutex& counterLock) {
while (true) {
size_t myTicket;
counterLock.lock();
if (remainingTickets == 0) {
counterLock.unlock();
break;
} else {
myTicket = remainingTickets;
remainingTickets--;
counterLock.unlock();
}
sleep_for(500); // simulate "selling a ticket"
cout << oslock << "Thread #" << id << " sold a ticket (" << myTicket << " remain)." << endl << osunlock;
}
...
}
static void sellTickets(size_t id, size_t& remainingTickets, mutex& counterLock) {
while (true) {
size_t myTicket;
counterLock.lock();
if (remainingTickets == 0) {
counterLock.unlock();
break;
} else {
myTicket = remainingTickets;
remainingTickets--;
counterLock.unlock();
}
sleep_for(500); // simulate "selling a ticket"
cout << oslock << "Thread #" << id << " sold a ticket (" << myTicket << " remain)." << endl << osunlock;
}
...
}
Deadlock is a situation where a thread or threads rely on mutually blocked-on resources that will never become available.
static void sellTickets(size_t id, size_t& remainingTickets, mutex& counterLock) {
while (true) {
size_t myTicket;
counterLock.lock();
if (remainingTickets == 0) {
break;
} else {
myTicket = remainingTickets;
remainingTickets--;
counterLock.unlock();
}
...
}
...
}
☐ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally?
☐ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings?
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)?
☐ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally? the ticket count.
☐ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings?
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)?
☑︎ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally? the ticket count.
☐ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings? one thread will check-and-sell a ticket at a time.
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)?
☑︎ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally? the ticket count.
☑︎ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings? one thread will check-and-sell a ticket at a time.
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)? add a mutex that must be acquired before checking-and-selling a ticket.
☑︎ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally? the ticket count.
☑︎ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings? one thread will check-and-sell a ticket at a time.
☑︎ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)? add a mutex that must be acquired before checking-and-selling a ticket.
https://commons.wikimedia.org/wiki/File:An_illustration_of_the_dining_philosophers_problem.png
static void philosopher(size_t id, mutex& left, mutex& right) {
...
}
int main(int argc, const char *argv[]) {
mutex forks[kNumForks];
thread philosophers[kNumPhilosophers];
for (size_t i = 0; i < kNumPhilosophers; i++) {
mutex& left = forks[i];
mutex& right = forks[(i + 1) % kNumPhilosophers];
philosophers[i] = thread(philosopher, i, ref(left), ref(right));
}
for (thread& p: philosophers) p.join();
return 0;
}
Goal: we must encode resource constraints into our program.
Example: how many philosophers can hold a fork at the same time?
How can we encode this into our program?
One.
Let's make a mutex for each fork.
A philosopher thinks, then eats, and repeats this three times.
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) {
...
}
static void philosopher(size_t id, mutex& left, mutex& right) {
for (size_t i = 0; i < kNumMeals; i++) {
think(id);
eat(id, left, right);
}
}
A philosopher thinks, then eats, and repeats this three times.
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();
}
Spoiler: there is a race condition here that leads to deadlock.
☐ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally?
☐ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings?
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)?
☐ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally? the "forks" (mutexes).
☐ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings?
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)?
☑︎ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally?
☐ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings?
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)?
A philosopher thinks, then eats, and repeats this three times.
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();
}
Discuss with your neighbor: come up with an ordering of events that causes deadlock.
What if: all philosophers grab their left fork and then go off the CPU?
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();
}
☑︎ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally?
☐ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings? we are assuming that someone is always able to pick up 2 forks.
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)?
☑︎ Identify shared data that may be modified concurrently. What shared data is used across threads, passed by reference or globally?
☑︎ Document and confirm an ordering of events that causes unexpected behavior. What assumptions are made in the code that can be broken by certain orderings? we are assuming that someone is always able to pick up 2 forks.
☐ Use concurrency directives to force expected orderings and add constraints. How can we use mutexes, atomic operations, or other constraints to force the correct ordering(s)?
When coding with threads, you need to ensure that:
Goal: we must encode resource constraints into our program.
Example: how many philosophers can try to eat at the same time? Four.
How can we encode this into our program?
What does this look like in code?
int main(int argc, const char *argv[]) {
// NEW
size_t permits = 4;
mutex permitsLock;
mutex forks[5];
thread philosophers[5];
for (size_t i = 0; i < 5; i++) {
mutex& left = forks[i];
mutex& right = forks[(i + 1) % 5];
philosophers[i] = thread(philosopher, i, ref(left), ref(right), ref(permits), ref(permitsLock));
}
for (thread& p: philosophers) p.join();
return 0;
}
static void philosopher(size_t id, mutex& left, mutex& right,
size_t& permits, mutex& permitsLock) {
for (size_t i = 0; i < kNumMeals; i++) {
think(id);
eat(id, left, right, permits, permitsLock);
}
}
static void eat(size_t id, mutex& left, mutex& right, size_t& permits, mutex& permitsLock) {
// NEW
waitForPermission(permits, permitsLock);
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;
// NEW
grantPermission(permits, permitsLock);
left.unlock();
right.unlock();
}
static void grantPermission(size_t& permits, mutex& permitsLock) {
permitsLock.lock();
permits++;
permitsLock.unlock();
}
static void waitForPermission(size_t& permits, mutex& permitsLock) {
while (true) {
permitsLock.lock();
if (permits > 0) break;
permitsLock.unlock();
sleep_for(10);
}
permits--;
permitsLock.unlock();
}
Problem: this is busy waiting! We are unnecessarily and arbitrarily using CPU time to check when a permit is available.
A condition variable is a variable that can be shared across threads and used for one thread to notify to another thread when something happens. Conversely, a thread can also use this to wait until it is notified by another thread.
class condition_variable_any {
public:
void wait(mutex& m);
template <typename Pred> void wait(mutex& m, Pred pred);
void notify_one();
void notify_all();
};
Idea:
Now we must create a condition variable to pass by reference to all threads.
int main(int argc, const char *argv[]) {
mutex forks[kNumForks];
size_t permits = kNumForks - 1;
mutex permitsLock;
// NEW
condition_variable_any permitsCV;
thread philosophers[kNumPhilosophers];
for (size_t i = 0; i < kNumPhilosophers; i++) {
mutex& left = forks[i];
mutex& right = forks[(i + 1) % kNumForks];
philosophers[i] = thread(philosopher, i, ref(left), ref(right),
ref(permits), ref(permitsCV), ref(permitsLock));
}
for (thread& p: philosophers) p.join();
return 0;
}
For grantPermission, we must signal when we make permits go from 0 to 1.
static void grantPermission(size_t& permits, condition_variable_any& permitsCV, mutex& permitsLock) {
permitsLock.lock();
permits++;
if (permits == 1) permitsCV.notify_all();
permitsLock.unlock();
}
For waitForPermission, if no permits are available we must wait until one becomes available.
Here's what cv.wait does:
static void waitForPermission(size_t& permits, condition_variable_any& permitsCV, mutex& permitsLock) {
permitsLock.lock();
while (permits == 0) permitsCV.wait(permitsLock);
permits--;
permitsLock.unlock();
}
Next time: more condition variables and introducing semaphores