Lecture 11 - Concurrency Intro (Threads)
Instructor: Tej Chajed Topics: Concurrency, threads, race conditions, synchronization, locks
Learning Outcomes
- Articulate the difference between processes and threads
- Explain why concurrency is hard
- Appreciate why we want things to be atomic
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:
| Region | Description |
|---|---|
| Code (text segment) | Program instructions (PC points here) |
| Static data (data segment) | Global variables |
| Heap | Dynamically allocated memory (grows down) |
| Stack | Local 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 100000gives 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:
| Step | Thread | Instruction | %eax | balance (0x9cd4) |
|---|---|---|---|---|
| 1 | T1 | mov 0x9cd4, %eax | 100 | 100 |
| 2 | T1 | add $0x1, %eax | 101 | 100 |
| 3 | T1 | mov %eax, 0x9cd4 | 101 | 101 |
| — | — | context switch | — | — |
| 4 | T2 | mov 0x9cd4, %eax | 101 | 101 |
| 5 | T2 | add $0x1, %eax | 102 | 101 |
| 6 | T2 | mov %eax, 0x9cd4 | 102 | 102 |
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:
| Step | Thread | Instruction | %eax | balance (0x9cd4) |
|---|---|---|---|---|
| 1 | T1 | mov 0x9cd4, %eax | 100 | 100 |
| 2 | T1 | add $0x1, %eax | 101 | 100 |
| — | — | context switch | — | — |
| 3 | T2 | mov 0x9cd4, %eax | 100 | 100 |
| 4 | T2 | add $0x1, %eax | 101 | 100 |
| 5 | T2 | mov %eax, 0x9cd4 | 101 | 101 |
| — | — | context switch | — | — |
| 6 | T1 | mov %eax, 0x9cd4 | 101 | 101 |
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:
- Head → Node 3 → Node 2 → Node 1 (T1 runs first, then T2 — correct)
- Head → Node 2 → Node 3 → Node 1 (T2 runs first, then T1 — different order, but correct)
- Missing/leaked nodes — if both threads read
headbefore 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.
| Layer | Primitives |
|---|---|
| High-level | Monitors, 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:
- Disable interrupts
- Locks using loads/stores
- 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, BStore r1, A
- Special hardware instructions
- Test&Set
- Compare&Swap
Related
- Navigation: ← Lecture 10 · Lecture 12 →
- Exam: Midterm 2
- Course: CS 537 Map of Content