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 increment count (i.e., the longer our program runs)
    • How does count get the incorrect value? It might help to consider the assembly for incrementing count in the thread 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:
        1. We load the global variable into a register
        2. We add one to the register
        3. 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 reads count as 0, then thread A adds 1 and writes 1 to count, then thread B adds 1 and writes 1 to count
        • 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

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)