14. Synchronization
Sharing is sometimes more demanding than giving.
14.1. Introduction
Concurrent programs allow multiple threads to be scheduled and executed, but the programmer doesn’t have a great deal of control over when threads execute. As explained in Section 13.6, the JVM and the underlying OS are responsible for scheduling threads onto processor cores.
While writing a concurrent program, you have to ensure that the program will work correctly even though different executions of the same program will likely lead to different sequences of thread execution. The problem we introduce next illustrates why one thread execution sequence might be perfectly fine while another might lead to unexpected and incorrect behavior. (And even people starving!)
14.2. Problem: Dining philosophers
Concurrency gives us the potential to make our programs faster but introduces a number of other problems. The way that threads interact can be unpredictable. Because they share memory, one thread can corrupt a value in another thread’s variables. We introduce synchronization tools in this chapter that can prevent threads from corrupting data, but these tools create new pitfalls. To explore these pitfalls, we give you another problem to solve.
Imagine a number of philosophers sitting at a round table with plates that are periodically filled with rice. Between adjacent philosophers are single chopsticks so that there are exactly the same number of chopsticks as there are philosophers. These philosophers only think and eat. In order to eat, a philosopher must pick up both the chopstick on her left and the chopstick on her right. Figure 14.1 illustrates this situation.
Your goal is to write a class called DiningPhilosopher
which extends
Thread
. Each thread created in the main()
method should be a
philosopher who thinks for some random amount of time, then acquires the
two necessary chopsticks and eats. No philosopher should starve. No
philosophers should be stuck indefinitely fighting over chopsticks.
Although this problem sounds simple, the solution is not. Make sure that you understand the concepts and Java syntax in this chapter thoroughly before trying to implement your solution. It’s important that no two philosophers try to use the same chopstick at the same time. Likewise, we need to avoid a situation where every philosopher is waiting for every other philosopher to give up a chopstick.
14.3. Concepts: Thread interaction
This dining philosopher problem highlights some difficulties which were emerging toward the end of the last chapter. In Exercise 13.10 two snippets of code could run concurrently and modify the same shared variable, potentially producing incorrect output. Because of the nondeterministic nature of scheduling, we have to assume that the code executing in two or more threads can be interleaved in any possible way. When the result of computation changes depending on the order of thread execution, it’s called a race condition. Below is a simple example of a race condition in Java.
public class RaceCondition extends Thread {
private static int counter = 0;
public static final int THREADS = 4;
public static final int COUNT = 1000000;
public static void main(String[] args) {
RaceCondition[] threads = new RaceCondition[THREADS];
for(int i = 0; i < THREADS; i++) {
threads[i] = new RaceCondition();
threads[i].start();
}
try {
for(int i = 0; i < THREADS; i++)
threads[i].join();
}
catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter:\t" + counter);
}
public void run() {
for(int i = 0; i < COUNT/THREADS; i++)
counter++;
}
}
This short (and pointless) class attempts to increment the variable
counter
until it reaches 1000000
. To illustrate the race condition,
we’ve divided the work of incrementing counter
evenly among a number
of threads. If you run this program, the final value of counter
will
often not be 1000000
. Depending on which JVM, OS, and how many cores
you have, you may never get 1000000
, and the answer you do get will
vary a lot. On all systems, if you change the value of THREADS
to 1
,
the answer should always be correct.
Looking at the code, the problem might not be obvious. Everything
centers on the statement counter++
in the for
loop inside the
run()
method. But, this statement appears to execute in a single step!
Each thread should increase the value of counter
a total of
COUNT/THREADS
times, adding up to 1000000
. The trouble is that
counter++
is not a single step. Recall that counter++
is shorthand
for counter = counter + 1
. To be even more explicit we could write it
as follows.
temp = counter;
counter = temp + 1;
One thread might get as far as storing counter
into a temporary
location, but then it runs out of time, allowing the next thread in the
schedule to run. When that’s the case, this next thread may do a series of
increments to count
which are all overwritten when the first thread
runs again. Because the first thread had an old value of counter
stored in temp
, adding 1 to temp
has the effect of ignoring
increments that happened in the interim. This situation can happen on a single
processor with threads switching back and forth, but it’s even more dangerous
on a multicore system.
The primary lesson here is that threads can switch between each other at any time with unpredictable effects. The secondary lesson is that the source code is too coarse-grained to show atomic operations. An atomic operation is one which cannot be interrupted by a context switch to another thread. The actual code that the JVM runs is much lower level than source code.
We can’t easily force a non-atomic operation to be atomic, but there are
ways to restrict access to certain pieces of code under certain
conditions. The name we give to a piece of code which should not be
accessed by more than one thread at a time is a critical section. In
the example above, the single line of code which increments counter
is
a critical section, and the error in the program would be removed if
only one thread were able to run that line of code at a time.
Protecting a critical section is done with mutual exclusion tools. They’re called mutual exclusion tools because they enforce the requirement that one thread executing a critical section excludes the possibility of another. There are many different techniques, algorithms, and language features in computer science which can be used to create mutual exclusion. Java relies heavily on a tool called a monitor which hides some of the details of enforcing mutual exclusion from the user. Mutual exclusion is a deeply researched topic with many approaches other than monitors. If you plan to write concurrent programs in another language, you may need to brush up on its mutual exclusion features.
14.4. Syntax: Thread synchronization
14.4.1. The synchronized
keyword
In Java, the language feature which allows you to enforce mutual
exclusion is the synchronized
keyword. There are two ways to use this
keyword: with a method or with an arbitrary block of code. In the method
version, you add the synchronized
modifier before the return type.
Let’s imagine a class with a private String
field called message
which is set to be "Will Robinson!"
by the constructor. Now, we define
the following method.
public synchronized void danger() {
message = "Danger, " + message;
}
If danger()
is called five times from different threads, message
will contain
"Danger, Danger, Danger, Danger, Danger, Will Robinson!"
Without the
synchronized
keyword, danger()
would suffer from a race condition
similar to the one in RaceCondition
. Some of the String
concatenations might be overwritten by other calls to danger()
. You
would never have more than five copies of "Danger, "
appended to the
beginning of message
, but you might have fewer.
Any time a thread enters a piece of code protected by the synchronized
keyword, it implicitly acquires a lock which only a single thread can
hold. If another thread tries to access the code, it’s forced to wait
until the lock is released. This lock is re-entrant. Re-entrant means
that, when a thread currently holds a lock and tries to get it again, it
succeeds. This situation frequently occurs with synchronized methods
which call other synchronized methods.
Consider method safety()
which does the “opposite” of danger()
, by
removing occurrences of "Danger, "
from the beginning of message
.
public synchronized void safety() {
if(message.startsWith("Danger, "))
message = message.substring(8);
}
Will the danger()
and safety()
methods play nicely together on the
same object? In other words, will a thread be blocked from entering
safety()
if another thread is already in danger()
? Yes! The locks
in Java are connected to objects. When you use the synchronized
keyword on a
method, the object the method is being called on (whichever object
this
refers to inside the method) serves as the lock. Thus, only one
thread can be inside of either of these methods on a given object. If you have 10
synchronized methods in an object, only one of them can execute at a
time for that object.
Perhaps this level of control is too restrictive. You may have six
methods which conflict with each other and four others which conflict
with each other but not the first six. Using synchronized
in each
method declaration would unnecessarily limit the amount of concurrency
your program could have.
Although it takes a little more work, using synchronized
with a block
of code allows more fine-grained control. The following version of
danger()
is equivalent to the earlier one.
public void danger() {
synchronized(this) {
message = "Danger, " + message;
}
}
Using synchronized
on a block of code gives us more flexibility in two
ways. First, we can choose exactly how much code we want to control,
instead of the whole method. Second, we can choose which object we want
to use for synchronization. For the block style, any arbitrary object
can be used as a lock. Objects keep a list of threads which are waiting
to get the lock and do all the other management needed to make the
synchronized
keyword work.
If you have two critical sections which are unrelated to each other, you can use the fine-grained control the block style provides. First, you’ll need some objects to use as locks, probably declared so that they can easily be shared, perhaps as static fields of a class.
private static Object lock1 = new Object();
private static Object lock2 = new Object();
Then, wherever you need control over concurrency, you use them as locks.
synchronized(lock1) {
// Do dangerous thing 1
}
// Do safe things
synchronized(lock2) {
// Do dangerous thing 2, unrelated to dangerous thing 1
}
Since declaring a method with synchronized
is equivalent to having its
body enclosed in a block beginning with synchronized(this)
, what about
static
methods? Can they be synchronized
? Yes, they can. Whenever a
class is loaded, Java creates an object of type Class
which
corresponds to that class. This object is what synchronized static
methods inside the class will use as a lock. For example, a synchronized
static method inside of the Eggplant
class will lock on the object
Eggplant.class
.
14.4.2. The wait()
and notify()
methods
Protecting critical sections with the synchronized
keyword is a
powerful technique, and many other synchronization tools can be built
using just this tool. However, efficiency demands a few more options.
Sometimes a thread is waiting for another thread to finish a task so
that it can process the results. Imagine one thread collecting votes
while another one’s waiting to count them. In this example, the
counting thread must wait for all votes to be cast before it can begin
counting. We could use a synchronized block and an indicator boolean
called votingComplete
to allow the collector thread to signal to the
counting thread.
while(true) {
synchronized(this) {
if(votingComplete)
break;
}
}
countVotes();
What’s the problem with this design? The counting thread is
running through the while
loop over and over waiting for
votingComplete
to become true
. On a single processor, the counting
thread would slow down the job of the collecting thread which is trying
to process all the votes. On a multicore system, the counting thread is
still wasting CPU cycles that some other thread could use. This
phenomenon is known as busy waiting, for obvious reasons.
To combat this problem, Java provides the wait()
method. When a thread’s
executing synchronized code, it can call wait()
. Instead of busy
waiting, a thread which has called wait()
will be removed from the
list of running threads. It will wait in a dormant state until someone
comes along and notifies the thread that its waiting is done. If you
recall the thread state diagram from Chapter 13,
there’s a Not Runnable state which threads enter by
calling sleep()
, calling wait()
, or performing blocking I/O. Using
wait()
, we can rewrite the vote counting thread.
synchronized(this) {
while(!votingComplete) {
wait();
}
}
countVotes();
Note that the while
loop has moved inside the synchronized block.
Doing so before might have kept our program from terminating: As long as
the vote counting thread held the lock, the vote collecting thread would
not be allowed to modify votingComplete
. When a thread calls wait()
,
however, it gives up the corresponding lock it’s holding until it wakes
up and runs again. Why use the while
loop at all now? There’s no
guarantee that the condition you’re waiting for is true
. Many threads
might be waiting on this particular lock. We use the while
loop to check
that votingComplete
is true
and wait again if it isn’t.
In order to notify a waiting thread, the other thread calls the
notify()
method. Like wait()
, notify()
must be called within a
synchronized block or method. Here is corresponding code the vote
collecting thread might use to notify the counting thread that voting is
complete.
// Finish collecting votes
synchronized(this) {
votingComplete = true;
notifyAll();
}
A call to notify()
will wake up one thread waiting on the lock object.
If there are many threads waiting, the method notifyAll()
used above
can be called to wake them all up. In practice, it’s usually safer to
call notifyAll()
. If a particular condition changes and a single
waiting thread is notified, that thread might need to notify the next
waiting thread when it’s done. If your code isn’t very carefully
designed, some thread might end up waiting forever and never be notified
if you only rely on notify()
.
To illustrate the use of wait()
and notify()
calls inside of
synchronized code, we give a simple solution to the producer/consumer
problem below. This problem is a classic example in the concurrent
programming world. Often one thread (or a group of threads) is
producing data, perhaps from some input operation. At the same time, one
thread (or, again, a group of threads) is taking these chunks of
data and consuming them by performing some computational or output task.
Every resource inside of a computer is finite. Producer/consumer problems often assume a bounded buffer which stores items from the producer until the consumer can take them away. Our solution does all synchronization on this buffer. Many different threads can share this buffer, but all accesses will be controlled.
public class Buffer {
public final static int SIZE = 10;
private Object[] objects = new Object[SIZE];
private int count = 0;
public synchronized void addItem(Object object) throws InterruptedException { (1)
while(count == SIZE) (2)
wait();
objects[count] = object;
count++;
notifyAll(); (3)
}
public synchronized Object removeItem() throws InterruptedException { (4)
while(count == 0) (5)
wait();
count--;
Object object = objects[count];
notifyAll(); (6)
return object;
}
}
1 | When adding an item, producers enter the synchronized addItem()
method. |
2 | If count shows that the buffer is full, the producer must wait
until the buffer has at least one open space. |
3 | After adding an item to the buffer, the producer then notifies all waiting threads. |
4 | The consumer
performs mirrored operations in removeItem() . |
5 | A consumer thread can’t consume anything if the buffer is empty and must then wait. |
6 | After there’s an object to consume, the consumer removes it and notifies all other threads. |
Both methods are synchronized, making access to the buffer completely sequential. Although it seems undesirable, sequential behavior is precisely what’s needed for the producer/consumer problem. All synchronized code is a protection against unsafe concurrency. The goal is to minimize the amount of time spent in synchronized code and get threads back to parallel execution as quickly as possible.
Although producer/consumer is a good model to keep in mind, there are other ways that reading and writing threads might interact. Consider the following programming problem, similar to one you might find in real life.
As a rising star in a bank’s IT department, you’ve been given the job
of creating a new bank account class called SynchronizedAccount
. This
class must have methods to support the following operations: deposit,
withdraw, and check balance. Each method should print a status message
to the screen on completion. Also, the method for withdraw should return
false
and do nothing if there are insufficient funds. Because the
latest system is multi-threaded, these methods must be designed so that
the bookkeeping is consistent even if many threads are accessing a
single account. No money should magically appear or disappear.
There’s an additional challenge. To maximize concurrency,
SynchronizedAccount
should be synchronized differently for read and
write accesses. Any number of threads should be able to
check the balance on an account simultaneously, but only one thread can
deposit or withdraw at a time.
To solve this problem, our implementation of the class has a balance
variable to record the balance, but it also has a readers
variable to
keep track of the number of threads which are reading from the account
at any given time.
public class SynchronizedAccount {
private double balance = 0.0;
private int readers = 0;
Next, the getBalance()
method is called by threads which wish to read
the balance.
public double getBalance() throws InterruptedException {
double amount;
synchronized(this) { (1)
readers++;
}
amount = balance; (2)
synchronized(this) {
if(--readers == 0) (3)
notifyAll(); (4)
}
return amount;
}
1 | Access to the readers variable is synchronized. |
2 | After passing that first synchronized block, the code which stores the
balance is no longer synchronized. In this way, multiple readers can access
the data at the same time. For this example, the concurrency controls we
have are overkill. The command amount = balance does not take a great
deal of time. If it did, however, it would make sense for readers to
execute it concurrently as we do. |
3 | After reading the balance, this method
decrements readers . |
4 | If readers reaches 0, a call to notifyAll() is
made, signaling that threads trying to deposit to or withdraw from the
account can continue. |
public void deposit(double amount) throws InterruptedException {
changeBalance(amount);
System.out.println("Deposited $" + amount + ".");
}
public boolean withdraw(double amount)
throws InterruptedException {
boolean success = changeBalance(-amount);
if(success)
System.out.println("Withdrew $" + amount + ".");
else
System.out.println("Failed to withdraw $" +
amount + ": insufficient funds.");
return success;
}
The deposit()
and withdraw()
methods are wrappers for the
changeBalance()
method, which has all the interesting concurrency
controls.
protected synchronized boolean changeBalance(double amount) (1)
throws InterruptedException {
boolean success;
while(readers > 0) (2)
wait();
if(success = (balance + amount > 0)) (3)
balance += amount;
return success;
}
}
1 | The changeBalance() method is synchronized so that it can have
exclusive access to the readers variable. It’s also marked protected
because SynchronizedAccount will be used as a parent class in
Chapter 17. |
2 | As long as readers is
greater than 0, this method will wait. |
3 | Eventually, the readers should finish their job and notify the waiting writer which can finish changing the balance of the account. |
14.5. Pitfalls: Synchronization challenges
As you can see from the dining philosophers problem, synchronization tools help us get the right answer but also create other difficulties.
14.5.1. Deadlock
Deadlock is the situation when two or more threads are both waiting for the others to complete, forever. Some combination of locks or other synchronization tools has forced a blocking dependence onto a group of threads which will never be resolved.
In the past, people have described four conditions which must exist for deadlock to happen.
-
Mutual Exclusion: Only one thread can access the resource (often a lock) at a time.
-
Hold and Wait: A thread holding a resource can ask for additional resources.
-
No Preemption: A thread holding a resource cannot be forced to release it by another thread.
-
Circular Wait: Two or more threads hold resources which make up a circular chain of dependency.
We illustrate deadlock with an example of how not to solve the dining philosophers problem. What if all the philosophers decided to pick up the chopstick on her left and then the chopstick on her right? If the timing was just right, each philosopher would be holding one chopstick in her left hand and be waiting forever for her neighbor on the right to give up a chopstick. No philosopher would ever be able to eat. Here’s that scenario illustrated in code.
public class DeadlockPhilosopher extends Thread {
public static final int SEATS = 5; (1)
private static boolean[] chopsticks = new boolean[SEATS]; (2)
private int seat;
public DeadlockPhilosopher(int seat) { (3)
this.seat = seat;
}
1 | We define a constant for the number of seats. |
2 | We create a shared boolean array called chopsticks so that all philosophers
can know which chopsticks are in use. |
3 | The constructor assigns each philosopher a seat number. |
public static void main(String args[]) {
DeadlockPhilosopher[] philosophers = new DeadlockPhilosopher[SEATS];
for(int i = 0; i < SEATS; i++) {
philosophers[i] = new DeadlockPhilosopher(i);
philosophers[i].start(); (1)
}
try {
for(int i = 0; i < SEATS; i++)
philosophers[i].join(); (2)
}
catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("All philosophers done.");
}
1 | In main() , we create and start a thread for each
philosopher. |
2 | Then, we wait for them to finish which, sadly, will never happen. |
After setting up the class and the main()
method, things get interesting
in the run()
method.
public void run() {
try {
getChopstick(seat); (1)
Thread.sleep(50); (2)
getChopstick((seat + 1) % SEATS); (3)
}
catch(InterruptedException e) {
e.printStackTrace();
}
eat();
}
1 | First a philosopher tries to get her left chopstick. |
2 | Then she sleeps for 50 milliseconds. |
3 | Finally, she tries to get her right chopstick. We mod by SEATS so that the
last philosopher will try to get the chopstick at the beginning of the array. |
Without sleeping, this code would usually run just fine. Every once in a while, the philosophers would become deadlocked, but it would be hard to predict when. By introducing the sleep, we can all but guarantee that the philosophers will deadlock every time.
The remaining two methods are worth examining to see how the synchronization is done, but by getting the two chopsticks separately above, we’ve already gotten ourselves into trouble.
private void getChopstick(int location) throws InterruptedException {
if(location < 0)
location += SEATS;
synchronized(chopsticks) {
while(chopsticks[location])
chopsticks.wait();
chopsticks[location] = true;
}
System.out.println("Philosopher " + seat +
" picked up chopstick " + location + ".");
}
private void eat() {
// Done eating, put back chopsticks
synchronized(chopsticks) {
chopsticks[seat] = false;
if(seat == 0)
chopsticks[SEATS - 1] = false;
else
chopsticks[seat - 1] = false;
chopsticks.notifyAll();
}
}
}
Here’s another example of deadlock. We emphasize deadlock because it’s one of the most common and problematic issues with using synchronization carelessly.
Consider two threads which both need access to two separate resources.
In our example, the two resources are random number generators. The goal
of each of these threads is to acquire locks for the two shared random
number generators, generate two random numbers each, and sum the numbers
generated. (Note that locks are totally unnecessary for this problem
since access to Random
objects is synchronized.)
import java.util.Random;
public class DeadlockSum extends Thread {
private static Random random1 = new Random();
private static Random random2 = new Random();
private boolean reverse;
private int sum;
The class begins by creating shared static
Random
objects
random1
and random2
. Then, in the main()
method, the main thread
spawns two new threads, passing true
to one and false
to the other.
public static void main(String[] args) {
Thread thread1 = new DeadlockSum(true);
Thread thread2 = new DeadlockSum(false);
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
}
catch(InterruptedException e) {
e.printStackTrace();
}
}
Next, the mischief begins to unfold. One of the two threads stores
true
in its reverse
field.
public DeadlockSum(boolean reverse) {
this.reverse = reverse;
}
Finally, we have the run()
method where all the action happens. If the
two running threads were both to acquire locks for random1
and random2
in
the same order, everything would work out fine. However, the reversed
thread locks on random2
and then random1
, with a sleep()
in
between. The non-reversed thread tries to lock on random1
and then
random2
.
public void run() {
if(reverse) {
synchronized(random2) {
System.out.println("Reversed Thread: locked random2");
try{ Thread.sleep(50); }
catch(InterruptedException e) {
e.printStackTrace();
}
synchronized(random1) {
System.out.println("Reversed Thread: locked random1");
sum = random1.nextInt() + random2.nextInt();
}
}
}
else {
synchronized(random1) {
System.out.println("Normal Thread: locked random1");
try { Thread.sleep(50); }
catch(InterruptedException e) {
e.printStackTrace();
}
synchronized(random2) {
System.out.println("Normal Thread: locked random2");
sum = random1.nextInt() + random2.nextInt();
}
}
}
}
}
If you run this code, it should invariably deadlock with thread1
locked on random2
and thread2
locked on random1
. No sane
programmer would intentionally code the threads like this. In fact, the
extra work we did to acquire the locks in opposite orders is exactly
what causes the deadlock. For more complicated programs, there may be
many different kinds of threads and many different resources. If two
different threads (perhaps written by different programmers) need
both resource A and resource B at the same time but try to acquire them
in reverse order, this kind of deadlock can occur without such an
obvious cause.
For deadlock of this type, the circular wait condition can be broken by ordering the resources and always locking the resources in ascending order. Of course, this solution only works if there is some universal way of ordering the resources and the ordering is always followed by all threads in the program.
Ignoring the deadlock problems with the example above, it gives a nice example of the way Java intended synchronization to be done: when possible, use the resource you need as its own lock. Many other languages require programmers to create additional locks or semaphores to protect a given resource, but this approach causes problems if the same lock is not consistently used. Using the resource itself as a lock is an elegant solution.
14.5.2. Starvation and livelock
Starvation is another problem which can occur with careless use of synchronization tools. Starvation is a general term which covers any situation in which some thread never gets access to the resources it needs. Deadlock can be viewed as a special case of starvation since none of the threads which are deadlocking makes progress.
The dining philosophers problem was framed around the idea of eating with humorous intent. If a philosopher is never able to acquire chopsticks, that philosopher will quite literally starve.
Starvation doesn’t necessarily mean deadlock, however. Examine the
implementation in Example 14.2 for the bank account.
That solution is correct in the sense that it preserves mutual
exclusion. No combination of balance checks, deposits, or withdrawals
will cause the balance to be incorrect. Money will neither be created
nor destroyed. A closer inspection reveals that the solution is not
entirely fair. If a single thread is checking the balance, no other
thread can make a deposit or a withdrawal. Balance checking threads
could be coming and going constantly, incrementing and decrementing the
readers
variable, but if readers
never goes down to zero, threads
waiting to make deposits and withdrawals will wait forever.
Another kind of starvation is livelock. In deadlock, two or more threads get stuck and wait forever, doing nothing. Livelock is similar except that the two threads keep executing code and waiting for some condition that never arrives. A classic example of livelock is two polite (but oddly predictable) people speaking with each other: Both happen to start talking at exactly the same moment and then stop to hear what the other has to say. After exactly one second, they both begin again and immediately stop. Lather, rinse, repeat.
Imagine three friends going to a party. Each of them starts getting ready at different times. They follow the pattern of getting ready for a while, waiting for their friends to get ready, and then calling their friends to see if the other two are ready. If all three are ready, then the friends will leave. Unfortunately, if a friend calls and either of the other two aren’t ready, he’ll become frustrated and stop being ready. Perhaps he’ll realize that he’s got time to take a shower or get involved in some other activity for a while. After finishing that activity, he’ll become ready again and wait for his friends to become ready.
If the timing is just right, the three friends will keep becoming ready, waiting for a while, and then becoming frustrated when they realize that their friends aren’t ready. Here’s a rough simulation of this process in code.
public class Livelock extends Thread {
private static int totalReady = 0; (1)
private static Object lock = new Object(); (2)
public static void main(String[] args) { (3)
Livelock friend1 = new Livelock();
Livelock friend2 = new Livelock();
Livelock friend3 = new Livelock();
1 | First, we create a shared variable called totalReady which
tracks the total number of friends ready. |
2 | To avoid race conditions, a shared Object called lock will be used to control
access to totalReady . |
3 | Then, the main() method creates Livelock
objects representing each of the friends. |
try {
friend1.start();
Thread.sleep(100);
friend2.start();
Thread.sleep(100);
friend3.start();
friend1.join();
friend2.join();
friend3.join();
}
catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("All ready!");
}
The rest of the main()
method starts each of the threads representing
the friends running, with a 100 millisecond delay before the next thread
starts . Then, it waits for them all to finish. If
successful, it’ll print All ready!
to the screen.
public void run() {
boolean done = false;
try {
while(!done) { (1)
Thread.sleep(75); // Prepare for party (2)
synchronized(lock) {
totalReady++; (3)
}
Thread.sleep(75); // Wait for friends (4)
synchronized(lock) {
if(totalReady >= 3) (5)
done = true;
else
totalReady--; (6)
}
}
}
catch(InterruptedException e) {
e.printStackTrace();
}
}
}
1 | In the run() method, each friend goes through a loop until the done
variable is true . |
2 | In this loop, an initial call to Thread.sleep()
for 75 milliseconds represents preparing for the party. |
3 | After that,
totalReady is incremented by one. |
4 | Then, the friend waits for another 75 milliseconds. |
5 | Finally, he checks to see if everyone else is ready by
testing whether totalReady is 3 . |
6 | If not, he decrements totalReady and repeats the process. |
At roughly 75 milliseconds into the simulation, the first friend becomes ready, but he doesn’t check with his friends until 150 milliseconds. Unfortunately, the second friend doesn’t become ready until 175 milliseconds. He then checks with his friends at 225 milliseconds, around which time the first friend is becoming ready a second time. However, the third friend isn’t ready until 275 milliseconds. When he then checks at 350 milliseconds, the first friend isn’t ready anymore. On some systems the timing might drift such that the friends all become ready at the same time, but it could take a long, long while.
In reality, human beings would not put off going to a party indefinitely. Some people would decide that it was too late to go. Others would go alone. Others would go over to their friends' houses and demand to know what was taking so long. Computers are not nearly as sensible and must obey instructions, even if they cause useless repetitive patterns. Realistic examples of livelock are hard to show in a short amount of code, but they do crop up in real systems and can be very difficult to predict.
14.5.3. Sequential execution
When designing a parallel program, you might notice that synchronization tools are necessary to get a correct answer. Then, when you run this parallel version and compare it to the sequential version, it runs no faster or, worse, runs slower than the sequential version. Too much zeal with synchronization tools can produce a program which gives the right answer but doesn’t exploit any parallelism.
For example, we can take the run()
method from the parallel
implementation of matrix multiply given in Example 13.11
and use the synchronized
keyword to lock on the matrix itself.
public void run() {
synchronized(c) {
for(int i = lower; i < upper; i++)
for(int j = 0; j < c[i].length; j++)
for(int k = 0; k < b.length; k++)
c[i][j] += a[i][k] * b[k][j];
}
}
In this case, only a single thread would have access to the matrix at any given time, and all speedup would be lost.
For the parallel version of matrix multiply we gave earlier, no synchronization is needed. In
the case of the producer/consumer problem, synchronization is necessary,
and the only way to manage the buffer properly is to enforce sequential
execution. Sometimes sequential execution can’t be avoided, but you
should always know which pieces of code are truly executing in parallel
and which aren’t if you hope to get the maximum amount of speedup. The
synchronized
keyword should be used whenever it’s needed, but no
more.
14.5.4. Priority inversion
In Chapter 13 we suggest that you rarely use thread priorities. Even good reasons to use priorities can be thwarted by priority inversion. In priority inversion, a lower priority thread holds a lock needed by a higher priority thread, potentially for a long time. Because the high priority thread cannot continue, the lower priority thread gets more CPU time, as if it were a high priority thread.
Worse, if there are some medium priority threads in the system, the low priority thread could hold the lock needed by the high priority thread for even longer because those medium priority threads reduce the amount of CPU time the low priority thread has to finish its task.
14.6. Solution: Dining philosophers
Here we give our solution to the dining philosophers problem. Although deadlock was the key pitfall we were trying to avoid, many other issues can crop up in solutions to this problem. A single philosopher might be forced into starvation, or all philosophers might experience livelock through some pattern of picking up and putting down chopsticks which never quite works out. A very simple solution could allow the philosophers to eat, one by one, in order. Then, the philosophers would often and unnecessarily be waiting to eat, and the program would approach sequential execution.
The key element that makes our solution work is that we force a philosopher to pick up two chopsticks atomically. The philosopher will either pick up both chopsticks or neither.
import java.util.Random;
public class DiningPhilosopher extends Thread {
public static final int SEATS = 5;
private static boolean[] chopsticks = new boolean[SEATS];
private int seat;
public DiningPhilosopher(int seat) {
this.seat = seat;
}
We begin with a similar setup as the deadlocking version given in Example 14.3.
public static void main(String args[]) {
DiningPhilosopher[] philosophers = new DiningPhilosopher[SEATS];
for(int i = 0; i < SEATS; i++) {
philosophers[i] = new DiningPhilosopher(i);
philosophers[i].start(); (1)
}
try {
for(int i = 0; i < SEATS; i++)
philosophers[i].join(); (2)
}
catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("All philosophers done.");
}
1 | In main() , we create and start a thread for each
philosopher. |
2 | Then, we wait for them to finish. |
public void run() { (1)
for(int i = 0; i < 100; i++) { (2)
think();
getChopsticks();
eat();
}
}
private void think() { (3)
Random random = new Random();
try {
sleep(random.nextInt(20) + 10);
}
catch(InterruptedException e) {
e.printStackTrace();
}
}
1 | This run() method is different from the deadlocking version but not in
a way that prevents deadlock. |
2 | We added the for loop so that you could
see the philosophers eat and think many different times without
problems. |
3 | We also added the think() method to randomize the amount of
time between eating so that each run of the program is less
deterministic. |
private void getChopsticks() { (1)
int location1 = seat;
int location2 = (seat + 1) % SEATS;
synchronized(chopsticks) { (2)
while(chopsticks[location1] || chopsticks[location2]) { (3)
try {
chopsticks.wait(); (4)
}
catch(InterruptedException e) {
e.printStackTrace();
}
}
chopsticks[location1] = true;
chopsticks[location2] = true;
}
System.out.println("Philosopher " + seat + " picked up chopsticks " +
location1 + " and " + location2 + ".");
}
1 | The real place where deadlock is prevented is in the getChopsticks()
method. As in Example 14.3, we mod by SEATS so that the last philosopher
tries to get the first chopstick in the array. |
2 | The philosopher acquires the chopsticks lock. |
3 | Then, she picks up the two chopsticks she needs only if both are available. |
4 | Otherwise, she waits. |
private void eat() { (1)
// Done eating, put back chopsticks
synchronized(chopsticks) { (2)
chopsticks[seat] = false;
if(seat == 0)
chopsticks[SEATS - 1] = false;
else
chopsticks[seat - 1] = false;
chopsticks.notifyAll(); (3)
}
}
}
1 | Finally, in the eat() method, the philosopher eats the rice. We would
assume that some other computation would be done here in a realistic
problem before entering the synchronized block. The eating itself
does not require a lock. |
2 | After eating’s done, the lock is acquired to give back the chopsticks (hopefully after some cleaning). |
3 | Then, all waiting philosophers are notified that some chopsticks may have become available. |
Our solution prevents deadlock and livelock because some philosopher will get control of two chopsticks eventually, yet there are still issues. Note that each philosopher only eats and thinks 100 times. If, instead of philosophers sharing chopsticks, each thread were a server sharing network storage units, the program could run for an unspecified amount of time: days, weeks, even years. If starvation is happening to a particular philosopher in our program, the other philosophers will finish after 100 rounds, and the starved philosopher can catch up. If there were no limitation on the loop, a starving philosopher might never catch up.
Even if we increase the number of iterations of the loop quite a lot,
we probably wouldn’t see starvation of an individual thread because we’re
cheating in another way. Some unlucky sequence of chopstick accesses
by two neighboring philosophers could starve the philosopher between
them. By making the think()
method wait a random amount of time, such
a sequence will probably be interrupted. If all philosophers thought for
exactly the same amount of time each turn, an unlucky pattern could
repeat. It’s not unreasonable to believe that the amount
of thinking a philosopher (or a server) will do at any given time will
vary, but the behavior depends on the system.
It’s very difficult to come up with a perfect answer to some synchronization problems. Such problems have been studied for many years, and research continues to find better solutions.
14.7. Exercises
Conceptual Problems
-
What’s the purpose of the
synchronized
keyword? How does it work? -
The language specification for Java makes it illegal to use the
synchronized
keyword on constructors. During the creation of an object, it’s possible to leak data to the outside world by adding a reference to the object under construction to some shared data structure. What’s the danger of leaking data in this way? -
If you call
wait()
ornotify()
on an object, it must be inside of a block synchronized on the same object. If not, the code will compile, but anIllegalMonitorStateException
may be thrown at run time. Why is it necessary to own the lock on an object before callingwait()
ornotify()
on it? -
Why is it safer to call
notifyAll()
thannotify()
? If it’s generally safer to callnotifyAll()
, are there any scenarios in which there are good reasons to callnotify()
? -
Imagine a simulation of a restaurant with many waiter and chef objects. The waiters must submit orders to the kitchen staff, and the chefs must divide the work among themselves. How would you design this system? How would information and food be passed from waiter to chef and chef to waiter? How would you synchronize the process?
-
Let’s reexamine the code that increments a variable with several threads from Section 14.3. We can rewrite the
run()
method as follows.public synchronized void run() { for(int i = 0; i < COUNT/THREADS; i++) counter++; }
Will this change fix the race condition? Why or why not?
-
Examine our deadlock example from Example 14.4. Explain why this example fulfills all four conditions for deadlock. Be specific about which threads and which resources are needed to show each condition.
-
What’s priority inversion? Why can a low priority thread holding a lock be particularly problematic?
Programming Practice
-
In Example 14.1 the
Buffer
class used to implement a solution to the producer/consumer problem only has a single lock. When the buffer is empty and a producer puts an item in it, both producers and consumers are woken up. A similar situation happens whenever the buffer is full and a consumer removes an item. Re-implement this solution with two locks so that a producer putting an item into an empty buffer only wakes up consumers and a consumer removing an item from a full buffer only wakes up producers. -
In Example 14.2 we used the class
SynchronizedAccount
to solve a bank account problem. As we mention in Section 14.5.2, depositing and withdrawing threads can be starved out by a steady supply of balance checking threads. Add additional synchronization tools toSynchronizedAccount
so that balance checking threads will take turns with depositing and withdrawing threads. If there are no depositing or withdrawing threads, make your implementation continue to allow an unlimited number of balance checking threads to read concurrently. -
The solution to the dining philosophers problem given in Section 14.6 suffers from the problem that a philosopher could be starved by the two philosophers on either side of her, if she happened to get unlucky. Add variables to each philosopher which indicate hunger and the last time a philosopher has eaten. If a given philosopher is hungry and hasn’t eaten for longer than her neighbor, her neighbor shouldn’t pick up the chopstick they share. Add synchronization tools to enforce this principle of fairness. Note that your solution must not cause deadlock. Although one philosopher may be waiting on another who is waiting on another and so on, some philosopher in the circle must have gone hungry the longest, breaking circular wait.
Experiments
-
Critical sections can slow down your program by preventing parallel computation. However, the locks used to enforce critical sections can add extra delays on top of that. Design a simple experiment which repeatedly acquires a lock and does some simple operation. Test the running time with and without the lock. See if you can estimate the time needed to acquire a lock in Java on your system.
-
Design a program which experimentally determines how much time a thread is scheduled to spend running on a CPU before switching to the next thread. To do this, first create a tight loop which runs a large number of iterations, perhaps 1,000,000 or more. Determine how much time it takes to run a single run of those iterations. Then, write an outer loop which runs the tight loop several times. Each iteration of the outer loop, test to see how much time has passed. When you encounter a large jump in time, typically at least 10 times the amount of time the tight loop usually takes to run to completion, record that time. If you run these loops in multiple threads and average the unusually long times together for each thread, you should be able to find out about how long each thread waits between runs. Using this information, you can estimate how much time each thread is allotted. Bear in mind that your this average is only an estimation. Some JVMs will change the amount of CPU time allotted to threads for various reasons. If you’re on a multicore machine, it will be more difficult to interpret your data since some threads will be running concurrently.
-
Create an experiment to investigate priority inversion in the following way.
-
Create two threads, setting the priority of the first to
MIN_PRIORITY
and the priority of the second toMAX_PRIORITY
. Start the first thread running but wait 100 milliseconds before starting the second thread. The first thread should acquire a shared lock and then perform some lengthy process such as finding the sum of the sines of the first million integers. After it finishes its computation, it should release the lock, and print a message. The second thread should try to acquire the lock, print a message, and then release the lock. Time the process. Because the lock is held by the lower priority thread, the higher priority thread will have to wait until the other thread is done for it to finish. -
Once you have a feel for the time it takes for these two threads to finish alone, create 10 more threads that must also perform a lot of computation. However, do not make these threads try to acquire the lock. How much do they delay completion of the task? How does this delay relate to the number of cores in your processor? How much does the delay change if you set the priorities of these new threads to
MAX_PRIORITY
orMIN_PRIORITY
?
-