Debuggingdebugginghigh cpumemory leakhangemfile

The OS Debugging Playbook: Four Symptoms, Fourteen Causes

High CPU, high memory, a hang and “too many open files” are the four symptoms the OS shows you; each hides three or four different causes with different fixes, and each cause has one observation — user vs system time, RSS vs virtual, D vs S state, what the descriptors are — that tells it apart from its neighbours.

Linux
▶ InteractiveInterview question
Progress

The problem

The pager says: CPU at 100%. Or: memory climbing. Or: the service stopped responding. Or: Too many open files. Each of those is not a diagnosis — it is a symptom with several causes whose fixes contradict each other. What is the shortest sequence of observations that gets from symptom to cause?

The method

Linux

Every playbook below has the same shape: a symptom, the candidate causes, and for each pair of causes the observation that separates them. The tools are the standard Linux ones — top, ps, vmstat, strace, perf, lsof, ss, /proc — and every transcript is labelled linux; macOS and Windows have counterparts (Activity Monitor/sample, Process Explorer/procdump) with the same logic and different names. The interactive presents the same playbooks as a clickable symptom tree: pick the symptom, answer each distinguishing observation, and arrive at the cause.

Two habits matter more than any tool. First, look at the process’s state before its code: ps -o pid,stat,pcpu,rss,vsz,nlwp,wchan -p <pid> gives state, CPU, resident and virtual memory, thread count and what the kernel is waiting on in one line. Second, watch the shape over time: a value climbing linearly forever, a value that plateaus, and a value that spikes and recovers are three different bugs even when the current number is the same.

The one-liner to run first
$ ps -o pid,stat,pcpu,pmem,rss,vsz,nlwp,wchan:20,cmd -p 4121
  PID STAT %CPU %MEM   RSS    VSZ NLWP WCHAN                CMD
 4121 Sl   187  6.2 1024812 4788640  47 -                    node server.js
#          ^^   ^^^        ^^^^^^^^^^^^ ^^
#   state  S=sleeping (interruptible), D=uninterruptible (disk), R=running, Z=zombie; l=multithreaded
#   %CPU 187 = almost two cores busy; RSS 1 GB resident, VSZ 4.8 GB virtual (mostly unbacked); 47 threads

Playbook 1 — High CPU

Linux

Four causes: an infinite loop (or an accidental O(n²) on a big n), excessive legitimate work (traffic doubled, a batch job), contention (threads fighting for a lock, cache line or the GIL), and a spin lock (threads burning CPU while waiting). The first cut is user vs system time in top: a loop in your code is %us; a spin lock built on sched_yield or a futex storm is %sy; a runaway process making millions of syscalls (a read loop with a 1-byte buffer, a busy-poll on a non-blocking descriptor) is also %sy, and strace -c -p <pid> for ten seconds shows *which* syscall.

The second cut is per-thread CPU: top -H -p <pid> (or ps -L). One thread at 100% and the rest idle is a loop; all threads at a modest equal share is legitimate work; all threads at 100% with throughput flat is contention or spinning. The third cut is what the CPU is executing: perf top -p <pid> shows the hottest functions live (pthread_mutex_lock / __lll_lock_wait at the top is contention; atomic_load in a tight loop is a spin; your own function is a loop or real work). For an interpreted runtime, py-spy top, Node --cpu-prof, or the JVM’s async-profiler read the language stacks instead of the C ones. A flame graph (perf record -g + FlameGraph, or the profiler’s own output) turns the same data into one picture: a wide flat plateau is where the time goes.

The tell for the infinite loop is that the hot function never returns — the stack is identical every sample. The tell for excessive work is that the profile looks like the service’s normal profile, only larger, and request counts went up. The tell for contention is lock functions at the top and cores that add no throughput; high-cpu-spin-lock and high-cpu-infinite-loop are the two challenges in this domain, and event-loop-blocked is the single-threaded variant where one loop iteration never yields.

top and top -H on a contended service: 100% CPU, three threads, throughput flat
$ top -b -n 1 -p 4121 | tail -2
  PID USER  PR NI  VIRT   RES  SHR S  %CPU %MEM   TIME+ COMMAND
 4121 app   20  0 4.5g  1.0g 18m S 298.0  6.2 412:07.9 server        # %Cpu(s): 41.2 us, 57.9 sy  ← system time dominates

$ top -H -b -n 1 -p 4121 | tail -4
  PID USER  PR NI  VIRT   RES  SHR S  %CPU %MEM   TIME+ COMMAND
 4130 app   20  0 4.5g  1.0g 18m R  99.7  6.2 137:22.1 worker-1
 4131 app   20  0 4.5g  1.0g 18m R  99.3  6.2 137:19.8 worker-2
 4132 app   20  0 4.5g  1.0g 18m R  99.0  6.2 137:24.0 worker-3     # all three pegged; requests/s unchanged

$ perf top -p 4121 --stdio 2>/dev/null | head -4
  38.1%  libpthread.so  [.] __lll_lock_wait
  22.4%  [kernel]       [k] futex_wait_queue
  11.7%  server         [.] Cache::get            # the lock inside Cache::get is the serial section

Playbook 2 — High memory

Linux

Four causes: a leak (objects allocated and never released), a large cache (by design, and possibly the wrong size), fragmentation (the allocator holds freed memory it cannot return), and an unbounded queue (a producer faster than its consumer, buffering in memory). The first cut is RSS vs virtual: VSZ is the address space reserved and is almost meaningless (a JVM or Go process reserves gigabytes it never touches); RSS is what is resident; a process with 40 GB VSZ and 1 GB RSS has no memory problem. Read VmRSS, RssAnon, RssFile and RssShmem from /proc/<pid>/status — anonymous RSS is *your* memory; file RSS is page cache that the kernel can drop.

The second cut is the growth curve. Plot RSS over hours: a leak is monotonic and roughly linear in traffic — it never comes down, including at 4 a.m. A cache grows to its configured limit and plateaus; if there is no limit, it looks like a leak and *is* one. An unbounded queue grows when the consumer falls behind and shrinks when it catches up — a sawtooth correlated with downstream latency (memory-leak-unbounded-queue). Fragmentation shows as RSS well above what the heap profiler says is live, with glibc malloc_stats() or MALLOC_ARENA_MAX making a difference — common in long-running multithreaded C/C++ and CPython services.

The third cut is a heap profiler, which answers *what* is growing: heaptrack or valgrind --tool=massif for native code, jemalloc’s prof mode, Node --heap-prof or a Chrome DevTools heap snapshot diff, Python tracemalloc snapshots compared across an hour, JVM jmap -histo. Take two snapshots an hour apart and diff; the type whose count grew by exactly the number of requests is the leak. If the profiler shows the live heap flat while RSS climbs, the allocator or a native library is the culprit, not your objects.

/proc/<pid>/status — anonymous vs file-backed residency
$ grep -E '^(VmSize|VmRSS|RssAnon|RssFile|RssShmem|Threads)' /proc/4121/status
VmSize:  4788640 kB       # virtual: ignore
VmRSS:   1024812 kB       # resident
RssAnon:  986104 kB       # heap + stacks: yours; this is the number to trend
RssFile:   38200 kB       # mapped files / page cache: reclaimable
RssShmem:    508 kB
Threads:      47

Playbook 3 — Process hangs

Linux

Three causes: a deadlock (threads waiting on each other), blocked I/O (waiting on a disk, a socket, a pipe with no writer), and a waiting dependency (a database, an upstream service, a DNS resolver that is not answering). The first cut is the state letter: D means the kernel is waiting on a device — disk, NFS, a driver — and no user-space lock is involved; S means interruptible sleep, which covers a lock (futex), a socket (recv), a pipe, a sleep or a condition variable. cat /proc/<pid>/wchan and /proc/<pid>/stack (root) name the kernel function it sleeps in.

The second cut is strace -p <pid> (or -f for all threads): a deadlocked thread shows a single futex(…, FUTEX_WAIT_PRIVATE, …) and nothing further, forever; a socket wait shows recvfrom(23, …) or epoll_wait returning nothing; a disk wait shows read(7, …) that never returns (and the process is in D). ss -tnp | grep <pid> then says who descriptor 23 is connected to — and if that peer is your database, playbook 3 has just become somebody else’s playbook. The third cut is a thread dump: gdb -p <pid> -batch -ex "thread apply all bt", jstack, py-spy dump, Node --inspect + kill -USR1. Draw the wait-for graph from the lock frames; a cycle is a deadlock (Deadlocks, Cycle Detection); a chain ending at a socket or a disk is a hang with an external cause.

The single-threaded case deserves its own line: an event loop blocked by one long synchronous call — a large JSON.parse, a sync file read, a regex with catastrophic backtracking — is a hang for every connection and 100% CPU on one thread (The Event Loop). process-hang-deadlock and process-hang-blocked-io are the two challenges; the dependency case is a networking problem, handled in Why Can’t I Connect?.

Two hangs, one command each
$ ps -o pid,stat,wchan:24,cmd -p 4121 5210
  PID STAT WCHAN                    CMD
 4121 Sl   futex_wait_queue         server          # S + futex → user-space lock: deadlock or slow holder
 5210 D    io_schedule              backup          # D + io_schedule → disk wait: kill -9 will not help

$ strace -f -p 4121 2>&1 | head -3
[pid  4130] futex(0x7f2c4c0018a0, FUTEX_WAIT_PRIVATE, 2, NULL   # waits forever
[pid  4131] futex(0x7f2c4c0018d0, FUTEX_WAIT_PRIVATE, 2, NULL   # …on a different lock: cycle candidate
[pid  4132] recvfrom(23, …                                       # this one is waiting on a socket
$ ss -tnp | grep 'pid=4121' | grep ':5432'
ESTAB 0 0 10.0.0.5:48210 10.0.0.9:5432 users:(("server",pid=4121,fd=23))   # fd 23 is PostgreSQL

Playbook 4 — Too many open files

Linux

One symptom, two questions: what are the descriptors, and is the limit wrong or the usage wrong. ulimit -n shows the soft limit for a new shell; the running process’s own limit is in /proc/<pid>/limits, and it is frequently 1024 because a systemd unit or a container runtime did not raise it. ls /proc/<pid>/fd | wc -l is the count; lsof -p <pid> (or ls -l /proc/<pid>/fd) says what they are — regular files, sockets, pipes, eventfds, anon_inodes from epoll and timers. Group by type and by target: 50,000 sockets to one upstream, or 20,000 opens of the same log file, is the answer.

Sockets dominate in practice, and the state to look for is CLOSE_WAIT: the peer sent FIN, the kernel acknowledged it, and your code never called close() — typically a response handler that returns early on an error path, or an HTTP client that never consumes the body (The Connection Lifecycle: Close, Reset, TIME_WAIT, CLOSE_WAIT). ss -tan state close-wait | wc -l counts them; if the number climbs with traffic and never falls, that is the leak, and raising the limit only delays it (too-many-open-files, fd-leak-under-load). The second common shape is legitimate: a proxy or a database with 5,000 connections and a 1024 limit — here the fix really is LimitNOFILE=65536 in the unit file or --ulimit nofile= on the container.

Do not forget the system-wide limits: fs.file-max and /proc/sys/fs/file-nr for the host, fs.nr_open as the ceiling any process can be raised to, and ENFILE (system-wide) vs EMFILE (per-process) in the error text telling you which one you hit.

A CLOSE_WAIT leak found in four commands
$ grep 'open files' /proc/4121/limits
Max open files            1024                 4096                 files
$ ls /proc/4121/fd | wc -l
1024                                            # at the soft limit: accept() now returns EMFILE
$ lsof -p 4121 -a -i | awk '{print $8, $10}' | sort | uniq -c | sort -rn | head -3
    961 TCP (CLOSE_WAIT)                        # the peer closed; we never did
     41 TCP (ESTABLISHED)
      1 TCP (LISTEN)
$ ss -tanp state close-wait | awk '{print $5}' | cut -d: -f1 | sort | uniq -c
    961 10.0.0.9                                # all to one upstream: the client library's error path

The tree, compressed

The interactive is this tree. Each leaf names the cause, the confirming observation and the challenge that rehearses it.

Two symptoms are missing from the tree on purpose. “The service is slow” is not an OS symptom until you have translated it into one of these four — or into a networking symptom (Why Can’t I Connect?, Where the Time Goes: The Request Timeline) — and “the process died” is an exit code: 137 is SIGKILL (usually the OOM killer, so go to high memory), 139 is SIGSEGV, 143 is SIGTERM from an orchestrator (Signals: Asynchronous Notifications From the Kernel). Translate first, then walk the tree.

Symptom → first cut → cause
  1. High CPU → %us vs %sy → per-thread → perf top / flame graphloop (one thread, same stack) · work (normal profile, more of it) · contention (lock functions on top) · spin (atomic loop, %sy)
  2. High memory → RSS vs VSZ → growth curve → heap profiler diffleak (linear, never falls) · cache (plateaus) · queue (sawtooth with downstream latency) · fragmentation (RSS ≫ live heap)
  3. Hang → D vs S → strace → thread dump → wait-for graphdeadlock (cycle) · blocked I/O (D, io_schedule) · dependency (recv on a socket to someone else)
  4. Too many open files → count vs limit → lsof by type/target → ss stateleak (CLOSE_WAIT climbing) · undersized limit (legitimate connections > 1024)

Key points

  • Start with ps -o stat,pcpu,rss,vsz,nlwp,wchan: state, CPU, resident vs virtual, threads and kernel wait point in one line.
  • High CPU: %us vs %sy, then per-thread, then perf top — loop, work, contention or spin.
  • High memory: trend RssAnon, not VSZ; a leak never comes down, a cache plateaus, a queue sawtooths, fragmentation is RSS ≫ live heap.
  • Hang: D is the kernel waiting on a device; S + futex is a lock; S + recv is a dependency. A cycle in the wait-for graph is a deadlock.
  • Too many open files: count vs /proc/<pid>/limits, lsof by type, ss state close-wait; a leak climbs, a limit is just too low.
  • Shape over time beats any single number.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why can one symptom have four causes with contradictory fixes?

Because the OS reports resource usage, not intent. 100% CPU is the same number whether the work is useful, wasted in a loop, or burned waiting. Only the distribution — which threads, user or kernel, which functions — carries the intent.

Why look at process state before code?

The state letter and the wait channel are the kernel telling you where the thread is stuck, for free, in a millisecond. A profiler or a debugger is minutes of work that the state often makes unnecessary.

Why does raising `ulimit -n` so often make things worse?

Because most EMFILEs are leaks. A higher limit lets the leak run longer, consume more kernel memory and more peer-side resources, and fail at a worse time.

OS debugging playbook

OS debugging playbook
Start from the symptom, answer each diagnostic question with the tool that answers it, and arrive at a diagnosis with its fix.
start
What is the symptom?
tooltop / ps
Linux

How it fails

What the failure looks like from inside real software.

  • Restarting the service on high CPU without a thread-level look: the loop returns in minutes and the profile that would have named it is gone.
  • Alerting on VSZ: a healthy JVM with 30 GB reserved pages the on-call engineer every night.
  • kill -9 on a D-state process: nothing happens, the engineer escalates to a reboot, and the failing disk is still failing afterwards.
  • Treating a socket wait on the database as an application deadlock and hunting for locks for an hour.
  • Fixing EMFILE with a bigger limit while 961 CLOSE_WAIT sockets say the response handler never closes on error.
  • Reading a heap-profiler snapshot in isolation: everything looks live; only a diff across time shows what grows.