How Binder IPC Works: Transactions, Parcels, and the Thread Pool

skydovesJaewoong Eum (skydoves)||22 min read

How Binder IPC Works: Transactions, Parcels, and the Thread Pool

Every Android developer relies on cross process communication without naming it. You call getSystemService(Context.ACTIVITY_SERVICE) and talk to a manager that lives in system_server, a different process from your app. You bindService() with an AIDL interface and start calling methods on an object that runs somewhere else. You launch an Activity, query a ContentProvider, or schedule a job, and each of these crosses a process boundary. Most of the time it feels like a normal method call, until one day you hand a large Bitmap to a remote method and get a TransactionTooLargeException. The surface looks like ordinary Java. The deeper question is how a method call on one object reaches a completely separate process, runs there, and returns a result, when the two processes share no memory and cannot see each other's objects.

In this article, you'll dive deep into the internal mechanism behind Binder IPC, exploring why isolated address spaces make a naive remote call impossible, how the proxy and stub pattern built on IInterface and IBinder bridges the gap, how a transact() call travels through the kernel into Binder.execTransact() and lands in onTransact(), how a Parcel flattens arguments into a byte buffer the kernel can copy, why the single copy design bounds every transaction to roughly 1MB, how the binder thread pool dispatches incoming calls, how the kernel reports the real getCallingUid() and why clearCallingIdentity() exists, and how linkToDeath notifies you when the remote process dies.

The fundamental problem: A pointer in one process means nothing in another

Start with the call you wish would work. Imagine a music service running in its own process, and you hold what looks like a reference to it:

IMusicService music = ...;
music.play("track-42");

In a single process this is trivial. The variable music holds the address of an object on the heap, and the JVM jumps to the method at that address. Now suppose the object lives in a different process. Each Android process runs with its own virtual address space, a private map from addresses to physical memory enforced by the kernel. The number stored in your music reference points at a location in your address space, and that same number points at unrelated memory, or nothing at all, in the service's address space. You cannot pass an object reference across the boundary, because the reference is only meaningful inside the process that created it.

So the call has to become data. The method identity, the string "track-42", and eventually the return value all have to be turned into bytes, handed to something that both processes can reach, and reconstructed on the other side. The only component that sees both address spaces is the kernel.

You might reach for the general purpose tools first. Shared memory lets two processes map the same physical pages, but it gives you a flat region of bytes with no notion of a method call, no return value, no identity, and no security. Sockets and pipes give you a byte stream, but you are responsible for framing messages, matching replies to requests, and you have no trustworthy way to know which process is on the other end. None of these carry the two properties an object oriented remote call needs: a stable identity for the remote object, so the same object can be passed around and recognized, and an unforgeable caller identity, so a service can check who is asking before it acts.

Binder is the kernel driver and userspace framework that provides exactly those properties. It turns a method call into a transaction, copies it once through the kernel, delivers the trustworthy UID of the caller, and lets binder objects be passed around as tokens that keep their identity across processes. The rest of this article is how that machine is built.

The proxy and stub pattern: IInterface and asBinder

The bridge starts with two small types. IBinder is the remotable object, the thing that can receive a transaction, and IInterface is the base of every interface you can call remotely. If you examine IInterface, it declares exactly one method:

public interface IInterface {
    public IBinder asBinder();
}

That single method is the hinge of the whole design. Any object that implements a remotable interface must be able to produce the IBinder underneath it. This matters because the same interface type, IMusicService, has two completely different implementations at runtime depending on which side you are on, and asBinder() is how you reach the binder regardless of which one you hold.

On the server side, the real implementation extends Binder, which implements IBinder. When you ask it for asBinder(), it returns itself. On the client side, you never hold the real object. You hold a proxy, an object that implements IMusicService but contains none of the playback logic. Its asBinder() returns a BinderProxy, a Java handle to a native reference that points at the remote object. This is the same proxy idea behind a Retrofit interface, where the object you call is generated and every method turns into a request sent elsewhere, except here the request goes through the kernel instead of over HTTP.

You rarely write either side by hand. When you declare an AIDL interface, the build tool generates both halves. Consider a music interface:

interface IMusicService {
    void play(String trackId);
    int currentPositionMs();
}

From this, AIDL generates an abstract Stub class on the server side and a Proxy class on the client side, both nested inside a generated IMusicService. The Stub extends Binder and implements IMusicService. You subclass it and write play and currentPositionMs. The Proxy also implements IMusicService, but each method packs its arguments and sends them. The two halves never meet in the same process. One is what the service registers, the other is what the client receives.

AIDL also generates an asInterface helper that decides which half you are holding:

public static IMusicService asInterface(IBinder obj) {
    if (obj == null) return null;
    IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
    if (iin != null && iin instanceof IMusicService) {
        return (IMusicService) iin;          // local: the real Stub
    }
    return new IMusicService.Stub.Proxy(obj); // remote: wrap in a proxy
}

The decision rests on queryLocalInterface. A real Binder remembers the interface it was attached to and returns it here, so if the object lives in your own process, you get the actual implementation and skip IPC entirely. A BinderProxy always returns null, so a remote object falls through to the Proxy branch. This is why binding to a service in the same process is a direct method call, while binding across processes routes through a transaction, and your code looks identical either way.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor