Class 5 CS 202 05 February 2025 On the board ------------ 1. Last time 2. Intro to concurrency, continued 3. Managing concurrency 4. Mutexes 5. Condition variables -------------------------------------------------------------------------- 1. Last time - shell - file descriptors - processes: the OS's view - intro to threads - begin intro to concurrency 2. Intro to concurrency, contd. --handout: 3: incorrect count in buffer --hardware makes the problem even harder [look at panel 4; what is the correct answer?] [answer: "it depends on the hardware"] --sequential consistency not always in effect --sequential consistency means: --maintain program order on individual processors --ensuring that writes happen to each memory location (viewed separately) in the order that they are issued --Memory consistency: what's the point? - On multiple CPUs, we can get "interleavings" _that are impossible on single-CPU machines_. In other words, the number of interleavings that you have to consider is _worse_ than simply considering all possible interleavings of sequential code. - explain why: write buffers, read from cache, compiler reordering, ... --assume sequential consistency until we explicitly relax it --good description, with some historical context: https://research.swtch.com/hwmm 3. Managing concurrency * critical sections * protecting critical sections * implementing critical sections --step 1: the concept of *critical sections* --Regard accesses of shared variables (for example, "count" in the bounded buffer example) as being in a _critical section_ --Critical sections will be protected from concurrent execution --Now we need a solution to _critical section_ problem --Solution must satisfy 3 properties: 1. mutual exclusion only one thread can be in c.s. at a time [this is the notion of atomicity] 2. progress if no threads executing in c.s., one of the threads trying to enter a given c.s. will eventually get in 3. bounded waiting once a thread T starts trying to enter the critical section, there is a bound on the number of other threads that may enter the critical section before T enters --Note progress vs. bounded waiting --If no thread can enter C.S., don't have progress --If thread A waiting to enter C.S. while B repeatedly leaves and re-enters C.S. ad infinitum, don't have bounded waiting --We will be mostly concerned with mutual exclusion (in fact, real-world synchronization/concurrency primitives often don't satisfy bounded waiting.) --step 2: protecting critical sections. --want lock()/unlock() or enter()/leave() or acquire()/release() --lots of names for the same idea --mutex_init(mutex_t* m), mutex_lock(mutex_t* m), mutex_unlock(mutex_t* m),.... --pthread_mutex_init(), pthread_mutex_lock(), ... --in each case, the semantics are that once the thread of execution is executing inside the critical section, no other thread of execution is executing there --step 3: implementing critical sections --hypothetical sledgehammer, from within a thread (here, we take the idea of thread as presented so far: an execution context schedulable by the OS), and assuming a uniprocessor machine: two system calls like: stop_all_other_threads(); // mark all threads as blocked allow_other_threads_again(); NOTE: these don't really exist as such, but they give you the idea. The idea is that if no other thread can run, then the thread that called these functions has assurance that it is executing without other threads interfering. also, this idea has a genuine correlate that is used (see immediately below) --on uniprocessor machines, from within the kernel, you can implement a critical section like this: enter() --> disable interrupts leave() --> reenable interrupts [convince yourself that this provides mutual exclusion] --we will study other implementations later. for now, focus on the use 4. Mutexes --using critical sections --linked list example --bounded buffer example --why are we doing this? --because *atomicity* is required if you want to reason about code without contorting your brain to reason about all possible interleavings --atomicity requires mutual exclusion aka a solution to critical sections --mutexes provide that solution --once you have mutexes, don't have to worry about arbitrary interleavings. critical sections are interleaved, but those are much easier to reason about than individual operations. --why? because of _invariants_. examples of invariants: "list structure has integrity" "'count' reflects the number of entries in the buffer" the meaning of lock.acquire() is that if and only if you get past that line, it's safe to violate the invariants. the meaning of lock.release() is that right _before_ that line, any invariants need to be restored. the above is abstract. let's make it concrete: invariant: "list structure has integrity" so protect the list with a mutex only after acquire() is it safe to manipulate the list --by the way, why aren't we worried about *processes* trashing each other's memory? (because the OS, with the help of the hardware, arranges for two different processes to have isolated memory space. such isolation is one of the uses of virtual memory, which we will study in a few weeks.) 5. Condition variables A. Motivation --producer/consumer queue --very common paradigm. also called "bounded buffer": --producer puts things into a shared buffer --consumer takes them out --producer must wait if buffer is full; consumer must wait if buffer is empty --shows up everywhere --Soda machine: producer is delivery person, consumer is soda drinkers, shared buffer is the machine --OS implementation of pipe() --DMA buffers --producer/consumer queue using mutexes (see handout04, 2a) --what's the problem with that? --answer: a form of *busy waiting* --It is convenient to break synchronization into two types: --*mutual exclusion*: allow only one thread to access a given set of shared state at a time --*scheduling constraints*: wait for some other thread to do something (finish a job, produce work, consume work, accept a connection, get bytes off the disk, etc.) B. Usage --API --void cond_init (Cond *, ...); --Initialize --void cond_wait(Cond *c, Mutex* m); --Atomically unlock m and sleep until c signaled --Then re-acquire m and resume executing --void cond_signal(Cond* c); --Wake one thread waiting on c [in some pthreads implementations, the analogous call wakes *at least* one thread waiting on c. Check the the documentation (or source code) to be sure of the semantics. But, actually, your implementation shouldn't change since you need to be prepared to be "woken" at any time, not just when another thread calls signal(). More on this below.] --void cond_broadcast(Cond* c); --Wake all threads waiting on c C. Important points (1) We MUST use "while", not "if". Why? --Because we can get an interleaving like this: --The signal() puts the waiting thread on the ready list but doesn't run it --That now-ready thread is ready to acquire() the mutex (inside cond_wait()). --But a *different* thread (a third thread: not the signaler, not the now-ready thread) could acquire() the mutex, work in the critical section, and now invalidates whatever condition was being checked --Our now-ready thread eventually acquire()s the mutex... --...with no guarantees that the condition it was waiting for is still true --Solution is to use "while" when waiting on a condition variable --DO NOT VIOLATE THIS RULE; doing so will (almost always) lead to incorrect code --NOTE: NOTE: NOTE: There are two ways to understand while-versus-if: (a) It's the 'while' condition that actually guards the program. (b) There's simply no guarantee when the thread comes out of wait that the condition holds. (2) cond_wait releases the mutexes and goes into the waiting state in one function call (see panel 2b of handout 04). --QUESTION: Why? --Answer: if those two steps were separate, could get stuck waiting. Example: Producer: while (count == BUFFER_SIZE) Producer: release() Consumer: acquire() Consumer: ..... Consumer: cond_signal(&nonfull) Producer: cond_wait(&nonfull) --Producer would never hear the signal!