27. Synchronization
In the previous lecture, we introduced concurrency, a powerful tool to split the execution of our code across multiple threads. Many recent advancements in computer hardware have involved increasing their parallelism by adding more CPU (or GPU) cores to the machines, and writing concurrent code enables us to take full advantage of this hardware. However, concurrency comes with a new set of challenges. Our code no longer executes as a single sequence of instructions; instead, it executes as a web of threads, interwoven in some non-deterministic way. Race conditions can arise from this non-determinism, leading our code to have different behaviors each time it executes. Since we no longer know the exact execution path that our code will take, tools such as invariants become harder to manage, and our unit testing does not provide a surefire way to assert the correctness of our code.
This presents us with a dilemma. We have seen that concurrency can greatly improve the performance of our code, especially in systems with many CPU cores. However, we may not be willing to realize these performance gains if they come at the expense of the reliability of our code (for example, if our code is controlling some medical device or airplane navigation system, where an errant calculation can have deadly consequences). Fortunately, there are tools that let us contend with race conditions while still enabling us to achieve concurrency. In today’s lecture, we’ll introduce synchronization and see some basic ways that we can incorporate it into our concurrent Java code.
Synchronization is the process of coordinating the execution of multiple threads, particularly their access and modification of shared state, within a piece of concurrent code.
Revisiting Race Conditions
At the end of the previous lecture, we considered the following simple multi-threaded application.
|
|
|
|
In this application, we create two threads that each have access to the same object, the SharedInt s. When they execute, both of these threads add 1 to s. While this will often result in s having value 2 once both threads have finished executing, we saw that there are some ways to interleave the instructions executed on these threads such that s will have final value 1. This is a race condition.
Let’s dig a little deeper into what went wrong here. In the “bad” execution path, one of the threads will update the value of the SharedInt between the times when the other thread reads its value (writing it to its xCurrent) and writes its incremented value back. Because of this, we cannot assert the following fact about the execution of the increment() method:
i.x will be incremented by 1 during the execution of line 7.
While we could make such an assertion in a single-threaded application (where we would be guaranteed that xCurrent would hold the value of i.x at the start of the execution of line 7), the presence of a second thread writing to the SharedInt messes things up. We lose the guarantee that an object’s state remains the same from the time when we last accessed it and the time we are ready to modify it. This highlights the conditions that are necessary for a race condition to occur.
To eliminate the possibility of the race condition, we need only address one of these things.
- First, and most heavy-handedly, we could eliminate concurrency and have only a single execution thread. However, then we could not reap the benefits of parallel execution.
- Second, we could eliminate the use of shared memory and limit each thread to perform its computation on a disjoint set of objects. This can become impractical when we wish to perform some calculation on a large data structure like a database or a graph.
- Third, we can have multiple threads accessing the same shared objects as long as they are only accessing their data without trying to modify it. In this case, the situation where one thread changes the state of an object between another thread’s reading and using the data cannot occur. This is the solution taken by the Swing framework, which requires that the event dispatch thread is the only thread that can modify the state of any Swing components. However, this is again limiting for situations where we want to perform an update operation on a large data structure.
The only other thing we can do is refine the time windows where each thread is allowed to modify the shared object. We’d like a way to say:
i.x is read on line 6 and re-assigned on line 7, no other thread should reassign this field.
In other words, we’d like our thread to gain exclusive control of the SharedInt object during this time window so it can guarantee to carry out its increment operation successfully. We say that this block of code is its critical section.
A critical section is a contiguous sequence of operations involving one or more shared variables that must be sequentially executed on a single thread (without concurrent modification of these shared variables by other threads) to ensure correctness.
Next, we’ll see a mechanism, a mutual exclusion lock (or mutex) that Java provides to allow us to specify these critical sections.
Mutexes and Synchronized Blocks
In our example, we want to make sure that lines 6 and 7 of the increment() method are executed by one of the threads without another thread messing with the state of the SharedInt. In other words, we’d like our thread to be able to “lock down” access to the SharedInt while it is executing these lines. We can achieve this using the synchronized keyword as follows:
|
|
|
|
In Java, any object can serve as a lock (or, more precisely, a mutual exclusion lock shortened to a mutex).
A lock or mutex is an object that helps to ensure that only one thread is accessing a piece of shared state at any given time.
You can imagine that a synchronized block of code is placed into a padlocked box. A thread is only allowed to execute the code within this box once it is able to obtain the key to unlock the box. The object that the block is synchronized on – in this example, the SharedInt i – is the owner of the only key for the lock. When the thread reaches the synchronized block, it asks the object for the key. As long as the key hasn’t been given to someone else, the object will hand it over so the thread can open the box and execute its code. At the end of the synchronized block, the thread will lock the box back up and return the key to the object.
What does this gain us? Here, the main insight is that there is exactly one key. When the first thread borrowed the key from the object, it gained exclusive control of the synchronized block. When the other thread (also executing the increment() method) reaches the synchronized block, it will also ask the SharedInt for the key, but it will be out of luck. The SharedInt only had one key to lend, and it has already lent it to the first thread. The second thread must wait for the first thread to return the key to the SharedInt before it can obtain the key and enter the synchronized block. In this way, the first thread (and similarly the second thread) can guarantee that the value of the SharedInt won’t change while it is executing the synchronized block, and the race condition has been removed: The value of the SharedInt will always be 2 since both increment() operations will have the intended effect of increasing the value of the SharedInt by 1.
By “locking” (i.e., placing inside of a synchronized block) the critical section of the increment() method, we turn it into an atomic operation.
An atomic operation on a piece of shared state is an instruction or sequence of instructions accessing/modifying that state that is guaranteed to be executed without preemption from another thread.
Within these atomic operations, the code executes as if there is only a single thread, and (assuming the rest of the code is disciplined with its synchronization; see the next section) we can get back many of the guarantees in invariants that we are used to from earlier in the course.
Any object in Java can serve as a lock. Often, when we want to ensure mutual exclusion during modifications to a specific object in the shared state, we'll use that object as the lock. When we need to guard some primitive value in the shared state (e.g., the field of a shared object), we might need to use a separate object as the lock. If we wish to enclose the entire body of an instance method within a synchronized block, we can add the synchronized keyword to the method declaration, which guards the method on its target object, this.
Disciplined Synchronization
We’ve just said that adding synchronized blocks to our code encloses it in boxes that can enforce mutual exclusion. This is a great synchronization tool, but it requires us to be disciplined programmers. It is our responsibility to build these boxes in the first place. Code running within a synchronized block is not guaranteed exclusive access by Java. Rather, it provides the guarantee that Java won’t hand out the key (belonging to the object the block is synchronized on) to any other thread that asks for it. To see where things go wrong, let’s consider the following example:
|
|
|
|
This is very similar to our previous demo, except now we have 10 threads that will each increment the SharedInt once and 10 threads that will each decrement the SharedInt once. If things work as we intend, the increments and decrements will exactly cancel out, and our SharedInt will have a final value of 0. However, this does not happen. When we run the code (or, more accurately, when we run an enhanced version in the lecture release code with some random sleeping to encourage race conditions), we obtain output:
Final value of shared.x: 6
While our increment() method uses synchronization to guard its modifications of the SharedInt, the decrement() method does not. It doesn’t place its critical section into a locked box, meaning it does not need to ask the SharedInt for a key. It just goes ahead and directly modifies the shared state, potentially in violation of the exclusive access that the increment() method went through the trouble to establish. Just a single method, decrement(), being undisciplined about its synchronization was enough to reintroduce a race condition into the code. This emphasizes that:
If you download the lecture code, you’ll see that IntelliJ will add a warning to the synchronized block in our increment() method: “Synchronization on method parameter ‘shared’”. This warning points out that synchronization is prone to bugs and warrants careful review. If you truly want your code to be synchronized, you need to make sure that every modification to a shared variable is done within a synchronized block.
Another common issue is choosing the incorrect objects on which to synchronize. Remember, synchronization will only work when all of the synchronized blocks guarding a piece of shared state need to wait their turn for the same key. In the following example, the incrementing threads synchronize on the mutex incrementLock and the decrementing threads synchronize on the mutex decrementLock. These different mutexes don’t resolve the race conditions between incrementing and decrementing threads. We need to use a single mutex, for example, the SharedInt object itself, to ensure proper synchronization.
|
|
|
|
Multiple Shared Resources
Sometimes, the critical section of a method may require the modification of multiple shared variables. To correctly synchronize these modifications, we may need to guard the critical section on multiple locks (one per shared variable). For example, consider the following (overly-simplified) code that simulates the state of pans in a restaurant. There are two types of threads. The “chef” threads grab a clean pan and use it, rendering it dirty. The “dishwasher” threads grab a dirty pan and clean it. Our code will track the state of all of the pans over the course of execution. We’ve set up the simulation to ensure that the chefs will never run out of clean pans and the dishwashers will never run out of dirty pans.
|
|
|
|
When we try to execute this code, it prints the following:
Kitchen opens with 10 clean pans and 10 dirty pans. Chef 0 grabs a clean pan and starts cooking. Dishwasher 0 starts cleaning a dirty pan.
Then, the code freezes up and makes no more progress. What has happened?
The issue here is with the nested synchronized blocks. Each of our “chef” threads immediately requests the key for the numClean variable, and they hold onto this key throughout the rest of the method body. In particular, they are holding this key when they request the numDirty key. If this key is unavailable, they will pause execution, still holding onto the numClean key. The opposite is true of the “dishwasher” threads. They start by grabbing the numDirty key, and they hold onto it as they are requesting (or perhaps waiting for) the numClean key.
This can lead to a bad situation called a deadlock, where a “chef” thread is holding the numClean key and waiting for the numDirty key at the same time that a “dishwasher” thread is holding the numDirty key and waiting for the numClean key. No progress can be made because neither thread is willing (or even able) to give up the key that the other thread needs to make progress (and relinquish control of the other key).
In a deadlock, multiple threads are unable to make progress since they are all stuck waiting to acquire a mutex that another thread controls.
A lot of things needed to happen for this deadlock to arise in the first place:
- We needed that multiple threads were attempting to acquire locks for the same shared resources.
- We needed each thread to require multiple of these locks at the same time, using nested
synchronizedblocks. - We needed a cyclic structure in the lock acquisition order so that each thread is able to grab a different “first” lock before any thread can grab its “second” lock.
Eliminating just one of these conditions is enough to eliminate the possibility of deadlock. We provide three examples of how to fix the previous demo below.
guard multiple pieces of shared state with a common mutex
standardize the synchronization order
Smaller critical sections
Condition Variables
To conclude our discussion of synchronization, we’ll dive a bit deeper into the idea of coordinating work across threads. So far today, we’ve only considered threads whose work was largely independent. While our threads needed to synchronize to control access to their shared state, it did not actually matter in which order they performed their actions. In practice, this is often not the case. One thread may rely on another thread to make some progress before it can continue its work. In this case, we’ll need a way to temporarily suspend the execution of a thread until some desired condition is met. We’ve already seen a few similar behaviors:
- By calling the
staticmethodThread.sleep(), we can cause a thread to pause execution for a prescribed amount of time before it moves on to execute its next line. - By calling the
Thread.join()method, the current thread can pause its execution until another thread (the target ofjoin()) stops executing. - When a thread enters a
synchronizedblock, it pauses execution until it is able to acquire the specified mutex.
Now, we’ll discuss a fourth mechanism. The wait() methods of the Object class, including the variants that accept time durations as parameters, are used to pause the execution on a thread until it is notified that it can resume. This method, in conjunction with the mutexes that we have already introduced, enables us to express richer conditions to synchronize the work across multiple threads.
Writing Code with Condition Variables
To build on our earlier example, let’s again imagine that we have a “chef” thread that dirties pans and a “dishwasher” thread that cleans pans. This time, we’ll have a single thread of each type that will interact with multiple pans over the course of its execution.
|
|
|
|
Take a minute to read through this code. What can go wrong in this simulation?
What can go wrong?
To address this, we’ll need to use condition variables (i.e., Object.wait() calls set up in the format that we’ll describe below). When the “chef” thread enters the block synchronized on numClean, it should first check whether there is a clean pan. If there is, it can proceed as normal. If there is not, however, it must relinquish control of the mutex so another thread (the “dishwasher” thread) can make another clean pan available.
The Object.wait() method serves this purpose. When a thread calls the wait() method on an object while holding the mutex for that object, Java:
- Releases the mutex (i.e., takes back the key from the running thread).
- Pauses the execution of the thread by placing it in a “wait set”.
Once the mutex is free, another thread that hits a synchronized block can acquire it. To get out of the “wait set”, our paused thread must be notified that the state of the shared resource has changed. To do this, we should make a call to notifyAll(), another Object method, at the end of any synchronized block that modifies that shared variable’s state. This has the effect of moving all paused threads in the “wait set” back in line to re-acquire the mutex and proceed with their execution.
You might notice that Object also has a notify() method that has the effect of notifying a single thread in the "wait set" that it can re-acquire the lock. We have no guarantee of which thread gets notified, and this can cause issues if we are not careful. It is safer to always notifyAll(), even if this wakes threads up from the "wait set" unnecessarily.
Let’s put these ideas into practice. We’ll start with the “chef” thread’s method, simulateChef(). When we acquire the numClean lock, we must check whether numClean.x == 0. If it is, there are no clean pans that the chef can use; they must wait for the dishwasher to catch up, so we call numClean.wait() to release the lock.
simulateChef()
|
|
|
|
When the execution of the simulateChef() method resumes after this wait() call, it is because the thread has been notified of a change to numClean. However, we can’t be certain that this change actually left numClean.x > 0; we can imagine the possibility of another “chef” thread on the “wait set” beating us to re-acquire the mutex and dirtying the pan before we have an opportunity to resume our execution. This means that we actually need to re-check the condition.
wait() within a while-loop guarded by the condition. This ensures that the condition will be met as soon as we exit the loop.
simulateChef()
|
|
|
|
In this revised code, we are guaranteed that numClean.x != 0 (in fact, it will be greater than 0) when we exit the while loop, so the decrement operation that follows is “safe”. The start of our simulateDishwasher() method follows a similar pattern:
simulateDishwasher()
|
|
|
|
We are not done, though. We must be careful to ensure that these threads can return from the “wait set” by notifying them appropriately. In particular, we should call notifyAll() immediately after modifying the value of either SharedInt. In our simulateChef() method, we write:
|
|
|
|
This code ensures that the “chef” thread will wake up the “dishwasher” thread when a pan is available for them to clean. Writing similar code in the “dishwasher” thread will ensure that the “chef” thread is woken up whenever they have a clean pan that they can cook in.
The full simulation with proper synchronization is provided with the lecture release code. When we execute this code, we see that it corrects the issue that we identified above.
The kitchen opens with 2 clean pans and 0 dirty pans. The chef grabs a clean pan and starts cooking. There are 1 clean pans remaining. The chef is done cooking. There are now 1 dirty pans. The dishwasher grabs a dirty pan and starts cleaning. There are 0 dirty pans remaining. The dishwasher is done cleaning. There are now 2 clean pans. The chef grabs a clean pan and starts cooking. There are 1 clean pans remaining. The chef is done cooking. There are now 1 dirty pans. The dishwasher grabs a dirty pan and starts cleaning. There are 0 dirty pans remaining. The chef grabs a clean pan and starts cooking. There are 0 clean pans remaining. The dishwasher is done cleaning. There are now 1 clean pans. The chef is done cooking. There are now 1 dirty pans. The dishwasher grabs a dirty pan and starts cleaning. There are 0 dirty pans remaining. The dishwasher is done cleaning. There are now 2 clean pans. Simulation Complete
As we already saw earlier today, we must exercise good discipline when we are writing concurrent code to ensure that our synchronization is done appropriately. Only once the synchronizing, waiting, and notifying are all done properly will it be guaranteed that our concurrent code will execute correctly, avoiding both deadlock and race conditions.
The Monitor Pattern
In many cases, we might like an entire class that we are writing to be “thread safe”, meaning multiple threads can call methods on objects from the class (both accessors and mutators) without the possibility of race conditions. To achieve this, we can coarsely synchronize all of the public instance methods of the class on their target’s mutex. By doing this, at most one thread can execute the code within the class at a time. If we are ever in a situation where we need another thread to make progress before we can resume execution of the instance method, we can use a condition variable (i.e., call this.wait()). This approach to synchronizing a class is referred to as the monitor pattern.
In the monitor pattern, the bodies of all of a class's public instance methods are guarded by its object's mutex. This ensures thread safety within the class.
While the monitor pattern guarantees thread safety, it also likely introduces inefficiencies. Locking down the complete state of an object is a very heavy-handed measure. We may instead be able to lock down one or two fields at a time, which can enable multiple threads to simultaneously interact with a class. As is typically the case with synchronization, we must trade off the performance of our code (enabled by fine-grained, sophisticated synchronization) with the overhead of maintainability and careful discipline that are required to carry out synchronization in a safe and principled way.
While this is the end of our discussion of concurrency and synchronization in CS 2110, you’ll go on to discuss it more in later courses such as CS 3410 and CS 4410 since it has grown to play an ever more important role in designing performant modern computer systems.
Main Takeaways:
- To enter a
synchronizedblock, a thread must obtain the unique mutex belonging to an object. This ensures that it is the only thread running codesynchronizedby that object. synchronizedblocks only work under good programmer discipline. They do not automatically prevent other threads from accessing shared state; they only enforce mutual exclusion among threads that are properly synchronizing.- When threads must acquire simultaneous access to multiple resources, the possibility of deadlock arises. Deadlock can be broken by having all threads acquire resources in a consistent order.
- In a condition variable, a thread voluntarily surrenders access to a mutex to allow another thread to make progress toward some desired condition. This is done with the
wait()method, which places the thread in the mutex's "wait set". AnotifyAll()call from another thread wakes up the thread and allows it to reacquire the mutex.
Exercises
Recall Exercise 26.1.c, where we defined two threads that share an instance s of the class Shared, defined as follows:
|
|
|
|
s.x is 2. The threads execute the following statements concurrently:
| Thread 1 | Thread 2 | |
|---|---|---|
s.x = s.x + 1; |
|
s in a way that guarantees that any updates made by both threads will be observed. What are the possible values of s.x?Consider the Counter class implemented below:
|
|
|
|
Counter be used safely by multiple threads? If not, how would you make it thread-safe?Two processes both need resources F1 and F2 to do their work. Only one process can control a resource at a time. If a process attempts to acquire a resource that is already controlled by another process, it will wait. The instructions followed by the processes are shown below.
| Process 1 | Process 2 |
|---|---|
|
|
Thread block? Let t be a thread that is accessible in the main thread.Suppose we have a kitchen simulation initialized with 2 chef threads, 1 dishwasher thread, 0 clean plates, and 6 dirty plates. For each of the following parts, determine the state of each thread after the specified thread releases the lock. Each subproblem continues from the state produced in the previous one, and if there are multiple possible outcomes, list all valid resulting states.
KitchenSimulation class thread safe by waiting on a specific event to occur. As an alternative without synchronization, a student proposes to use a spin lock. A spin lock can be implemented as a while-loop without a loop body. The student reasons that this would intuitively mimic the idea of an Object.wait() call as it should prevent a method from continuing until a condition is reached. Suppose the simulateChef() is edited to use spin locks.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Define the constructor of Barrier. Does the constructor need synchronization?
|
|
|
|
Implement Barrier.await(). Does this require synchronization?
|
|
|
|
main() method, spawn three threads that modify a SharedInt s. Each thread should first add one to s, then use a barrier (with numThreads = 3) to await(), and finally multiply s by 2. What is the expected result of s.x after this method is executed? Without using barriers, what are the possible values of s.x?
|
|
|
|
Implement Semaphore.acquire().
|
|
|
|
Implement Semaphore.release().
|
|
|
|
new Semaphore(1). This is often called a binary semaphore. Does this behave like anything we’ve already studied?
RingBuffer class that supports enqueue()ing and dequeue()ing elements into this bounded queue. Here we are giving you the liberty to define your class. You may want to consider the following questions. What are the class invariants of your data structure? If a client attempts to add an element to a full queue, should that be handled via preconditions or exceptions?
In a produce-consumer problem, you have multiple threads attempting to add and remove elements from a bounded queue. For instance, consider a fast food restaurant setting where the queue is a rack of fries, consumers are customers, and producers are chefs. Let's define a
ProducerConsumer utility class to solve this problem.
|
|
|
|
Implement ProducerConsumer.produce(). This method should block when attempting to add to a full queue.
|
|
|
|
Implement ProducerConsumer.consume(). This method should block when attempting to remove from an empty queue.
|
|
|
|
ProducerConsumer to simulate this restaurant setting in a main() method.
RingBuffer thread-safe. What are the trade-offs between each option?
ReadersWritersLock class to allow thread-safe reads and writes to some text file.
|
|
|
|
Implement the locking and unlocking mechanism for readers.
|
|
|
|
Implement the locking and unlocking mechanism for writers.
|
|
|
|
Car as follows:
|
|
|
|
main() method in the Car class that spawns 50 new Threads simulating Cars using a roundabout. Note that Car implements Runnable.
Semaphore (see Exercise 27.7). Why do we initialize the semaphore with 3 slots if we are simulating a roundabout with 4 segments?
Roundabout, which makes use of bidirectional maps.
|
|
|
|
Implement enterSegment(). Consider when you should wait() and notifyAll(). Also, ensure that the class invariants are held.
|
|
|
|
Implement remove().
|
|
|
|