Linux Sockets & IPC

fd model · AF_UNIX · accept queues · buffers · epoll · shared memory · ss
a socket is a file descriptor with a protocol behind it // one object model, four dispatch tables // local IPC from first principles

The Object Model

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".

close() is a refcount decrement, not a shutdown It drops one reference to the 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.

Families & Types

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.

FamilyNamespaceReachesNotes
AF_UNIXpath or abstract namethis hostfd passing, peer creds, no checksums
AF_INET / AF_INET6IP:portanything routable, incl. lothe full TCP or UDP state machine
AF_NETLINKprotocol id + groupsthe kernelrtnetlink, nl80211, audit, uevent
AF_PACKETifindexraw L2 framesPACKET_MMAP / TPACKET_V3 for capture
AF_VSOCK(CID, port)host to guestno network stack in the path
AF_XDPumem + queuea NIC ringstack bypass, frames land in shared umem rings
TypeOrderedReliableBoundariesWhere
SOCK_STREAMyesyesnoTCP, AF_UNIX
SOCK_DGRAMover UNIX onlyover UNIX onlyyesUDP, AF_UNIX
SOCK_SEQPACKETyesyesyesAF_UNIX, SCTP
SOCK_RAWnonoyesneeds 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);
Stream sockets have no messages A single 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 Syscall Arc

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.

OptionDoesRead this way
SO_REUSEADDRbind over a lingering TIME_WAITevery server sets it, always
SO_REUSEPORTN sockets on the identical addr:port, kernel hashes the 4-tupleshard the listener per worker: no accept lock, no herd
SO_ATTACH_REUSEPORT_EBPFreplace that hash with your own programpin flows to a core or a cgroup
tcp_tw_recyclenothing, removed in 4.12any guide recommending it is a decade stale
Read the LISTEN row of 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).

Unix Domain Sockets

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.

Naming: two namespaces, two scopes

FormAddressAccess controlScoped byCleanup
pathname/run/svc.sockfilesystem perms on file + dirmount namespacemanual unlink()
abstractsun_path[0]=='\0'nonenetwork namespaceautomatic 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.

Passing file descriptors

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);

Credentials the kernel vouches for

OptionGivesWhen
SO_PEERCREDpeer pid, uid, gidfrozen at connect() time, unforgeable
SO_PASSCRED + SCM_CREDENTIALSsame, per messagedatagram flows
SO_PEERSECpeer LSM security contextSELinux / AppArmor policy checks
Abstract sockets have no access control Anything in the same network namespace can connect to an abstract name. If you need authorization, use a pathname socket in a mode-0700 directory, or check 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.

Buffers & Backpressure

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
KnobEffectGotcha
SO_SNDBUF / SO_RCVBUFset the buffer explicitlykernel doubles the value for overhead, and setting it disables autotuning
tcp_rmem / tcp_wmemmin, default, max triples for autotuningthe max is a ceiling, not a reservation
rmem_max / wmem_maxhard cap on setsockoptSO_SNDBUFFORCE overrides with CAP_NET_ADMIN
TCP_NODELAYdisables Naglea fix for framing, not a substitute for batching
TCP_CORKhold partial segments until uncorked or 200 msexplicit batching beats fighting Nagle
SO_LINGERclose() waits for pending data, or RSTs at timeout 0timeout 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.

Removing copies, one layer at a time

MechanismRemovesCost
sendfile(2)the userspace round trip for file to socketfile source only
splice(2)the copy between any two fds, via a pipe conduitmoves page refs, needs the pipe
MSG_ZEROCOPYthe send copy, pinning user pages insteadasync completion on MSG_ERRQUEUE; buffer is not reusable until it lands
io_uring send zcsame, folded into the completion ringregistered buffers, io_uring availability
Measure the copy before you remove it Zero copy trades a 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.

Readiness & epoll

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 / flagSemanticsObligation
level triggeredreports readiness while the condition holdsnone, forgives a partial read
EPOLLETreports only the transitiondrain to EAGAIN or lose the event forever
EPOLLONESHOTdisarms after one eventrearm explicitly; removes a class of MT races
EPOLLEXCLUSIVEwakes one waiter, not all4.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.

io_uring needs an epoll fallback in anything you ship The attack surface has produced a steady stream of CVEs, and it is disabled outright in Docker's default seccomp profile, on ChromeOS and Android, and by several distro hardening configs. Detect at startup, do not assume.

The IPC Toolbox

Sockets are the general answer, not always the right one. Roughly ordered by how often the answer is actually this:

MechanismContractReachWhen it wins
pipe / FIFOunidirectional byte streamrelated procs, or a pathshell plumbing, splice conduits
socketpairbidirectional, seqpacket availableparent to childsupervisor channels, fd passing
POSIX shmraw shared pagesanyone with the namebulk data, ring buffers
memfd_createanonymous shared pages + sealspassed via SCM_RIGHTSsealed buffers, no filesystem name
futexwait / wake on a shared wordshared memorythe sync layer under shm
eventfd64-bit counter, pollablepassed or inheritedwakeups, semaphore mode
signalfd / timerfd / pidfdsignals, timers, processes as fdslocalfolds everything into one epoll loop
SysV msg / sem / shmkeyed global objectsanyone with the keylegacy interop, mostly
process_vm_readvdirect cross-process copyPTRACE_MODE_ATTACHdebuggers, checkpoint tools
netlinkkernel to userspace messagesthe kernelroutes, 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
Socket activation is fd passing at boot systemd creates and binds the listening socket, then hands it to the service as fd 3 onward with 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.

Choosing a Transport

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.

PathRound tripThroughputWhat it costs you
shm, busy poll~200 ns to 1 usmemory bandwidtha burned core; all synchronization is yours
shm + futex~1 to 3 usmemory bandwidthsame, plus wakeup latency
AF_UNIX stream~5 to 15 usvery high, one copy each waylocal only
pipe~5 to 15 usgoodunidirectional, related procs
loopback TCP~15 to 30 ushigha full TCP state machine per connection
TCP over NIC, same rack~50 to 200 uslink limitedthe network, and everything on it
Loopback is not a shortcut, it is the full stack 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.

Instrumentation & Forensics

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'
SymptomLook atUsually means
clients time out, server idleTcpExtListenOverflows, ss -lntaccept queue full, ACK dropped silently
40 ms latency plateaumessage sizes, TCP_NODELAYNagle meeting delayed ACK
throughput ceiling on a long pathss -i cwnd vs BDPreceive window or buffer max too small
memory grows, no leak in the heapss -m wmem_queuedslow consumer, backpressure ignored
connection hangs with data pendingyour epoll modeEPOLLET without drain-to-EAGAIN
peer never sees EOFlsof for the same inodea forked child still holds the fd
tcpdump cannot see AF_UNIX There are no packets on any interface, so there is nothing to capture. Use 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
// object model · families · syscall arc · af_unix · buffers · epoll · toolbox · transport · forensics //