It's not a secret that I'm pretty enthusiastic about Kotlin as a programming language, despite a few shortcomings and strange design choices. I got the chance to work on a medium-sized project using Kotlin, Kotlin Coroutines and the coroutine-driven server framework KTOR. Those technologies offer a lot of merit, however I've found them difficult to work with in comparison to e.g. plain old Spring Boot.
Disclaimer: I do not intend to "bash" any of those things, my intention is to provide my "user experience" and explain why I will refrain from using them in the future.
Debugging
Consider the following piece of code:
suspend fun retrieveData(): SomeData {
val request = createRequest()
val response = remoteCall(request)
return postProcess(response)
}
private suspend fun remoteCall(request: Request): Response {
// do suspending REST call
}
Let us assume we want to debug the retrieveData
function. We place a breakpoint in the first line. Then we start the debugger (in IntelliJ in my case), it stops at the breakpoint. Nice. Now we perform a Step Over
(skipping the call createRequest
). That works too. However, if we Step Over
again, the program will just run. It will NOT stop after remoteCall
.
Why is that? The JVM debugger is attached to a Thread
object. For all means and purposes, this is a very reasonable choice. However, when Coroutines enter the mix, one thread no longer does one thing. Look closely: remoteCall(request)
calls a suspend
ing function - we don't see it in the syntax when we call it though. So what happens? We tell the debugger to "step over" the method call. The debugger runs the code for the remote call and waits.
This is where things get difficult: the current thread (to which our debugger is bound) is only an executor for our coroutine. What will happen when we call a suspending
function is that at some point, the suspending function will yield
. This means that a different Thread
will continue the execution of our method. We effectively tricked the debugger.
The only workaround I've found is to place a breakpoint on the line I want to go to, rather than using Step Over
. Needless to say, this is a major pain. And apparently it's not just me.
Furthermore, in general debugging it is very hard to pin down what a single coroutine is currently doing, as it jumps between threads. Sure, coroutines are named and you can enable the logging to print not only the thread but the coroutine name as well, but the mental effort required to debug coroutine-based code is a lot higher in my experience than thread-based code.
Binding data to the REST call
When working on a microservice, a common pattern is to receive a REST call with some form of authentication, and pass the same authentication along for all internal calls to other microservices. In the most trivial case, we at least want to keep the username of the caller.
However, what if those calls to other microservices are nested 10 levels deep in our call stacks? Surely we don't want to pass along an authentication
object as a parameter in each and every function. We require some form of "context" which is implicitly present.
In traditional thread-based frameworks such as Spring, the solution to this problem is to use a ThreadLocal
object. This allows us to bind any kind of data to the current thread. As long as one thread corresponds to the processing of one REST call (which you should always aim for), this is exactly what we need. A good example for this pattern is Spring's SecurityContextHolder
.
With coroutines, the situation is different. A ThreadLocal
will no longer do the trick, because your workload will jump from one thread to another; it is no longer the case that one thread will accompany a request during the entirety of its lifetime. In kotlin coroutines, there's the CoroutineContext
. In essence, it is nothing more than a HashMap
which is carried alongside the coroutine (no matter on which thread it mounts). It has a horribly over-engineered API and is cumbersome to use, but that is not the main issue here.
The real problem is that coroutines do not inherit the context automatically.
For example:
suspend fun sum(): Int {
val jobs = mutableListOf<Deferred<Int>>()
for(child in children){
jobs += async { // we lose our context here!
child.evaluate()
}
}
return jobs.awaitAll().sum()
}
Whenever you call a coroutine builder, such as async
, runBlocking
or launch
, you will - by default - lose your current coroutine context. You can avoid it by passing the context into the builder method explicitly, but god forbid you ever forget to do that (the compiler won't care!).
A child coroutine could start off with an empty context, and if a request for a context element comes in and nothing is found, the parent coroutine context could be asked for the element. However, that does not happen in Kotlin, the programmer is required to do that manually, every single time.
If you are interested in the details of this issue, I recommend having a look at this blog post.
synchronized
will no longer do what you think it does
When working with locks or synchronized
blocks in Java, the semantic I am thinking about is usually along the lines of "nobody else can enter while I'm in this block". The nobody else part of course implies that there is an "identity" of some sort, which in this case is the Thread
. That should raise a big red warning sign in your head by now.
Let's consider the following example:
val lock = ReentrantLock()
suspend fun doWithLock(){
lock.withLock {
callSuspendingFunction()
}
}
This is dangerous. Even if callSuspendingFunction()
does nothing harmful, the code will not behave as you might think it does:
- Enter the lock
- Call the suspending function
- The coroutine yields, the current thread still holds the lock
- Another thread picks up our coroutine
- We are the same coroutine, but we do not own the lock anymore!
The number of potentially conflicting, deadlocking or otherwise unsafe scenarios is staggering. You might argue that we "just" need to engineer our code to handle that. I would agree, however we are talking about the JVM. There is a vast ecosystem of libraries out there. And they are not prepared to handle it.
The upshot here is: the moment you start using coroutines, you forfeit the possibility to use a lot of Java libraries, simply because they expect a thread-based environment.
Throughput vs. Horizontal Scaling
A big advantage of coroutines for the server-side is that a single thread can handle a lot more requests; while one request waits for a database response, the same thread can happily serve another request. In particular for I/O bound tasks, this can increase the throughput.
However, as this blog post has hopefully demonstrated to you, there is a non-zero cost associated with using coroutines, on many levels.
The question which arises is: is the benefit worth that cost? And in my opinion the answer is no. In cloud- and microservice-environments, there should always be some scaling mechanism, whether it is Google AppEngine, AWS Beanstalk, or some form of Kubernetes. Those technologies will simply spawn new instances of your microservice on demand if the current load increases. Therefore, the throughput one individual instance can handle is much less important, considering all the hoops we would have to jump through for using coroutines. This reduces the value we get from using coroutines.
Coroutines have their place
Coroutines are still useful. When developing client-side UIs where there is only one UI thread, coroutines can help to improve your code structure while being compliant with the requirements of your UI framework. I've heard that this works pretty well on Android. Coroutines are an interesting topic, however for the server-side I feel that we are not quite there yet. The JVM developers are currently working on Fibers, which are in essence also coroutines, but they have the goal to play nicely with the JVM infrastructure. It will be interesting to see how this effort develops, and how Jetbrains will react to it with respect to their own coroutine solution. In the best possible scenario, Kotlin coroutines will just "map" to Fibers in the future, and debuggers will be smart enough to handle them properly.