AOSP Framework & Internals
5 min read

Threads in Linux Kernel

Learn how Linux implements threads under the hood and how they differ from standard processes.

The Illusion of the Lightweight Thread

Most developers learn early on that a process is a heavy, isolated container. They also learn that a thread is a lightweight execution unit inside that container. Windows enforces this strict structural separation. That mental model breaks down entirely when you look at how Android handles background workers. If you try to debug Android system performance with that mindset, the kernel traces will make no sense.

The Linux kernel takes a completely unified approach to execution. It does not actually distinguish between a process and a thread. To the kernel, every execution unit is simply a task.

When an Android application creates a new background thread, the runtime executes the clone() system call. The kernel responds by creating a new task_struct object in its execution queue. The only difference between this new thread and a completely separate application process is a single memory sharing flag. If Zygote forks a new app, the kernel provides an isolated virtual memory space. Conversely, when you create a thread, the kernel points the new task_struct to the exact same memory space as the parent task.

Sharing memory pointers allows threads to read and write to the same variables instantly. This makes them incredibly fast to create. To the kernel scheduler, they are just standard tasks waiting for CPU time.

The following flowchart illustrates how the kernel structures tasks and memory. It helps clarify why threads can communicate so easily without heavy inter-process communication. Look for how the worker task in the second app points to the same memory block as its main task.

Notice how the second application has multiple tasks pointing to a single memory instance. The kernel schedules these tasks independently, even though they operate on the exact same data.

Untangling Process and Thread Identifiers

Debugging kernel logs gets confusing quickly when threads and processes use the identical underlying structure. You will often see multiple tasks claiming the same identifier. Sometimes, a single task will display two different identifiers depending on the debugging tool you use. Engineers need a reliable way to track exactly which execution unit is doing what.

Linux solves this confusion by assigning distinct identifiers to every task. The Thread ID (TID) is the unique kernel identifier for that specific execution unit. A Process ID (PID) identifies the overall application. Finally, the Thread Group ID (TGID) groups multiple threads together.

In a multi-threaded application, all threads share the same TGID. This group ID matches the TID of the original main thread. When you run ps in the ADB shell, the column labeled PID is actually displaying the TGID. To see the actual individual threads, you must pass a specific flag.

Tip: The standard ps output only shows the main thread of an application. Always pass the -T flag when investigating CPU spikes to see which specific thread is at fault.

adb shell ps -T -p <SYSTEM_SERVER_PID>

This command exposes the raw task list for the system server process. The output will show you the exact threads consuming CPU cycles. A common mistake is trying to kill a specific TID using the kill command because the kernel will terminate the entire thread group instead.

USER           PID   TID CMD
system        1500  1500 system_server
system        1500  1505 HeapTaskDaemon
system        1500  1510 ReferenceQueueD
system        1500  1542 ActivityManager

The output reveals that the system server is just a massive collection of threads. The TID is unique for every row, but they all share the PID of 1500. This shared ID tells the kernel they belong to the same group and share resources.

Why Android Rejects Raw Threads

If threads are just fast tasks sharing memory, you might assume you can create one whenever you need background work. Android applications must render at 60 frames per second on the main thread. Running database queries there will freeze the UI and trigger an Application Not Responding crash. You clearly need background execution. But asking the kernel to allocate a new task_struct for every short burst of work introduces unacceptable context switching overhead.

The Android framework relies on Thread Pools instead of raw thread creation. A thread pool creates a fixed number of sleeping threads upfront.

When a task arrives via an executor service, the runtime wakes an existing thread. The thread executes the work and goes back to sleep. This avoids the clone() system call entirely during normal operation.

Common Mistake: Creating a new thread via new Thread().start() for every background task forces the kernel to allocate a new task_struct. This constant allocation and destruction will severely degrade app performance and drain the battery.

ExecutorService pool = Executors.newFixedThreadPool(4);

pool.execute(new Runnable() {
    @Override
    public void run() {
        performHeavyDatabaseQuery();
    }
});

This pattern shifts the scheduling burden from kernel allocation to user-space management. Reusing threads dramatically reduces kernel overhead. Doing so keeps the application responsive and saves battery life. We now know how Linux schedules standard execution. But what happens when you need to run these tasks while the device itself is trying to sleep?