 All right, another exciting day of operating systems. So today, we will continue talking about locking, because we had lots of fun yesterday talking about Centiphores. Today, we're going to see another option to using Centiphores, and I guess also what you'll be doing in Lab 3. So a little preview for that. So first, I will talk about monitors because they're built into some languages with object-oriented programming. Locks are kind of covered some a little bit. You have to do a lock and unlock. Make sure you always lock and unlock, and then you have the critical section. So when you have object-oriented programming, someone thought it would be a brilliant idea to make it much, much easier. So in Java, you can kind of mark a method as monitored with the synchronized keyword, and then let the compiler handle locking for you. And it will create a lock for you on a per object basis. So basically, it's one mutex per object, and it's created for you. And any methods that are synchronized, essentially, the compiler will insert a lock for you at the beginning, and then unlock for you right before it returns. So in Java, everyone's seen Java before, right? Ish? No? OK, well, just in case you see Java, it's kind of like C++, so you can have a class with methods. And one of the keywords you can put on a method is a synchronized keyword. And basically, that will just let the compiler do some transformations for you. Or basically, this object will have a new field called monitor, which is basically a mutex, and it will always lock and unlock it. So no matter what, any methods you have marked synchronized, you only have one thread that can access it at a time. And therefore, for an object, you won't have any data races. So that's just one thing you might encounter if you happen to program in Java or other object-oriented languages. I don't, C++ does not have this. But that's just an aside. Today, the main thing we'll be talking about are condition variables. And they're kind of behave like centerfors. And you can think of them as a kind of custom queue that you can create and then put threads on at will, essentially, when you're waiting for a condition. So they have the same kind of interfaces as we're familiar with from mutexes. They have an int. They have a destroy. And then the difference is that they have a signal, a broadcast, a wait, and a time wait. So wait will put itself, add itself to the queue, and it also takes a mutex. So it needs to be paired with a mutex always. And you're essentially waiting on some condition and we'll see some example, but whenever you change that condition, you have to have the mutex as well. So we'll see what that looks like an example. And then time wait is just the non-blocking version. So if you want it to time out so that you are guaranteed it always wakes up at some point, you can use the time wait function. And then the difference between signal and broadcast is signal will wake up one thread from the queue and then broadcast will wake up every thread that is currently on the queue. And that's the only difference between them. So you can think of wait as adding the currently running thread to the queue and it needs to be woken up somehow either through a signal or broadcast. And through this course, I think you only have to implement signal and we'll probably only ever use signal and not broadcast. So like I said, they must be paired with a mutex. So any call to wait must already hold the mutex. So it must be locked at the time you call wait. And for signal and broadcast, you don't have to hold the lock. There's like some way you can ensure order if you have the lock, but it's kind of outside the scope of this course. So we'll just, for signal and broadcast, you may or may not hold the lock and it doesn't really matter. So why you might think of it as wait needs to add itself to a queue safely so it doesn't have any data races. It would need the argument to be able to unlock that mutex before it puts it to sleep so another thread can go ahead and run and hopefully set that condition to true so that it can wake up and actually do something. So that wait function is atomic so it will atomically unlock the lock and then put itself in the queue and then at some point whenever it wakes up again it will hold the lock. So it will hold the lock before the call and it will hold the lock after the call and the idea of it is you don't have to worry about the implementation details and it's atomic. So you don't have to worry about data races. And then another caveat is that one mutex can protect multiple condition variables that we'll kind of see as an example. But again, we'll only consider calls to wait and signal. We won't really use time wait for now and we won't use broadcast. So this is how we could modify our producer consumer example. So we could have two queues, one queue that is essentially waiting for a filled slot and another queue that is waiting for an empty slot. And then our condition instead of having two center fours that are keeping track of how many empty slots there are and how many full slots there are we can just have one variable that keeps track of how many slots are filled which may or may not be easier to understand. So how you would use this is you would in the producer, you would lock a mutex because we're going to be modifying that end filled variable so we want it to not have any data races. So first thing we would check our condition. So the producer thread should not produce anything if all the slots are filled. So if the number of filled slots equals N which would be the buffer size in this case then it should wait. It should put itself in this has empty queue which is all the threads that are waiting for at least one empty slot. And then it has to give it the mutex. Yeah, so the condition variable you can think of as just representing a queue. Yeah, it's an easier way to think about it. So the condition in this case there's what I should probably hide myself get myself out of the way. So the one condition is this condition where there's the number of every slot is filled and the other condition is there are no filled slots all the way in the consumer thread. So that's kind of my condition I'll have two queues. One queue are threads waiting for something that threads are waiting for at least one empty slot. And then another queue on the producer's or on the consumer side, whoops, that is not the right window. Another thread on the consumer side that is waiting for at least one filled slot. So let's first look at the producer code. So the condition here is while the number of filled slots is equal to essentially the buffer size I should wait because I can't do anything. So it would add itself to the queue relinquish the lock and then wait for a corresponding signal to wake it up. So we'll see that if it doesn't get put to sleep it would just fill a slot like it's supposed to and then increase the number of filled slots and then it could signal, you know, it has filled. Then it would unlock the mutex. Yeah. So the wait is waiting for someone to call signal on it. So in this case, so assume the queue is filled already. So the producer would hit this while condition, check this condition is true. So it would wait and add itself to a has empty queue. So there's, you know, one thread, one producer thread waiting in that queue and wait atomically adds itself to the queue and gives up the mutex. So a consumer thread could run here, grab the lock, check the condition. In this case, the number of filled slots would not be zero because they're all full. So it could empty a slot, decrease the number of filled slots and then it would signal. So after it's signal, it just unblocks it. So it can run at any time. You don't know it essentially will try and get the lock again and then it unlocks. So then at this point, the producer thread is now active and it may grab the lock and then return from P thread wait with the lock. And because it's in a while loop, it would check the condition again. So because the consumer thread decremented the number of filled slots, you know, that condition is not true anymore. So it's false, so I can run and then I would fill up my slot and then increase and filled back up to the buffer size. And then I could signal just in case anything's waiting for a filled slot. Yep. Yeah, while you're holding a mutex, there's only one active thread at the time. So at the weight? Yeah. Yeah, so as part of the weight atomically, it will add itself to the queue and give up the lock. So you can think of weight as having, you know, it's atomic, but it's doing two things. It's going to unlock the lock so something else can go ahead and hopefully signal it and it will add itself to the queue of threads and do that atomically. Yeah, so it does those two things. It unlocks the lock, adds itself to the queue and then it's blocked. It's in that queue. So it's going to be blocked and wait for another thread to signal it to wake it up. And then when it's woken up, it'll try and grab the lock again. Yeah, another thread could grab the lock instead, which is why it's in a while loop, too, because another thread might come and take the lock before it and, you know, the condition, something might wake it up, be like, oh, the condition, we'll see an example of it, too. But the while loop is important. Yeah, yeah. So when you call weight, the thread will add itself to this empty queue. This has empty queue and it will only run again if something calls signal on has empty, which in this case would be the consumer thread. And yeah, the question back there. Yeah, so the question is, why do I need the while if I'm blocking that point? And we'll see an example. So we'll make it a bit simpler than this. Yeah, so the signal, if it's fair, would wake up whatever's at the front. It could be first in, first out. But I think in POSIX, if you read the standards, I think there's actually a defined order. Yeah. Yeah, so in this case, there's two queues. There's essentially a condition that's waiting for a filled slot, at least one. And then another condition that's waiting for a empty slot. Yeah, signal will only wake up one thread. So we'll look at the full producer consumer example, but first let's argue about some simpler examples. So let's read this real quick and then someone tell me something very bad that could happen here. So we have two threads. Again, we can't ensure any ordering between the threads. So in thread one, it's gonna lock a mutex. Check a condition, so if that condition's false, it will put itself to sleep and put itself in that condition's queue and then unlock the mutex and then thread two will can set the condition equal to true and then it would just signal a thread that would be waiting on that condition. And initially, the condition is false. Yeah, so let's think about this for like another 30 seconds and then we'll see 20 seconds for other people. Okay, go for it. Sorry, am I unlocking and unlocking there? Okay, so thread one would run first, grab the lock, right? So you're saying thread one first executes, grabs the mutex and then calls wait? Yeah. Okay, so it would call wait and then put itself in the queue atomically and give up the mutex. Okay, and then so it's waiting there. The only thing that can happen is thread two can execute and it would set the condition equal to true and then wake up that thread, right? Signal would just wake it up. So when it returns back here, it would re-grab the lock because no one has it and then it would check the condition. Now the condition is true. So it would fall out of the while loop and just unlock the mutex, everything's good. So this is okay, it woke up when the condition changed, right? Yeah, all right, was that what you were gonna say? Okay, well here, let's go through that quickly. So bad thing that could happen is so thread one starts executing, right? I guess we don't have the example up, but first thing it would do would be to grab the lock. So it would lock the mutex and now it's locked. It could check the condition. So it would be a read of condition and in this case it returns false. So let's look back at the code. So thread one, check the condition, it's false. So the next thing it's about to execute is wait. But we could unfortunately have a context switch right after it checks the condition and then thread two could come in, then it could write condition equals true. And then the next thing it would do is signal condition variable, right? And then thread one would start executing again and then call a wait, oops, call a wait on our condition. And now it's stuck, right? So yeah, you're allowed to call signal if there's nothing in the queue, it just won't do anything. It just won't wake up any threads. Yeah, like this, like. Yeah, like say it's a word problem. Yeah. And then it blocks itself, it gets off the lock, then thread two can run, it sends the signal, which puts the locked thread back into the queue. Yeah, okay, so. And where does it run from though? Right after the wait call. Oh, it starts right after the wait call. Yeah, so it would pick itself back up. So ideally what would have happened, so this is a case where we essentially have a lost wake up where we had a signal that should have woken up a thread but didn't wake up a thread. So ideally instead of this, what should have happened is right after thread one checks the condition, it should have waited and then it would have atomically added itself to the queue. So our condition queue here, con queue, would have thread one on it. And then if thread two executed and set the condition equal to true and then signaled, it would essentially thread two would be eligible to run again, it would try and get the lock and whenever it gets that mutex, it will just pick up right where it left off. So right after the wait call. So it would get signaled, it would grab the lock again, so it would return from wait with the lock, check the condition again, now it's true. So it would go here, it could do whatever it needed to do and then unlock the lock. Yeah, which one? So this? So this lock and unlock call is only in thread one. Yeah, because remember this is like preemptive scheduling so you can get context switched at any time. So how I would fix this is I would put a lock in thread two. Yeah, so if I wanted to fix this, where should I put locks in thread two? Yeah, I could put them right around this, right? Wouldn't that work? Okay, so let's go back because, so instead of, here I'll write the code down too. So in thread one, we have essentially lock mutex, then while not condition wait on some condition variable on some condition variable with the mutex and then unlock. So in thread two, one of the proposed solutions is to put a lock mutex and then set condition equals to true. And then we could have a signal here, it doesn't really matter, and then a unlock, right? Because my problem was when I essentially had this happen, right? So that was my problem, this context switch right before the wait after I read it. So now if I have, if I have this lock here, is that possible anymore? Yeah, because right now if I read the condition in the while loop and then I get context to switch to thread two, the first thing it actually has to do now is called lock, whoops, that is thick, called lock on the mutex and it'll get stuck there, right? Because it's already locked. So it can't execute anything else at that point, right? Everyone agree with us, or me, or the whole class, whoever, right? And now at some point it's gonna have to get context, at least between these two threads, the only thread that can make progress is whenever a context switch is back over to thread one. Yeah, yeah, because thread one at this point has the lock and hasn't unlocked it. So if another lock call comes in, it's locked, you're gonna have to sit there and wait. Yep. So the calls unlocked are locked? No. So how not? No, so wait, wait, before you call it, the lock is locked and when you return from it, the lock is also locked. So that's part of the guarantee you get as part of wait. Yeah, so wait has essentially two parts. The first part, atomically, it's going to unlock the lock and add itself to the queue and then whenever it's signaled again to return, it's going to try and grab the lock again. And it's only going to return from wait whenever it successfully grabs the lock. So it will own the lock before the call and own the lock after the call, which makes it a hell of a lot easier to program than that. If it's unlocked and if it's unlocked when it returns, then it's a mess and it is really, really painful. Yeah. Yeah, so let's keep carrying on with this example. So now we had thread one executed, locked the mutex, it read the condition and it got interrupted right before it called wait, which we saw was bad, right? But now when thread two starts running again, its first call would be to lock the mutex, but it's already locked, so it can't progress, right? So the only thing that can happen to have our program make any progress is context switch over back to thread two, or sorry, thread one, and then thread one would call, oops, thick, would call wait on that condition and use that mutex. So at this point, thread one can't execute anymore. So our condition queue, if we want to think of it that way, we'll have thread one on it. And right after, as part of the wait, it is now unlocked and added itself to the queue, so the mutex is now unlocked and thread one can't run anymore. So the only option to make progress again is to go switch back to thread two, which was still waiting at that locked mutex, but now that mutex is unlocked, so it can pass go. So now at this point, it would pass go, change the condition over to true, right? And then it could signal, conned, and then right now there's only thread one in the queue, so now it is unblocked. So now it's eligible to get scheduled again, right? Then at this point, even if it gets scheduled again right after this call, essentially internally it's gonna try and lock the mutex and it's still locked so it wouldn't be able to return at this point, right? Yeah, it's unblocked so it could be scheduled, but it has to grab the lock again before it can return from the wait. Yeah, that's just how it works. Yeah, yeah, yes. You'll figure that out in lab three because you have to implement one. That's something that the thread one upon exiting the wait call. Yeah, yeah. But also to answer your question, because it's part of the wait, we hold the mutex going into it, we could add ourselves to the queue and not have any data races, right? We have the lock. So as long as the only operations to the queue are with the lock, you're good, right? Yeah, yeah, so the question is, why do I have a while loop if I know and we'll see that in the next slide. So, okay, so yeah, at this point, it is now unblocked, it could run, but internally it has to call lock, so even if it gets scheduled, it will try and call lock, thread two still has the lock. So eventually thread two will unlock the mutex and then thread one would finally, whoops, return from wait, the mutex locked, right? Yeah, so we did signal and then unlock, right? You can do either. So internally it's not going to return from wait until it can grab the lock. So you could signal before the unlock or signal after the lock, they both work. If you don't, so if we change the order like here, no. No, whenever I signal, the only thing it does is it changes thread one from being blocked and it can't be scheduled at all to being unblocked and it can be scheduled. So if thread one got scheduled right after the signal, so thread one got scheduled here, it has to lock the lock to return from the wait. So it would try and grab the lock, it can't get the lock and it would just try over and over again, doesn't matter. The only time it can make progress is whenever we have the unlock, right? Yeah, signal only unblocks one thread in that queue. So, oops, I should put thread. So thread one, as soon as thread two unlocks it, it's allowed to continue from the wait with the lock held. So it would go check the condition again, right? So it would read the condition and now it's true, which is good, which is what we expect and then we could do something cool and then it would unlock itself. Good with that? Okay, so then this is the question, can we change the while to an if, right? So if I know the condition's gonna change anyways and it's gonna be true, then why do I recheck it again? And the answer is you probably will likely have code that looks more like this, where you have multiple threads changing the condition. So say we have thread two changing the condition to true and then thread three changing the condition to false. Well, actually I could remove the signal here. Yeah, so sorry, I could actually remove the signal call here. But anyways, if I have something that looks more like this, this thread would put itself to sleep whenever the condition is false. But what might happen is that this thread comes through, changes the condition to true, wakes it up so that thread's eligible to run, but it gets unlucky and then this thread happens to run before the one that gets woken up and then the condition changes back to false again. So this one would wake up and now the condition is false. So it shouldn't execute any, so it shouldn't pass that condition anymore, right? So if I didn't have a while loop, it would just assume that condition is true when it might not be. So does that make sense? There's also a iffy case where under some implementations weight can just return whenever the condition, without even being signaled, weight just might kind of return and have the lock anyways. And the only way to do that, it's better just to put a while loop in front of it at all times because some weird things can happen. Under some implementations on some systems, this is like super, don't need to know, but just to be safe on some systems, weight will just return with the lock even though that it was never signaled. Why? I don't know to make your life difficult, I guess. This is why programming these things is kind of a pain. Yeah, yeah, yeah. You could broadcast too and wake them all up or have other threads that just are using that lock anyways or any combination of things. Yeah. No wake up or something like that? No, so it would get woken up from the true, but it would check it again and then the condition's not true, so it should wait again, right? Okay, so it needs to eventually come out. Yeah, your program's gonna not make progress if there's some thread waiting for that to be set to true and then nothing ever sets it to true, right, and that's just your program thing. It's not condition's variable's fault, it's your fault now. Yeah. Oh no, you're good. So they kind of serve a similar purpose to center force. You can think of center force kind of as a special case of condition variables because I could just make the condition a value and then wait if it's zero and then have anything that increments it, essentially signal something that should wake up, but you could also implement it in the reverse way, so you could use center force instead of condition variables, but it gets really messy. And then, but for complex conditions, generally, condition variables, even though they're equivalent, are much, much easier to read and much, much easier to argue about because for the most part, for threading now, we just have to argue. We can't actually, running it does not necessarily mean it works properly, right? So now we can talk about practical matters. So locking granularity is kind of the extent of your locks. You need locks to prevent data races, that's just a given, but if you lock large sections of your program, you're gonna have problems where you essentially make it the whole thing serial, right? And then one thing you could do is divide locks into smaller sections. So for example, one thing you could do is try and paralyze your hash table implementation. Wouldn't that, anyone wanna do that? That sounds fun. Uh-oh. No, this is for fun. So how many of you use probing for your hash table implementation? Yeah, so anyone that use probing, that's gonna be really hard to paralyze, right? Because if there's any collisions, you don't know how far you should go and what you should lock. While if you use just a list for each individual element, you could just have a lock for every individual list and so that if two threads come in and wanna access two individual lists, that's cool. They can do that, they can do it in parallel. And you could have one lock for individual list and then that's it, that's your whole implementation and it's really easy to paralyze. While if you have linear probing, it's gonna be just a giant pain. So yeah, those are some things you have to think about because now your implementation kind of dictates how easy it is to paralyze it. So some things you have to consider about locks is the overhead, of course. If you have more locks, locking and unlocking isn't free, they might be fighting over it, right? So contention is just how often that lock is locked for. So if every thread is fighting for the same lock, they're gonna be essentially in serial, but if you have eight locks and generally only one or two threads fight for a lock at a time, it will be mostly parallel, which is good. And then we'll see dead locks. So some locking overheads, locks, of course, have to allocate some memory. So if you have a million locks that is going to take up a lot of unnecessary memory, it takes some time to create and destroy them, of course, and it, of course, takes time to acquire and release the locks. So the more locks you have, the worse this is gonna be, which kind of goes against our goal of, if you wanna divide it into the small sections possible, well, you also kind of don't wanna go overboard because then the cost of the locks themselves gets significant and then you're kind of screwed. So now we get to talk about dead locks. So the more locks you have to, the more you have to worry about dead locks. So the conditions for dead locking are one, you have to have some type of mutual exclusion, which for mutexes, of course that's the case. You have to have another condition called hold and wait. So you have to have at least two locks. So you have to hold the lock and then try to get another lock. Then the third condition is you have no preemption, which in this case is preemption for the locks, like I can take your lock away from you and make it unlocked and make you have to try again. So no preemption just means you can't take locks away and then circular wait where I'm waiting for a lock held by another process or thread. So here's a simple deadlock example. You have two processors or two threads or two processes trying to get two locks. So in the thread one code, it gets lock one, gets lock two. And then in thread two, it gets lock two and then gets lock one. Well, an easy deadlock is first thread one executes, grabs lock one, and then we context switch over to thread two, it gets lock two. And now the next thing that thread one wants to do is get lock two, which is held by thread two. And then if thread two gets scheduled, it tries to get lock one, which is held by thread two. So we are kind of at an impasse now, right? So no matter what thread gets scheduled, we now can't make any progress and that is a deadlock. Everyone see a deadlock? All right, you will probably get a deadlock and it will drive you crazy. So one way to prevent deadlocks is you can ensure ordering between them. So if you acquire locks in the exact same order, you can't get a deadlock, right? So if you lock, always lock lock one and then always lock lock two, then you won't have a deadlock because one implies the other. So I will only get lock two if I already have lock one, right? Yeah, so you could have code that just, so this could be protecting two variables and lock two could be protecting a whole different variable, right? There are cases where you would actually want this or yeah, essentially for protecting two different values that you might wanna change in parallel, that's in under some conditions, but might affect each other. So a good example for this one is like, if this was a bank account, right? There might be a lock for someone's balance and a lock for another person's balance. So if I need to change someone's balance, right? It needs to, I can't have a data race, otherwise the accounting won't work. There won't be a net zero at the end of it. So you might have a lock for each individual bank account. So in this case, right? If you have lock one, then you get context switch to another thread. It would try and get lock one. It can't get lock one, so you're always going to get lock two in this case. The other way to prevent deadlocks is by using a try lock. So now we get to actually see where this comes into play. So if you don't know the, if you can't ensure ordering on the locks, you could have try locks. So the idea here is that I could try to grab lock one and then, or sorry, I could grab lock one and then as soon as I have lock one, I try and grab lock two and then if I don't get it, which means it returns zero. So zero means I didn't get the lock. I would give up the lock I already have and then I would essentially wait, do something and then try and grab lock one again. So then I would try it again. So the only way out of this while loop is if I have both the locks, but if I have lock one and I can't get lock two, I'll give it up. So does that make sense? So this can't deadlock under any scenario, yep. So try lock if it returns one means it's locked. So try lock will try and lock the lock and tell you if it did. Try lock locks lock two. Yeah, if it returns like one, it means it locked it. So in the case where I actually get it, I would lock lock one and then I would try lock lock two and if I get it, this condition wouldn't be true. So at the end of the while loop, I have both locks, right? And if I didn't get it, I would unlock lock one, wait a bit, get lock one again and then try. If I didn't get it, I'd do the same thing again. If I do get it, I'd jump out and then right before the protected code, I always have both locks. Yeah. So the thinking behind that is just to break the deadlock. So during this wait, if someone else was trying to do the same thing, like so if we had this situation before where someone had lock two and was trying to get lock one, I would have, instead of us being deadlocked, waiting on each other's locks, if I have lock one, I just give up lock one if I don't get the other one. So that thread could actually run and make progress now, right? Any questions? Yeah. Yeah, for the wait, you could just put a yield there. That's probably a good thing to put there. All right, so I guess let's see. So also in the examples if you want, there is the condition variable version of that producer consumer. So this is kind of what it would look like, right? Kind of on the slide just so you have it. Curious. Hands up if you'd like the center four implementation better if that was more clear. So here is the center four example. Remember, it looked kind of like this. So this was the center four. So do we like center four? It was like ish 30. All right, who likes condition variables better? Eh, like 10%. Okay, so half the class is undivided. So in this case, condition variables, in my opinion, are a little clearer because you don't have to worry about the initial value being correct. But yeah, it's up to you. Whatever you can argue about better and make a more convincing case for is probably what you should use. Okay, so let's wrap it up. So today we explored some more advanced locking before we saw mutual exclusion and then we saw one way to ensure order using center fours and now we have another way using condition variables. Right, remember center fours were some atomic value that can be used for signaling. Condition variables are often clearer for more complex conditions. Locking granularity matters. You'll kind of find out in lab three, maybe in this case, I'm actually, yeah, but you're actually implementing condition variables so maybe you won't figure that out. But you could paralyze your hash table and figure it out. That would be lots of fun. And then of course, now you have to prevent data races and then as part of that, you might be looking to paralyze as much as you can, adding more locks, making it more complicated and now suddenly the dead locks are going to become more relevant and you also have to prevent those. So you have a lot of factors to consider when you're trying to make something run in parallel or concurrently. Yeah. So there's just a whole bunch of different tools just depending on the job. Cause even for locks, remember there's mutexes, there's read write locks, there's spin locks and they all have their own benefits in whatever situation they're good for and you'll kind of get used to it. All right. Well, just remember, I'm pulling for you. We're all in this together.