CS110: Principles of Computer Systems
Winter 2021-2022
Stanford University
Instructors: Nick Troccoli and Jerry Cain
https://comic.browserling.com/53
Introduction to Threads
Mutexes and Condition Variables
Semaphores
Multithreading Patterns
assign5: implement your own multithreaded news aggregator to quickly fetch news from the web!
We can have concurrency within a single process using threads: independent execution sequences within a single process.
thread
A thread object can be spawned to run the specified function with the given arguments.
thread myThread(myFunc, arg1, arg2, ...);
To pass objects by reference to a thread, use the ref() function:
void myFunc(int& x, int& y) {...}
thread myThread(myFunc, ref(arg1), ref(arg2));
thread
For multiple threads, we must wait on a specific thread one at a time:
thread friends[5];
...
for (size_t i = 0; i < 5; i++) {
friends[i].join();
}
To wait on a thread to finish, use the .join() method:
thread myThread(myFunc, arg1, arg2);
... // do some work
// Wait for thread to finish (blocks)
myThread.join();
cout << oslock << "Hello, world!" << endl << osunlock;
for (size_t i = 0; i < kNumFriends; i++) {
friends[i] = thread(greeting, ref(i));
}
_start
greeting
main
argc
argv
i
args
args
args
args
args
args
created thread stacks
main stack
Here, we can just pass by copy instead. But keep an eye out for consequences of shared memory!
int main(int argc, const char *argv[]) {
thread ticketAgents[kNumTicketAgents];
size_t remainingTickets = 250;
for (size_t i = 0; i < kNumTicketAgents; i++) {
ticketAgents[i] = thread(sellTickets, i, ref(remainingTickets));
}
for (thread& ticketAgent: ticketAgents) {
ticketAgent.join();
}
cout << "Ticket selling done!" << endl;
return 0;
}
$ ./confused-ticket-agents
....
Thread #1 sold a ticket (7 remain).
Thread #5 sold a ticket (6 remain).
Thread #3 sold a ticket (4 remain).
Thread #4 sold a ticket (4 remain).
Thread #2 sold a ticket (3 remain).
Thread #8 sold a ticket (1 remain).
Thread #9 sold a ticket (0 remain).
Thread #0 sold a ticket (0 remain).
Thread #0 sees no remaining tickets to sell and exits.
Thread #9 sees no remaining tickets to sell and exits.
Thread #8 sees no remaining tickets to sell and exits.
Thread #6 sold a ticket (18446744073709551615 remain).
Thread #7 sold a ticket (18446744073709551613 remain).
Thread #1 sold a ticket (18446744073709551613 remain).
...
Output
There is a race condition here!
static void sellTickets(size_t id, size_t& remainingTickets) {
while (remainingTickets > 0) {
sleep_for(500); // simulate "selling a ticket"
remainingTickets--;
...
}
static void sellTickets(size_t id, size_t& remainingTickets) {
while (remainingTickets-- > 0) {
sleep_for(500); // simulate "selling a ticket"
...
}
// gets remainingTickets
0x0000000000401a9b <+36>: mov -0x20(%rbp),%rax
0x0000000000401a9f <+40>: mov (%rax),%eax
// Decrements by 1
0x0000000000401aa1 <+42>: lea -0x1(%rax),%edx
// Saves updated value
0x0000000000401aa4 <+45>: mov -0x20(%rbp),%rax
0x0000000000401aa8 <+49>: mov %edx,(%rax)
// gets remainingImages
0x0000000000401a9b <+36>: mov -0x20(%rbp),%rax
0x0000000000401a9f <+40>: mov (%rax),%eax
// Decrements by 1
0x0000000000401aa1 <+42>: lea -0x1(%rax),%edx
// Saves updated value
0x0000000000401aa4 <+45>: mov -0x20(%rbp),%rax
0x0000000000401aa8 <+49>: mov %edx,(%rax)
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;
}
A critical section is a section of code that should be executed transactionally, without competition from other threads.
This means we want critical sections to be atomic; to other observers, it has either executed or not.
If we can fix this issue here, then sellTickets will be a thread-safe function; it will always execute correctly, even when called concurrently from multiple threads.
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;
}
We will put a lock here with only one key to unlock it. We will make it such that the lock must be unlocked to proceed.
🔓🔑
If a thread gets here and the key is available, the thread takes the key, locks the lock, and runs the code while holding onto the key.
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;
}
🔑
thread #1
🔒
If another thread gets here and the lock is locked, it must wait its turn.
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;
}
thread #2
🔑
thread #1
🔒
waiting...
When the executing thread gets here, it unlocks the lock and returns the key. Now another thread may use it.
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;
}
thread #2
thread #1
now it's my turn!
🔓🔑
When the executing thread gets here, it unlocks the lock and returns the key. Now another thread may use it.
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;
}
thread #2
thread #1
🔑
🔒
// 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()
If you don't pass by reference, every thread will get its own mutex copy (its own lock-and-key); thus every thread will be able to acquire its own lock and run the code!
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();
}
...
}
...
}
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();
}
...
}
...
}
thread #1
🔓🔑
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();
}
...
}
...
}
thread #1
🔒
🔑
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();
}
...
}
...
}
thread #1
🔒
🔑
"See ya!"
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();
}
...
}
...
}
thread #1
🔒
🔑
"See ya!"
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();
}
...
}
...
}
thread #2
Huh. Guess I gotta wait for the key.
🔒
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();
}
...
}
...
}
thread #2
*100 years later*
🔒
🤨
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) {
counterLock.unlock();
break;
} else {
myTicket = remainingTickets;
remainingTickets--;
counterLock.unlock();
}
...
}
...
}
We can fix the issue here by making sure to unlock in all scenarios where a thread no longer needs the lock, including when we exit the loop.
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));
}
...
}
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;
}
...
}
Other times you need a mutex:
Why do you not need a mutex when there are no writers (only readers)?
https://www.flickr.com/photos/ofsmallthings/8220574255
A mutex is a variable type that is like a "locked door".
You can lock the door:
- if it's unlocked, you go through the door and lock it
- if it's locked, you wait for it to unlock first
If you most recently locked the door, you can unlock the door:
- door is now unlocked, another may go in now
A mutex is a variable type that is like a "ball in a bucket".
To proceed, you must take the ball from the bucket and hold onto it.
If you find the bucket is empty, you must wait for the ball to be returned.
When you are done executing, you return the ball to the bucket.
⚾️
🪣
☐ 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.
Next time: adding more constraints with condition variables.