When two threads operate on a common object there is the risk of a situation known as interference or race condition. We will see how to solve this problem in Java by using synchronization.
We can model the bank account as an object of the following class:
class Account {
private double balance;
public Account(double initialDeposit) {
balance = initialDeposit;
}
public double getBalance() {
return balance;
}
public void deposit(double amount) {
balance += amount;
}
public void withdraw(double amount) {
if ( balance >= amount ) { balance -= amount; }
} // no negative balance allowed
}
We can model the account holders as two threads:
class AccountHolder extends Thread {
private Account acc;
public AccountHolder(Account a) {
acc = a;
}
public void run() {
...
acc.withdraw(100);
...
}
}
The following fragment of code causes the creation of the account and of the two account
holders:
...
Account accnt = new Account(150);
AccountHolder John = new AccountHolder(accnt).start();
AccountHolder Joe = new AccountHolder(accnt).start();
...
Note: accnt is a reference to the account, thus, although it is passed by
value in AccountHolder(accnt), John and Joe will share the same account
which accnt refers to. Namely, the changes made by John will be visible to Joe and
viceversa.
There is a problem with this code:
the activities of John and Joe can interfere in an odd way, and generate erroneous situations.
This may happen for instance when they both try to do a withdraw "at the same time"
At the beginning: balance is 150
John: accnt.withdraw(100) Joe: accnt.withdraw(100)
balance >= 100 ?
|
| yes balance >= 100 ?
| |
V | yes
balance = balance - 100 |
V
balance = balance - 100
At the end: balance is -50.
The problem derives from the fact that the scheduler may interleave the activities of John and Joe on the account in an arbitrary way. In the example, the activities are interleaved in such a way that both John and Joe do the test before the other does the withdraw. The consequence is a final (undesired) situation of a negative balance.
Perhaps more surprisingly, in the previous code there is a problem also with the deposit method.
At the beginning: balance is 150
public void deposit(double amount) {
balance += amount;
}
In fact, although the instruction balance += amount looks "atomic",
in the compiled code it is "broken" in a sequence of several,
more elementary actions. Typically the balance is copied in an internal register,
then the register is increased by the desired amount, and finally the
register is copied back in the balance.
b = balance;
b += amount;
balance = b;
It should now be clear that also two deposits may create an erroneous situation:
John: accnt.deposit(100) Joe: accnt.deposit(100)
b = balance (b = 150)
|
| b = balance (b = 150)
V |
b += 100 (b = 250) |
| V
| b += 100 (b = 250)
V |
balance = b |
V
balance = b
At the end: balance is 250 (instead of 350).
In general, there is a risk of race condition whenever two threads apply concurrently to the same object a method (or two different methods) operating on a common field, and at least one of the methods updates the field.
Note that there is a problem even if one of the methods updates the field and the other only reads the field. In general reading a field that is being updated by another thread might give unpredictable results.
There is no problem, on the contrary, with methods accessing common fields only in reading mode. Such read-only methods can (and should) run concurrently, thus optimizing execution time.
John: accnt.deposit(100) Joe: accnt.deposit(100) +--------------------------+ | | delay until the critical section | b = balance (b = 150) | executed by John is completed | | | | | | ... | V | | b += 100 (b = 250) | | | | | | | | V | ... | balance = b | | | +--------------------------+ John has terminated, Joe can start +--------------------------+ | | | b = balance (b = 250) | | | | | | | | V | | b += 100 (b = 350) | | | | | | | | V | | balance = b | | | +--------------------------+
class Account { private double balance; public Account(double initialDeposit) { balance = initialDeposit; } public synchronized double getBalance() { ------------ return balance; } public synchronized void deposit(double amount) { ------------ balance += amount; } public synchronized void withdraw(double amount) { ------------ if ( balance >= amount ) { balance -= amount; } } // no negative balance allowed }
John: accnt.deposit(100) Joe: accnt.deposit(100) acquire the lock on accnt +--------------------------+ try to acquire the lock on accnt | | since the lock is not available, | b = balance (b = 150) | go in the "waiting list" of accnt | | | | | | ... wait ... | V | | b += 100 (b = 250) | | | | | | | | V | | balance = b | ... wait ... | | +--------------------------+ release the lock on accnt acquire the lock on accnt +--------------------------+ | | | b = balance (b = 250) | | | | | | | | V | | b += 100 (b = 350) | | | | | | | | V | | balance = b | | | +--------------------------+ release the lock on accnt
More precisely, there is a counter
associated to a lock, which is incremented each time the
thread enters a synchronized method on the object, and decremented
when it completes a synchronized method. We will refer to the value of this
counter as "level of nesting", for it is associated with
the number of nested executions of synchronized methods on the same object
(by the same thread).
class C {
...
public synchronized m1() {
------------
... m2(); ...;
}
public synchronized m2(){
------------
...
}
}
obj.m1()
if obj.lock == 0
then obj.lock = 1 (acquire lock)
else go to wait list of obj
+--------------------------+
| |
| ... |
| |
| obj.m2() |
| obj.lock = 2 |
| +-------------------+ |
| | | |
| | ... | |
| | | |
| +-------------------+ |
| obj.lock = 1 |
| |
+--------------------------+
obj.lock = 0 (release lock)
The second method is specific to concurrent programming. We illustrate it with an example.
+-------+ P --->| B |---> C +-------+
The interactions of the producer and the consumer with the buffer will
be made possible by two synchronized
methods put() and get()
class Buffer {
private Information content;
public Buffer() { }
public synchronized void put(Information i) {
content = i;
}
public synchronized Information get() {
return content;
}
}
class Producer extends Thread {
private Buffer buff;
public Producer(Buffer b) { buff = b; }
public void run() {
while (...) {
...
buff.put(info);
...
}
}
}
class Consumer extends Thread {
private Buffer buff;
public Consumer(Buffer b) { buff = b; }
public void run() {
while (...) {
...
x = buff.get();
...
}
}
}
Unfortunately, this code is not correct.
In fact, it does not guarrantee the "once and only once" condition:
class Buffer { private Information content; private boolean empty; --------------------- public Buffer() { empty = true; } ------------ public synchronized boolean is_empty() { return empty; ---------- } public synchronized void put(Information i) { content = i; empty = false; } ------------- public synchronized Information get() { empty = true; return content; } ------------ } class Producer extends Thread { ... public void run() { ... if (buff.is_empty()) buff.put(info); -------------------- } } class Consumer extends Thread { ... public void run() { ... if (!buff.is_empty()) x = buff.get(); -------------------- } }Unfortunately, also this code is incorrect. In fact, the separation between the test and the put() (respectively, get()) operation might cause interference. In this particular case nothing bad can happen, but if we add another producer or consumer, then wrong results may be produced.
One way to solve this problem is by performing both the test and the put() (respectively, get()) operation inside the same synchronized method. This is the basic idea on which next solution is based.
class Buffer { ... public synchronized boolean put(Information i) { if (empty) { content = i; empty = false; return true; } else return false; } ... }And, correspondingly, the producer would be defined as follows:
class Producer extends Thread { ... public void run() { ... success = buff.put(info); if (!success) { ... } ... } }The code is now correct. However, what does the producer do if the put() operation does not succeed? There are two possibile answers:
class Producer extends Thread { ... public void run() { ... success = buff.put(info); while (!success) { b = buff.put(info); } ... } }
With the second case there is a problem of efficiency called "busy waiting".
and
class Buffer { private Information content; private boolean empty; public Buffer() { empty = true; } public synchronized void put(Information i) throws InterruptedException { while (!empty) wait(); content = i; empty = false; notifyAll(); } public synchronized int get() throws InterruptedException { while (empty) wait(); empty = true; notifyAll(); return content; } } class Producer extends Thread { ... public void run() { while (...) { ... buff.put(info); ... } } } class Consumer extends Thread { ... public void run() { while (...) { ... x = buff.get(); ... } } }
public synchronized void put(Information i) throws InterruptedException { if (!empty) wait(); ------------------- content = i; empty = false; notifyAll(); }then there would be the risk of interference between two producers
public synchronized void put(Information i) throws InterruptedException { while (!empty) wait(); content = i; empty = false; notify(); --------- }then, in case of more producers and consumers, the program would not work correctly. More precisely, there is a risk of deadlock, because for instance a producer may wake up another producer instead of a consumer.