Photo by oğuz can on Unsplash

Recently, I watched a video about runBlocking. It’s a good explanation of runBlocking behavior.

In summary, runBlocking documentation highlights several key limitations and recommendations:

This video effectively covered these points. In this post, however, I aim to dive deeper. In this post, I will try to understand what happens in more complex scenarios.

First of all, run and runCatching are synchronous, and runBlocking and runInterruptible as asynchronous. run and runCatching are part of the standard Kotlin library, available across all supported platforms. runBlocking and runInterruptible are part of Coroutines.

At the moment of writing this post support of suspend functions in KMM was experimental.

We will need a class:

data class Event(
val id: UUID,
val value: Int,
val message: String?,
var badModifyablePropertyForDemoPurposes = "Some string"
)

run is a scope function (but can run without an object). This means you can call it on an object and code block will have direct access to object’s properties and methods without this (but you can use this too). Also run can return a result which can be used in subsequent steps.

val event = Event(
id = UUID.randomUUID(),
value = 10,
message = null
)

val isEven = event
.run {
value % 2 == 0
}

println("Is Event.value even? $isEven.")

Prints

Is Event.value even? true.

run can modify the original object.

val event = Event(
id = UUID.randomUUID(),
value = 10,
message = null,
badModifyablePropertyForDemoPurposes = "Some string"
)

event.run {
badModifyablePropertyForDemoPurposes = "Hello"
}

Event(..., badModifyablePropertyForDemoPurposes=Hello)

So, what sets run apart from apply? Well, the main difference is in their return values. run is flexible. It can return any type, not only the type of the object it’s called on. apply, on the other hand, always returns the object itself, which is excellent for chaining object configurations.

Also, as mentioned earlier, run can operate independently of an object. This is in contrast to apply, which always requires an object to work with.

val event = Event(
id = UUID.randomUUID(),
value = 10,
message = null
)

event.message?.let {
println("The message is $it")
} ?: run {
println("The message is null")
}

run is used as a fallback for the case when event.message is null.

run quite handy, especially when combined with other scope functions while aiming to maintain consistent code architecture. For safety, it’s ideal to ensure the code within a run block is less prone to throwing exceptions. However, for situations where exception handling is necessary, runCatching is the better choice.

This is a variation of run. runCatching is literally try…catch block but with important difference. It encapsulates the result of the block execution into a Result object. This encapsulation not only makes the code more readable but also facilitates safe data retrieval. An added advantage is that results of runCatching blocks exectution can be compared.

data class Event(
val id: UUID,
val value: Int,
val message: String?,
var badModifyablePropertyForDemoPurposes: String
)

val event = Event(
id = UUID.randomUUID(),
value = 10,
message = null,
badModifyablePropertyForDemoPurposes = "Some string"
)

val result = event.runCatching {
value / 0
}.onFailure {
println("We failed to divide by zero. Throwable: $it")
}.onSuccess {
println("Devision result is $it")
}

println("Returned value is $result")

Prints

18:01:58.722  I  We failed to divide by zero. Throwable: java.lang.ArithmeticException: divide by zero
18:01:58.723 I Returned value is: Failure(java.lang.ArithmeticException: divide by zero)

So, as you see using runCatching offers several advantages. The result of the block execution can be consumed in a chainable manner or returned into a variable and processed later, for example, emitted in flow.

Result class provides many useful methods and properties to work with holding value. What is more interesting is that you can extend its methods and add more sophisticated logic to exception handling.

The only common ground between runBlocking, runInterruptible, and the synchronous run and runCatching is their ability to execute a block of code. However, runBlocking and runInterruptible differ significantly not only from their namesakes run and runCatching but also from each other in terms of functionality and use cases.

For the demo, we’ll be using the FlowGenerator class that I used in my series of articles about Kotlin flows

class EventGenerator {
/**
* Simulates a stream of data from a backend.
*/
val coldFlow = flow {
val id = UUID.randomUUID().toString()

// Simulate a stream of data from a backend
generateSequence(0) { it + 1 }.forEach { value ->
delay(1000)
println("Emitting $value")
emit(value)
}
}
}

This class provides a single instance of infinite cold flow with a suspension point (delay). This flow is suspendable and cancellable. It follows coroutine rules and controls.

Also it represents async flow that never ends, what we actually should expect from any flow. It helps understand asynchronous sand parallel execution problems better.

Primary use cases are (once again):

The main question is why only these?

Why does this function need to be avoided as I see in replies on StackOverflow, for example. Yes, it blocks the current thread, but we can spawn our own thread and it will not affect other code.

Let’s try.


private fun runFlows() {
thread {
runCollection()
}
}

private fun runCollection() {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator
.coldFlow
.take(2)
.collect {
println("Collection in runCollections #1: $it")
}
}

CoroutineScope(Dispatchers.Default).launch {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator.coldFlow.collect {
println("Collection in runCollections #2: $it")
}
}
}
}

In this example, I’ve intentionally called runBlocking from within a coroutine. Despite the documentation advising against this practice, doing so doesn’t trigger any warnings or errors in the IDE, build log, or at runtime.

This means that identifying and tracking such usage falls entirely on you, the developer.

Direct insertions of runBlocking are relatively easy to spot and fix. However, imagine a scenario where runBlocking is hidden behind a function call from a library or other module and cannot be spotted easily. The behavior remains the same, but debugging turns into a nightmare.

The code prints

18:24:28.091  I  Emitting 0
18:24:28.096 I Collection in runCollections #1: 0
18:24:29.099 I Emitting 1
18:24:29.099 I Collection in runCollections #1: 1
18:24:30.102 I Emitting 2
18:24:30.102 I Collection in runCollections #1: 2
18:24:31.103 I Emitting 3
18:24:31.103 I Collection in runCollections #1: 3
18:24:32.105 I Emitting 4
18:24:32.105 I Collection in runCollections #1: 4

As you see there is no “Collection in runCollections #2” in the log. The reason is that flow is infinite and will never end. The thread stays locked forever.

In real life, you might have a long network or database operation. Running it in runBlocking will severely affect an app performance… or library performance. Try to debug it in lib…

If the flow is finite then collection in coroutine will start, but in normal asynchronous code, next operation should not wait. It’s potential performance degradation. Except in case you really need to do something before the rest of async code starts. It could be, as mentioned in docs — external library handling.

Let’s modify code

private fun runFlows() {
thread(name = "Manual Thread") {
runCollection()
}

}

private fun runCollection() {
val coroutine1 = CoroutineScope(Dispatchers.Default).launch {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator
.coldFlow
.collect {
println("Collection in runCollections #1: $it")
}
}
}

val coroutine2 = CoroutineScope(Dispatchers.Default).launch {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator.coldFlow.collect {
println("Collection in runCollections #2: $it")
}
}
}
}

Prints

21:33:38.848  I  Emitting 0
21:33:38.851 I Collection in runCollections #1: 0
21:33:38.867 I Emitting 0
21:33:38.867 I Collection in runCollections #2: 0
21:33:39.852 I Collection in runCollections #1: 1
21:33:39.876 I Collection in runCollections #2: 1
21:33:40.854 I Emitting 2
21:33:40.854 I Collection in runCollections #1: 2
21:33:40.879 I Emitting 2
21:33:40.879 I Collection in runCollections #2: 2

Everything looks OK by the log, both coroutines are running. This is because CoroutineScope(Dispatchers.Default).launch selects a thread for the coroutine, thereby mitigating the negative impact of a thread being locked by runBlocking.

This thread management mitigates the issue with blocked coroutines, ensuring smoother execution even when runBlocking is used within a coroutine context.

1. runFlows
+- thread
+- Thread[Manual Thread,5,main]
2. runFlows
+- thread
+- runCollections
+- coroutine1
+- Thread[DefaultDispatcher-worker-3,5,main]
3. runFlows
+- thread
+- runCollections
+- coroutine1
+- Thread[DefaultDispatcher-worker-2,5,main]

Everything seems to be working: the application doesn’t crash, and performance is moderate. However, this approach raises a question about its practicality. Here app spawns a coroutine, which in turn spawns a thread, only to then call runBlocking which creates another coroutine, and to get exactly the same behavior with regular use of coroutines.

This method contradicts the very principles of efficient and predictable code. It disrupts the logical flow and makes it challenging to predict the long-term implications on the application’s performance and behavior. If you encounter such a pattern in your code, it’s better to fix the code as soon as possible.

Now, let’s take a look on a more realistic scenario, with use of a viewModel.

class MainViewModel : ViewModel() {
fun runFlows() {
thread(
name = "Manual Thread",
) {
println("Thread: ${Thread.currentThread()}")
runCollection()
}

}

private suspend fun collect(action: (Int) -> Unit) {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator
.coldFlow
.collect {
action(it)
}
}
}

private fun runCollection() {
viewModelScope.launch {
collect {
println("Collection in runCollections #1: $it: ${Thread.currentThread()}")
}
}

viewModelScope.launch {
collect {
println("Collection in runCollections #2: $it: ${Thread.currentThread()}")
}
}
}
}

Prints

00:40:44.332  I  Emitting 0
00:40:44.334 I Collection in runCollections #1: 0: Thread[main,5,main]
00:40:45.336 I Emitting 1
00:40:45.336 I Collection in runCollections #1: 1: Thread[main,5,main]
00:40:46.337 I Emitting 2
00:40:46.338 I Collection in runCollections #1: 2: Thread[main,5,main]

Pay attention that the spawn thread gives nothing it just spawns a thread which does not affect async operations at all. viewModelScope is bound to the main dispatcher which in the end comes to the main thread (This is a simplified explanation, of course, as digging into the details of dispatchers and the distinctions between Main and Main.immediate is off this article).

If runBlocking removed from the collect() implementation then call to runFlows() prints

01:05:48.180  I  Emitting 0
01:05:48.181 I Collection in runCollections #1: 0: Thread[main,5,main]
01:05:48.181 I Emitting 0
01:05:48.181 I Collection in runCollections #2: 0: Thread[main,5,main]
01:05:49.182 I Emitting 1
01:05:49.182 I Collection in runCollections #1: 1: Thread[main,5,main]
01:05:49.183 I Emitting 1
01:05:49.183 I Collection in runCollections #2: 1: Thread[main,5,main]

Which is what we normally expect from async operations. Yes, expected, but not obvious if you do not keep in mind what viewModelScope is bound to.

Moving thread to collect() function

private suspend fun collect(action: (Int) -> Unit) {
thread(
name = "Manual Thread",
) {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator
.coldFlow
.collect {
action(it)
}
}
}
}

also gives a similar result

01:08:51.944  I  Emitting 0
01:08:51.944 I Emitting 0
01:08:51.946 I Collection in runCollections #2: 0: Thread[Manual Thread,5,main]
01:08:51.947 I Collection in runCollections #1: 0: Thread[Manual Thread,5,main]
01:08:52.948 I Emitting 1
01:08:52.948 I Emitting 1
01:08:52.948 I Collection in runCollections #1: 1: Thread[Manual Thread,5,main]
01:08:52.948 I Collection in runCollections #2: 1: Thread[Manual Thread,5,main]

But… definitely, you should clearly understand what is happening with such constructions. Using runBlocking you easily lose track of async operations and lose powerful features of coroutines for automated management of suspension and switching coroutines to perform. Not the best thing if you are not an expert in Java and threads on Android and for some reason coroutines implementation does not fit your needs.

In other cases, limit the use of runBlocked to what documentation is defined. It feels that at least in mobile app development it should be used mostly in tests.

The final one. No it is not a counterpart for runBlocking 🙂

The documentation states that a block of code will be called in an interruptible manner. This function does not spawn threads and follow the dispatcher you supply as a parameter.

I added new methods into the viewModel.

fun runInterruptible() {
viewModelScope.launch {
println("Start")

kotlin.runCatching {
withTimeout(100) {
runInterruptible(Dispatchers.IO) {
interruptibleBlockingCall()
}
}
}.onFailure {
println("Caught exception: $it")
}
println("End")
}
}

private fun interruptibleBlockingCall() {
Thread.sleep(3000)
}

Prints

11:06:29.259  I  Start
11:06:30.431 I Caught exception: kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 100 ms
11:06:30.431 I End

Note the chain of calls. runCatching (could be try…catch) then withTimeout. I’m using Kotlin 1.9.20, and withTimeout throws exception but I do not see it log. If I add try…catch or runCatching then I can retrieve exception, without it — coroutine stops working but silently.

I didn’t find reason of this behavior and I see no reports in tracker. So keep in mind to use try…catch or withTimeoutOrNull.

I expected that this function would be just a boring function. However, it turned out to be a very puzzling function. To make it work, you need to clearly understand what code you are running is doing and how it is implemented. Yes, as usual, but here you need to know if any of parts is not cancellable or does not handle thread cancellation. This is tricky.

Source link