跪拜 Guibai
← Back to the summary

Thread Scheduling's Home: Java Hands It to the Caller, Kotlin Coroutines Tuck It Inside the API

Java Threads From Top Down, Kotlin Coroutines From Bottom Up

Who should bear the responsibility for thread scheduling?

In Android development, we often run into a problem: which thread should an operation execute on?

Reading a file should go to an IO thread, complex calculations to a background thread, and UI updates must return to the main thread. These rules are not complex. What is really worth thinking about is: who should bear the responsibility for thread switching?

In my view, thread scheduling should not become a burden on upper-layer business code, but should be part of the underlying API design.

In the Java era, we usually express thread execution requirements through @WorkerThread annotations or asynchronous callbacks. The former hands the responsibility for thread scheduling to the caller, while the latter organizes asynchronous flow through callbacks.

Kotlin coroutines offer a different idea: through suspend functions and the context mechanism, the execution environment can become an internal implementation detail of the API.

This is what I mean:

Java threads are top-down, Kotlin coroutines are bottom-up.

Here, "top and bottom" do not refer to the execution direction of threads, but to the organizational approach for thread scheduling responsibility. This is an architectural generalization, not an absolute difference between Java and Kotlin.


1. Java: The caller often needs to care about threads

In traditional Android Java development, there are usually two approaches to handling time-consuming operations.

1. Blocking methods: telling the caller via annotations

Consider a common example:

@WorkerThread
public User getUser() {
    return database.userDao().getUser();
}

The @WorkerThread here is a thread constraint declaration.

It tells the caller: this method may execute a time-consuming operation and should not be called on the main thread.

But note that the annotation itself does not switch threads, nor does it automatically create a background task.

The caller still needs to arrange the execution environment themselves:

executor.execute(() -> {
    User user = repository.getUser();
    mainHandler.post(() -> {
        updateUI(user);
    });
});

The entire process is the caller's responsibility:

In other words, the underlying API only declares the execution requirement; the real thread scheduling responsibility still lies with the caller.

2. Asynchronous callbacks: the underlying layer handles asynchrony, the caller receives the result

Another common approach is asynchronous callbacks:

public void getUser(Callback<User> callback) {
    executor.execute(() -> {
        User user = database.userDao().getUser();
        mainHandler.post(() -> {
            callback.onSuccess(user);
        });
    });
}

The caller:

repository.getUser(new Callback<User>() {
    @Override
    public void onSuccess(User user) {
        updateUI(user);
    }
});

This approach encapsulates thread scheduling inside the API.

The caller does not need to create threads themselves, nor manually switch back to the main thread; they only need to implement the callback.

But the problems that follow are also obvious:

Of course, Java also has higher-level abstractions like Executor, Future, CompletableFuture, and others that can improve these issues.

The problem is not that Java lacks abstraction capability, but that traditional asynchronous programming easily involves the caller in orchestrating asynchronous tasks.

Blocking methods + thread annotations: the caller arranges threads; asynchronous callbacks: the caller organizes the callback flow.

Both approaches carry their own design costs.


2. Kotlin coroutines: letting the underlying layer encapsulate thread switching

Now consider Kotlin coroutines:

suspend fun readData(): String =
    withContext(Dispatchers.IO) {
        readFile()
    }

There is a key change here.

readData()'s caller does not need to know that file reading requires switching to an IO dispatcher. It only needs to call:

val data = readData()

The underlying layer handles the switching of the execution environment.

When the operation inside withContext finishes, the coroutine resumes on the context corresponding to where it was called.

The caller does not need to write callbacks themselves, nor manually switch back to the original thread.

Note that suspend itself does not mean thread switching. What actually handles thread scheduling is withContext(Dispatchers.IO).

Consider a suspend function that does not switch threads:

suspend fun readData(): String {
    return readFile()
}

It is a suspend function, but it does not specify a new execution context. If the coroutine calling it runs on Main, then the ordinary code inside it will also run on Main by default.

Therefore, you cannot simply assume that all suspend functions execute in the background.

There is also a design principle worth emphasizing:

Suspend functions should be as Main-safe as possible.

That is, even if the caller invokes this API on the main thread, the underlying layer should avoid blocking the main thread through an appropriate execution mechanism.

For example:

suspend fun readData(): String =
    withContext(Dispatchers.IO) {
        readFile()
    }

The caller does not care which dispatcher is used internally; they only care about the business flow.

Coroutines do not eliminate thread scheduling; they make thread scheduling encapsulatable inside the API.

This is the change worth paying attention to.


3. Comparison of the two modes

Putting the two approaches side by side makes the difference clearer.

Java: caller orchestrates scheduling

executor.execute(() -> {
    String data = readFile();
    mainHandler.post(() -> {
        updateUI(data);
    });
});

The caller needs to:

Submit task to thread pool → get result → manually switch back to main thread → update UI.

Every step of scheduling is written at the call site.

Kotlin coroutines: underlying layer encapsulates scheduling

suspend fun loadData(): String =
    withContext(Dispatchers.IO) {
        readFile()
    }

viewModelScope.launch {
    val data = loadData()
    updateUI(data)
}

loadData() internally handles IO scheduling; the caller only cares about when to load data and what to do with the result.

If viewModelScope launches on the main thread and there are no other context switches afterward, then after loadData() returns, the coroutine resumes on the main thread to continue execution.

The difference is not that Java cannot encapsulate threads, nor that Kotlin coroutines always switch threads automatically. The real difference is: we can use coroutines to place scheduling responsibility into a more appropriate abstraction layer.

▼ Flowchart: Java vs Kotlin thread scheduling responsibility comparison

1svg.png


4. Why this matters for Android architecture

Suppose a project has the following call chain:

ViewModel → Repository → DataSource

If every layer needs to care about specific threads, business logic is easily polluted by scheduling details.

For example, the ViewModel knows database operations need IO, the Repository knows network requests need IO, and the DataSource is also doing thread switching—thread responsibilities are scattered and hard to maintain.

Now take a different approach.

ViewModel—cares about business flow:

viewModelScope.launch {
    val user = repository.getUser()
    uiState.value = user
}

Repository—cares about data retrieval and composition:

suspend fun getUser(): User {
    return dataSource.getUser()
}

DataSource—cares about execution environment requirements:

suspend fun getUser(): User =
    withContext(Dispatchers.IO) {
        database.userDao().getUser()
    }

This way, each layer's responsibilities are clearer:

For instance, Retrofit's suspend APIs have their own asynchronous execution mechanism, and Room has its own thread management strategy; developers do not unconditionally need to wrap another layer of withContext(Dispatchers.IO).

Where exactly to place it should be determined by the nature of the operation and the architectural responsibility.

But one principle is worth holding to:

Whoever best understands the execution requirements of an operation should be responsible for ensuring those requirements are met.

▼ Flowchart: Scheduling responsibility distribution in a three-layer architecture

2svg.png


5. Coroutines bring more than code simplicity

If we only understand coroutines as a nicer way to write asynchronous code, we underestimate their value.

Coroutines bring at least three important design capabilities.

1. Express asynchronous flow sequentially

val user = repository.getUser()
val orders = repository.getOrders(user.id)

The code looks like synchronous calls, but suspend functions can suspend the coroutine while waiting for results, rather than blocking the current thread.

Developers can organize asynchronous business in a more natural, sequential way.

2. Encapsulate thread switching

suspend fun calculate(): Result =
    withContext(Dispatchers.Default) {
        performCalculation()
    }

The calculation logic knows it needs an execution environment suitable for CPU-intensive tasks; the caller does not need to repetitively orchestrate thread switching.

3. Manage the lifecycle of asynchronous tasks

viewModelScope.launch {
    val data = repository.getData()
    render(data)
}

When the ViewModel is cleared, coroutines in its scope are cancelled.

This lets the lifecycle of asynchronous tasks be tied to business components, rather than relying on developers to manually manage each thread.

Of course, structured concurrency is not unconditional: underlying APIs need to correctly propagate cancellation signals, must not swallow CancellationException, and should not secretly launch work into a scope that is detached from the caller's lifecycle.

▼ Flowchart: Three design capabilities that coroutines bring

3svg.png


Summary

From Java to Kotlin coroutines, what increasingly concerns me is not how many fewer lines of code we write, but who should be responsible for the code.

In the Java era, blocking methods use @WorkerThread to tell callers about thread requirements, and asynchronous callbacks use Callback to organize asynchronous flow. Callers often have to participate in thread arrangement or asynchronous task orchestration.

Kotlin coroutines offer another option: through suspend functions and coroutine contexts, we can encapsulate execution environment requirements inside the API.

Threads are resources that execute tasks; coroutines are a programming mechanism for organizing suspension, resumption, and asynchronous tasks.

When we start using coroutines, we should not only think about replacing Thread with launch, nor should we cram all business logic into withContext.

More importantly, re-examine the responsibilities of each layer.

Details the upper layer should not know should be encapsulated in the lower layer as much as possible; execution requirements that the lower layer knows about should be handled with scheduling responsibility there.