System calls and strace

What a program asks the kernel, and its cost.

Advanced14 min · lesson 17 of 21

A process that is slow, stuck or failing with a vague message is usually waiting for, or being refused by, the kernel. strace shows every request a process makes to the kernel, with its arguments, its result and how long it took. This lesson uses it the way you would on a server: attach to a running process, time and count its system calls correctly, follow the child processes of a script to find the call that failed, and measure what tracing costs so you know when not to use it. What a system call is, and the first strace example, are in the essentials course's architecture lesson.

How strace works

strace is built on ptrace, the kernel interface debuggers use. When strace traces a process, the kernel stops that process at the entry and at the exit of every system call and wakes strace, which reads the call number, the arguments and the return value from the stopped process and prints them. Then it lets the process continue. That model explains both what strace can show (every call, including the ones that fail) and what it costs (two stops and two context switches per call). strace is installed with Ubuntu Server (version 6.19 on 26.04) and on RHEL 10 (6.12).

Because ptrace can read and change another process's memory, the kernel restricts it. Ubuntu enables the Yama security module's ptrace_scope restriction.

deploy@web01 · Ubuntu 26.04 LTS
$ strace -V | head -1
strace -- version 6.19
$ sysctl kernel.yama.ptrace_scope
kernel.yama.ptrace_scope = 1

With kernel.yama.ptrace_scope = 1, a process may only trace its own descendants, unless it has the CAP_SYS_PTRACE capability. RHEL leaves it at 0, where any process may trace others of the same user. The hardening course (optional) explains the setting; the practical rule is to use sudo for a one-off trace and leave the setting alone.

Attach to a running process

The symptom: jobs dropped into a queue directory sometimes wait half a second before anything happens, and the worker process uses almost no CPU. The hypothesis is that the worker is waiting on purpose rather than working slowly. The worker is this small script. Save it as /var/tmp/pt-worker.py, create its spool directory, make it executable, and start it as a transient service under your own user.

/var/tmp/pt-worker.py
#!/usr/bin/python3
# pt-worker: a queue worker that checks its spool directory twice a second.
import os, time
SPOOL = '/var/tmp/pt-spool'
while True:
for name in sorted(os.listdir(SPOOL)):
path = os.path.join(SPOOL, name)
with open(path) as f:
f.read()
os.remove(path)
time.sleep(0.5)
deploy@web01 · Ubuntu 26.04 LTS
$ mkdir -p /var/tmp/pt-spool chmod 755 /var/tmp/pt-worker.py
$ sudo systemd-run --unit=pt-worker --uid=$USER /var/tmp/pt-worker.py
Running as unit: pt-worker.service; invocation ID: 52459bf881d8457eabb7acfda2638dfa
$ strace -p $(pgrep -x pt-worker.py)
strace: attach: ptrace(PTRACE_SEIZE, 213060): Operation not permitted

strace -p PID attaches to a running process; pgrep -x finds the PID by the exact process name. The worker belongs to you, but systemd started it, so it is not a descendant of your shell and Yama refuses. With sudo it works. -T adds the time spent in each call in angle brackets. strace runs until you press Ctrl-C; here timeout -s INT 2 sends the same signal after two seconds. strace then detaches and the worker carries on as before.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo timeout -s INT 2 strace -T -p $(pgrep -x pt-worker.py)
strace: Process 213060 attached clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, {tv_sec=3347, tv_nsec=750313110}, NULL) = 0 <0.348855> openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 <0.000062> fstat(3, {st_mode=S_IFDIR|0775, st_size=4096, ...}) = 0 <0.000020> getdents64(3, 0x3a0b0fa0 /* 2 entries */, 32768) = 48 <0.000056> getdents64(3, 0x3a0b0fa0 /* 0 entries */, 32768) = 0 <0.000033> close(3) = 0 <0.000070> clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, {tv_sec=3348, tv_nsec=364096645}, NULL) = 0 <0.501921> openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 <0.000137> fstat(3, {st_mode=S_IFDIR|0775, st_size=4096, ...}) = 0 <0.000011> getdents64(3, 0x3a0b0fa0 /* 2 entries */, 32768) = 48 <0.000022> getdents64(3, 0x3a0b0fa0 /* 0 entries */, 32768) = 0 <0.000004> close(3) = 0 <0.000024> clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, {tv_sec=3348, tv_nsec=866595090}, NULL) = 0 <0.504066> openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 <0.000112> fstat(3, {st_mode=S_IFDIR|0775, st_size=4096, ...}) = 0 <0.000023> getdents64(3, 0x3a0b0fa0 /* 2 entries */, 32768) = 48 <0.000248> getdents64(3, 0x3a0b0fa0 /* 0 entries */, 32768) = 0 <0.000081> close(3) = 0 <0.000065> clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, {tv_sec=3349, tv_nsec=371941779}strace: Process 213060 detached <detached ...>

Each round the worker opens the spool directory, reads its entries with getdents64 (only . and .., so the second call returns 0), closes it, and then sits in clock_nanosleep for about 0.5 seconds. That confirms the hypothesis: a job waits for the next poll, up to half a second, and nothing is slow. The fix is in the design (a shorter interval, or inotify to be woken when a file arrives), not in the system. The last call was still running when strace detached, so it ends in <detached ...>.

Counting calls: CPU time or wall-clock time

For a busy process, a line per call is too much to read, and -c prints a summary table instead: calls, errors and time per system call. What the time column measures is the part most people get wrong. By default strace -c sums system time, the CPU time the kernel spent executing each call. -w switches it to wall-clock time, from the start of the call to its end, including the time the process was blocked.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo timeout -s INT 5 strace -c -p $(pgrep -x pt-worker.py)
strace: Process 213060 attached strace: Process 213060 detached % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 57.07 0.002268 226 10 clock_nanosleep 24.16 0.000960 96 10 openat 12.20 0.000485 24 20 getdents64 3.35 0.000133 13 10 close 3.22 0.000128 12 10 fstat ------ ----------- ----------- --------- --------- ---------------- 100.00 0.003974 66 60 total
$ sudo timeout -s INT 5 strace -c -w -p $(pgrep -x pt-worker.py)
strace: Process 213060 attached strace: Process 213060 detached % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 99.90 4.901959 490195 10 clock_nanosleep 0.07 0.003294 329 10 openat 0.02 0.000799 39 20 getdents64 0.01 0.000336 33 10 close 0.01 0.000335 33 10 fstat ------ ----------- ----------- --------- --------- ---------------- 100.00 4.906722 81778 60 total

The default table adds up 4 milliseconds of kernel CPU time in five seconds. clock_nanosleep leads it with 2.3 ms, the CPU cost of setting up ten sleeps (in other runs openat came first), and nothing in the table says where the five seconds went. With -w the same five seconds are 99.90% clock_nanosleep, 4.9 seconds of waiting, which is what the worker actually does. A call that blocks, such as a sleep, a read from an idle socket, a futex wait on a lock or epoll_wait, uses almost no CPU while it waits, so without -w it can take seconds and still rank at the bottom. When the kernel works hard inside the calls, the two views agree.

deploy@web01 · Ubuntu 26.04 LTS
$ strace -c dd if=/dev/zero of=/dev/null bs=1M count=2000
2000+0 records in 2000+0 records out 2097152000 bytes (2.1 GB, 2.0 GiB) copied, 0.226189 s, 9.3 GB/s % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ------------------ 84.33 0.106124 52 2015 read 6.66 0.008385 4 2000 write … ------ ----------- ----------- --------- --------- ------------------ 100.00 0.125849 30 4173 8 total
$ strace -c -w dd if=/dev/zero of=/dev/null bs=1M count=2000
2000+0 records in 2000+0 records out 2097152000 bytes (2.1 GB, 2.0 GiB) copied, 0.316454 s, 6.6 GB/s % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ------------------ 72.76 0.148963 73 2015 read 25.04 0.051258 25 2000 write … ------ ----------- ----------- --------- --------- ------------------ 100.00 0.204740 49 4171 8 total

dd copying 2 GB from /dev/zero spends its time inside read, where the kernel fills the buffer with zeros, so read leads both tables (0.106 s of CPU time, 0.149 s of wall-clock time; the rest of the wall-clock figure is the tracing itself). Use plain -c to ask where the kernel spends CPU on behalf of a process, and -c -w to ask where the time goes, which is the question when something is slow. For a multi-threaded program, -p alone attaches only the thread with that ID; add -f to attach every thread. Expect futex near the top of a -w table even in a healthy program, because idle worker threads also wait in futex; the question is which thread waits, and what it waits for.

RHEL gives the owner of a process more room: with ptrace_scope 0, the same attach works without sudo.

deploy@rocky10 · Rocky Linux 10.2
$ strace -V | head -1
strace -- version 6.12
$ sysctl kernel.yama.ptrace_scope
kernel.yama.ptrace_scope = 0
$ sudo systemd-run --unit=pt-worker --uid=$USER python3 /var/tmp/pt-worker.py
Running as unit: pt-worker.service; invocation ID: 6f6c64fdffc54a8997d2c1c855d2ea04
$ timeout -s INT 3 strace -c -w -p $(systemctl show -P MainPID pt-worker)
strace: Process 35652 attached strace: Process 35652 detached % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 99.93 2.974638 495772 6 clock_nanosleep 0.03 0.000897 149 6 openat 0.02 0.000514 42 12 getdents64 0.01 0.000296 49 6 fstat 0.01 0.000296 49 6 close ------ ----------- ----------- --------- --------- ---------------- 100.00 2.976640 82684 36 total

On Rocky the worker runs as python3 /var/tmp/pt-worker.py, because SELinux does not let systemd execute a script stored in /var/tmp (the unit fails with status=203/EXEC). strace 6.12 attaches without sudo and shows the same wall-clock picture.

Follow child processes and filter the calls

The next symptom is a script that prints "report failed" and nothing else, because it sends its commands' errors to /dev/null, as many real scripts do.

/var/tmp/pt-lab/pt-report.sh
#!/bin/bash
# pt-report.sh: summarise the day's sales (like many scripts, it hides the real error)
cd /var/tmp/pt-lab || exit 1
if ! sort -t, -k2 -n data/sales.csv > /dev/null 2>&1; then
echo 'report failed'
exit 1
fi
echo 'report ok'

To reproduce it, create /var/tmp/pt-lab, save the script there, make it executable, and give it a small data file. -e trace=openat limits the output to file opens. Without -f, strace traces only the process it started.

deploy@web01 · Ubuntu 26.04 LTS
$ mkdir -p /var/tmp/pt-lab
$ chmod 755 /var/tmp/pt-lab/pt-report.sh printf 'widget,3\ngadget,1\n' > /var/tmp/pt-lab/sales.csv cat /var/tmp/pt-lab/sales.csv
widget,3 gadget,1
$ /var/tmp/pt-lab/pt-report.sh
report failed
$ strace -e trace=openat /var/tmp/pt-lab/pt-report.sh
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 openat(AT_FDCWD, "/usr/lib/aarch64-linux-gnu/libtinfo.so.6", O_RDONLY|O_CLOEXEC) = 3 openat(AT_FDCWD, "/usr/lib/aarch64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 … openat(AT_FDCWD, "/var/tmp/pt-lab/pt-report.sh", O_RDONLY) = 3 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=213420, si_uid=1001, si_status=2, si_utime=0, si_stime=0} --- report failed +++ exited with 1 +++

The only opens shown are bash's own: its libraries, locale files and the script. The work happened in a child process, and the only trace of it is the SIGCHLD line: a child exited with status 2. -f follows every child and thread, and -Z (--failed-only) prints only calls that returned an error.

deploy@web01 · Ubuntu 26.04 LTS
$ strace -f -Z -e trace=openat,execve /var/tmp/pt-lab/pt-report.sh
openat(AT_FDCWD, "/dev/tty", O_RDWR|O_NONBLOCK) = -1 ENXIO (No such device or address) … strace: Process 213448 attached [pid 213448] openat(AT_FDCWD, "/usr/share/coreutils/locales/sort/en-US.ftl", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) [pid 213448] openat(AT_FDCWD, "/sys/fs/cgroup/cpu.max", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) strace: Process 213449 attached strace: Process 213450 attached [pid 213448] openat(AT_FDCWD, "data/sales.csv", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) … --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=213448, si_uid=1001, si_status=2, si_utime=0, si_stime=0} --- report failed +++ exited with 1 +++

Lines from other processes carry [pid N]. Skipping bash's locale lookups (failed attempts that are normal, because glibc tries several file names), the child sort tried to open data/sales.csv relative to /var/tmp/pt-lab and got ENOENT: the file is sales.csv in that directory, not in a data subdirectory. The other two attached processes are sort's threads. -e trace= also takes classes: %file for every call that takes a file name, %process for fork, exec and wait, %network for sockets. -o FILE writes the trace to a file, and -s 200 prints longer strings than the default 32 characters.

What tracing costs

Two stops per system call are cheap for a process that makes a few calls a second and ruinous for one that makes hundreds of thousands. dd with a block size of one byte makes a read and a write for every byte, and prints its own timing.

deploy@web01 · Ubuntu 26.04 LTS
$ dd if=/dev/zero of=/dev/null bs=1 count=100000
100000+0 records in 100000+0 records out 100000 bytes (100 kB, 98 KiB) copied, 0.057362 s, 1.8 MB/s
$ strace -o /dev/null dd if=/dev/zero of=/dev/null bs=1 count=100000
100000+0 records in 100000+0 records out 100000 bytes (100 kB, 98 KiB) copied, 11.3875 s, 8.8 kB/s
$ strace -f --seccomp-bpf -e trace=openat -o /dev/null dd if=/dev/zero of=/dev/null bs=1 count=100000
100000+0 records in 100000+0 records out 100000 bytes (100 kB, 98 KiB) copied, 0.0574635 s, 1.8 MB/s

100,000 bytes take 0.057 seconds without tracing and 11.4 seconds under strace, even with the output thrown away: the stops themselves are the cost, about two hundred times here. Filtering with -e trace= alone does not help, because strace still stops the process on every call and discards the ones you did not ask for. --seccomp-bpf does help: strace installs a seccomp filter in the traced process so the kernel stops it only for the traced calls, and the run takes 0.057 seconds, the same as without strace. It works only for a process strace starts (with -f), not with -p.

perf trace reads the kernel's system call tracepoints (the hooks the eBPF tools lesson uses) instead of stopping the process.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo perf trace -s -o /dev/null -- dd if=/dev/zero of=/dev/null bs=1 count=100000
100000+0 records in 100000+0 records out 100000 bytes (100 kB, 98 KiB) copied, 1.71621 s, 58 kB/s
$ sudo perf trace -e openat -- cat /etc/hostname
0.000 ( 0.016 ms): cat/213585 openat(dfd: CWD, filename: 0x9888e680, flags: RDONLY|CLOEXEC, __filename_val: 524288) = 3 0.041 ( 0.010 ms): cat/213585 openat(dfd: CWD, filename: 0x988a2140, flags: RDONLY|CLOEXEC, __filename_val: 524288) = 3 …
$ sudo perf trace -s -- sleep 1
Summary of events: sleep (213611), 290 events, 94.5% syscall calls errors total min avg max stddev (msec) (msec) (msec) (msec) (%) --------------- -------- ------ -------- --------- --------- --------- ------ clock_nanosleep 1 0 1001.685 1001.685 1001.685 1001.685 0.00% …

It took 1.7 seconds on the same workload, far less than strace's 11.4 but about 30 times the untraced run, and -s prints a per-call summary with wall-clock times. Ubuntu's perf is built without the BPF skeletons that perf trace uses to copy string arguments, so it shows file names as pointer values; for arguments, strace is still the tool. The eBPF tools lesson covers tracing that aggregates in the kernel.

Before you strace a production process
Attaching slows every system call the process makes, and a busy service slows by the same factor while real requests wait. Prefer a short -c -w window with timeout, and --seccomp-bpf when you can start the program yourself. perf trace is cheaper than strace but still formats every event: on this lab's workload it made the program about 30 times slower (1.7 s against 0.057 s), which a busy service would not survive either. For a busy process, count or build histograms inside the kernel instead, with no output per call (a bpftrace count() or hist(), shown in the eBPF tools lesson). A process in the D state shows a single call that never returns; its kernel stack (/proc/PID/stack, covered in the lessons on /proc and on processes) says more.

Try this

Watch the worker pick up a job. It is still running from the attach section (if you stopped it, start it again with the same systemd-run command). In one command line, trace only openat and unlinkat in the background for three seconds, and drop a file into the spool while it runs. Predict the order before you look: the directory open on every poll, then the open of job-1 and its removal (unlinkat) in the round after the file appears; the strace: Process ... attached line first, and detached when timeout sends SIGINT. Then compare with the lab's run, and stop the worker.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo timeout -s INT 3 strace -e trace=openat,unlinkat -p $(pgrep -x pt-worker.py) & sleep 1 echo 'job 1' > /var/tmp/pt-spool/job-1 wait
strace: Process 213060 attached openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 openat(AT_FDCWD, "/var/tmp/pt-spool/job-1", O_RDONLY|O_CLOEXEC) = 3 unlinkat(AT_FDCWD, "/var/tmp/pt-spool/job-1", 0) = 0 openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 openat(AT_FDCWD, "/var/tmp/pt-spool", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 strace: Process 213060 detached
$ sudo systemctl stop pt-worker

Takeaway

When a process is slow, time its calls with strace -c -w (and -f for threads), because the default summary counts only CPU time; and keep strace for processes that make few calls or that you can start with --seccomp-bpf, because every traced call stops the process twice.

Quick check
01An API worker takes two seconds per request. strace -c -p on it for ten seconds shows read and write at the top and futex with 0.4% of the time, so a colleague rules out lock waits. What is wrong with that conclusion?
Correct — A waiting thread uses almost no CPU, so only a wall-clock summary shows where the two seconds go, and -f includes every thread.
Incorrect — The window is long enough to see a two-second wait many times; the problem is what the time column measures.
Incorrect — The fast path of a lock stays in user space, but a thread that has to wait makes a futex system call, which strace sees.
Incorrect — strace -c counts errors in its own column; nothing is left out of the table because it failed.
02A batch job makes about 300,000 system calls a second, and you need to know which files it opens. You can restart it. Which way of running strace disturbs it least?
Incorrect — strace still stops the process on every call and discards the others, so the job slows as much as with no filter.
Incorrect — Less output does not mean fewer stops; the cost is the two ptrace stops per call, not the printing.
Incorrect — The lab measured 11 seconds instead of 0.06 with output sent to /dev/null: the stops are the cost.
Correct — The seccomp filter makes the kernel stop the process only for traced calls; it needs -f and a process strace starts.
03A deploy wrapper script fails with "deploy failed". strace -e trace=%file ./deploy.sh shows no failing call except bash's locale lookups, and ends with a SIGCHLD whose si_status is 1. What should you do next?
Incorrect — The trace covered only the shell; it cannot clear the child processes it never traced.
Correct — Without -f only the shell is traced; the SIGCHLD shows a child exited with 1, and -f shows its calls.
Incorrect — strace does not hide calls it could trace; it traced only the process it started, which is the gap.
Incorrect — Longer strings help reading arguments, but the child's calls are not in the output at all.

Related