Kotlin concurrency without sleep: an in-depth comparison of delay() and sleep()

This article is translated from:
https://blog.shreyaspatil.dev/sleepless-concurrency-delay-vs-threadsleep

There is no doubt that Coroutine in the Kotlin language has greatly helped developers handle asynchronous programming more easily. The many efficient APIs encapsulated in this feature can ensure that developers spend less effort to complete concurrent tasks. Generally speaking, it is enough for developers to understand how to use these APIs!

But from the perspective of the JVM, coroutines reduce the problem of “callback hell” to a certain extent and effectively improve the coding method of asynchronous processing.

I believe that many developers, including the author, are often curious about how exactly coroutines are done. Therefore, this article will use delay() as the starting point to help developers analyze the principles behind coroutines.

Table of contents preview:

  1. delay() What is it used for?
  2. What about sleep()?
  3. Compare delay() and sleep()
  4. Analyze the principle of delay()

1. What is delay() used for?

Developers who have used coroutines are most likely familiar with delay(). Anyway, let’s take a look at the official description of this function:

“delay() is used to delay the coroutine for a period of time without blocking the thread, and can resume the execution of the coroutine after the specified time.”

Let’s look at a sample code that executes task2 after 2000ms of task1 execution:

scope.launch {<!-- -->
    doTask1()
    delay(2000)
    doTask2()
}

The code is very simple, but again some important features of delay() need to be reminded:

  • It does not block the currently running thread
  • But it allows other coroutines to run on the same thread
  • When the delay time is up, the coroutine will be resumed and execution will continue.

Many developers often compare delay() with the Java language’s sleep(). But in fact, these two functions are used in completely different scenarios, but their names look a bit similar. . .

2. What about sleep()?

sleep() is a standard multi-threading API in the Java language: Prompts the currently executing thread to sleep for a specified period of time.

“This method is generally used to tell the CPU to give up processing time to other threads of the App or threads of other Apps.”

If this function is used in a coroutine, it will cause the currently running thread to be blocked, and will also cause other coroutines of the thread to be blocked until the specified blocking time is completed.

To understand more details, let’s further compare sleep() and delay() with examples.

3. Compare delay() and sleep()

Suppose we want to perform concurrent tasks in a single thread (such as the main thread in Android development).

Take a look at the following code snippet: two coroutines are started respectively, and each calls delay() or sleep() for 1000ms.

The external link image transfer failed. The source site may have an anti-hotlinking mechanism. It is recommended to save the image and upload it directly

Compare:

  • Coroutine startup time:
    • The two coroutines in the code calling delay() are executed at the same time (05:48:58)
    • The second coroutine in the code that calls sleep() is executed after 1s.
  • The end time of the coroutine:
    • Calling the two coroutines in the delay() code took a total of 1045ms.
    • Calling the two coroutines in the sleep() code took a total of 2044ms.

This also confirms the feature difference mentioned above: delay() only suspends the coroutine and allows other coroutines to reuse the coroutine, while sleep() directly blocks the entire thread for a period of time.

In fact, delay() also has other magical features. Let’s take a look at the following code example:

  1. First, a thread pool context example that creates a maximum of 2 threads is defined.

  2. When the first coroutine starts and executes a task, call delay() to suspend for 1000ms, and then execute another task.

  3. While the first coroutine is executing, start the second coroutine to execute a time-consuming task

img

By looking at the log printed in the task, we were surprised to find that before the delay function was executed, it was running in the Duet-1 thread. But when delay completes, it resumes to another thread: Duet-2.

why is that?

It turns out that the original thread is busy processing the time-consuming task started by the second coroutine, so it can only resume to another thread after delay.

This is interesting, take a look at the description in the official documentation. . .

“Coroutines can suspend one thread and resume to another thread!”

Now that we feel the magic of delay(), let’s understand the working principle behind it.

4. Analysis of delay() principle

delay() will first find the implementation of Delay in the coroutine context, and then perform specific delay processing.

public suspend fun delay(timeMillis: Long) {<!-- -->
    if (timeMillis <= 0) return

    return suspendCancellableCoroutine sc@ {<!-- --> cont: CancellableContinuation<Unit> ->
        if (timeMillis < Long.MAX_VALUE) {<!-- -->
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

Delay is an interface type, which defines methods such as scheduleResumeAfterDelay() for scheduling coroutines after a delay. The delay() and withTimeout() directly called by developers are exactly the support provided by the Delay interface.

public interface Delay {<!-- -->
    public fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>)

    public fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle =
        DefaultDelay.invokeOnTimeout(timeMillis, block, context)
}

In fact, the Delay interface is implemented by each CoroutineDispatcher that runs the coroutine.

We know that CoroutineDispatcher is an abstract class, and the Dispatchers class will use thread-related APIs to implement it.

for example:

  • Dispatchers.Default, Dispatchers.IO are implemented using the Executor API under the java.util.concurrent package
  • Dispatchers.Main is implemented using the unique Handler API on the Android platform

Next, each Dispatcher also needs to implement the Delay interface, mainly to implement scheduleResumeAfterDelay() to return a Continuation instance that executes the coroutine after the specified ms.

The following is the specific code for the ExecutorCoroutineDispatcherImpl class to implement this method:

override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {<!-- -->
    (executor as? ScheduledExecutorService)?.scheduleBlock(
        ResumeUndispatchedRunnable(this, continuation),
        continuation.context,
        timeMillis
    )
    // Other implementation
}

As you can see: it uses the schedule() of the Java package ScheduledExecutorService to schedule the recovery of the Continuation.

Let’s take a look at how the Android platform Dispatcher, namely HandlerDispatcher, implements this method.

override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {<!-- -->
    val block = Runnable {<!-- -->
        with(continuation) {<!-- --> resumeUndispatched(Unit) }
    }
    handler.postDelayed(block, timeMillis.coerceAtMost(MAX_DELAY))
    // Other implementation
}

It directly uses Handler’s postDelayed() to post the Runnable object restored by Continuation. This also explains why delay() does not block the thread.

If you execute delay() logic in the coroutine of the Android main thread, the effect is equivalent to calling the code on the right side of the Handler.

img

This implementation is very interesting: calling delay() on the Android platform is actually equivalent to posting a delayed runnable through the Handler; while on the JVM platform, a similar idea is used using the Executor API.

But if the same business logic is used and delay() is replaced by sleep(), the effect will be quite different. It can be said that delay() and sleep() are two completely different APIs, so don’t confuse them.

At this point, we can feel the elegance and wonder of coroutines: writing asynchronous logic with simple synchronous code can effectively help developers avoid the trouble of “callback hell”.

I hope this article can help you understand the usage and working principle of delay() in Kotlin coroutines, and understand the obvious differences with sleep(). Thank you for reading.