Unix File Descriptors: The Hidden Plumbing Behind Every Pipeline
File descriptors are the kernel’s integer handles for open resources. This post explains how they work, why close-on-exec prevents leaks, how to inspect them via /proc, and how to pass them between processes.
22 Nov 2025, 06:33 UTC

The problem you actually hit
\nYour long-running Go service crashes with EMFILE: too many open files after three days. A shell pipeline cmd1 | cmd2 | cmd3 hangs because cmd2 never sees EOF. A privileged helper opens port 443, but you can't figure out how to hand that listening socket to an unprivileged worker without running the whole thing as root.
All three symptoms trace to the same mechanism: file descriptors (FDs) — the per-process integer handles the kernel uses to represent every open resource. Understanding how FDs are created, inherited, and turned these mysteries into debuggable, designable behavior.
\n\nWhat an FD really is
\nWhen a process calls open(), socket(), or pipe(), the kernel allocates an open file description — a kernel object holding the file offset, access mode, and status flags (like O_APPEND or O_NONBLOCK). The call returns a small integer: the file descriptor. That integer is merely an index into the process's file descriptor table, which points to the open file description.
By convention, FD 0 is stdin, 1 is stdout, 2 is stderr. But the kernel doesn't enforce this; it's just what the C runtime and shells set up before main() runs.
Crucially, dup() and dup2() create a new FD that points to the same open file description. The offset and flags are shared. fork() copies the entire FD table, so parent and child share every open file description — and therefore the same file offset. Concurrent reads or writes from both processes race on that offset unless you use pread()/pwrite() or open with O_APPEND.
The close-on-exec safety net
\nWhen a process calls execve() to run a new program, all open FDs survive by default. That's convenient for pipelines but dangerous for libraries and daemons: a database connection or temporary file opened during initialization can leak into an unrelated child process, causing resource exhaustion or security exposure.
The fix is the close-on-exec flag (FD_CLOEXEC). Set it with fcntl(fd, F_SETFD, FD_CLOEXEC) or, better, atomically at creation time using openat(path, flags | O_CLOEXEC, ...) or socket(..., SOCK_CLOEXEC). Modern Linux also provides close_range(2) for bulk cleanup before execve().
Worked example — verifying close-on-exec:
\n# Open a file, mark it close-on-exec, then exec a shell that lists its own FDs\npython3 -c \"\nimport os, fcntl\nf = open('/tmp/testfd', 'w')\nfcntl.fcntl(f.fileno(), fcntl.F_SETFD, fcntl.FD_CLOEXEC)\nos.execlp('bash', 'bash', '-c', 'ls -l /proc/self/fd/')\n\"\n\nRun this and you'll see only FDs 0, 1, 2 (and possibly 255 for the shell's internal use). The FD for /tmp/testfd disappears because the kernel closed it during execve(). Remove the fcntl line and it reappears as FD 3.
Peeking inside a live process
\nThe /proc//fd/ directory exposes every open FD as a symlink pointing to its target: regular files, sockets, pipes, devices, even deleted-but-still-open files. This is a diagnostic goldmine.
Quick inspection:
\n# Start a background sleep, then list its FDs\nsleep 100 &\nls -l /proc/$!/fd/\n\nOutput shows something like:
\nlrwx------ 1 user user 64 Oct 12 00:12 0 -> /dev/pts/3\nlrwx------ 1 user user 64 Oct 12 00:12 1 -> /dev/pts/3\nlrwx------ 1 user user 64 Oct 12 00:12 2 -> /dev/pts/3\n\nIf the process had opened a log file, you'd see 3 -> /var/log/app.log. If it holds a deleted file open (common with rotated logs), the symlink shows (deleted) — but cat /proc//fd/3 can read from a deleted-but-still-open file.
Permissions note: /proc//fd/ is readable only by the process owner (or root). Containers may restrict /proc visibility, breaking tools like lsof or custom inspectors.
Passing FDs between unrelated processes
\nOne of the most powerful — and underused — Unix features is passing an open FD from one process to another over a Unix domain socket using SCM_RIGHTS control messages. This enables privilege separation: a small root helper binds to port 443, then passes the listening socket FD to an unprivileged worker that handles all traffic.
Minimal sender (C, error handling omitted):
\nint sock = socket(AF_UNIX, SOCK_STREAM, 0);\nstruct sockaddr_un addr = { .sun_family = AF_UNIX, .sun_path = \"/tmp/fdpass.sock\" };\nbind(sock, (struct sockaddr*)&addr, sizeof(addr));\nlisten(sock, 1);\nint conn = accept(sock, NULL, NULL);\n\nint listen_fd = socket(AF_INET, SOCK_STREAM, 0);\n// ... bind listen_fd to :443, listen() ...\n\nstruct msghdr msg = {0};\nstruct cmsghdr *cmsg;\nchar cbuf[CMSG_SPACE(sizeof(int))];\nmsg.msg_control = cbuf;\nmsg.msg_controllen = sizeof(cbuf);\ncmsg = CMSG_FIRSTHDR(&msg);\ncmsg->cmsg_level = SOL_SOCKET;\ncmsg->cmsg_type = SCM_RIGHTS;\ncmsg->cmsg_len = CMSG_LEN(sizeof(int));\n*(int*)CMSG_DATA(cmsg) = listen_fd;\nsendmsg(conn, &msg, 0);\n// KEEP listen_fd OPEN until recvmsg() completes on receiver side\n\nReceiver:
\nint conn = accept(sock, NULL, NULL);\nstruct msghdr msg = {0};\nstruct cmsghdr *cmsg;\nchar cbuf[CMSG_SPACE(sizeof(int))];\nmsg.msg_control = cbuf;\nmsg.msg_controllen = sizeof(cbuf);\nrecvmsg(conn, &msg, 0);\ncmsg = CMSG_FIRSTHDR(&msg);\nint listen_fd = *(int*)CMSG_DATA(cmsg);\n// listen_fd is now a valid listening socket in this process\n\nThe kernel transfers the reference to the open file description, not a copy. The sender must keep the FD open until recvmsg() returns; closing early breaks the transfer.
Limits and portability traps
\nEach process has a soft and hard limit on open FDs (RLIMIT_NOFILE, shown by ulimit -n). The default is often 1024. Servers raise it via setrlimit(2) or systemd's LimitNOFILE=. System-wide usage appears in /proc/sys/fs/file-nr (allocated, free, max). Exhaustion manifests as EMFILE/ENFILE — 'too many open files' — and can be diagnosed via /proc/sys/fs/file-nr.
Portability: signalfd, timerfd, eventfd, and userfaultfd are Linux-specific. BSD/macOS use kqueue for similar unified event loops. Guard with #ifdef and provide alternatives.
FD numbers are reused aggressively after close(). Never cache an FD value across calls that might open/close. If you need a specific number, use dup2(oldfd, desired_fd) to reserve it.
Your FD hygiene checklist
\n- \n
- Open with
O_CLOEXEC/SOCK_CLOEXECby default; opt in to inheritance. \n - Use
close_range(2)(Linux 5.9+) beforeexecve()in complex launchers. \n - Monitor
/proc/sys/fs/file-nrand per-processulimit -nin production dashboards. \n - Debug leaks with
ls -l /proc//fd/— look for unexpected sockets, pipes, or(deleted)files. \n - For privilege separation, pass listening sockets via
SCM_RIGHTSinstead of running workers as root. \n
The file descriptor abstraction is small, but it's the connective tissue of every Unix pipeline, daemon, and container. Master its mechanics and the \"mystery\" failures become design decisions you control.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.