Open Files per Process
Problem statement
Look inside /proc/PID/fd to see which files a process has open, watch the list change as files are opened and closed, spot a leak, and see what happens when an open file is deleted. "Too many open files" errors and disks that will not free up both come down to file descriptors.
The script opens files in its own shell, so it can inspect itself through /proc/$$/fd ($$ is the shell's own PID). It works in a temporary folder that contains:
b.txt
hello- Open
a.logfor writing as descriptor 3,b.txtfor reading as 4 andc.logfor appending as 5, then list the open files. - Close descriptor 4 and list again.
- Open 3 more files and never close them, then count.
- Delete
a.logwhile descriptor 3 still has it open, and show what/procsays.
Expected output:
== files this shell has open ==fd 3 -> a.logfd 4 -> b.txtfd 5 -> c.log== after closing fd 4 ==fd 3 -> a.logfd 5 -> c.log== a leak: opened 3 more and never closed them ==open files here: 5== a.log deleted while fd 3 still has it open ==fd 3 -> a.log (deleted)Hints
exec 3> a.log opens a file on descriptor 3 for the rest of the shell's life; exec 4<&- closes descriptor 4.Approach
Optimal: Read /proc/PID/fd
Covers: file descriptors, /proc/PID/fd, readlink, exec N> file, exec N<&-, exec {fd}> (bash 4.1+), ulimit -n, lsof -p, deleted but open files.
A file descriptor is a numbered handle. When a program opens a file, the kernel gives it a small number, the file descriptor (fd). The program reads and writes through that number. Every process starts with three:
stdin"]:::gray P --> F1["fd 1
stdout"]:::gray P --> F2["fd 2
stderr"]:::gray P --> F3["fd 3
a.log"]:::blue P --> F5["fd 5
c.log"]:::blue classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
| fd | Name | Usually |
|---|---|---|
| 0 | stdin | keyboard or a pipe |
| 1 | stdout | screen or a pipe |
| 2 | stderr | screen |
| 3 and up | files, sockets, pipes the program opened |
Sockets count too, so a web server with 10,000 connections has more than 10,000 descriptors open.
/proc shows them. Linux exposes every process as a folder in /proc. In /proc/PID/fd, each open descriptor is a link named by its number, pointing to what it is connected to. ls -l /proc/1201/fd on a server shows everything process 1201 has open. You can read your own processes; root can read all of them.
Opening and closing from the shell. exec with a redirect and no command changes the current shell's own descriptors:
| Command | Does |
|---|---|
exec 3> a.log |
open a.log for writing on fd 3 (truncates it) |
exec 4< b.txt |
open b.txt for reading on fd 4 |
exec 5>> c.log |
open c.log for appending on fd 5 |
exec 4<&- |
close fd 4 |
exec {fd}> x.log |
pick a free number (10 or more) and store it in $fd |
Leaks and limits. A program that opens files or connections and forgets to close them leaks descriptors. The count only grows, until it hits the limit and every new open fails with "Too many open files". ulimit -n shows the limit for your shell, often 1024; services have their own, set with LimitNOFILE= in systemd. Watching the count over time is how you spot a leak.
Deleted, but still open. Deleting a file only removes its name. If a process still has it open, the data stays on disk and /proc shows the link as a.log (deleted). The space comes back only when the descriptor is closed, which is the cause of the Deleted Files Still Using Space page.
a.log (deleted)"]:::red --> D2["space returns
when fd 3 closes"]:::green end NAME ~~~ DATA classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style NAME fill:transparent,stroke:#d97706,stroke-width:2px style DATA fill:transparent,stroke:#dc2626,stroke-width:2px
Walking through the code. The # Setup: lines only create the folder and b.txt, so skip past them.
list_fdsloops over/proc/$$/fd/*, reads each link, and prints only links to files in this folder, so the shell's own pipes and terminal do not clutter the output.sort -k2,2norders by descriptor number.- After
exec 4<&-, fd 4 is gone. - The loop opens three files with
exec {fd}>and keeps none of the numbers, a small leak; the count goes to 5. - After
rm a.log, fd 3 still points at it, marked(deleted).
Edge cases. /proc exists only on Linux; on macOS use lsof -p PID. A process can close and reopen files between two looks, so take several samples before calling it a leak.
# Setup: work in a fresh temporary folder with one file to read
cd "$(mktemp -d)"
echo "hello" > b.txt
# Show this shell's open descriptors that point at files in this folder
list_fds() {
for p in /proc/$$/fd/*; do
t=$(readlink "$p")
[[ $t == "$PWD"/* ]] && echo "fd ${p##*/} -> ${t#"$PWD"/}"
done | sort -k2,2n
}
exec 3> a.log 4< b.txt 5>> c.log
echo "== files this shell has open =="
list_fds
exec 4<&-
echo "== after closing fd 4 =="
list_fds
for i in 1 2 3; do exec {fd}> "leak-$i.log"; done
echo "== a leak: opened 3 more and never closed them =="
echo "open files here: $(list_fds | wc -l)"
rm a.log
echo "== a.log deleted while fd 3 still has it open =="
list_fds | grep 'fd 3 'Interview follow-ups
Get back the space of a deleted log without restarting the process.
Find the descriptor that still holds it, for example
fd 3 -> app.log (deleted)in/proc/1201/fd. Truncate the file through that link:: > /proc/1201/fd/3. The process keeps its descriptor, but the file's size drops to zero and the space is freed. Be careful: the process may keep writing at its old position, which can leave a sparse file, so a planned restart or log reopen (kill -HUPfor many daemons) is the clean fix.
Frequently asked questions
Count the entries in every process's fd folder and sort: for p in /proc/[0-9]*; do echo "$(ls $p/fd 2>/dev/null | wc -l) ${p#/proc/} $(cat $p/comm 2>/dev/null)"; done | sort -rn | head. Run it with sudo to see every process. lsof | awk '{print $2}' | sort | uniq -c | sort -rn | head gives a similar answer if lsof is installed, though lsof also lists memory-mapped files, so its numbers are higher.
For a systemd service, add LimitNOFILE=65536 under [Service] in an override (sudo systemctl edit name), then restart it. For login sessions, the limits live in /etc/security/limits.conf. ulimit -n 4096 raises it only for the current shell and the programs it starts, and only up to the hard limit (ulimit -Hn). Check the running process's real limit with cat /proc/PID/limits.
No. A busy server opens more connections when traffic grows and closes them when it drops, so the count follows load. A leak looks different: the count keeps climbing even when traffic is flat or at night, and never comes back down. Graph it next to the request rate before deciding. Restarting the process resets the count, which hides a leak for a while but does not fix it.