Coroutines answer to the problem with the mutable state
Before we start, take a look at the below class
UserDownloader. It allows to fetch a user by id or to get all the users that were downloaded before. What is wrong with this implementation?
Notice the use of defensive copy
toList. It is to avoid a conflict between reading the object returned by
downloaded, and adding an element to the mutable list. We could also represent users using the read-only list (
List<User>) and read-write property (
var). Then we would not need to make a defensive copy, and
downloadedwould not need to be protected at all, but we would decrease the performance of element addition to the collection. I personally prefer the second approach, but I decided to show the one using mutable collection, as I see it more often in real-life projects.
The implementation is not prepared for concurrent use. Each
fetchUser call modifies
users. This can conflict with other calls of the same function. The problem is presented below:
Because of conflicts, the above code will print some number smaller than 1 000 000 (like 998 242 for example), or might throw an exception.
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 22 at java.util.ArrayList.add(ArrayList.java:463) ...
Here we are facing a problem with a shared state. To see it more clearly, we will test our solutions using the
massiveRun function. Below you can see, how using it without any synchronization leads to many numbers being lost.
The problem can be solved using classic tools we know from Java, like
synchronized block or synchronized collections.
This solution works, but there is a problem: we are blocking threads when they are waiting for their turn. I hope that after the chapter about dispatchers you understand, that we do not want to block threads. What if it is the Main thread? What if we have only a limited pool of threads? Why wasting those resources? We should use coroutines-specific tools instead. Ones that will not block, but instead suspend or avoid conflict. So let's set aside this solution and explore some others.
There is another Java solution that can help us in some simple cases. Java has a set of atomic values. All their operations are fast and guaranteed to be "thread-safe". They are called atomic. Their operations are implemented in a low level, without locks, so this solution is efficient and appropriate for us. There are different atomic values we can use. For our case we can use
It works perfectly here, but in general, the utility of atomic values is very limited. We need to be careful because just because we know a single operation will be atomic, does not help us when we have a bundle of operations.
We often use atomics to secure a single primitive, but for more complicated cases we still need a better tool.
Single thread dispatcher
We've met a dispatcher with a single thread in the chapter Dispatchers. This is the easiest solution for most problems with shared states.
In practice, this approach can be used in two ways.
The first approach is known as coarse-grained thread confinement. It is an easy approach, where we just wrap the whole function with
withContext with a single-thread dispatcher. This solution is easy and leads to correct applications, but the problem is that we are losing multithreading capabilities on the whole functions. Let's take a look at the example below.
api.fetchUser(id) could happen concurrently on many threads, but it will not because the whole body will be running on a single-thread dispatcher. It would make a bigger difference if it is blocking or CPU-intensive.
The second approach is known as fine-grained thread confinement. In this approach we wrap (using
withContext with a single-thread dispatcher) only those statements which modify state. In our example, those are all the lines where
users is used. This approach is more demanding. In return, it offers us better performance if the functions we managed to not call on a single-thread dispatcher (like
fetchUser in our example) are blocking or CPU-intensive. If they are just plan and simple suspending functions, the performance improvement is unlikely to be seen, or might be negative (because of the additional cost of thread-switching).
The last popular approach is to use a
Mutex. You can imagine it like a room with a single key (or maybe a toilet at some cafeteria). Its most important function is
lock. When a first coroutine calls it, it kind of takes the key and passes that function without suspension. If now another coroutine call
lock, it will be suspended until the first one calls
unlock (like a person waiting for a key to the toilet1). If another coroutine reaches
lock function, it is suspended waiting in a queue, just after the second coroutine. When the first coroutine finally calls
unlock function, it is giving back the key, so the second coroutine (the first one in the queue) is now resumed, and can finally pass the
lock function. This way only one coroutine will be between
unlock directly is risky, as any exception (or premature return) in between would lead to the key never being given back (
unlock never been called), and as a result, no other coroutines would be able to pass through the
lock. This is a serious problem known as a deadlock (imagine a toilet, that cannot be used, because someone was in hurry and forgave to give back the key). So instead we can use the
withLock function, which starts with
lock, but calls
unlock on the
finally block, so that any exceptions thrown inside the block will successfully release the lock. In use, it is similar to the synchronized block or a single-thread dispatcher.
The important advantage of mutex comparing to synchronized block is that we are suspending a coroutine instead of blocking a thread. This is a safer and lighter approach. Comparing to using a dispatcher with a single thread, the mutex is lighter, and so it is preferred when we optimize for performance. On the other hand, it is also harder to use it properly. It has one important danger: a coroutine cannot pass the lock twice (maybe the key stays in the door, so another door requiring the same key would be impossible to pass). This is why the below code will never end (will be in a state known as a deadlock).
This is why we avoid using a mutex to wrap whole functions (coarse-grained approach). And if we do, we need to do it with great care, because if one such function calls another, a deadlock will happen.
Fine-grained thread confinement (wrapping only the place where we modify the shared state) would help, but in the above example, I would prefer to use a single-thread dispatcher.
There are many ways how coroutines can be orchestrated to avoid conflicts when modifying a shared state. The most important solution is to modify a shared state in a single-thread dispatcher. This can be fine-grained thread confinement, that is wrapping only concrete places where synchronization is needed, or coarse-grained thread confinement, which is wrapping the whole functions. The second approach is easier, but might be slower. We might also use atomic values or a mutex. Both those approaches are better in terms of performance, but are also more demanding and need more carefulness.
To not give a false impression of my country, asking for a key to the toilet is something I mainly experience outside of Poland. For instance, in Poland practically every petrol stations have a toilet available for everyone, no key required (and they are generally clean and good-looking). Although in many other European countries, those toilets are better protected from people, who might try to use them without buying anything.