14 minutes read

As you know, any program works with data stored in a special memory area. Each process has its own memory, but if we use multithreading in this process, threads have access to that specific area in order to interact with each other. In this topic, we will consider how threads can communicate using shared data and what problems developers may encounter in this respect.

Sharing data between threads

Threads that belong to the same process share common memory (it is called heap). They may communicate by using shared data in memory. To be able to access the same data from multiple threads, each thread must have a reference to this data (by an object). The picture below demonstrates the idea:

example
of using shared objects by different threads

Multiple threads of a single process have references to objects in the Heap

Let's consider an example. Here's a class named Counter:

class Counter {
    var value = 0

    fun increment() {
        value++
    }
}

The class has one function: increment. Each invocation of increment adds 1 to the field value.

And here's a class that extends Thread:

class MyThread(val
counter: Counter) : Thread() {
    override fun run() {
        counter.increment()
    }
}

The constructor of MyThread takes an instance of Counter and stores it to the field. The function run invokes the function increment of the counter object.

Let's now create an instance of Counter and two instances of MyThread. Both instances of MyThread have the same reference to the counter object.

val counter: Counter =
Counter()

val thread1: MyThread = MyThread(counter)
val thread2: MyThread = MyThread(counter)

Now, let's see what happens when we start both threads one by one and print out the result of counter.getValue().

thread1.start() //
start the first thread
thread1.join()  // wait for the first thread

thread2.start() // start the second thread
thread2.join()  // wait for the second thread

println(counter.value) // it prints 2

As you can see, the result is 2 because both threads work with the same data by using a reference.

In this example, we started the first thread and waited until it completed its work (by that time an increment happened), then we started the second thread and waited till it also completed its work (an increment happened again). The result is exactly what we would've expected.

When you write your code in different threads that work with the same data concurrently, it is important to understand a few things:

  • some operations are non-atomic;

  • changes of a variable performed by one thread may be invisible to other threads;

  • if changes are visible, their order might be not (reordering).

Let's now learn more about the above issues.

Thread interference

A non-atomic operation is an operation that consists of multiple steps. A thread may operate on an intermediate value of a non-atomic operation performed by another thread. This leads to a problem called thread interference – the sequences of steps of non-atomic operations performed by several threads may overlap.

Let's start by explaining why the function increment is a non-atomic operation and how exactly it works. As an example, consider the class Counter again.

class Counter {
    var value = 0

    fun increment() {
        value++
    }
}

Note: in the previous example, the two threads did not work with the data at the same time. Before the start of the second thread, the first had already terminated.

The operation value++ can be decomposed into three steps:

  1. read the current value;

  2. increment the value by 1;

  3. write the incremented value back in the field.

Since the increment operation is non-atomic and takes 3 steps, thread interference may occur in case the two threads call the function increment on the same instance of Counter.

In the same way, the operation value-- may be decomposed into three steps.

Suppose that we have an instance of the Counter class:

val counter =
Counter()

The initial value of the field is 0.

Now, if Thread A invokes the function increment on this instance and Thread B also invokes the function at the same time, the following can happen:

  1. Thread A: read the value from the variable.

  2. Thread A: increment the read value by 1.

  3. Thread B: read value from the variable (it reads the intermediate value 0).

  4. Thread A: write the result in the variable (now, the current value of the field is 1).

  5. Thread B: increment the read value by 1.

  6. Thread B: write the result in the variable (now, the current value of the field is 1).

In this case, after calling the function increment from the two threads, we may obtain an unexpected result (1 instead of 2). That means that the result of Thread A was lost, overwritten by Thread B. Although sometimes the result may be correct, such interleaving is possible.

We've just seen that increment and decrement operations are non-atomic. In this topic, we will not discuss how this problem may be solved, just keep it in mind for now.

Let's consider another case: assignment of 64-bit values. It may be surprising, but even the reading and writing fields of double and long types (64-bits) may not be atomic on some platforms.

class MyClass {
     var longVal: Long = 0 // reading and writing may be
not atomic

     var doubleVal: Double = 0.0 // reading and writing may
be not atomic
}

It means that while a thread writes a value to a variable, another thread can access the intermediate result (for example, only 32 written bits). To make such operations atomic, fields should be declared using the Volatile keyword. You'll find out what that means in the next section!

class MyClass {
    @Volatile
     var longVal: Long = 0 // reading and writing are
atomic now

    @Volatile
     var doubleVal: Double = 0.0 // reading and writing are
atomic now
}

Meanwhile, the operations of reading from and writing to the fields of certain types (Boolean, Byte, Short, Int, Char, and Float) are guaranteed to be atomic.

In large applications, thread interference bugs can be difficult to detect.

Visibility between threads

Sometimes, when a thread changes shared data, another thread may not notice these changes or obtain them in a different order. It means that different threads may have inconsistent views of the same data.

The reason is that the compiler, runtime, or processor may apply all sorts of optimizations to speed up program execution. Even though these optimizations are often really useful, sometimes they can cause critical issues.
Caching and reordering are among those optimizations that may surprise us in concurrent contexts. Kotlin and the JVM provide many ways to control memory order, and the main method is using the @Volatile annotation.

Example. Here's a thread Reader, which spins while the variable ready is false:

var number = 0
var ready = false

class Reader : Thread() {
    override fun run() {
        while (!ready) {
             yield() // thread does not need resources
right now but wants to request them soon
        }
        println(number)
    }
}

There is also a main method which creates a custom thread and changes the values of number and ready:

fun main() {
    Reader().start()
    number = 42
    ready = true
}

As you might expect, the result will be 42. However, the program can also print zero. The reasons for such anomalies are weak visibility and memory reordering.

Let's see why that may happen:

Most modern processors do not write to memory immediately after receiving a request. These requests are added to a queue in a separately allocated write buffer. After some time, all requests from the queue are immediately executed and the data is written to the main memory.

Thus, when the main thread changes the values of the number and ready variables, there is no guarantee that the reading thread will see it. That is, the reading thread might see the updated value either immediately, or with some delay, or never at all!

@Volatile
var number = 0
@Volatile
var ready = false

The @Volatile annotation allows us to provide visibility to data changes. Thus, this annotation becomes very useful when you have multithreading in a program and access a block of code in parallel with multiple threads.

Other cases of visibility

Sometimes we don't need to use the @Volatile annotation. The following procedures will also guarantee visibility:

  • changes of variables performed by a thread before starting a new thread are always visible to the new thread;

  • changes of variables inside a thread are always visible to any other threads after it successfully returns from join (we used this at the beginning of this topic).

We will not consider all possible ways of guaranteeing visibility now. They are formalized using a special relationship named "happens-before". For now, keep in mind the use of @Volatile and the two cases above.

The annotation @Volatile doesn't make increment/decrement and similar operations atomic.

As a matter of fact, there are more abstract and complex things about @Volatile, but we'll skip this information for now.

Conclusion

Let's conclude what we've learned in this topic:

  • Threads that belong to the same process share common memory to communicate with each other – the heap.

  • Thread interference: the sequences of steps of non-atomic operations performed by several threads may overlap. This bug is a frequent problem in multithreading and can be difficult to detect.

  • The annotation @Volatile is used to guarantee that all changes made to a field by one thread are visible to other threads and that operations of reading and writing values are atomic.

59 learners liked this piece of theory. 4 didn't like it. What about you?
Report a typo