CS 208 s22 — Synchronization
1 Thread Safe Code
A function is said to be thread-safe if and only if it will always produce correct results when called repeatedly from multiple concurrent threads.
One class of thread-unsafe code are functions that do not protect shared variables
/* * badcount.c - An improperly synchronized counter program */ /* WARNING: This code is buggy! */ #include "csapp.h" void *thread(void *vargp); /* Thread routine prototype */ /* Global shared variable */ volatile long count = 0; /* Counter */ int main(int argc, char **argv) { long niters; pthread_t tid1, tid2; /* Check input argument */ if (argc != 2) { printf("usage: %s <niters>\n", argv[0]); exit(0); } niters = atoi(argv[1]); /* Create threads and wait for them to finish */ Pthread_create(&tid1, NULL, thread, &niters); Pthread_create(&tid2, NULL, thread, &niters); Pthread_join(tid1, NULL); Pthread_join(tid2, NULL); /* Check result */ if (count != (2 * niters)) printf("BOOM! count=%ld\n", count); else printf("OK count=%ld\n", count); return 0; } /* Thread routine */ void *thread(void *vargp) { long i; long niters = *((long *) vargp); for (i = 0; i < niters; i++) count++; return NULL; }
What can go wrong when we run this code?
❯❯❯ for i in {1..10}; do ./badcount 1000; done OK count=2000 OK count=2000 OK count=2000 OK count=2000 OK count=2000 OK count=2000 OK count=2000 OK count=2000 OK count=2000 OK count=2000 ❯❯❯ for i in {1..10}; do ./badcount 10000; done OK count=20000 OK count=20000 OK count=20000 BOOM! count=17674 OK count=20000 OK count=20000 BOOM! count=16438 OK count=20000 OK count=20000 OK count=20000 ❯❯❯ for i in {1..10}; do ./badcount 100000; done BOOM! count=154306 BOOM! count=112723 BOOM! count=115288 BOOM! count=141942 BOOM! count=123803 BOOM! count=119012 BOOM! count=154541 BOOM! count=119871 BOOM! count=129058 BOOM! count=120441
- The global variable
count
can end up with the wrong value! And it appears to get more likely the more times we incrementcount
(i.e., the longer our program runs)
- The global variable
How does
count
get the incorrect value? It might help to consider the assembly for incrementingcount
in thethread
function:0x0000000000401d63 <+29>: mov 0x204546(%rip),%rax # 0x6062b0 <count> 0x0000000000401d6a <+36>: add $0x1,%rax 0x0000000000401d6e <+40>: mov %rax,0x20453b(%rip) # 0x6062b0 <count>
- It takes three steps:
- We load the global variable into a register
- We add one to the register
- We store the resulting register value back into memory
- If these three steps happen without interruption, then everything is fine
- But the system makes no guarantee this will be the case!
- Thread A reads
count
as 0, then thread B readscount
as 0, then thread A adds 1 and writes 1 tocount
, then thread B adds 1 and writes 1 tocount
- The first write is lost due to how the operations were interleaved
- When a program's behavior depends on how accesses to shared data are interleaved, we say there is a data race or race condition
- Thread A reads
- It takes three steps:
1.1 Data Races
- a multi-threaded program's execution depends on how instructions are interleaved
- threads sharing data may be affected by race conditions (or data races)
Thread A | Thread B |
---|---|
x = 1 |
x = 2 |
- What are the final possible values of
x
? Result depends on which thread "wins." - Suppose
y=12
initially:
Thread A | Thread B |
---|---|
x = y + 1 |
y = y * 2 |
- What are the final possible values of
x
? Either 13 or 25 depending on which thread goes first - Suppose
x = 0
initially:
Thread A | Thread B |
---|---|
x = x + 1 |
x = x + 2 |
- What are the final possible values of
x
? Remember that register values are preserved for each thread:
Interleaving 1 | Interleaving 2 | Interleaving 3 | |||
---|---|---|---|---|---|
load x into r1 |
load x into r1 |
load x into r1 |
|||
add 1 to r1 |
load x into r1 |
load x into r1 |
|||
store r1 in x |
add 1 to r1 |
add 1 to r1 |
|||
load x into r1 |
add 2 to r1 |
add 2 to r1 |
|||
add 2 to r1 |
store r1 in x |
store r1 in x |
|||
store r1 in x |
store r1 in x |
store r1 in x |
- interleaving 1 results in
x == 3
- interleaving 2 results in
x == 2
- interleaving 3 results in
x == 1
1.2 Mutual Exclusion
We might be tempted to try to fix this with a boolean variable
/* * goodcount.c - A correctly synchronized counter program * using pthread mutex */ #include "csapp.h" #include <stdbool.h> void *thread(void *vargp); /* Thread routine prototype */ /* Global shared variables */ volatile long count = 0; /* Counter */ bool adding_to_count = false; int main(int argc, char **argv) { long niters; pthread_t tid1, tid2; /* Check input argument */ if (argc != 2) { printf("usage: %s <niters>\n", argv[0]); exit(0); } niters = atoi(argv[1]); /* Create threads and wait for them to finish */ pthread_mutex_init(&mutex, NULL); /* mutex = 1 */ Pthread_create(&tid1, NULL, thread, &niters); Pthread_create(&tid2, NULL, thread, &niters); Pthread_join(tid1, NULL); Pthread_join(tid2, NULL); /* Check result */ if (count != (2 * niters)) printf("BOOM! count=%ld\n", count); else printf("OK count=%ld\n", count); return 0; } /* Thread routine */ void *thread(void *vargp) { long i; long niters = *((long *)vargp); for (i = 0; i < niters; i++) { while (adding_to_count) {/* wait in this loop */} adding_to_count = true; count++; adding_to_count = false; } return NULL; }
- But all we've done is created a data race on
adding_to_count
- We want something like this boolean that we can set and unset without interruption
- This requires hardware support: some instruction that the system guarentees is atomic
One example is a compare-and-swap (CAS) instruction, outlined in pseudocode below
function CAS(p: pointer to int, old: int, new: int) if *p ≠ old return false *p ← new return true
- Once we have an atomic operation like this, we can use it to build concurrency primitives—structures that allow concurrent operations to by synchronized
- One common example is the mutual exclusion lock or mutex
- It has two states: locked and unlocked
- A thread can try to acquire the lock
- If it's unlocked, the thread locks it
- If it's locked, the thread blocks until it can acquire the lock
- This is where compare-and-swap (or a similar atomic instruction) comes in
- Atomically check if the mutex is unlocked, and if so, set it to be locked
Relatively straightforward to make thread-safe: protect the shared variable with a mutex
/* * goodcount.c - A correctly synchronized counter program * using pthread mutex */ #include "csapp.h" void *thread(void *vargp); /* Thread routine prototype */ /* Global shared variables */ volatile long count = 0; /* Counter */ pthread_mutex_t mutex; /* Mutex that protects counter */ int main(int argc, char **argv) { long niters; pthread_t tid1, tid2; /* Check input argument */ if (argc != 2) { printf("usage: %s <niters>\n", argv[0]); exit(0); } niters = atoi(argv[1]); /* Create threads and wait for them to finish */ pthread_mutex_init(&mutex, NULL); Pthread_create(&tid1, NULL, thread, &niters); Pthread_create(&tid2, NULL, thread, &niters); Pthread_join(tid1, NULL); Pthread_join(tid2, NULL); /* Check result */ if (count != (2 * niters)) printf("BOOM! count=%ld\n", count); else printf("OK count=%ld\n", count); return 0; } /* Thread routine */ void *thread(void *vargp) { long i; long niters = *((long *)vargp); for (i = 0; i < niters; i++) { pthread_mutex_lock(&mutex); count++; pthread_mutex_unlock(&mutex); } return NULL; }
- The downside is that synchronization operations slow down the code (but we probably care more about getting a correct result than getting an incorrect result fast)