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.
By the end of this page, every clause in that quote should feel obvious.
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.
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)
};
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.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.
};
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.
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:
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.
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.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() 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() 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.
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.”
Everything stacked. Read it top to bottom — each layer only knows about the one beneath it through a pointer.
struct file the descriptor points to. One per open instance. Shared across fork; reference-counted.read/write mean different things for files, pipes, and sockets.