A socket is not a network thing. It is a file descriptor backed by a protocol state machine. TCP over a NIC, a Unix socket under /run, a netlink channel to the routing table and a vsock to a guest are the same kernel object wearing different ops tables. Three structs stack up: struct file is what your fd indexes, struct socket is the generic VFS-facing layer, and struct sock is the protocol control block holding the queues, timers and wakeup callbacks.
user space kernel fd 3 --+ | fdtable[3] -> struct file f_op = socket_file_ops +---------------+ v struct socket VFS view: type, state, .ops | .ops = inet_stream_ops | unix_stream_ops v struct sock sk_receive_queue, sk_write_queue, | sk_backlog, timers, sk_data_ready v tcp_prot | udp_prot | unix_proto | netlink_proto one shape, four dispatch tables - the family only changes the bottom row
Sockets live in sockfs, an internal filesystem, which is why they carry an inode number you can see in ss -e and /proc/net/unix but have no path unless you bind one. Because a socket is a struct file, everything that works on files works on it: dup() shares it, fork() inherits it, epoll watches it beside a timerfd in the same interest set. That uniformity is the actual design win behind "everything is a file descriptor".
struct file. If a forked child, a dup, or an fd in flight over SCM_RIGHTS still holds a reference, the connection stays open and the peer never sees EOF. shutdown(fd, SHUT_WR) acts on the socket itself regardless of refcount, which is why graceful protocols send FIN with shutdown and only then close.
The family picks the namespace and the stack. The type picks the delivery contract. They are orthogonal, and half the confusion in this area comes from treating SOCK_DGRAM as a synonym for UDP.
| Family | Namespace | Reaches | Notes |
|---|---|---|---|
| AF_UNIX | path or abstract name | this host | fd passing, peer creds, no checksums |
| AF_INET / AF_INET6 | IP:port | anything routable, incl. lo | the full TCP or UDP state machine |
| AF_NETLINK | protocol id + groups | the kernel | rtnetlink, nl80211, audit, uevent |
| AF_PACKET | ifindex | raw L2 frames | PACKET_MMAP / TPACKET_V3 for capture |
| AF_VSOCK | (CID, port) | host to guest | no network stack in the path |
| AF_XDP | umem + queue | a NIC ring | stack bypass, frames land in shared umem rings |
| Type | Ordered | Reliable | Boundaries | Where |
|---|---|---|---|---|
| SOCK_STREAM | yes | yes | no | TCP, AF_UNIX |
| SOCK_DGRAM | over UNIX only | over UNIX only | yes | UDP, AF_UNIX |
| SOCK_SEQPACKET | yes | yes | yes | AF_UNIX, SCTP |
| SOCK_RAW | no | no | yes | needs CAP_NET_RAW |
creation, correctly// OR the flags into type: no fork race between socket() and fcntl() int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); // same for the accept side - accept4(), never accept() + fcntl() int c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); // a preconnected bidirectional pair, no namespace at all int sv[2]; socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv);
recv() may return half of one write or three writes concatenated. Framing is your problem: length prefix or delimiter, chosen once and enforced everywhere. Every "worked in testing, corrupted under load" bug in a hand-rolled protocol is a partial read the author assumed could not happen. SOCK_SEQPACKET on AF_UNIX gives you boundaries for free and is the underused answer.
The classic sequence hides two queues, and those queues are where a service either sheds load gracefully or falls over silently.
client server socket() socket() + setsockopt(SO_REUSEPORT) | bind() + listen(backlog) connect() --- SYN ------------> [ SYN queue ] tcp_max_syn_backlog | <-- SYN+ACK --------- | | --- ACK -------------> [ accept queue ] min(backlog, somaxconn) ESTABLISHED | accept4() pops one +--> connected fd (a NEW struct socket) accept queue overflow: the ACK is dropped silently, TcpExtListenOverflows++
Two backlogs, two failure modes. The SYN queue holds half-open request_socks; its overflow triggers SYN cookies if tcp_syncookies is on. The accept queue holds fully established connections your process has not yet accepted, bounded by min(backlog, net.core.somaxconn). On overflow the kernel drops the client's ACK silently, so the client sits in ESTABLISHED sending data to a server that has no idea the connection exists. tcp_abort_on_overflow=1 sends an RST instead: honest, but sheds harder. somaxconn was 128 for two decades and moved to 4096 in 5.4, and plenty of running boxes still carry the old value.
| Option | Does | Read this way |
|---|---|---|
| SO_REUSEADDR | bind over a lingering TIME_WAIT | every server sets it, always |
| SO_REUSEPORT | N sockets on the identical addr:port, kernel hashes the 4-tuple | shard the listener per worker: no accept lock, no herd |
| SO_ATTACH_REUSEPORT_EBPF | replace that hash with your own program | pin flows to a core or a cgroup |
| tcp_tw_recycle | nothing, removed in 4.12 | any guide recommending it is a decade stale |
ss -lnt correctly
On a listening socket Recv-Q is the number of established connections waiting to be accepted, and Send-Q is the configured maximum. A Recv-Q that tracks Send-Q under load means your accept loop is the bottleneck, not the network. TIME_WAIT, meanwhile, belongs to whoever closes first and lasts a fixed 60 s on Linux (TCP_TIMEWAIT_LEN, compile time, not a sysctl).
AF_UNIX is not "TCP over loopback with a shorter path". No IP header, no checksum, no congestion control, no retransmit timer, no port space. A write copies into the peer's receive queue and wakes it. What you give up is routability. What you get back is three capabilities no IP socket has.
| Form | Address | Access control | Scoped by | Cleanup |
|---|---|---|---|---|
| pathname | /run/svc.sock | filesystem perms on file + dir | mount namespace | manual unlink() |
| abstract | sun_path[0]=='\0' | none | network namespace | automatic on close |
That asymmetry decides which one crosses a container boundary: a pathname socket follows the mount namespace (bind-mount it in), an abstract name follows the netns. A stale socket file is the classic restart failure, since bind() returns EADDRINUSE on the leftover inode and a connect() to it returns ECONNREFUSED rather than ENOENT, which reads as a live-but-broken server.
sendmsg with an SCM_RIGHTS control message moves an open fd into another process. The receiver gets a new fd number pointing at the same struct file, sharing the offset and status flags. This is capability passing: a privileged process opens a socket, a device or a memfd, hands it across, drops privileges, and the receiver uses something it could never have opened itself.
proc A proc B fd 7 -> struct file X | sendmsg(msg_control = SCM_RIGHTS [7]) +---------> [ AF_UNIX ] ---------> recvmsg(MSG_CMSG_CLOEXEC) fd 9 -> struct file X (same offset) in-flight fds hold a refcount; unix_gc() reaps sockets that only reference each other
SCM_RIGHTS, the whole dancechar buf[CMSG_SPACE(sizeof(int))] = {0}; struct iovec io = { .iov_base = "x", .iov_len = 1 }; // must send >= 1 real byte struct msghdr m = { .msg_iov = &io, .msg_iovlen = 1, .msg_control = buf, .msg_controllen = sizeof buf }; struct cmsghdr *c = CMSG_FIRSTHDR(&m); c->cmsg_level = SOL_SOCKET; c->cmsg_type = SCM_RIGHTS; c->cmsg_len = CMSG_LEN(sizeof(int)); memcpy(CMSG_DATA(c), &fd_to_send, sizeof(int)); sendmsg(sock, &m, 0); // receiver: ALWAYS MSG_CMSG_CLOEXEC, and check msg_controllen before trusting the count recvmsg(sock, &m, MSG_CMSG_CLOEXEC);
| Option | Gives | When |
|---|---|---|
| SO_PEERCRED | peer pid, uid, gid | frozen at connect() time, unforgeable |
| SO_PASSCRED + SCM_CREDENTIALS | same, per message | datagram flows |
| SO_PEERSEC | peer LSM security context | SELinux / AppArmor policy checks |
SO_PEERCRED on every accept and enforce there. This is exactly why polkit, systemd and the container runtimes speak over AF_UNIX: authentication is structural rather than a token you have to manage.
Both directions are bounded queues, and those bounds are the flow control. send() copies into the send buffer and returns; that means the kernel took custody, not that anything was delivered. When the buffer fills, a blocking socket sleeps and a nonblocking one returns EAGAIN. That moment is the only backpressure signal your application ever gets. Ignoring it is how you grow an unbounded userspace queue and turn a slow consumer into an OOM kill.
app --write()--> [ sk_write_queue ] --> qdisc --> NIC --> wire ^ full = EAGAIN = the only backpressure you get wire --> NIC --> softirq --> [ sk_receive_queue ] --read()--> app ^ full = shrinking advertised window = sender stalls BDP = rate x RTT | 10 Gb/s at 30 ms needs ~37.5 MB in flight to fill
| Knob | Effect | Gotcha |
|---|---|---|
| SO_SNDBUF / SO_RCVBUF | set the buffer explicitly | kernel doubles the value for overhead, and setting it disables autotuning |
| tcp_rmem / tcp_wmem | min, default, max triples for autotuning | the max is a ceiling, not a reservation |
| rmem_max / wmem_max | hard cap on setsockopt | SO_SNDBUFFORCE overrides with CAP_NET_ADMIN |
| TCP_NODELAY | disables Nagle | a fix for framing, not a substitute for batching |
| TCP_CORK | hold partial segments until uncorked or 200 ms | explicit batching beats fighting Nagle |
| SO_LINGER | close() waits for pending data, or RSTs at timeout 0 | timeout 0 is the "reset now" idiom, use deliberately |
Nagle plus delayed ACK remains the most common self-inflicted latency bug: the sender holds a small trailing segment waiting for an ACK, the receiver holds the ACK waiting for data to piggyback on, and you eat up to 40 ms per exchange. The real fix is writing a complete message with one sendmsg or a writev, so there is no small trailing segment to hold. TCP_NODELAY is the belt to that suspenders.
| Mechanism | Removes | Cost |
|---|---|---|
| sendfile(2) | the userspace round trip for file to socket | file source only |
| splice(2) | the copy between any two fds, via a pipe conduit | moves page refs, needs the pipe |
| MSG_ZEROCOPY | the send copy, pinning user pages instead | async completion on MSG_ERRQUEUE; buffer is not reusable until it lands |
| io_uring send zc | same, folded into the completion ring | registered buffers, io_uring availability |
memcpy for page pinning, TLB work and a completion protocol. Below roughly 10 KB per operation the accounting costs more than the copy it saves.
This is the one place where the API generations are genuinely different algorithms rather than different spellings. select and poll are stateless: you hand the kernel the whole set every call, it walks the set, you walk the result. Cost is linear in watched descriptors no matter how few are active, and select additionally caps at FD_SETSIZE (1024) with a bitmap that corrupts memory above it. epoll is stateful: the interest set lives in the kernel and the cost is linear in ready descriptors.
epfd
+-- interest set : rb-tree keyed by (fd, struct file) epoll_ctl ADD/MOD/DEL
+-- ready list : doubly-linked list of epitems epoll_wait drains
^
| ep_poll_callback() links the epitem when the wait queue fires
sk_data_ready() -> wake_up(sk_sleep) -> callback
register O(log n) · wakeup O(1) · wait O(ready), not O(watched)
| Mode / flag | Semantics | Obligation |
|---|---|---|
| level triggered | reports readiness while the condition holds | none, forgives a partial read |
| EPOLLET | reports only the transition | drain to EAGAIN or lose the event forever |
| EPOLLONESHOT | disarms after one event | rearm explicitly; removes a class of MT races |
| EPOLLEXCLUSIVE | wakes one waiter, not all | 4.5+, the fix for accept thundering herd |
edge-triggered accept loop, the only correct shape// EPOLLET + nonblocking + drain-to-EAGAIN is ONE indivisible pattern. // Using two of the three is a hang, not a slowdown. ev.events = EPOLLIN | EPOLLET | EPOLLEXCLUSIVE; epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev); for (;;) { int n = epoll_wait(ep, evs, MAXEV, -1); for (int i = 0; i < n; i++) { for (;;) { // drain, always int c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK|SOCK_CLOEXEC); if (c < 0) { if (errno == EAGAIN) break; // queue empty: NOW you may wait if (errno == EINTR) continue; break; } register_conn(ep, c); } } }
io_uring is a different model again: two shared-memory rings, submission and completion, so a batch of operations is issued and reaped with zero syscalls once IORING_SETUP_SQPOLL has a kernel thread polling the submission side. It expresses the operation rather than readiness, and supports multishot accept and receive, provided buffer pools, and registered files and buffers.
Sockets are the general answer, not always the right one. Roughly ordered by how often the answer is actually this:
| Mechanism | Contract | Reach | When it wins |
|---|---|---|---|
| pipe / FIFO | unidirectional byte stream | related procs, or a path | shell plumbing, splice conduits |
| socketpair | bidirectional, seqpacket available | parent to child | supervisor channels, fd passing |
| POSIX shm | raw shared pages | anyone with the name | bulk data, ring buffers |
| memfd_create | anonymous shared pages + seals | passed via SCM_RIGHTS | sealed buffers, no filesystem name |
| futex | wait / wake on a shared word | shared memory | the sync layer under shm |
| eventfd | 64-bit counter, pollable | passed or inherited | wakeups, semaphore mode |
| signalfd / timerfd / pidfd | signals, timers, processes as fds | local | folds everything into one epoll loop |
| SysV msg / sem / shm | keyed global objects | anyone with the key | legacy interop, mostly |
| process_vm_readv | direct cross-process copy | PTRACE_MODE_ATTACH | debuggers, checkpoint tools |
| netlink | kernel to userspace messages | the kernel | routes, links, uevents |
Pipes are a circular buffer of pages, 64 KiB (16 pages) by default and resizable with F_SETPIPE_SZ up to /proc/sys/fs/pipe-max-size. Writes up to PIPE_BUF (4096) are atomic against interleaving from other writers, which is a real guarantee worth using for multi-writer line logs and worthless above that size.
Shared memory is the only mechanism with no per-message kernel involvement at all, which makes it both the fastest and the most dangerous. A single-producer single-consumer ring in a memfd with futex wakeups on the empty and full transitions gets sub-microsecond handoff, and you now own memory ordering, cache line alignment, false sharing, and the fact that a crashed peer leaves the structure exactly as it was mid-write. Robust futexes and sequence-number protocols exist for that.
memfd + seals + fd passing: the modern shmint fd = memfd_create("ring", MFD_CLOEXEC | MFD_ALLOW_SEALING); ftruncate(fd, SZ); void *p = mmap(NULL, SZ, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); // freeze the size so the receiver can trust it; trusting contents would need F_SEAL_WRITE fcntl(fd, F_ADD_SEALS, F_SEAL_SHRINK|F_SEAL_GROW|F_SEAL_SEAL); send_fd(uds, fd); // SCM_RIGHTS; refcount handles cleanup, no ipcrm ever
LISTEN_FDS and LISTEN_PID set. The service never binds anything. That is how zero-downtime restart and on-demand activation work: connections queue in the kernel accept queue while nothing is running.
Order-of-magnitude figures, commodity x86, single hop, small messages. Ratios, not absolutes: NUMA placement, C-states and whether the pair shares an L3 move every row substantially.
| Path | Round trip | Throughput | What it costs you |
|---|---|---|---|
| shm, busy poll | ~200 ns to 1 us | memory bandwidth | a burned core; all synchronization is yours |
| shm + futex | ~1 to 3 us | memory bandwidth | same, plus wakeup latency |
| AF_UNIX stream | ~5 to 15 us | very high, one copy each way | local only |
| pipe | ~5 to 15 us | good | unidirectional, related procs |
| loopback TCP | ~15 to 30 us | high | a full TCP state machine per connection |
| TCP over NIC, same rack | ~50 to 200 us | link limited | the network, and everything on it |
SOCK_SEQPACKET, or a length-prefixed stream. Credentials and fd passing come free.lo has a 65536 byte MTU and elides checksum validation, but every packet still allocates an sk_buff, traverses netfilter, and runs the congestion control and retransmit machinery. If both ends are guaranteed local, AF_UNIX is strictly less work for roughly half the latency.
ss replaced netstat and exposes far more of struct sock than the old tool ever did. These are the flags worth muscle memory.
ss, the useful subsetss -tanp # tcp, all states, numeric, with owning process ss -lnt # listeners: Recv-Q = pending accepts, Send-Q = backlog max ss -i # rtt, cwnd, retrans, bytes_acked, pacing rate ss -m # skmem: rmem_alloc, rcv_buf, wmem_alloc, snd_buf, drops ss -x -p # unix sockets: path, peer inode, process ss -e # the sockfs inode - joins to /proc/net/unix and lsof ss -tn state time-wait # state filters take an expression, not just a flag # the four counters that explain most mysteries (deltas, not totals) nstat -az | grep -E 'ListenOverflows|ListenDrops|BacklogDrop|Syncookies'
| Symptom | Look at | Usually means |
|---|---|---|
| clients time out, server idle | TcpExtListenOverflows, ss -lnt | accept queue full, ACK dropped silently |
| 40 ms latency plateau | message sizes, TCP_NODELAY | Nagle meeting delayed ACK |
| throughput ceiling on a long path | ss -i cwnd vs BDP | receive window or buffer max too small |
| memory grows, no leak in the heap | ss -m wmem_queued | slow consumer, backpressure ignored |
| connection hangs with data pending | your epoll mode | EPOLLET without drain-to-EAGAIN |
| peer never sees EOF | lsof for the same inode | a forked child still holds the fd |
strace -f -e trace=%network -yy (the -yy annotates fds with socket details), a bpftrace probe on unix_stream_sendmsg / unix_dgram_sendmsg, or interpose socat as a man in the middle when you need the bytes on the wire.
reference: socket(7) unix(7) tcp(7) epoll(7) pipe(7) cmsg(3) memfd_create(2) source : net/socket.c · net/unix/af_unix.c · fs/eventpoll.c · net/ipv4/tcp.c docs : Documentation/networking/{ip-sysctl,msg_zerocopy}.rst books : Kerrisk TLPI ch.43-61 · Stevens UNP vol.1 & vol.2