wisconsin
Lecture 11 - Concurrency Intro (Threads)

Lecture 11 - Concurrency Intro (Threads)

Curated by Last modified: 9 min read

Instructor: Tej Chajed Topics: Concurrency, threads, race conditions, synchronization, locks


Motivation for Concurrency

CPU performance growth has slowed dramatically:

  • 1978-1986: 25%/year improvement
  • 1986-2003: 52%/year improvement
  • 2003-2011: 23%/year
  • 2011-2015: 12%/year
  • 2015-2018: 3.5%/year

Moore’s Law continues — transistor counts still double roughly every two years — but individual core speed has plateaued.

CPU trend: Same speed, but multiple cores (64 cores as of 2019)

Goal: Write applications that fully utilize many cores


Approaches to Concurrency

Option 1: Multiple Processes

Build apps from many communicating processes

  • Example: Chrome (process per tab)
  • Communicate via pipe() or similar

Pros:

  • Don’t need new abstractions; good for security

Cons:

  • Cumbersome programming
  • High communication overheads
  • Expensive context switching (requires changing address space)

Option 2: Threads

New abstraction: thread

Threads are like processes, except: multiple threads of the same process share an address space

  • Divide large task across several cooperative threads
  • Communicate through shared address space

Thread vs. Process

Shared State

Multiple threads within a single process share:

  • Process ID (PID)
  • Address space: code (instructions), most data (heap)
  • Open file descriptors
  • Current working directory
  • User and group id

Per-Thread State

Each thread has its own:

  • Thread ID (TID)
  • Set of registers, including program counter and stack pointer
  • Stack for local variables and return addresses (in same address space)

Lecture Note

Threads share the same PTBR (page table base register) — they point to the same page directory. But each thread has its own IP (instruction pointer) since they may be executing different code at the same time. Each thread also has its own SP (stack pointer) since threads executing different functions need different stacks.

Process Address Space with Threads

Single-threaded process address space:

RegionDescription
Code (text segment)Program instructions (PC points here)
Static data (data segment)Global variables
HeapDynamically allocated memory (grows down)
StackLocal variables and return addresses (grows up)

With multiple threads, the address space now contains multiple stacks — one per thread. Each thread has its own SP pointing to its own stack, and its own PC pointing into the shared code segment.


Thread Context Switch

Like process context switch:

  • Trap to kernel
  • Save context of currently running thread
    • Push machine state onto thread stack
  • Restore context of the next thread
    • Pop machine state from next thread’s stack
  • Return as the new thread
    • Execution resumes at PC of next thread

What’s not done:

  • Change address space (no need — threads share the same one)

Thread API

Variety of thread systems exist:

  • POSIX Pthreads

Common thread operations:

  • Create — start a new thread
  • Exit — terminate current thread
  • Join — wait for a thread to finish (instead of wait() for processes)

Notice: no fork() — why not? Threads share the address space, so there’s no need to duplicate it.


OS Support for Threads

User-Level Threads (Many-to-One)

  • Implemented by user-level runtime libraries
  • Create, schedule, synchronize threads at user-level
  • OS is not aware of user-level threads — OS thinks each process contains only a single thread of control

Advantages:

  • Does not require OS support; portable
  • Lower overhead thread operations since no system call

Disadvantages:

  • Cannot leverage multiprocessors
  • Entire process blocks when one thread blocks

Kernel-Level Threads (One-to-One)

  • OS provides each user-level thread with a kernel thread
  • Each kernel thread scheduled independently
  • Thread operations (creation, scheduling, synchronization) performed by OS

Advantages:

  • Each kernel-level thread can run in parallel on a multiprocessor
  • When one thread blocks, other threads from process can be scheduled

Disadvantages:

  • Higher overhead for thread operations
  • OS must scale well with increasing number of threads

Race Conditions

The Problem: balance++

volatile int balance = 0;
int loops;

void *worker(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
        balance++;
    }
    pthread_exit(NULL);
}

int main(int argc, char *argv[]) {
    loops = atoi(argv[1]);
    pthread_t p1, p2;
    printf("Initial value : %d\n", balance);
    Pthread_create(&p1, NULL, worker, NULL);
    Pthread_create(&p2, NULL, worker, NULL);
    Pthread_join(p1, NULL);
    Pthread_join(p2, NULL);
    printf("Final value   : %d\n", balance);
    return 0;
}

Lecture Note

Running ./threads 100000 gives Initial value: 0, Final value: 162901 — not the expected 200000!

The statement balance++ compiles to three assembly instructions:

0x195  mov 0x9cd4, %eax    // load balance into register
0x19a  add $0x1, %eax      // increment register
0x19d  mov %eax, 0x9cd4    // store register back to balance

Schedule #1 (Correct Result)

Thread 1 runs all three instructions, then context switches to Thread 2:

StepThreadInstruction%eaxbalance (0x9cd4)
1T1mov 0x9cd4, %eax100100
2T1add $0x1, %eax101100
3T1mov %eax, 0x9cd4101101
——context switch——
4T2mov 0x9cd4, %eax101101
5T2add $0x1, %eax102101
6T2mov %eax, 0x9cd4102102

Final balance: 102 (correct — incremented twice)

Schedule #2 (Wrong Result)

Thread 1 executes load and add, then context switches to Thread 2 before the store:

StepThreadInstruction%eaxbalance (0x9cd4)
1T1mov 0x9cd4, %eax100100
2T1add $0x1, %eax101100
——context switch——
3T2mov 0x9cd4, %eax100100
4T2add $0x1, %eax101100
5T2mov %eax, 0x9cd4101101
——context switch——
6T1mov %eax, 0x9cd4101101

Final balance: 101 (wrong — one increment lost)

Key Insight

T1 loaded the old value (100) into its register, then got interrupted. T2 read the same old value and wrote back 101. When T1 resumes, it writes its stale value (101), overwriting T2’s update.


Timeline View

The interleaving of instructions determines correctness. Thread 1 adds 1, Thread 2 adds 2 to a shared variable:

Correct interleaving — T1 completes, then T2 runs:

Thread 1              Thread 2
mov 0x123, %eax
add %0x1, %eax
mov %eax, 0x123
                      mov 0x123, %eax
                      add %0x2, %eax
                      mov %eax, 0x123

Result: 3 added (correct)

Incorrect interleaving — T2 reads before T1 writes back:

Thread 1              Thread 2
mov 0x123, %eax
add %0x1, %eax
                      mov 0x123, %eax
mov %eax, 0x123
                      add %0x2, %eax
                      mov %eax, 0x123

Result: 2 added (incorrect — T1’s update lost)

Another incorrect interleaving:

Thread 1              Thread 2
                      mov 0x123, %eax
mov 0x123, %eax
                      add %0x2, %eax
add %0x1, %eax
                      mov %eax, 0x123
mov %eax, 0x123

Result: 1 added (incorrect — T2’s update lost)


Scheduling: Possible Outcomes (Linked List)

struct node {
    struct node* next;
    int data;
}
struct node* head;

Thread 1 inserts node2, Thread 2 inserts node3 at the head of the list:

// Thread 1:              // Thread 2:
node2->next = head;        node3->next = head;
head = node2;              head = node3;

Initially: head = node1. Possible outcomes after both threads run:

  1. Head → Node 3 → Node 2 → Node 1 (T1 runs first, then T2 — correct)
  2. Head → Node 2 → Node 3 → Node 1 (T2 runs first, then T1 — different order, but correct)
  3. Missing/leaked nodes — if both threads read head before either writes, one node’s insertion is lost (node2 or node3 becomes unreachable)

How Race Conditions Happen

Multiple CPUs

Two threads run simultaneously on different CPUs, both accessing the same memory location at the same time.

Interrupts

On a single CPU, a timer interrupt causes a context switch in the middle of a critical section. Thread 1 is interrupted between its load and store, and Thread 2 runs its entire critical section before T1 resumes.


Non-Determinism

Concurrency leads to non-deterministic results:

  • Different results even with same inputs
  • Race conditions

Whether a bug manifests depends on the CPU schedule!

How to program: imagine the scheduler is malicious?!


What Do We Want?

Want the 3 instructions (load, add, store) to appear to execute as an uninterruptible group — that is, we want them to be atomic.

mov 0x123, %eax
add %0x1, %eax
mov %eax, 0x123

Critical section: A piece of code that accesses a shared resource and must not be concurrently executed by more than one thread.

More general: Need mutual exclusion for critical sections — if thread A is in critical section C, thread B isn’t (okay if other threads do unrelated work).


Synchronization

Build higher-level synchronization primitives in OS. Operations that ensure correct ordering of instructions across threads. Use help from hardware.

Motivation: Build them once and get them right.

LayerPrimitives
High-levelMonitors, Locks, Condition Variables, Semaphores
Low-level (hardware)Loads, Stores, Disable Interrupts, Test&Set

Locks

Goal: Provide mutual exclusion (mutex)

Lock API

Allocate and initialize:

  • Pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER;

Acquire:

  • Acquire exclusive access to lock
  • Wait if lock is not available (some other process in critical section)
  • Spin or block (relinquish CPU) while waiting
  • Pthread_mutex_lock(&mylock);

Release:

  • Release exclusive access to lock; let another process enter critical section
  • Pthread_mutex_unlock(&mylock);

Timeline View with Locks

Thread 1              Thread 2
mutex_Lock()          mutex_Lock()
mov 0x123, %eax
add %0x1, %eax
mov %eax, 0x123
mutex_unlock()
                      mov 0x123, %eax
                      add %0x2, %eax
                      mov %eax, 0x123
                      mutex_unlock()

Result: 3 added (always correct — lock prevents interleaving)

Lock Implementation Goals

Correctness:

  • Mutual exclusion — only one thread in critical section at a time
  • Progress (deadlock-free) — if several simultaneous requests, must allow one to proceed
  • Bounded (starvation-free) — must eventually allow each waiting thread to enter

Fairness: Each thread waits for same amount of time

Performance: CPU is not used unnecessarily


Implementing Locks

Atomic operation: No other instructions can be interleaved.

Approaches:

  1. Disable interrupts
  2. Locks using loads/stores
  3. Using special hardware instructions

Approach 1: Disable Interrupts

Turn off interrupts for critical sections:

  • Prevent dispatcher from running another thread
  • Code between interrupts executes atomically
void acquire(lockT *l) {
    disableInterrupts();
}

void release(lockT *l) {
    enableInterrupts();
}

Disadvantages:

  • Only works on uniprocessors
  • Process can keep control of CPU for arbitrary length
  • Cannot perform other necessary work

Approach 2: Load+Store

Code uses a single shared lock variable:

// shared variable
boolean lock = false;
void acquire(Boolean *lock) {
    while (*lock) /* wait */ ;
    *lock = true;
}

void release(Boolean *lock) {
    *lock = false;
}

Does this work? No — race condition with load and store:

*lock == 0 initially

Thread 1              Thread 2
while(*lock == 1)
                      while(*lock == 1)
                      *lock = 1
*lock = 1

Both threads grab the lock! Problem: testing and setting are not atomic.

Atomic Operations

To implement synchronization, we need atomic operations:

  • Code between interrupts on uniprocessors
    • Disable timer interrupts, don’t do any I/O
  • Loads and stores of single words
    • Load r1, B
    • Store r1, A
  • Special hardware instructions
    • Test&Set
    • Compare&Swap

Linked to this page