V Lecture 27 — data races and memory reordering, deadlock, reader/writer locks, condition variables
V critical section granularity
* how much work should be done while holding the lock?
* too long = performance loss
* too short = bugs
* guideline #3: do not do expensive computations or I/O in critical sections, but also don’t introduce race conditions
V data races
* class C {
private:
int x = 0;
int y = 0;
public:
void f() {
x = 1;
y = 1;
}
void g() {
int a = y;
int b = x;
assert(b >= a);
}
}
V exercise: can the assert ever fail due to an interleaving?
* Proof #1: exhaustively consider all possible orderings of access to shared memory (there are 6)
* Proof #2: if !(b>=a), then a==1 and b==0. But if a==1, then y=1 happened before a=y.
Because programs execute in order: a=y happened before b=x and x=1 happened before y=1.
So by transitivity, b==1. Contradiction.
V not so fast!
* this code has a dace race (two actually), and therefore the assert can fail
V why?
* for performance reasons the compiler and hardware are allowed to reorder memory operations
V compilers do optimizations that make code faster without changing the meaning
* this is pretty much essential for reasonable performance
V hardware isn’t actually just one nice single chunk of memory
* various caches and buffers will allow some memory to be accessed faster
* some memory operations might become “visible” in a different order than in the code
V have the compilers and hardware gone mad?!
V the guarantee:
* the programmer promises not to write data races
* the compiler/hardware will never perform a memory reordering that affects the result of a data-race-free multi-threaded program
V the fix:
* class C {
private:
int x = 0;
int y = 0;
std::mutex mix;
public:
void f() {
mtx.lock();
x = 1;
mtx.unlock();
mtx.lock();
y = 1;
mtx.unlock();
}
void g() {
mtx.lock();
int a = y;
mtx.unlock();
mtx.lock();
int b = x;
mtx.unlock();
assert(b >= a);
}
}
V more realistic example
* class C {
boolean stop = false;
void f() {
while(!stop) {
// draw a monster
}
}
void g() {
stop = didUserQuit();
}
}
V there is a data race on stop
* may not ever exit loop when user quits
* will probably be ok in practice, but no guarantee!
V deadlock
V multiple threads are all blocked waiting for each other
* if drawn as a graph, there is a cycle of waiting
V motivating example
V void transferTo(double amt, BankAccount a) {
mtx.lock();
this.withdraw(amt);
a.deposit(amt);
mtx.unlock()
}
* withdraw also guarded by lock
* deadlock can occur when two accounts try and transfer to each other: http://courses.cs.washington.edu/courses/cse374/16wi/lectures/deadlock.pdf
V dining philosophers
V n philosophers are seated around a table, each with a plate of spaghetti
* on the table in between each philosopher is a fork
* in order to eat their spaghetti, a philosopher needs to have a fork in each hand
V exercise: what protocol should the philosophers follow so that they all get to eat?
* should be independent of who is at the table
* number the forks, a philosopher must be holding the lower number fork in order to pick up the higher number
* a resource hierarchy solution
V back to bank account example
* can you see how the dining philosophers solution could apply here?
V options for deadlock-proof transfer:
V make a smaller critical section: transferTo not synchronized
* exposes intermediate state after withdraw before deposit
* may be okay, but exposes wrong total amount in bank
V coarsen lock granularity: one lock for all accounts allowing transfers between them
* works, but sacrifices concurrent deposits/withdrawals
V give every bank-account a unique number and always acquire locks in the same order (resource hierarchy)
* entire program should obey this order to avoid cycles
* code acquiring only one lock can ignore the order
V another example
V Java standard library’s StringBuffer
* synchronized append(StringBuffer sb) {
int len = sb.length();
if(this.count + len > this.value.length)
this.expand(…);
sb.getChars(0,len,this.value,this.count);
}
* exercise: spot the problem
V lock for sb not held between call to sb.length and sb.getChars
* sb could get longer in the meantime, causing append to throw ArrayBoundsException
* if append tried to hold the lock, deadlock could occur, just like in transferTo
V not easy to fix both problems without extra copying:
* do not want unique ids on every StringBuffer
* do not want one lock for all StringBuffer objects
V actual Java library: fixed neither (left code as is; changed javadoc)
* up to clients to avoid such situations with own protocols
V other mechanisms for shared-memory concurrency
V reader/writer locks
* like a mutex, but a thread can acquire it in reader mode or writer mode
* any number of reader mode threads can hold the lock
* only one thread can hold the lock in writer mode, no readers allowed
V useful when reads are common and writes are rare
* a shared hashtable has lots of simultaneous lookups, infrequent inserts
V condition variables
V consider the scenario where threads are sharing a bounded buffer (queue with a fixed size)
* some threads (producers) enqueue units of work into the buffer
* other threads (consumer) dequeue units of work from the buffer and process them
* what should a producer thread do if the queue is full?
* what should a consumer thread do if the queue is empty?
* they could use busy-waiting, just sitting in a while loop checking the condition
* better if they just wait (i.e., sleep) until they are notified that they can proceed
V a condition variable is an object with three operations
* wait: thread atomically releases lock and blocks
* notify: wake up one waiting thread (it will then try and reacquire the lock)
* notifyAll: wake up all waiting threads