Operating Systems 2022F Lecture 13
Video
Video from the lecture given on November 1, 2022 is now available:
Video is also available through Brightspace (Resources->Zoom meeting->Cloud Recordings tab)
Notes
Lecture 13 ---------- Bit behind on things - grading in progress - should have A2 posted shortly - midterm will take another week - A3 will be out this week, hopefully by Thursday - it is just based on T5 and T6, so make sure you understand those well - currently it is due on Nov 9th but you'll get more time, at least until the 11th, still figuring that out. When doing the tutorials - make sure you understand all the code! - if there are things you don't understand, investigate/ask questions Topic of today: Concurrency This is a HARD topic, causes even the most advanced developers headaches - so bad, most avoid it as much as possible Why is it so hard? two or more processes/threads running "at the same time" (in parallel generally, but not always), coordinating their activities through shared resources and/or messages - resources are mostly files on UNIX-like systems, but could be a device or anything at all - particularly tricky when shared memory What's hard is race conditions - the relative order of execution affects the outcome Threads vs processes A process is - an address space + one or more execution contexts A thread is - an execution context in an address space So a process contains one or more threads What is an execution context? - registers - stack So a multithreaded process has multiple stacks, one for each thread - also each thread logically has its own set of registers Note that a multithreaded process is executing 2 or more instructions at the same time, always. Why don't I like processes with multiple threads? - because they can step on each other *so* easily - all memory & many other resources are shared - and so there are lots of opportunities for interaction Threads are one way to get concurrent computation. But there are other ways - in particular, we will focus on multiple processes interacting as a safer way to do concurrency - but if you look at the programming literature, there are other ways The trick to sane concurrency is limiting the interactions - uncontrolled interactions means chaos Just think about two threads modifying a linked list at the same time - how can you insert a node without corrupting the data structure? On modern CPUs, most instructions are NOT atomic - it is possible for them to be divided, because they don't all execute in one go - modern CPUs divide instructions into micro ops that are executed in parallel as much as possible - and, of course, there is the disconnect between memory and registers Where is a variable located? - if we say X is on the stack, when we change its value it *isn't* on the stack, it is in a register - if Thread 1 wants to add 1 to X, it will load X, modify it, and then write X - if Thread 2 accesses X while 1 is modifying it, what value will it see? - Thread 2 can't see 1's registers Because different cores are working with registers, and registers are private to the core, the states of variables can get skewed between threads. If we want consistent views of data between threads, we have to coordinate, or we have to do things in a way that doesn't require coordination (i.e., separate data access) We want to enforce mutual exclusion - make sure that if one bit of code is accessing data, nobody else can do it - so that code can assume that its data won't change from under it This can manifest as a security problem: TOCTTOU "time of check to time of use" Consider this primitive attempt to lock something: if (lock == 0) { lock = 1; /* do something, access shared resource */ lock = 0; } So when lock is 1, the lock is "held". So whoever set it to 1 has the lock until it is released (set to 0). THE ABOVE CODE IS WRONG We need the check (the comparison) and the change (the assignment) to lock to happen atomically, i.e., we can't have any other thread mess with the value of lock between checking its value and changing it. Note we should wait while the lock is held, so something like this: while (lock == 1) {} // spin while lock is held /* now lock is 0 */ lock = 1; /* grab the lock */ /* do something */ lock = 0; /* release the lock */ This is also wrong, again because we aren't using atomic operations. Again we would need to grab the lock right when we check it. Big thing to know: YOU CAN'T WRITE THIS CODE CORRECTLY IN C - you have to use special assembly language instructions to do a test & set operation atomically - we don't want to use these instructinos by default because THEY ARE SLOW (they force the CPU cores to coordinate at a very low level, rather than doing their own work) So whenever you use any sort of binary semaphore or similar mechanism for locking, there is some deep assembly language level magic happening under the hood - don't write your own! - even experts will try to create semaphores and will mess them up! A semaphore is a variable that allows for signalling & messaging between concurrent computations (threads) using shared memory - metaphor are signal lights for trains to keep two trains from running into each other Two kinds of semaphores: counting an binary - counting can have an arbitrary integer - binary is 1 or 0 We normally use binary semaphores for locks - but they can be used for other things - called here a "mutex" (for mutual exclusion) So there are many standard problems in concurrency, we're going to focus on one: the producer-consumer problem (If you want to learn a complex version of a concurrency problem, look up paxos) There are also file locks, we aren't going to discuss them - they aren't normally used on UNIX systems - files are locked all the time on Windows however - used to ensure exclusive access Remember that semaphores require shared memory to work. - if there isn't shared memory, you're doing something else, probably sending messages back and forth Producer-consumer is all about coordinating relative rates of execution - producer "makes" data - consumer "uses" data producer and consumer don't run at the same rates - so sometimes producer is faster, sometimes consumer is faster How do you make sure that they coodinate? - you want the one who needs to wait goes to sleep until it is needed (so you save CPU resources, no need to run if there is nothing to do) Key idea: buffer between the producer and consumer - shared data structure for storing the work of the producer - producer puts things into the buffer, consumer takes things out When the buffer is full, producer should wait until it isn't full When the buffer is empty, consumer should wait until it isn't empty UNIX has very good support for producer/consumer between two processes - one process can write, and the consumer can read - don't do it to a file, but do it to a pipe using the same read/write system calls you'd use on a file If we type something like "ls /usr/bin | less" we are doing producer/consumer - ls is the producer, less is the consumer - when less has filled the screen, ls will stop writing The pipe operator is actually creating two file descriptors - one assigned to standard out for the producer - one assigned to standard in for the consumer What is between these two file descriptors? the kernel implementing a shared data structure to coordinate the reads and writes The | operator is implemented using the pipe(2) library function/system call - if it is in <unistd.h>, it is probably a system call - use strace to verify If you want to have two processes talk to each other, one solution is to have two pipes, one for each direction. - but also, you could use a socket So how does the signalling/start/stop happen with pipes? - the kernel pauses the producer when the pipe's buffer is filled: the write system call that the producer makes doesn't finish until there is room to save the output - the kernel pauses the consumer when the pipe's buffer is empty the read system call that the consumer makes doesn't finish until there is something to read Remember that read and write system calls can pause (block) for arbitrary periods of time by default. You have to pass them an option (to the open) to make them return immediately if the operation would otherwise cause it to wait. - but pipes always block As long as both the producer and consumer are running, the kernel will manage access to the pipe by causing read/write system calls to the pipe to block - but if either the producer or consumer have terminated, the other will get an error when they access the pipe (SIGPIPE signal) Note that pipes work on the principle of first in/first out, so they are also called FIFOs