Linux internals · conceptual model

A socket is a number
that points to a thing.

You're right that fd = 3 is just an integer. The integer is a handle — a ticket the kernel hands you. The real object lives inside the kernel, and the handle is how your process refers to it without ever touching it directly. Let's follow the handle all the way down.

“File descriptors (fd=3, fd=4) are the actual open sockets… open FDs survive a fork() and remain valid across exec() — they're just integer handles pointing to kernel socket objects. The kernel keeps the socket alive as long as at least one process holds a reference to it.”

By the end of this page, every clause in that quote should feel obvious.

§ 01 — THE MAPPING

The integer is an index into a per-process array

When you call socket() and it returns 3, the kernel did not give you a socket. It gave you the smallest free slot number in a little table that belongs to your process.

Every process has a file descriptor table — conceptually an array of pointers. The descriptor you hold (0, 1, 3…) is the index into that array. Each occupied slot points at a kernel object called struct file — the kernel's record of one open thing.

USER SPACE (your process) KERNEL SPACE fd table — array of struct file* 0 stdin 1 stdout 2 stderr 3 socket ● 4 socket ● ↑ the integer you hold is just the row number struct file #A f_pos · f_count=1 · f_op → … private_data → socket struct file #B f_pos · f_count=1 · f_op → … private_data → socket
Fig 1. Two integers (3, 4) → two slots → two kernel struct file objects.

Here is the slightly-simplified shape of those two pieces. The table is just an array; struct file is the per-open-instance record:

conceptual / simplified// lives inside your process's task_struct
struct files_struct {
    struct file **fd;   // the array. fd[3], fd[4] are pointers
};

// one of these per OPEN instance — shared, reference-counted
struct file {
    loff_t              f_pos;      // read/write offset
    atomic_t            f_count;    // how many refs point here
    const struct file_operations *f_op;  // what read/write MEAN
    void                *private_data; // → the real object (e.g. the socket)
};
Vocabulary worth pinning down The integer is the file descriptor. The struct file it points to is the open file description. One is a per-process number; the other is the actual open instance the kernel maintains. This distinction is the whole reason fork/refcounting works the way it does — keep it in your pocket for §04.
§ 02 — THE TRICK

“Everything is a file” is really “everything answers the same questions”

You sensed it's “a sort of file but actually a stream.” Both are true, because a file in Unix isn't a thing — it's an interface.

Look back at struct file: the field f_op is a pointer to a table of function pointers. When you call read(fd, …), the kernel finds the struct file for that fd and calls file->f_op->read(...). It never asks “is this a disk file or a socket?” — it just calls whatever function lives in that slot. This is polymorphism, hand-built in C. The dispatch layer that does this is the VFS (Virtual File System).

the vtable, basicallystruct file_operations {
    ssize_t (*read) (struct file*, char*, size_t, ...);
    ssize_t (*write)(struct file*, const char*, size_t, ...);
    int     (*release)(...);  // called on the final close()
    // ...poll, ioctl, etc.
};
read(fd, buf, n) same syscall, always file→f_op .read ● .write ● .release ● ext4: read from disk socket: drain RX queue pipe: read from buffer
Fig 2. One read(), many meanings — chosen by the function pointer in f_op.

So a socket is a file in the sense that it plugs into this interface and responds to read/write/close. It is not a file in the sense of bytes parked on a disk. For a socket, read doesn't fetch stored data — it pulls bytes the network already delivered. That's exactly your “stream” intuition, and §03 shows the machinery behind it.

§ 03 — THE OBJECT

Inside a socket: two queues and a state

Strip away the abstraction and a socket object is mostly two buffers and a state machine. The private_data pointer from struct file leads here.

The kernel splits a socket across two layers: struct socket (the generic, protocol-independent wrapper you interact with) and struct sock (the protocol guts — for TCP, the sequence numbers, window, and crucially the data queues). Conceptually:

simplified — the two layersstruct socket {              // generic wrapper (BSD layer)
    socket_state   state;   // CONNECTED, LISTENING, ...
    const struct proto_ops *ops;  // TCP vs UDP behaviour
    struct sock    *sk;      // → the protocol-level object
};

struct sock {                // the network-layer heart
    int             sk_state;        // TCP state: ESTABLISHED...
    struct sk_buff_head sk_receive_queue; // bytes arrived ← network
    struct sk_buff_head sk_write_queue;   // bytes to send → network
    int             sk_rcvbuf, sk_sndbuf;     // buffer size limits
};

The data itself lives in those queues as little packet structures (sk_buffs). You never see them; you only see the bytes flowing in and out:

your process socket object ESTABLISHED sk_write_queue (TX →) sk_receive_queue (← RX) filled cells = bytes buffered in kernel memory NIC / network write() read()
Fig 3. The socket object (struct sock): write() appends to the TX queue, the kernel drains it onto the wire; arrivals land in the RX queue, read() drains that.

This is why a socket behaves like a stream rather than a fixed file. There's no “file” of stored bytes — there are two moving FIFOs:

write() just copies your bytes into the send queue and returns; the kernel's TCP code sends them, retransmits if lost, and frees them when acknowledged. read() just removes whatever is currently sitting in the receive queue. If the queue is empty, read() blocks (or returns EAGAIN if non-blocking) — there's nothing to hand you yet. That “maybe nothing's here yet” quality is the essence of a stream, and it's the part a plain disk file never has.

Why read() can return fewer bytes than you asked You asked for 4096 bytes but got 1400? Because read() hands you what's in the queue right now, not what's coming. The rest may still be in flight on the network. The socket is a pipe of moving water, not a bucket of stored water.
§ 04 — THE LIFETIME

fork(), exec(), and the reference count

Now the last clause of your quote dissolves. Recall: the integer is per-process, but the struct file (and the socket under it) is a shared, reference-counted object.

fork() copies the table, not the object

fork() gives the child its own copy of the fd table — same integers, same slots. But each copied slot points at the same struct file, and the kernel bumps that file's f_count by one. Now two processes' fd 3 are two handles to one socket. Both can read and write it.

exec() keeps the table

exec() throws away the program's code and memory and loads a new program — but it leaves the fd table untouched (unless a descriptor was marked O_CLOEXEC). The new program inherits fd 3 already open. This is exactly how a shell wires up pipes: it sets up the descriptors, then exec()s your command, which finds its input/output already plumbed.

close() just decrements

When you close(3), the kernel clears that one slot and does f_count--. The socket is destroyed only when the count reaches zero — i.e. when the last handle, across every process, is gone. That's the precise meaning of “the kernel keeps the socket alive as long as at least one process holds a reference.”

▸ INTERACTIVE — drive the lifecycle

Process A pid 100

fd 3──▶ open file
fd 0,1,2 std streams
struct file
1
f_count
socket: ESTABLISHED · alive

Process B child of A · pid 101

fd 3──▶ same open file
fd 0,1,2 inherited from A
A single process holds fd 3. f_count = 1.
The payoff A descriptor is cheap and local — an integer. The thing it names is real, shared, and ref-counted. fork duplicates the cheap part and shares the real part; close releases one share; the socket dies only when shares hit zero. That single idea explains survival across fork, persistence across exec, and why one process closing a socket doesn't yank it out from under another.
§ 05 — THE WHOLE PATH

From integer to wire, in one picture

Everything stacked. Read it top to bottom — each layer only knows about the one beneath it through a pointer.

int fd = 3; a number you hold — user space files_struct.fd[3] slot 3 of your per-process table struct file f_count (refs) · f_op (what read/write mean) · private_data struct socket generic wrapper · state · proto_ops (TCP/UDP) struct sock RX queue · TX queue · TCP state — the actual bytes ↓ NIC → network user kernel: VFS kernel: net stack
Fig 4. The full descent: a number → a slot → an open-file record → a socket → bytes on the wire.
§ 06 — POCKET GLOSSARY

The five words, in one line each

file descriptor
The integer you hold. An index into your process's fd table. Per-process, cheap, meaningless to other processes.
open file description
The struct file the descriptor points to. One per open instance. Shared across fork; reference-counted.
VFS / f_op
The table of function pointers that makes read/write mean different things for files, pipes, and sockets.
struct socket / sock
The socket object itself: protocol state plus a send queue and a receive queue. The “stream” you read and write.
f_count
The reference count. fork increments it, close decrements it, and the socket is destroyed only at zero.