|
Multithreaded programming is simplest to implement when the various threads can operate independently, meaning when they share no data objects during their execution. That has been the case with all the programs we have shown so far. When the threads are given the ability to access and modify the same data, there is always a potential for a program to exhibit incorrect behavior.
In this section, we will present two examples of thread interference. The first example shows a subtle and insidious form of thread interference, caused by the threads interfering at the level of low-level instructions that the high-level Java statements get decomposed into. The second example will show thread interference for the special case of I/O.
In this example, all the threads will share the same data object of the following type:
class DataObject { int dataItem1; int dataItem2; // .... }
Both the data members of the class, dataItem1 and dataItem2, will be set to 50 initially. When a thread is executed, its job will be to make equal and opposite modifications to the two data members so that their sum will always be equal to 100. So, as shown in lines (A2) and (A3) of the program below, we supply the class Data Object with a constructor where we initialize the two data members. We also supply the class with a method itemSwap in line (B1) for making equal and opposite modifications to the two data members in lines (B3) and (B5). Additional method for DataObject is the method test in line (C1) to test the sum of the values of dataItem1 and dataItem2 after the method itemSwap is invoked. You would expect test to always print out 100 on the console.
Note the invocation of keepBusy(10) inside itemSwap in line (B4). The keepBusy function tries to merely keep the processor busy for the designated number of milliseconds. The purpose of this function is to increase the length of the time the processor would need for completing itemSwap so as to increase the probability that there would be a timeslicing event at some point during the execution of itemSwap. As we will see later, it is this timeslicing action that is responsible for the thread interference we will show for this example.
For the purpose of invoking the method itemSwap repeatedly on an instance of type DataObject and to be able to do so in separate threads, we will define a class RepeatedSwaps in line (E) by subclassing from Thread. The run method of this multithreadable class, in line (G1), goes through 20,000 iterations and, via the statement in line (G4), we print out the sum of the two data members of the data object every 4000 iterations.
Finally, we define a class, UnsynchedSwaps in line (H) for creating different threads of type RepeatedSwaps, as shown in lines (J1) through (J5). Note that all the threads are supplied with the same data object, created in line (I). The source code follows:
//UnsynchedSwaps.java ///////////////////////// class DataObject ////////////////////////// class DataObject { int dataItem1; int dataItem2; DataObject() { //(A1) dataItem1 = 50; //(A2) dataItem2 = 50; //(A3) } void itemSwap() { //(B1) int x = (int) ( -4.999999 + Math.random() * 10 ); //(B2) dataItem1 -= x; //(B3) keepBusy(10); //(B4) dataItem2 += x; //(B5) } void test() { //(C1) int sum = dataItem1 + dataItem2; //(C2) System.out.println( sum ); //(C3) } public void keepBusy( int howLong ) { //(D) long curr = System.currentTimeMillis(); while ( System.currentTimeMillis() < curr + howLong ) ; } } //////////////////////// class RepeatedSwaps //////////////////////// class RepeatedSwaps extends Thread { //(E) DataObject dobj; RepeatedSwaps( DataObject d ) { //(F) dobj = d; start() } public void run() { //(G1) int i = 0; while ( i < 20000 ) { //(G2) dobj.itemSwap(); //(G3) if ( i % 4000 == 0 ) dobj.test(); //(G4) try { sleep( (int) (Math.random() * 2 ) ); } //(G5) catch( InterruptedException e ) {} i++; } } public void keepBusy() { long curr = System.currentTimeMillis(); while ( System.currentTimeMillis() < curr + (int) (Math.random()*;10) ) ; } } /////////////////////// class UnsynchedSwaps //////////////////////// public class UnsynchedSwaps { //(H) public static void main( String [] args ) { DataObject d = new DataObject(); //(I) new RepeatedSwaps( d ); //(J1) new RepeatedSwaps( d ); //(J2) new RepeatedSwaps( d ); //(J3) new RepeatedSwaps( d ); //(J4) } }
This program produces the following output:[8]
100 98 100 100 105 98 100 99 103 94 98 91 100 97 98 96 100 104 98 105
Evidently, something is wrong. Given that the method itemSwap() makes equal and opposite changes to the two data members, dataItem1 and dataItem2, the sum of the two should always add up to 100. So, obviously, it must the case that the threads are interfering with one another.
If you examine the Java code in the above program, it would seem that even if the threads interfered with one another, the sum of the values in dataItem1 and dataItem2 should always add up to 100. One would think that even if a thread, say thread1, was interrupted (on account of timeslicing) after executing the first data-modifying instructionof itemSwap:
dataItem1 -= x;
and some other thread, say thread2, went ahead executed both of the following instructions
dataItem1 -= x; dataItem2 += x;
with possibly a different value of x, after thread1 woke up to execute its own
dataItem2 += x;
the overall result would still be the same, meaning the sum of the two data members dataItem1 and dataItem2 would still add up to 100.
But, unfortunately, it is not as simple as that, primarily because a single high-level instruction like
dataItem2 += x;
is decomposed in the Java byte code into more atomic operations of the form[9]
1. Load dataItem2 into a register 2. Add x 3. Move the result back to dataItem2
Let's say thread1 is interrupted after it completes the first two steps. After thread1 is interrupted, let's say thread2 is able to complete all three steps. If thread1 wakes up soon after thread2 is done with step 3 and executes its own step 3, the result moved back to dataItem1 will not correspond to thread1 but to thread2.
So, basically, threads can corrupt the shared data any time they get interrupted in the middle of executing high-level source code instructions that access and modify the data.
We will now show an example of thread interference for the case that involves file I/O. In the following program, the class DataFile is responsible for two things: (1) Its constructor creates a file named "hello.dat" and then deposits the string "Hello" in the file, as shown in line (B2). And (2) its method fileIO first tries to read the string that is in the file "hello.dat" and writes whatever was thus read back into the file; see lines (C2) and (C3). The class FileIO is the same as described in one of the homework problems in Chapter 10.
We next define in line (D) a multithreadable class ThreadedFileIO whose sole data member, defined in line (E), is an object of type DataFile. By invoking the fileIO method on this data member, we can do file I/O in each thread separately, but all the threads will of course be accessing the same file. The run method of this class in line (G) calls for four invocations of fileIO in each thread. After each invocation of fileIO, we print out the name of the thread and the string contained in the file.
Finally, we have in line (H) the class UnsynchedFileIO for constructing and launching five ThreadedFileIO threads in lines (J1) through (J5), all opening and closing the same file, "hello.dat". Note that each invocation, such as ThreadedFileIO("t0", dd) in line (J1), creates a new thread and makes it runnable automatically because the method start is embedded in the constructor of ThreadedFileIO. If we had not included this method there, our invocations would have to be of the form ThreadedFileIO("t0", dd).start().
Here is the source code for this example:
//UnsynchedFileIO.java /////////////////////////// class DataFile ////////////////////////// class DataFile { //(A) public DataFile () { //(B1) try { FileIO.writeOneString( "Hello", "hello.dat" ); //(B2) } catch( FileIOException e ) {} } void fileIO() { //(C1) try { String str = FileIO.readOneString( "hello.dat" ); //(C2) FileIO.writeOneString( str , "hello.dat"); //(C3) } catch( FileIOException e ) {} } } /////////////////////// class ThreadedFileIO //////////////////////// class ThreadedFileIO extends Thread { //(D) DataFile df; //(E) ThreadedFileIO( String threadName, DataFile d ) { //(F) df = d; setName( threadName ); start(); } public void run() { //(G) int i = 0; while ( i++ < 4 ) { try { df.fileIO(); String str = FileIO.readOneString( "hello.dat" ); System.out.printIn( getName() + ": " + "hello.dat contains: " + str ); sleep( 5 ); } catch( InterruptedException e ) {} catch( FileIOException e ) {} } } } /////////////////////// class UnsynchedFileIO /////////////////////// public class UnsynchedFileIO { //(H) public static void main( String[] args ) { DataFile dd = new DataFile(); //(I) new ThreadedFileIO( "t0", dd ); //(J1) new ThreadedFileIO( "t1", dd ); //(J2) new ThreadedFileIO( "t2", dd ); //(J3) new ThreadedFileIO( "t3", dd ); //(J4) new ThreadedFileIO( "t4", dd ); //(J5) } }
As with the previous example, the output of this program depends on which platform you run it on. The following output was produced by a state-of-the-art multiprocessor machine running Solaris:
t1: hello.dat contains: t2: hello.dat contains: t0: hello.dat contains: Hello t4: hello.dat contains: Hello t3: hello.dat contains: Hello t1: hello.dat contains: Hello t2: hello.dat contains: Hello t0: hello.dat contains: Hello t3: hello.dat contains: Hello t1: hello.dat contains: Hello t0: hello.dat contains: t2: hello.dat contains: t4: hello.dat contains: t3: hello.dat contains: Hello t0: hello.dat contains: Hello t1: hello.dat contains: Hello t4: hello.dat contains: t2: hello.dat contains: Hello t3: hello.dat contains: Hello t4: hello.dat contains: Hello
Remember, each thread should make four appearances in the output. And for each thread the content of the file should ideally say "Hello." As is clear from the output, for some of the threads some of the time, the file appears to contain nothing. Evidently, the threads are getting in each other's way once again. The code of the method fileIO() of line (C1) where the threads can step on each other's toes consists of the following two statements in lines (C2) and (C3):
String str = FileIO.readOneString( "hello.dat" ); FileIO.writeOneString( str , "hello.dat" );
The first statement reads the string that is contained in the file "hello.dat," and the second statement writes the same string back into the same file. But, as in the previous example, each of these high-level statements gets decomposed by the compiler into a number of low-level instructions. One of these low-level instructions for the write statement above erases the previous contents of the file before the file is written into.[10] Suppose one of the threads, call it t1, was interrupted immediately after "zeroing out" the file and suppose another thread, call it t2, was allowed to read this file next. The read method in t2 thread would return an empty string. Let's say that t2 is taken off the processor after this read step. Next, suppose the processor is assigned to t1 and it writes out the string "Hello" into the file. If t2 is given the processor soon thereafter, it could overwrite an empty string into the file. The behavior of the other threads subsequently would depend on whether they saw an empty file or the string "Hello" in the file. Also, after all the threads have run to completion, the file "hello.dat" may either contain the string "Hello" or it could be empty.
[8]This output was produced on a modern multiprocessor machine running the Solaris operating system. The output of this program will vary from platform to platform and from one run to another.
[9]To see this decomposition of high-level Java statements into the low-level instructions of the sort that would be susceptible to thread interference, you can "decompile" a previously compiled class by using the javap tool, as in
javap -c -v DataObject
This will show the bytecode in human readable form that the source code from the DataObject class got compiled into. The following segment of this bytecode is for the itemSwap method:
Method void itemSwap() 0 ldc2_w #4 <Double -4.999999> 3 invokestatic #6 <Method double random()> 6 ldc2_ #7 <Double 10.0> 9 dmul 10 dadd 11 d2i 12 istore_1 13 aload_0 14 dup 15 getfield #2 <Field int dataItem1> 18 iload_1 19 isub 20 putfield #2 <Field int dataItem1> 23 aload_0 24 bipush 10 26 invokevirtual #9 <Method void keepBusy(int)> 29 aload_0 30 dup 31 getfield #3 <Field int dataItem2> 34 iload_1 35 iadd 36 putfield #3 <Field int dataItem2> 39 return
[10]Earlier we said that threads block on I/O operations—that is, the processor cannot be wrested away from a thread until the thread has completed the I/O operation it is engaged in. The reader might now ask, How come the threads do not block on the I/O implied by readOneString and writeOneString? The answer is that a thread blocks during just the atomic / steps. A method such as writeOneString does many things besides data transfer: It has to either create a new file or open an existing file. In the latter case, it must also erase the contents of the file. Only then can the data be transfered to the file. A thread will block only during the data transfer phase.
|