File Descriptors and I/O in Linux: What Every Backend Engineer Should Know

Every network connection, open file, pipe, and socket in Linux is represented as a file descriptor. Understanding file descriptors, the kernel's I/O models, and their limits is essential for diagnosin

Introduction#

Every network connection, open file, pipe, and socket in Linux is represented as a file descriptor. Understanding file descriptors, the kernel’s I/O models, and their limits is essential for diagnosing performance issues in high-throughput servers.

What Is a File Descriptor#

A file descriptor (FD) is a non-negative integer that refers to an open file description in the kernel. When a process opens a file or socket, the kernel returns the lowest available integer — starting at 3, since 0 (stdin), 1 (stdout), and 2 (stderr) are always open.

1
2
3
4
5
6
7
8
# View open file descriptors for a process
ls -la /proc/$(pgrep nginx | head -1)/fd/

# Count open FDs
ls /proc/$(pgrep nginx | head -1)/fd/ | wc -l

# Show per-process FD limits
cat /proc/$(pgrep nginx | head -1)/limits | grep "open files"

FD Limits#

Linux enforces limits at two levels:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# System-wide max open files
cat /proc/sys/fs/file-max

# Per-process soft limit (can be raised up to hard limit)
ulimit -n         # soft limit
ulimit -Hn        # hard limit

# Raise limit for current session
ulimit -n 65536

# Permanent system-wide change
echo "fs.file-max = 1048576" >> /etc/sysctl.conf
sysctl -p

# Per-service limit (systemd)
# /etc/systemd/system/my-service.service
# [Service]
# LimitNOFILE=65536

For services handling thousands of concurrent connections, the default limit of 1024 is far too low. Most production servers set this to 65536 or higher.

I/O Models#

Blocking I/O#

The classic model. The process blocks until data is available.

1
2
3
4
5
6
7
8
9
10
11
import socket

server = socket.socket()
server.bind(("0.0.0.0", 8080))
server.listen(5)

while True:
    conn, addr = server.accept()  # blocks until connection arrives
    data = conn.recv(1024)        # blocks until data arrives
    conn.send(b"HTTP/1.1 200 OK\r\n\r\nHello")
    conn.close()

One thread per connection. Does not scale past a few thousand concurrent connections.

Non-Blocking I/O with select/poll/epoll#

The process registers interest in multiple FDs and waits for any to become ready.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import select
import socket

server = socket.socket()
server.setblocking(False)
server.bind(("0.0.0.0", 8080))
server.listen(5)

inputs = [server]
outputs = []

while inputs:
    readable, writable, exceptional = select.select(inputs, outputs, inputs)
    for s in readable:
        if s is server:
            conn, addr = s.accept()
            conn.setblocking(False)
            inputs.append(conn)
        else:
            data = s.recv(1024)
            if data:
                outputs.append(s)
            else:
                inputs.remove(s)
                s.close()

select has O(n) scaling and is limited to 1024 FDs. poll removes the 1024 limit but is still O(n). epoll (Linux) is O(1) for event notification and is used by all modern servers (nginx, Node.js, Go’s net package).

epoll#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Simplified epoll usage in C
int epfd = epoll_create1(0);

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // edge-triggered
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);

struct epoll_event events[MAX_EVENTS];
while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        // Handle events[i].data.fd
    }
}

Python’s asyncio, Go’s runtime, and nginx all use epoll internally. You rarely call epoll directly.

/proc/sys/fs/file-nr#

Monitor system-wide FD usage:

1
2
3
4
5
6
7
8
9
10
11
cat /proc/sys/fs/file-nr
# 3456   0   1048576
# ^      ^   ^
# allocated  unused  max

# Check if you're approaching the limit
python3 -c "
data = open('/proc/sys/fs/file-nr').read().split()
used, unused, max_fds = int(data[0]), int(data[1]), int(data[2])
print(f'Used: {used}, Available: {max_fds - used}, Utilization: {used/max_fds:.1%}')
"

FD Leaks#

A FD leak occurs when a process opens files/sockets but never closes them. Eventually it hits the limit and fails to accept new connections.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# LEAK: file not closed if exception occurs
def read_config(path):
    f = open(path)       # FD allocated
    data = f.read()
    parse(data)          # if this raises, f is never closed
    f.close()

# CORRECT: context manager guarantees close
def read_config(path):
    with open(path) as f:
        data = f.read()
    parse(data)

# Detect leaks by monitoring FD count over time
import os
def current_fd_count():
    return len(os.listdir(f"/proc/{os.getpid()}/fd"))

Pipe and Unix Socket FDs#

Pipes and Unix domain sockets are also file descriptors.

1
2
3
4
5
6
7
8
9
# Create a pipe between two commands
ls -la | wc -l
# Shell creates a pipe: ls stdout → pipe write end → wc stdin

# Unix socket (faster than TCP for local IPC)
# Used by PostgreSQL, Docker daemon, etc.
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker ... /var/run/docker.sock
# 's' = socket

Conclusion#

File descriptors are the universal abstraction for I/O in Linux. Key takeaways: raise ulimit -n for high-concurrency services, use epoll-based async I/O for scalable network servers, always close FDs with context managers, and monitor /proc/sys/fs/file-nr to detect system-wide pressure.

Contents