I understand that it is generally not advisable for suspend functions to spawn coroutines that don’t end when the function does. One example would be a suspend function that starts a background coroutine for some sort of regular network ping or keep-alive mechanism. Doing that can break structured concurrency and lead to resource leaks.
However, sometimes functions have to start coroutines that keep running until some sort of associated stop function cancels these coroutines. The recommendation I’ve seen so far has been to use non-suspend functions, have them spawn coroutines with launch
or async
, and return the Job
or Deferred
. So far so good.
But - in that function, suspend functions may have to be called. One example is a handshake procedure. So I am thinking that in some cases, it may be OK to use a suspend function.
Here is an example. This is roughly how my code looks like in an application I am writing.
fun connectAsync(scope: CoroutineScope): Deferred<Unit> {
return scope.async {
// suspending functions are called here, in the newly created coroutine
device = openDevice()
device.sendPacket(someHandshakePacket) // sendPacket is a suspend function that internally uses the IO dispatcher
val response = device.receivePacket() // receivePacket is a suspend function that internally uses the IO dispatcher
processResponse(response)
// [...] some more handshake processing here, including invocation of various suspend functions
keepaliveJob = scope.launch {
runKeepAliveLoop()
}
}
}
suspend fun disconnect() {
keepaliveJob?.cancelAndJoin()
keepaliveJob = null
device?.sendPacket(createSessionEndPacket())
device?.closeDevice()
device = null
}
Since by convention suspending functions should not accept a CoroutineScope, I made connectAsync
non-suspending. It is important though to be able to wait for the connect procedure to finish, hence the Deferred
return value - the user can then run await()
in a coroutine to wait for the connection to be established. (This is also why I can’t just stick the sendPacket
and receivePacket
calls inside the coroutine that is associated with keepaliveJob
.)
I doubt though that insisting on making connectAsync
non-suspending here really is helpful, especially if I have to wait for the connection to be established. Look at a suspending variant of connectAsync
(disconnect
stays the same):
suspend fun connectAsync(scope: CoroutineScope) {
device = openDevice()
device.sendPacket(someHandshakePacket) // sendPacket is a suspend function that internally uses the IO dispatcher
val response = device.receivePacket() // receivePacket is a suspend function that internally uses the IO dispatcher
processResponse(response)
// [...] some more handshake processing here, including invocation of various suspend functions
keepaliveJob = scope.launch {
runKeepAliveLoop()
}
}
It looks similar, except that it suspends the calling coroutine instead of one that is newly spawned by async
. In both versions, a background coroutine is started. In both versions, that coroutine’s lifetime is managed by disconnect
and the “scope” coroutine scope, and not by the function itself. (Arguably though, the suspending variant shouldn’t have an “Async” suffix in its name.) But - in the second version, I don’t need to worry about having to call await
.
Furthermore, the non-suspending variant becomes more cumbersome to use if in a higher level class additional steps have to be taken after connectAsync
finishes. Example:
fun higherLevelConnectAsync(scope: CoroutineScope): Deferred<Unit> {
return scope.async {
connectAsync(scope).await()
// additional steps that are necessary at a higher level are done here
}
}
Contrast this with a suspending variant:
suspend fun higherLevelConnectAsync(scope: CoroutineScope) {
connectAsync(scope)
// additional steps that are necessary at a higher level are done here
}
The suspending variant composes much nicer and cleaner in my opinion.
To sum up, my question is: If a suspend function does spawn a coroutine that persists even after the function finishes, but the lifetime of that coroutine is well defined and controlled, is there any other reason why this shouldn’t be done, and why in the examples above connectAsync
still should never be suspending?