CSE 428: Lecture 15
Concurrent Programming in Java
We will dedicate a group of lectures to the principles of concurrent programming.
We will use Java as the reference langauge for our examples and for the practice exercises.
Concurrent Programming
A concurrent program is characterized by the
presence of multiple threads
of control. This is in contrast to a
sequential program, where there is only a
single thread of control.
Clearly, the possibility of multiple threads enhances the capabilities of a program.
In particular, with concurrent progarmming we can:
- Perform multiple computations
- in parallel, in a multiprocessor architecture
- in interleaving, in a uniprocessor architecture
- Control multiple external activities
(reactive programming)
There are several reasons why a concurrent program may be preferable to a
sequential one. For instance:
- In multiprocessor architectures, parallelizing the activities reduces
the time necessary to perform certain tasks
(performance gain).
- Certain applications require the program to interact with
the environment, to control multiple activities, and to handle multiple events.
(Interactive and reactive programming.)
These programs are more naturally
structured as concurrent programs.
- With concurrent programming it is easier to control the
speed of the various activities.
For instance, we can set the requests from certain users to
be handled by threads with high priority.
- With concurrent programming we can increase the throughput and
responsiveness
of the application. For instance, we can devote one thread to receive a certain input.
In this way only that thread need to be blocked waiting on the input, while the
rest of the program proceeds.
The programmer's perspective
Concurrency introduces new concepts and introduces new challenges for the programmer.
Examples of new concepts are:
- Cooperation: the various threads may need to cooperate to achieve a common goal
- Communication: the various threads may need to interact and exchance information
- Competition for shared resources: various threads may need to use the same resource
- Nondeterminism: due to the (unpredictable) decisions of the scheduler, and to the
interactions among the processes,
different execution of te same program may give different results.
Examples of new challenges are
- The problem of interference (aka race condition).
Certain sequence of operations may
need to be encapsulated in order to avoid undesirable interleavings
with operations performed by other threads.
Such sequences are called critical sections.
- The problem of deadlock (aka deadly embrace).
This situation occurs when two (or more) threads are blocking each other. Typically, this happens
when two threads are both holding one resource, and each of them needs also the other resource to proceed.
An example of a situation where deadlock may occur is the dining philosophers.
It is not possible to rule out the possibility of deadlock from a concurrent programming
language, because it would restrict too much the expressive power. Thus avoiding deadlock is
responsibility of the programmer. The situation is analogous to the problem of infinite loops
or of using an unitialized variable: they cannot be avoided by the language,
avoiding them is the responsability of the programmer.
- The problem of livelock.
This situation occurs when a process is not able to progress. In contrast to the
case of deadlock, it is not blocked, however it is not doing anything useful.
Analogously to deadlock, it is responsibility of the programmer to avoid it.
- In general, concurrent programming is more difficult. Besides the usual difficulty of
the specification of the algorithm,
the programmer has halso to worry about the interaction between threads. Such interactions may be quite
complicated, and the possible cases tend to increase exponentially with the number of threads
involved.
- Debugging is much more difficult, because nondeterminism makes tests inconclusive.
The Java Programming Language
Java has a syntax similar to C and C++, and, like C++,
it has Object-Oriented features.
One of the main differences with C++ is that it
does not require explicit memory management,
because it has automatic Garbage Collection.
Another difference is that
in Java all objectes are allocated dynamically in the heap.
To be more precise, assume that we have a class
class C {
...
void f(){...}
...
}
In C++, we have two ways of creating an object of class C:
As a global or local variable in the stack, with a declaration of the form
C x;
or as a dynamic variable in the heap, by using the "new" operator:
C* y = new C();
The application of a method to the above objects, in C++, is done in the following ways:
x.f();
y->f(); // syntactic sugar for (*y).f()
In Java, only the second way of object creation is available. Namely, the only
way of creating an object is the following:
C y = new C();
which corresponds to the above C++ creation of an object pointed by y.
Also in Java y is a reference to an object, not the object itself.
However, Java does not allow the typical
operations supported by C++ on pointers, like
pointer arithmetic, addressing operator, etc.
The applicatioon of a method to y, in Java,
is done as following:
y.f();
Another difference is that Java does not support call by reference, only call by value.
However, when the parameter is an object, the changes made by the callee
will be transmitted to the caller, because the actual
parameter really stands for the reference to the object.
The situation is similar to passing a pointer by value in C or in C++.
Definition of threads in Java
A thread manages a single sequential thread of control. In Java,
threads may be created and destroyed dynamically.
The typical way of creating a thread
is as an object of a class based on a special java class, called
"Thread".
This class is characterized by two special methods, run() and
start().
The former has an empty definition in Thread, and it is meant to
be overriden in the derived classes. The latter starts the thread, namely
tells the JVM to spawn a new thread and to activate it. When the JVM activates
the new thread, it runs its method
run(), thus making the thread active.
The actual code executed depends of course on the implementation provided
for run() in the derived class.
class MyThread extends Thread {
public void run() {
//...
}
}
------------------
| Thread |
------------------
| |
| run() |
| |
------------------
^
|
|
------------------
| MyThread |
------------------
| |
| run() |
| |
------------------
Life-cycle of a thread in Java
In Java a thread has three principal states:
- Created. This is done by using the expression
new MyThread()
- Alive. This is done by applying to the new thread
the method
start()
The predicate isAlive() can be used to test if a
thread has been started and is not yet terminated.
- Terminated. This is the state of the thread
when the method run() returns.
Termination can also be provoked by another thread
by applying the method stop().
Once terminated, a thread cannot be restarted.
The substates of an alive thread
An alive thread has three possible substates
- Running: the thread is being executed
- Runnable or ready: the thread is not being executed
because of lack of processors, but it is ready for execution and may be scheduled any moment.
The scheduler decides the transitions from running to runnable (dispatch), and viceversa.
The transition from running to runnable can also be forced by the exceution of the
method yield().
- Non-runnable or suspended:
for some reason the thread cannot be scheduled for execution. The main methods which cause
the transitions to and from suspended are the following:
- sleep() and wait()
: running -> suspended
- suspend() : running or runnable -> suspended
- notify() ,notifyAll() and resume()
: non-runnable -> runnable
Note: notify() and notifyAll() only cause the transition of the
threads which had been suspended by the wait() method.
Example
The following program prints the words ``ping'' and ``pong'' at different rates
public class PingPong extends Thread {
private String word; // what word to print
private int delay; // how long to pause (in milliseconds)
public PingPong(String whatToSay, int delayTime) {
word = whatToSay;
delay = delayTime;
}
public void run() {
try {
for (;;) {
System.out.print(word + " ");
sleep(delay); // pause before printing next word
}
} catch (InterruptedException e) {
return; // end this thread
}
}
public static void main(String[] args) {
new PingPong("ping", 33).start(); // 1/30 second
new PingPong("PONG", 100).start(); // 1/10 second
}
}
Possible effect of an execution of PingPong.main:
ping PONG ping PONG ping ping PONG ping ping ping
PONG ping ping ping PONG ping ping PONG ping ping
ping PONG ping ping PONG ping ping ping PONG ping
ping PONG ping ping ping PONG ping ping PONG ping
...
Different execution may give different results. The interleaving
(and interaction) between threads depends on many factors and it is
in general unpredictable.