article banner (priority)

Launching coroutines vs suspend functions

This is a chapter from the book Kotlin Coroutines. You can find it on LeanPub or Amazon.

Let's consider a use case, where you need to concurrently make a number of actions. There are two kinds of functions that can be used for that:

  • a regular function operating on a coroutine scope object,
  • a suspending function.
class NotificationsSender( private val client: NotificationsClient, private val notificationScope: CoroutineScope, ) { fun sendNotifications( notifications: List<Notification> ) { for (n in notifications) { notificationScope.launch { client.send(n) } } } }
class NotificationsSender( private val client: NotificationsClient, ) { suspend fun sendNotifications( notifications: List<Notification> ) = supervisorScope { for (n in notifications) { launch { client.send(n) } } } }

Those two options might look similar in some ways, but they represent essentially different use cases. To see some essential differences, you do not even need to read their bodies.

When we know that a function starts coroutines, and it is a regular function, we know it needs to use a scope object. Such functions typically only start coroutines and do not await their completion, so most likely sendNotifications will take milliseconds to execute. Starting coroutines on external scope also means, that exceptions in those coroutines will be handled by this scope (most often printed and ignored). Those coroutines will inherit context from the scope object, and to cancel them, we need to cancel the scope.

class NotificationsSender( private val client: NotificationsClient, private val notificationScope: CoroutineScope, ) { // Does not wait for started coroutines // Exceptions are handled by the scope // Takes context from the scope // and builds relationship to the scope fun sendNotifications( notifications: List<Notification> ) { // ... } }

When we know that a function starts coroutines, and it is a suspend function, we can assume that such a function will not finish until all the coroutines are finished. Such sendNotifications will suspend until the last notification is fully handled. That is an important synchronization mechanism. We also know, that suspending functions do not cancel their parent. Instead, they might throw or silence exceptions, just like regular functions can throw or silence exceptions. The coroutines started by such a function should inherit context from and build relation with the function that called this function.

class NotificationsSender( private val client: NotificationsClient, ) { // Waits for its coroutines // Handles exceptions // Takes context and builds relationship to // the coroutine that started it suspend fun sendNotifications( notifications: List<Notification> ) { // ... } }

Both of those use cases are important in our applications. When we have a choice, we should prefer suspending functions. They are easier to control and synchronize. But coroutines need to start somewhere, and for that we use regular functions with a scope object.

In some applications, we might have a situation, where we need to mix those two kinds of functions. We start coroutine on suspend function scope, when this coroutine is part of the same process. Only then this coroutine inherits context, function awaits its completion, and is cancelled with this function. We launch a coroutine on an object representing scope when we want to start some independent process.

Now consider a function, that needs to execute some operations that are an essential parts of its execution, but it also needs to start an independent process. In the example below, the process of sending an event is considered an external process, so it is started on an external scope. Thanks to that:

  • updateUser will not wait until sendEvent execution is completed.
  • sendEvent process will not be cancelled when the coroutine that has started updateUser is cancelled. (What makes sense, user synchronization has completed anyway.)
  • eventsScope decides what should be the context for sending events, and if they should happen or not. (If eventsScope is not active, event will not be sent)
suspend fun updateUser() = coroutineScope { val apiUserAsync = async { api.fetchUser() } val dbUserAsync = async { db.getUser() } val apiUser = apiUserAsync.await() val dbUser = dbUserAsync.await() if (apiUser.lastUpdate > dbUser.lastUpdate) { db.updateUser(apiUser) } { api.updateUser(dbUser) } eventsScope.launch { sendEvent(UserSunchronized) } }

In some situations, this hybrid behavior is what we want in our applications, but it should be used consciously.