Table of Contents
Study Guide 3: Synchronization
Mutual Exclusion
Oftentimes, a multithreaded program will involve threads having shared states, meaning threads will share data with one another. After all, that is one of the main purposes of using a multithreaded program since all threads in the same process share the same address space. These are known as cooperating threads. If threads do not share any states, they are known as independent threads.
Cooperating threads pose a difficult challenge of synchronization. Since threads are not guaranteed to be scheduled in any order, there is no deterministic output which we can expect. This is known as a race condition where the output is dependent on the interleaving of operations of different threads.
To ensure cooperation between threads (i.e. synchronization), we can use atomic operations which always run to completion or not at all. Atomic operations are indivisible, meaning they cannot be stopped in the middle nor can the state be modified in the middle. On most machines, memory loads and stores are typically atomic operations.
A popular and basic way of synchronization is mutual exclusion where only one thread does a particular action at a time. In essence, one thread excludes the other while doing its task. The code that runs as part of mutual exclusion is known as the critical section. Critical sections allows for the illusion of each thread operating atomically on a shared state. To allow for maximum concurrency, critical sections should be as short as possible.
Locks
A lock is a synchronization variable that provides mutual exclusion. When one thread holds a lock, no other thread can hold it (i.e. other threads are excluded).
Locks provide two atomic operations.
Acquire / Lock
Waits until the lock is free, then mark it as busy. When returning from this call, the thread is said to be holding the lock.
Release / Unlock
Marks lock as free. When returning from this call, the thread no longer holds the lock. This can only be called if the thread is holding the lock.
When using locks, the code in between an acquire and release is the critical section. A typical workflow will involve acquiring the lock, performing memory operations on shared data, then releasing the lock.
Semaphores
A semaphore is a synchronization variable with a nonnegative integer.
Semaphores provide two atomic operations.
Down / Wait / P
Waits for semaphore to become positive, then decrements it by 1.
Up / Post / V
Increments the semaphore by 1.
Note that you are technically not allowed to examine the value of a semaphore or set it to a specific value. While most implementations will allow you to do this since it’s just a member of a struct, this is not the use case of a semaphore.
While a semaphore can be used like a lock for mutual exclusion, it has another workflow of a scheduling constraint where one thread needs to wait for another thread to terminate. The two workflows are demonstrated below.
Mutual Exclusion | Scheduling |
---|---|
1. Initialize semaphore to 1. 2. Down semaphore when entering a critical section.3. Up semaphore when exiting critical section. |
1. Initialize semaphore to 0. 2. Down semaphore in the waiting thread.3. After finishing desired work, up semaphore in the working thread. |
Synchronization Stuffs
Example 1
It’s the year 3162. Floopies are the widely recognized galactic currency. Floopies are represented in digital form only, at the Central Galactic Floopy Corporation (CGFC). You receive some inside intel from the CGFC that they have a Galaxynet server running on some old OS called x86 Ubuntu 14.04 LTS. Anyone can send requests to it. Upon receiving a request, the server creates a POSIX thread to handle the request. In particular, you are told that sending a transfer request will create a thread that will run the following function immediately, for speedy service.
// cgfc.c
typedef struct account {
/* ... */
int balance;
/* ... */
} account_t;
void transfer(account_t *donor, account_t *recipient, float amount) {
assert (donor != recipient);
if (donor->balance < amount) {
printf("Insufficient funds.\n");
return;
}
donor->balance -= amount;
recipient->balance += amount;
}
Describe how a malicious user might exploit some unintended behavior. What changes could you make to the CGFC to defend against exploits?
Solution
We observe that the money transfer is not an atomic instruction, meaning we can break down a money transfer into further lines.
donor->balance -= amount;
would be equivalent to:
temp = donor->balance; temp = temp - amount; donor->balance = temp;
likewise:
recipient->balance += amount;
would be equivalent to:
temp = recipient->balance; temp = temp + amount; recipient->balance = temp;
While the
temp = temp - amount
andtemp = temp + amount
are still not technically atomic operations, this level of granularity suffices to see race conditions.Let’s say we have two accounts of
account t*
namedalice
andbob
. We send two requests:transfer(alice, bob, 5)
andtransfer(bob, alice, 5)
. Let’s say the following interleaving of threads occurs:/* transfer(alice, bob, 5) */ /* transfer(bob, alice, 5) */ temp1 = alice->balance; temp1 = temp1 - 5; alice->balance = temp1; temp1 = bob->balance; temp1 = temp1 + 5; temp2 = bob->balance; temp2 = temp2 - 5; bob->balance = temp2; temp2 = alice->balance; temp2 = temp2 + 5; alice->balance = temp2; bob->balance = temp1;
This will result in
bob->balance
being10
whilealice->balance
being5
, essentially making money out of thin air.Another possible race condition achieves a negative balance. If there is a thread switch after the
donor->balance < amount
check succeeds, thedonor->balance
may fall below amount. However, the transaction will still go through, resulting in money transfer that the donor does not have.The solution is to simply make the entire function a critical section. A lock can be acquired at the first line of the function and released at the last line.
Example 2
A recent popular game is having issues with its servers lagging heavily due to too many players being connected at a time. Below is the code that a player runs to play on a server:
// game_server.c
void play_session(struct server* s) {
connect(s);
play();
disconnect(s);
}
After testing, it turns out that the servers can run without lagging for a max of up to
1000
players connected concurrently. How can you use synchronization to enforce a strict limit of1000
players connected at a time? Assume that a game server can create synchronization variables and share them amongst the player threads.Solution
Introduce a semaphore for each server, initialized to
1000
, to control the ability to connect to the game. A player willdown
the semaphore before connecting andup
the semaphore after disconnecting.The order here is important - downing the semaphore after connecting but before playing means that there is no block on the
connect()
call, and upping the semaphore before disconnecting could lead to “zombie” players, who were preempted before disconnecting. Both of these cases mean that the limit of 1000 could be violated.
Test and Set
Suppose the test and set function is an atomic operation.
// test_and_set.c
int value = 0;
int hello = 0;
int test_and_set(int* value) {
int old_value = *value;
*value = 1;
return old_value;
}
void print_hello() {
while (test_and_set(&value));
hello += 1;
printf("Child thread: %d\n", hello);
value = 0;
pthread_exit(0);
}
void main() {
pthread_t thread1;
pthread_t thread2;
pthread_create(&thread1, NULL, &print_hello, NULL);
pthread_create(&thread2, NULL, &print_hello, NULL);
while (test_and_set(&value));
printf("Parent thread: %d\n", hello);
value = 0;
}
Assume the following sequence of events:
- main starts running and creates both threads and is then context switched right after.
- Thread2 is scheduled and runs until after it increments hello. It is then switched.
- Thread1 runs until it is switched.
- Main thread resumes and runs until it is switched.
- Thread2 runs to completion and exits.
- Main thread runs to completion but doesn’t exit.
- Thread1 runs to completion.
Answer the following questions.
Why is this sequence of events possible?
Solution
In steps 3 and 4, the main thread and thread1 make no progress. They can only run to completion after thread2 resets value to 0.
For steps where test and set is called, what does it return?
Solution
In step 2, thread2 runs, being the first thread to modify value. Since value is initialized to 0, test and set) will return
0
and only be called once as the while loop condition will not be satisfied. Now, value is set to1
.In steps 3 and 4, both thread1 and main thread will not advance past the while loop (in their respective methods) since value is
1
. Thread2 has not yet ran its line ofvalue = 0
.After thread2 runs to completion in step 5, main thread will run to completion in step 6. Since the
value = 0
line from thread2 will have ran, test and set will now return 0 and allow main to advance. Note that main also has avalue = 0
line at the end before it exits, meaning thread1 will also run the entirety of its method since test and set will return0
.
What is the output of the program?
Solution
Child thread: 1 Parent thread: 1 Child thread: 2
What is a major issue with this implementation of synchronization?
Solution
Using a while loop with test and set involves a ton of busy waiting where the CPU is being used to essentially do nothing. We should let the thread sleep while it can’t make any progress, so the CPU is available for use by another thread.
Shared Data
This problem is designed to help you implement the wait syscall in Project 1 by implementing a wait functionality that allows the main thread to wait for another thread to finish writing some data.
Assume you don’t have access to pthread_join
for this problem. Instead, you’re going to use synchronization primitives, namely locks and semaphores.
You’re going to implement three functions to initialize the shared struct and synchronize the 2 threads. initialize shared data will initialize shared data. wait for data (called by the main thread) will block until the data is available. save data (called by the other thread) will write 4103 to the data member of shared data. Another requirement is that the shared data needs to be freed once as soon as it is no longer in use. When using synchronization primitives, the critical section should be as few lines as possible while still ensuring a race free environment.
Consider you’re given the following code
// wait_shared.c
typedef struct shared_data {
sem_t sem;
pthread_mutex_t lock;
int ref_cnt;
int data;
} shared_data_t;
void initialize_shared_data(shared_data_t* shared_data) {
_______________________;
_______________________;
_______________________;
shared_data->data = -1;
}
int wait_for_data(shared_data_t* shared_data) {
_______________________;
int data = shared_data->data;
_______________________;
int ref_cnt = --shared_data->ref_cnt;
_______________________;
if (ref_cnt == 0)
_______________________;
return data;
}
void* save_data(void* shared_pg) {
_______________________;
shared_data->data = 4103;
_______________________;
_______________________;
int ref_cnt = --shared_data->ref_cnt;
_______________________;
if (ref_cnt == 0)
_______________________;
return NULL;
}
int main() {
void *shared_data = malloc(sizeof(shared_data_t));
initialize_shared_data(shared_data);
pthread_t tid;
int error = pthread_create(&tid, NULL, &save_data, shared_data);
int data = wait_for_data(shared_data);
printf("Parent: Data is %d\n", data);
return 0;
}
Explain the purpose of each member in the shared data t struct.
Solution
sem
allows to perform the waiting itself. A waiting thread candown
the semaphore while the thread that saves the data canup
the semaphore.
lock
allows for mutual exclusion on members in the struct that can be modified by both threads.
ref_cnt
allows for reference counting, which is an indicator for how many threads still hold a reference to this struct. Once it reaches 0, we can safely deallocate it from memory.
data
is the actual data.
Fill in the missing lines in initialize shared data to correctly initialize the members of shared data.
Solution
sem
needs to be initialized to0
. When the main thread is waiting for the data to be updated it needs to block until the other thread ups the semaphore. There’s no guarantee of thread execution order, meaning the main thread could end up downing the semaphore first.
lock
is initialized with default attributes which is specified by the second argument asNULL
.
ref_cnt
is initialized to2
since there are two threads who will have access to the shared data.// wait_shared_initialize.c void initialize_shared_data(shared_data_t* shared_data) { sem_init(&shared_data->sem, 0, 0); pthread_mutex_init(&shared_data->lock, NULL); shared_data->ref_cnt = 2; shared_data->data = -1; }
Fill in the missing lines in wait for data for the main thread to correctly wait for the other thread until the data is updated.
Solution
The main thread waits for the other thread to finish updating by downing the semaphore. As initialized, the semaphore will start at
0
, meaning the main thread will wait until its value is1
before it can down it.Since
ref_cnt
can be modified by either thread concurrently, it needs to be put in a critical section by surrounding it with locks.Finally, if
ref_cnt
is0
, we need to make sure to free the shared data.// wait_shared_wait.c int wait_for_data(shared_data_t* shared_data) { sem_wait(&shared_data->sem); int data = shared_data->data; pthread_mutex_lock(&shared_data->lock); int ref_cnt = --shared_data->ref_cnt; pthread_mutex_unlock(&shared_data->lock); if (ref_cnt == 0) free(shared_data); return data; }
Fill in the missing lines in save data for the other thread to correctly update the data.
Solution
Since this function is called through
pthread_create
, we need to cast the argumentshared_pg
to ashared_data_t*
type.After we set the desired value of
shared_data->data
, weup
the the semaphore, so the parent no longer waits. Keep in mind this does not guarantee the parent will run automatically since as said before, there are no guarantees of execution orders.Just like we did in wait for data, we need to decrement the
ref_cnt
in a race free manner and make sure to free shared data if necessary.// wait_shared_save.c void* save_data(void* shared_pg) { shared_data_t* shared_data = (shared_data_t*) shared_pg; shared_data->data = 4103; sem_post(&shared_data->sem); pthread_mutex_lock(&shared_data->lock); int ref_cnt = --shared_data->ref_cnt; pthread_mutex_unlock(&shared_data->lock); if (ref_cnt == 0) free(shared_data); return NULL; }
Why does shared data->data not need to be surrounded by locks when reading and writing to it in wait for data and save data?
Solution
The semaphore guarantees that the main thread will never access
shared_data->data
before the other thread sets it. Similarly,shared_data->data
is never modified by the other thread afterwards, so the main thread is guaranteed to have the correct value by the time it is unblocked from the semaphore.
Condition Variables
Condition variables are synchronization variables that let a thread efficiently wait for a change to a shared state. They store a queue of threads such that the waiting threads are allowed to sleep inside the critical section which is in contrast to other synchronization variables like semaphores.
Condition variables are used in conjunction with a lock which together form a monitor.
Condition variables provide the following operations. For all these methods, the thread calling them must be holding the lock.
Wait
Atomically releases lock and suspends execution of calling thread.
Signal
Wake up the next waiting thread in the queue.
Broadcast
Wake up all waiting threads in the queue.
Infinite Synchronized Buffer
Consider an infinite synchronized buffer problem of vending machines where there’s a producer and consumer. “Infinite” refers to the fact that the machine has no limit on how much coke it can hold.
Lock bufferLock;
ConditionVar bufferCV;
Producer() {
bufferLock.acquire();
put_1_coke_in_machine();
bufferCV.signal();
bufferLock.release();
}
Consumer() {
bufferLock.acquire();
while (machine_is_empty())
bufferCV.wait(bufferLock);
take_1_coke_out();
bufferLock.release();
}
By using condition variables, we can avoid busy waiting inside a critical section.
Semantics
Different semantics define signal
and wait
differently.
When a thread is signaled using Hoare semantics, the ownership of the lock is immediately transferred to the waiting thread. Furthermore, the execution of this thread resumes immediately. After this thread releases the lock, ownership of the lock is transferred back to the signaling thread. As a result, signaling in Hoare semantics can be thought of as an atomic operation.
On the other hand, Mesa semantics makes no guarantees about the execution order when a thread is signaled.
This leads to a subtle but important difference in the code. Using the same bounded buffer example as before:
Hoare Semantics
if (machine_is_empty()) bufferCV.wait(bufferLock); take_1_coke_out()
Mesa Semantics
while (machine_is_empty()) bufferCV.wait(bufferLock); take_1_coke_out()
We can use a if
statement for Hoare semantics because we’ve guaranteed an execution order between the waiting and signaling thread. However, that is not the case for Mesa semantics, meaning between the time the waiting thread is signalled and it actually executes, some other thread could have run in between and rendered the condition false again, so a while loop is necessary.
Condition Check
Will this program compile/run?
// cv_hello.c pthread_mutex_t lock; pthread_cond_t cv; int hello = 0; void print_hello() { hello += 1; printf("First line (hello=%d)\n", hello); pthread_cond_signal(&cv); pthread_exit(0); } void main() { pthread_t thread; pthread_create(&thread, NULL, (void *) &print_hello, NULL); while (hello < 1) pthread_cond_wait(&cv, &lock); printf("Second line (hello=%d)\n", hello); }
Solution
This program will not run because the thread needs to be holding a lock before performing a condition variable operation like
wait
orsignal
.Moreover, the lock and condition variable was never initialized, which would lead to undefined behavior.
Fill in the blanks such that the program always prints “Yeet Haw”. Assume the system behaves with Mesa semantics.
// cv_howdy.c int ben = 0; _______________________; _______________________; void *helper(void *arg) { _______________________; ben += 1; _______________________; _______________________; pthread_exit(NULL); } void main() { pthread_t thread; pthread_create(&thread, NULL, &helper, NULL); pthread_yield(); _______________________; _______________________; _______________________; if (ben == 1) printf("Yeet Haw\n"); else printf("Yee Howdy\n"); _______________________; }
Solution
// cv_howdy_sol.c int ben = 0; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cv = PTHREAD_COND_INITIALIZER; void *helper(void *arg) { pthread_mutex_lock(&lock); ben += 1; pthread_cond_signal(&cv); pthread_mutex_unlock(&lock); pthread_exit(NULL); } void main() { pthread_t thread; pthread_create(&thread, NULL, &helper, NULL); pthread_yield(); pthread_mutex_lock(&lock); while (ben != 1) pthread_cond_wait(&cv, &lock); if (ben == 1) printf("Yeet Haw\n"); else printf("Yee Howdy\n"); pthread_mutex_unlock(&lock); }
Office Hours Queue
Suppose we want to use condition variables to control access to a CSC4103 (digital) office hours room for three types of people: students, TAs, and professors. A person can attempt to enter the room (or will wait outside until their condition is met), and after entering the room they can then exit the room. The follow are each type’s conditions:
- Suppose professors get easily distracted and so they need solitude, with no other students, TAs, or professors in the room, in order to enter the room.
- TAs don’t care about students being inside and will wait if there is a professor inside, but there can only be up to 2 TAs inside (any more would clearly be imposters from CSC3380 or CSC1254).
- Students don’t care about other students or TAs being in the room, but will wait if there is a professor.
- Students and TAs are polite to professors, and will let a waiting professor in first.
To summarize the constraints:
- Professor must wait if anyone else is in the room
- TA must wait if there are already 2 TAs in the room
- TA must wait if there is a professor in the room or waiting outside
- Students must wait if there is a professor in the room or waiting outside
// oh.c
typedef struct room {
pthread_mutex_t lock;
pthread_cond_t student_cv;
int waiting_students, active_students;
pthread_cond_t ta_cv, prof_cv;
int waiting_tas, active_tas;
int waiting_profs, active_profs;
} room_t;
void enter_room(room_t* room, int mode) {
pthread_mutex_lock(&room->lock);
if (mode == 0) {
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
room->active_students++;
} else if (mode == 1) {
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
room->active_tas++;
} else {
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
room->active_profs++;
}
pthread_mutex_unlock(&room->lock);
}
void exit_room(room_t* room, int mode) {
pthread_mutex_lock(&room->lock);
if (mode == 0) {
room->active_students--;
_______________________;
_______________________;
_______________________;
} else if (mode == 1) {
room->active_tas--;
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
} else {
room->active_profs--;
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
_______________________;
}
pthread_mutex_unlock(&room->lock);
}
Fill in
enter_room
.Solution
// oh_enter.c void enter_room(room_t* room, int mode) { pthread_mutex_lock(&room->lock); if (mode == 0) { while (room->active_profs + room->waiting_profs > 0) { room->waiting_students++; pthread_cond_wait(&room->student_cv, &room->lock); room->waiting_students--; } room->active_students++; } else if (mode == 1) { while (room->active_profs + room->waiting_profs > 0 || room->active_tas >= TA_LIMIT) { room->waiting_tas++; pthread_cond_wait(&room->ta_cv, &room->lock); room->waiting_tas--; } room->active_tas++; } else { while (room->active_profs + room->active_tas + room->active_students > 0) { room->waiting_profs++; pthread_cond_wait(&room->prof_cv, &room->lock); room->waiting_profs--; } room->active_profs++; } pthread_mutex_unlock(&room->lock); }
Fill in
exit_room
.Solution
// oh_exit.c void exit_room(room_t* room, int mode) { pthread_mutex_lock(&room->lock); if (mode == 0) { room->active_students--; if (room->active_students + room->active_tas == 0 && room->waiting_profs > 0) { pthread_cond_signal(&room->prof_cv); } } else if (mode == 1) { room->active_tas--; if (room->active_students + room->active_tas == 0 && room->waiting_profs > 0) { pthread_cond_signal(&room->prof_cv); } else if (room->active_tas < TA_LIMIT && room->waiting_tas) { pthread_cond_signal(&room->ta_cv); } } else { room->active_profs--; if (room->waiting_profs) { pthread_cond_signal(&room->prof_cv); } else { if (room->waiting_tas) pthread_cond_broadcast(&room->ta_cv); if (room->waiting_students) pthread_cond_broadcast(&room->student_cv); } } pthread_mutex_unlock(&room->lock); }