When a thread is Sleeping, the lock or syscall it‘s blocked on rarely tells you why wall-clock time is being spent. The holder of that resource is itself often off-CPU, blocked further down a chain of waits. The Critical Path follows the kernel scheduler’s wakeup graph backwards from the sleeping thread and, at every instant in the wait window, names the thread that is actually on-CPU on its behalf.
This guide covers:
The same machinery applies to binder calls, IO waits, and other sleeps where the kernel records a waker.
Consider an app where MainActivity.onCreate blocks for ~60 ms inside synchronized (LOCK_AB). The shape is three threads deep:
main thread wants LOCK_AB ────────┐
↓
Worker-A holds LOCK_AB,
wants LOCK_BC ────────┐
↓
Worker-B holds LOCK_BC,
doing 120 ms of CPU work
The Java that produces it:
// Worker-B synchronized (LOCK_BC) { burnCpu(120); } // Worker-A synchronized (LOCK_AB) { synchronized (LOCK_BC) { /* blocks for 120 ms */ } } // main synchronized (LOCK_AB) { /* blocks behind Worker-A */ }
With ART's dalvik atrace category enabled, two slices appear on main during the wait:
Lock contention on a monitor lock (owner tid: <Worker-A tid>) monitor contention with owner Worker-A at MainActivity.lambda$startContenders$0(MainActivity.java:84) blocking from MainActivity.onCreate(MainActivity.java:60)
The owner is Worker-A, which suggests fixing Worker-A. Two things make that wrong:
LOCK_AB the whole time, but is itself in S state, blocked on LOCK_BC. The wall-clock cost of main‘s wait belongs to whichever thread is on-CPU on Worker-A’s behalf — Worker-B.The Critical Path resolves this by walking the wakeup graph and attributing each instant of the wait to whichever thread is actually running.
buffers: { size_kb: 65536 } duration_ms: 8000 data_sources: { config { name: "linux.ftrace" ftrace_config { ftrace_events: "sched/sched_switch" ftrace_events: "sched/sched_waking" ftrace_events: "sched/sched_wakeup_new" ftrace_events: "sched/sched_process_exit" ftrace_events: "task/task_newtask" atrace_categories: "dalvik" atrace_categories: "am" atrace_categories: "wm" atrace_categories: "view" atrace_categories: "gfx" atrace_apps: "<your.package>" } } } data_sources: { config { name: "linux.process_stats" } }
sched_switch and sched_waking are required to populate the wakeup graph the analysis walks. dalvik is what gives you ART's Lock contention on a monitor lock slices — without it you see the wait but not the contention shape that names a nominal owner. The remaining categories make the surrounding slice context readable.
See recording system traces for capture mechanics. Open the resulting .perfetto-trace at ui.perfetto.dev.
The Critical path lite action, rooted at a Sleeping segment of any thread-state track, adds one row below that thread segmented by which thread was on-CPU on the blocked thread's behalf at each instant.
Click the Sleeping segment, then use the “Critical Path Lite” button on the Thread State details panel that appears at the bottom of the UI:
Pin the three threads and their thread-state tracks, run Critical path lite on main's long Sleeping segment, then zoom in on the wait:
The Lite chain attributes each instant of main's 60 ms wait to the thread that was on-CPU on its behalf:
| relative ms | duration | on-CPU on main's behalf |
|---|---|---|
| 0.00 – 0.21 ms | 0.21 ms | main self-run, finishing its quantum |
| 0.21 – 51.44 ms | 51.23 ms | Worker-B (the CPU burn under LOCK_BC) |
| 51.46 – 53.77 ms | 2.31 ms | Worker-A (re-acquiring LOCK_BC and LOCK_AB) |
| 53.78 – 60.03 ms | 6.25 ms | main self-run, after monitor release |
Worker-B accounts for 85% of the 60 ms wait. Worker-A — the thread the ART contention slice names as the owner — is on the chain for ~2 ms total, the time it takes to release the monitors. The fix is on Worker-B:
// Before synchronized (LOCK_BC) { burnCpu(120); // CPU work inside the lock } // After synchronized (LOCK_BC) { // only the lock-protected mutation } burnCpu(120); // CPU work outside the lock
After this change Worker-B releases LOCK_BC immediately, Worker-A's critical section completes in microseconds, and onCreate exits in a few ms.
The general pattern: when an ART contention slice names an owner that doesn‘t account for your wait, follow the critical path backwards until the chain reaches a thread in Running — that’s the one to fix.
The same data is available through the sched.thread_executing_span stdlib module — useful for batch and programmatic analysis. Root the query at the waiter and bound it to the wait slice itself so that root-thread self-time outside the wait doesn't dominate the result:
INCLUDE PERFETTO MODULE sched.thread_executing_span; SELECT thread.name AS blocker_thread, process.name AS blocker_process, sum(cp.dur) / 1e6 AS total_ms, count(*) AS segments FROM _thread_executing_span_critical_path( (SELECT thread_track.utid FROM slice JOIN thread_track ON slice.track_id = thread_track.id WHERE slice.name = 'main waits for LOCK_AB'), (SELECT ts FROM slice WHERE name = 'main waits for LOCK_AB'), (SELECT dur FROM slice WHERE name = 'main waits for LOCK_AB')) AS cp JOIN thread ON cp.utid = thread.utid LEFT JOIN process USING (upid) GROUP BY thread.name, process.name ORDER BY total_ms DESC;
"blocker_thread", "blocker_process", "total_ms", "segments" "Worker-B", <app process>, 51.25, 1 <app main thread>, <app process>, 6.47, 2 "Worker-A", <app process>, 2.31, 1
Numbers sum to the wait dur (60.03 ms).
NOTE: Looking up the wait by slice name (here main waits for LOCK_AB, an app-emitted atrace slice wrapping the contended synchronized block) is more robust than by process name. On cold launch process.name may still report the zygote name because linux.process_stats raced ahead of setprocname; the slice carries the right utid regardless.
Monitor contention puts a single thread on the chain. Kernel-lock contention from a thread storm looks different: a process that creates many threads at once contends with itself on the per-process mmap_lock, and the chain attribution is spread across every concurrent sibling. Same machinery, different shape.
Consider an app whose initialiser spawns 200 threads in a tight loop:
Thread[] children = new Thread[200]; for (int i = 0; i < 200; i++) children[i] = new Thread(work, "Storm-" + i); for (Thread t : children) t.start(); // <-- the storm for (Thread t : children) t.join();
200 Thread.start() calls take ~140 ms — averaging ~700 µs per spawn. Without contention pthread_create is ~5 µs, so most of this is wait time inside the kernel.
thread_state.blocked_function (the kernel symbol the thread is blocked in) needs three things on top of the monitor-case config:
ftrace_events: "sched/sched_blocked_reason" # the event itself symbolize_ksyms: true # resolve symbols ksyms_mem_policy: KSYMS_RETAIN # keep them at runtime
sched_blocked_reason records a kernel address; perfetto resolves it against /proc/kallsyms if symbolize_ksyms is on. /proc/kallsyms itself only exposes real addresses when /proc/sys/kernel/kptr_restrict is 0 — on most Android builds it defaults to 2 (zeroed), so on userdebug:
adb root adb shell 'echo 0 > /proc/sys/kernel/kptr_restrict'
If you skip these, thread_state.blocked_function stays NULL and you can see the wait but not which kernel lock it's on.
The mm-lock contention shows up directly on the spawn-loop thread's thread-state track: short, frequent D segments labelled with kernel functions (prctl_set_vma, __vm_munmap, vm_mmap_pgoff, mmap_read_lock_killable, do_mprotect_pkey, do_exit). Aggregating across the storm window:
SELECT thread_state.blocked_function, count(*) AS cnt, sum(thread_state.dur)/1e6 AS total_ms FROM thread_state JOIN thread USING(utid) JOIN process USING(upid) WHERE process.pid = <app_pid> AND thread_state.blocked_function IS NOT NULL AND thread_state.ts BETWEEN <storm_start_ts> AND <storm_end_ts> GROUP BY blocked_function ORDER BY total_ms DESC;
"blocked_function", "cnt", "total_ms" "__vm_munmap", 76, 62.03 "prctl_set_vma", 76, 52.51 "do_mprotect_pkey", 62, 41.55 "vm_mmap_pgoff", 85, 38.82 "mmap_read_lock_killable", 31, 18.10 "do_exit", 32, 17.34 "mmap_read_lock", 16, 4.53
Every entry is a path that takes the per-process mmap_lock (write side for mmap/munmap/mprotect/prctl_set_vma, read side for the _killable variant during signal-aware waits). pthread_create mmap‘s a new thread stack, the exit path munmap’s it, and the process is contending on its own mm_struct from many CPUs at once.
Running Critical path lite on the spawn-loop thread over the storm window:
A 10% slice of the storm, where individual chain hops are visible:
The blocker ranking confirms the contention is intra-process: every blocker is in the same process as the spawn-loop thread.
INCLUDE PERFETTO MODULE sched.thread_executing_span; SELECT CASE WHEN cp.utid = (SELECT utid FROM thread WHERE tid = <main_tid>) THEN 'self (main, on-CPU)' ELSE 'other thread in same process' END AS attribution, count(distinct cp.utid) AS distinct_threads, sum(cp.dur) / 1e6 AS total_ms FROM _thread_executing_span_critical_path( <main_utid>, <storm_start_ts>, <storm_dur>) cp GROUP BY attribution ORDER BY total_ms DESC;
"attribution", "distinct_threads", "total_ms" "self (main, on-CPU)", 1, 81.79 "other thread in same process", 147, 77.99
Half the wait is main running on-CPU; the other half is fragmented across 147 distinct sibling threads, with no single thread dominant. Many distinct blockers in the same process is the signature of intra-process kernel-lock contention. The same query against a victim blocked on something external — for example a binder call to a slow service — would show a different process in the attribution and a much lower distinct_threads count.
This is a parallelism problem, not a correctness one. Three mitigations apply:
Executors.newCachedThreadPool() (or a hand-rolled spawn loop) with newFixedThreadPool(n) sized to the cores you actually have. Reuse threads rather than paying mmap+munmap per task.onCreate.pthread_create mmap’s per spawn. A smaller stack proportionally reduces the mmap work done under mmap_lock.The general pattern: when blocked_function on a victim names a kernel symbol and the critical-path attribution stays inside the same process, the cause is intra-process kernel-lock self-contention. The fix is reducing parallelism or per-operation work, not chasing an offending peer.
Beyond the two case studies, the same machinery applies to any sleep where the kernel records a waker:
Looper.poll or a framework call is the visible symptom; the cause is often a thread not implicated by the symptom itself. The scheduling-blockages case study has more.The button doesn't produce any output. The trace is missing sched_switch and/or sched_waking. Re-record with the config above. On Android the device must allow ftrace capture (userdebug build, or appropriate permissions).
The Critical Path Lite track has gaps. Two reasons:
trace_start() only the time inside the trace window can be attributed.Rooting at a non-main thread. The action works on any thread-state segment; the SQL function takes an arbitrary utid. Useful for tracing why a binder thread on a service process was slow to reply, or why a kworker stalled.
sched.thread_executing_span — stdlib module reference for the SQL surface used above.