Thread switching optimization with Retrofit suspend support

Viewed 170

Retrofit 2.6.0 brings us support for the suspend function. Call and enqueue are used under the hood:

Behind the scenes this behaves as if defined as fun user(...): Call and then invoked with Call.enqueue. You can also return Response for access to the response metadata.

This means that request is asynchronous and network call is being done on the ThreadPoolExecutor form OkHttp. We don't care about switching threads as described in this question.

interface ApiService {
    @GET("custom/data/api")
    suspend fun getData(): String
}

class CustomViewModel: ViewMode() {
    // I omitted the initialization of the repository or usecase for simplicity
    fun getData() {
        viewModelScope.launch { // Dispatchers.Main
            apiService.getData() // network call on ThreadPoolExecutor
            // continue on Main thread
        }
    }
}

At this point we have one thread context switching.

But what if I want to do some additional work after network call, for example mapping. And I want to do it not on the main thread:

fun getData() {
    viewModelScope.launch { // Dispatchers.Main
        val result = apiService.getData() // network call on ThreadPoolExecutor
        // continue on Main thread
        val uiData = withContext(Dispatchers.IO) { // Coroutine runs on a shared thread pool
            mapResult(result) // suspending long running task
        }
        // continue on Main thread
    }
}

At this point we have two thread context switching: one for a network cal, the other for mapping.

And my question is about optimization. Is it more optimized to not use the suspend function in the Retrofit interface and use one thread switching with coroutine dispatcher on which the network call and other work will run?

interface ApiService {
    @GET("custom/data/api")
    fun getData(): Call<String>
}

fun getData() {
    viewModelScope.launch { // Dispatchers.Main
        // Main thread
        val uiData = withContext(Dispatchers.IO) { // Coroutine runs on a shared thread pool
            val result = apiService.getData().execute().body() // network call
            mapResult(result) // suspending long running task
        }
        // continue on Main thread
    }
}

I know that in a simple application the optimization is not that big and is measured in nanoseconds, but that is not the main question. Also, the question is not about the code, exceptions handling etc. The question is about understanding the internal mechanism of multithreading with Retrofit suspend support and coroutines.

1 Answers

Context switching between coroutines is far less expensive than context switching between threads if the coroutines run in the same thread. However, if they are in different threads, switching coroutine contexts requires an expensive thread context switch. Consider next example:

suspend fun doWork() {
    val data1 = fetchData1() // calling suspend function
    val data2 = fetchData2(data1) // calling another suspend function

    withContext(Dispatchers.Main) {
        updateUI(data2)
    }
} 

Here switching coroutine context requires an expensive thread context switch.

In your case the Api call happens in background thread, and you use Dispatchers.IO to run mapping in background thread. So there is a possibility that the mapping will happen in the same background thread. So the operation is not expensive in your case.

But if OkHttp has it's own ExecutorService, to make sure coroutines and OkHttp can use the same Threads we can create common ExecutorService and use it for OkHttp (set it when constructing OkHttpClient with OkHttpClient.Builder.dispatcher) and for coroutines (set it when switching contexts withContext(commomExecutorInstance.asCoroutineDispatcher())).

Related