Unix-Domain Sockets
A Unix-domain socket (address family
AF_UNIX, synonymAF_LOCAL) is the Berkeley socket Application Programming Interface (API) turned inward: the samesocket()/bind()/connect()/accept()/read()/write()calls you use for the network, but both endpoints live on the same host and the kernel moves bytes directly between two socket buffers without ever building a packet, computing a checksum, or entering the Internet Protocol (IP) stack (unix(7)). The address is not an IP-and-port pair but a filesystem path, a Linux-only abstract name, or nothing at all. Unix-domain sockets are the substrate of local inter-process communication (IPC) on Linux — the transport under systemd, D-Bus, Wayland, the X Window System, container runtimes, and database client/server links — precisely because they are full-duplex, poll-able like any other file descriptor, and carry two capabilities no other IPC channel has: they can pass open file descriptors (SCM_RIGHTS) and kernel-attested process credentials (SCM_CREDENTIALS/SO_PEERCRED) between mutually untrusting processes. On the machine this note was written on,ss -xacounts 2,629 open Unix-domain sockets against a handful of loopback Transmission Control Protocol (TCP) connections; that ratio is the honest measure of how central they are.
This note is pinned to Linux 6.12, a maintained long-term-support (LTS) release (mainline is now in the 7.x series). Every structure, function and constant quoted below was read from the v6.12 tree while writing; anything that changed after 6.12 is dated explicitly. The runtime measurements and live observations were taken on Fedora 44 running kernel 7.1.8 on 2026-09-04, and are labelled as such. Manual-page quotations come from the man-pages edition published on man7.org and read the same day.
This note owns the local-IPC socket as a whole. Three of its facets are deep enough to have their own notes and are cross-linked rather than duplicated: The Abstract Socket Namespace (the @-prefixed addressing mode), Passing File Descriptors with SCM_RIGHTS (the fd-passing mechanism in detail), and Socket Credential Passing SCM_CREDENTIALS (peer authentication in detail). The generic socket layer beneath all of them — struct socket, struct sock, protocol-family dispatch — belongs to Linux Networking Stack MOC.
Mental Model — A Pipe With an Address, and Two Super-Powers
Think of a Unix-domain socket as a bidirectional pipe that has a name. A pipe is anonymous (it can only be inherited across fork()) and one-directional. A Unix-domain socket is addressable (any process that can reach the address may connect) and full-duplex (both ends read and write). A loopback TCP connection is also named, bidirectional and local — but it pays for machinery that exists solely to survive a lossy, reordering, adversarial network: segmentation, sequence numbers, acknowledgements, retransmission timers, congestion control, checksums, Nagle’s algorithm, and a trip through the loopback network device. A Unix-domain socket is what loopback TCP would be if you deleted everything whose only purpose is surviving a network. What remains is a hand-off between two sk_receive_queues.
flowchart LR subgraph PA["Process A"] FDA["fd 3<br/>struct file → struct socket"] end subgraph PB["Process B"] FDB["fd 7<br/>struct file → struct socket"] end subgraph K["Kernel — net/unix/af_unix.c"] direction TB SKA["<b>unix_sock A</b><br/>sk_receive_queue (skb list)<br/>sk_sndbuf / sk_rcvbuf<br/>addr (may be NULL)"] SKB["<b>unix_sock B</b><br/>sk_receive_queue (skb list)<br/>sk_sndbuf / sk_rcvbuf<br/>addr (may be NULL)"] SKA -. "unix_peer(A) = B" .-> SKB SKB -. "unix_peer(B) = A" .-> SKA end FDA --> SKA FDB --> SKB SKA == "write(A): alloc skb, copy bytes,<br/>lock B's queue, skb_queue_tail,<br/>B->sk_data_ready()" ==> SKB SKB == "write(B): same, mirrored" ==> SKA NOPE["<i>none of this exists on this path:</i><br/>IP header · TCP state machine · checksum<br/>congestion window · retransmit timer · netdevice"] style NOPE fill:#fff,stroke-dasharray: 4 4
How a connected pair of Unix-domain sockets is actually wired. What it shows: each endpoint is a unix_sock — the AF_UNIX specialization of struct sock — owning its own sk_receive_queue; the two are joined by reciprocal peer pointers, and a write on one side allocates a socket buffer (skb), copies the user’s bytes into it, and appends it to the other side’s receive queue before waking the reader. The insight to take: there is no protocol between the two queues. The exact sequence in unix_stream_sendmsg() is sock_alloc_send_pskb() → skb_copy_datagram_from_iter() → skb_queue_tail(&other->sk_receive_queue, skb) → other->sk_data_ready(other) (net/unix/af_unix.c, lines 2288–2347). Everything crossed out on the right is machinery a loopback TCP write would execute and this one does not — which is where the measured 1.9× to 3.3× latency advantage later in this note comes from.
One consequence of that picture deserves stating early because it explains several later behaviours: the send buffer is the sender’s, the queued data is the receiver’s. sock_alloc_send_pskb() charges the allocation against the sender’s sk_sndbuf, but the skb then lives on the receiver’s sk_receive_queue. There is no separate “in flight” storage and no third copy. A slow reader therefore back-pressures the writer directly, through the writer’s own send-buffer accounting — which is why a Unix socket blocks rather than dropping, and why SIOCINQ on the reader reports exactly the bytes the writer’s sk_sndbuf is holding.
The Three Socket Types, and Where Message Boundaries Live
socket(AF_UNIX, type, 0) accepts three types, dispatched in unix_create(), which switches on sock->type and installs a different proto_ops table for each (af_unix.c:1063):
switch (sock->type) {
case SOCK_STREAM:
sock->ops = &unix_stream_ops;
break;
/*
* Believe it or not BSD has AF_UNIX, SOCK_RAW though
* nothing uses it.
*/
case SOCK_RAW:
sock->type = SOCK_DGRAM;
fallthrough;
case SOCK_DGRAM:
sock->ops = &unix_dgram_ops;
break;
case SOCK_SEQPACKET:
sock->ops = &unix_seqpacket_ops;
break;
default:
return -ESOCKTNOSUPPORT;
}SOCK_RAW is silently rewritten to SOCK_DGRAM for BSD compatibility, complete with the kernel’s own bemused comment; anything else is -ESOCKTNOSUPPORT, and a protocol argument other than 0 or PF_UNIX is -EPROTONOSUPPORT. So there are exactly three real types, and the only thing that meaningfully separates them is who is responsible for message framing.
The behaviour is easiest to see run rather than described. Each block below is real output from a program compiled and executed on the host described above:
SOCK_STREAM — the kernel concatenates; framing is YOUR job
sender: write("HELLO") write("WORLD")
receiver: read(buf[32]) -> 10 bytes "HELLOWORLD"
+--------------------------------------------------+
| H E L L O W O R L D . . . one undifferentiated |
| byte stream; where a write ended is unknowable |
+--------------------------------------------------+
SOCK_SEQPACKET — the kernel frames; a short read DISCARDS the remainder
sender: send("HELLOWORLD" 10B) send("SECOND" 6B)
receiver: recvmsg(buf[4]) -> 4 bytes "HELL" msg_flags & MSG_TRUNC = 1
read(buf[16]) -> 6 bytes "SECOND" <-- NOT "OWORLD"
+-------------------+ +-----------+
| HELLOWORLD (10B) | | SECOND(6B)| record boundaries are kernel-enforced
+-------------------+ +-----------+
^ read 4, got "HELL", MSG_TRUNC set, "OWORLD" thrown away
SOCK_DGRAM — same framing as SEQPACKET, but connectionless
socketpair(SOCK_DGRAM): 278 one-byte datagrams accepted before EAGAIN
(limited by sk_sndbuf accounting, not by a datagram count)
The framing behaviour of the three AF_UNIX socket types, transcribed from a live run. Fallback note: this is an ASCII/box diagram rather than mermaid because what is being drawn is the contents and boundaries of a byte buffer, which mermaid has no vocabulary for; packet-beta draws bit-exact wire formats, not variable-length record streams. What it shows: SOCK_STREAM gives you a stream and hands you the framing problem; SOCK_SEQPACKET and SOCK_DGRAM give you records the kernel maintains. The insight to take: the MSG_TRUNC line is the one that catches people. A SEQPACKET short read does not leave the remainder for the next call the way a stream does — it discards it and tells you so via msg_flags. That is a feature (it makes a message-oriented protocol impossible to desynchronize) but it means you must size the receive buffer to your largest message or check MSG_TRUNC on every call.
The case for SOCK_SEQPACKET, which almost nobody makes
SOCK_SEQPACKET is documented as “a sequenced-packet socket that is connection-oriented, preserves message boundaries, and delivers messages in the order that they were sent,” available since Linux 2.6.4 (unix(7)). Read that as a feature list against the alternatives:
SOCK_STREAM | SOCK_DGRAM | SOCK_SEQPACKET | |
|---|---|---|---|
Connection-oriented (listen/accept, peer death is visible) | yes | no | yes |
| Message boundaries preserved | no | yes | yes |
| Reliable, in-order | yes | yes (on AF_UNIX) | yes |
| Short read behaviour | remainder stays queued | remainder discarded, MSG_TRUNC | remainder discarded, MSG_TRUNC |
| Peer disconnect signalled | EOF / EPIPE | nothing — sends just fail | EOF / EPIPE |
SO_PEERCRED usable | yes | pairs only | yes |
| Framing code you must write | length prefixes, buffering, partial-read state machine | none | none |
| Measured 64-byte round-trip (this host) | 2.37 µs | 3.36 µs | 3.45 µs |
The argument for it is simple: almost every local protocol is message-oriented, and SOCK_STREAM forces every one of them to reimplement framing. Length-prefix bugs — a 4-byte header read partially, a length field trusted without bounds-checking, a state machine that loses sync after a short read — are a recurring source of both crashes and vulnerabilities, and SOCK_SEQPACKET deletes the entire category by moving framing into the kernel. Unlike SOCK_DGRAM, it keeps the connection semantics you actually want: you accept() clients, you learn when a peer dies, and SO_PEERCRED works on the accepted socket. Its cost, measured above, is roughly 1 µs of extra round-trip latency versus a stream socket on this hardware — about 45% on a 64-byte exchange, which sounds large in percentage terms and is negligible against the several microseconds any real handler will spend.
It is also not a theoretical recommendation. On the host used here, ss -xa reports 50 SOCK_SEQPACKET sockets, and inspecting who holds them (ss -xap) shows them belonging to brave, chrome, chrome_crashpad, spotify and steamwebhelper — i.e. Chromium’s sandbox IPC channel and the crash handlers, exactly the privilege-separation shape SOCK_SEQPACKET is best at. Against 2,509 SOCK_STREAM sockets on the same machine, that is a 2% share for a type that ought to be the default for new local protocols.
Uncertain
Verify: that the
SOCK_SEQPACKETsockets attributed to Chromium-family processes above are its sandbox IPC channel specifically. Reason: the attribution is inferred fromss -xapprocess names on one live system, not from Chromium’s source or design documentation; the same processes use many sockets for many purposes. To resolve: read Chromium’ssandbox/linuxandmojotransport code, or its IPC design docs, and confirm the socket type used for the broker channel. uncertain
Addressing: Pathname, Abstract, Unnamed
A Unix-domain address is a struct sockaddr_un, whose user-visible definition has not changed in decades (include/uapi/linux/un.h):
#define UNIX_PATH_MAX 108
struct sockaddr_un {
__kernel_sa_family_t sun_family; /* always AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* 108 bytes */
};sun_family is always AF_UNIX. Everything else is decided by how many bytes of the structure you pass (the addrlen argument) and what the first byte of sun_path is. unix(7) puts it as three distinguished address kinds, and the distinction is genuinely a length-and-first-byte test, not a flag:
flowchart TD START["bind(fd, (struct sockaddr *)&addr, addrlen)"] --> Q1{"addrlen == sizeof(sa_family_t)<br/>i.e. 2 bytes — no sun_path at all?"} Q1 -->|yes| AUTO["<b>AUTOBIND</b><br/>kernel invents an abstract name:<br/>a NUL byte plus 5 chars from 0-9a-f<br/>so 2^20 possible names<br/><i>also happens implicitly if SO_PASSCRED<br/>is set on an unbound socket</i>"] Q1 -->|no| Q2{"is the first byte of sun_path a NUL?"} Q2 -->|yes| ABS["<b>ABSTRACT</b> (Linux only)<br/>name = the bytes after that NUL,<br/>up to addrlen; embedded NULs<br/>are ordinary bytes<br/>NO filesystem object<br/>NO permission check<br/>scoped to the NETWORK NAMESPACE<br/>auto-freed on last close"] Q2 -->|no| PATH["<b>PATHNAME</b><br/>unix_bind_bsd(): vfs_mknod() creates a<br/>real S_IFSOCK inode on disk<br/>mode = S_IFSOCK plus the socket inode mode<br/>masked by the process umask<br/>directory and file permissions gate connect()<br/><b>survives close()</b> — must be unlink()ed"] NOBIND["never called bind(),<br/>or created by socketpair()"] --> UNNAMED["<b>UNNAMED</b><br/>no address at all<br/>getsockname() returns addrlen = 2<br/>sun_path must not be inspected"] ABS --> NS["visible only inside the creating<br/>network namespace — see CVE-2020-15257"] PATH --> FS["visible to anything that can traverse<br/>the directory and open the path"]
The complete AF_UNIX address decision tree, as implemented by unix_bind(). What it shows: there is no address-type field; the kernel infers the kind from addrlen and sun_path[0], which is why passing the wrong addrlen silently changes what you bound. The insight to take: the three kinds have three completely different lifetimes and three completely different access-control stories. A pathname socket is a file — it obeys directory permissions and outlives your process. An abstract socket is a kernel-table entry — it obeys nothing, it disappears when the last reference closes, and its containment boundary is the network namespace, not the mount namespace and not the filesystem. An unnamed socket cannot be reached by anyone who was not handed the descriptor.
Three practical notes on that diagram, each verified on the live kernel:
Pathname sockets create a real file, and it is the directory that protects them. unix_bind_bsd() calls vfs_mknod() with mode = S_IFSOCK | (SOCK_INODE(sk->sk_socket)->i_mode & ~current_umask()) (af_unix.c:1289). The umask term is why a socket usually appears as srwxr-xr-x. That mode looks alarmingly permissive — and on this host, the Wayland compositor’s socket really is world-traversable:
$ ls -l /run/user/1000/wayland-1
srwxr-xr-x. 1 linman linman 0 Aug 20 18:27 /run/user/1000/wayland-1=
$ ls -ld /run/user/1000
drwx------. 23 linman linman 1.1K Sep 4 15:57 /run/user/1000/The socket is rwxr-xr-x; the directory containing it is rwx------. No other user can traverse into /run/user/1000 at all, so the socket’s own mode is irrelevant. This is the standard Linux pattern and the thing to internalize: for pathname Unix sockets, access control is normally the directory, not the socket file. The same host shows the counter-example too — /run/dbus/system_bus_socket is srw-rw-rw- in a world-readable directory, because the D-Bus daemon does its own authorization from SO_PEERCRED and does not rely on the filesystem at all — and the classic cautionary case, /var/run/docker.sock at srw-rw---- root docker, which is the entire technical content of “membership of the docker group is equivalent to root.”
Abstract sockets ignore permissions entirely, and their boundary is the network namespace. unix(7) states the first half flatly: “Socket permissions have no meaning for abstract sockets: the process umask(2) has no effect when binding an abstract socket, and changing the ownership and permissions of the object (via fchown(2) and fchmod(2)) has no effect on the accessibility of the socket.” The second half is stated in network_namespaces(7): “network namespaces isolate the UNIX domain abstract socket namespace.” Verified directly:
$ # bind @vaultns and hold it, then try again from a fresh network namespace
$ unshare -n python3 -c "s=socket.socket(AF_UNIX,SOCK_STREAM); s.bind('\0vaultns')"
NEW netns: bind to @vaultns SUCCEEDED -> abstract names are per-NETWORK-namespace
$ unshare -rm python3 -c "s.bind('\0vaultmnt')"
bound @vaultmnt inside a private MOUNT ns -> a mount namespace confines nothing hereThat pairing — no permission checks plus scoped only by the netns — is not an academic concern. It is the whole of CVE-2020-15257: containerd’s shim exposed its control API on an abstract socket and “verified that the connecting process had an effective UID of 0, but did not otherwise restrict access to the abstract Unix domain socket. This would allow malicious containers running in the same network namespace as the shim, with an effective UID of 0 but otherwise reduced privileges, to cause new processes to be run with elevated privileges” (GHSA-36xw-fx78-c5r4). Any container started with docker run --net=host or hostNetwork: true shared the shim’s namespace and could reach the socket. The advisory’s own workaround is telling — it is an AppArmor rule, deny unix addr=@**, because there is no filesystem permission to set. The mechanism, the naming rules and the full security discussion belong to The Abstract Socket Namespace.
Autobind produces names you did not choose, and you will see them. If addrlen is exactly sizeof(sa_family_t), or SO_PASSCRED is enabled on a socket that was never bound, the kernel invents an abstract address: “a null byte followed by 5 bytes in the character set [0-9a-f]. Thus, there is a limit of 2^20 autobind addresses” (unix(7)). In unix_dgram_sendmsg() this is an explicit branch — if ((test_bit(SOCK_PASSCRED, ...) || test_bit(SOCK_PASSPIDFD, ...)) && !READ_ONCE(u->addr)) unix_autobind(sk). Those five-hex-character names show up constantly in ss -xa output on any desktop:
$ ss -xap | grep -E '@[0-9a-f]{5}\b' | head -3
u_seq ESTAB @3d0de ... users:(("chrome_crashpad",pid=1540379,fd=5))
u_seq ESTAB @aa6f3 ... users:(("chrome_crashpad",pid=1540381,fd=4))
u_seq ESTAB @503b4 ... users:(("spotify",pid=4067306,fd=7))The 108-byte limit is hard and is not a PATH_MAX. sun_path is 108 bytes, full stop; a longer path cannot be bound. Deeply nested XDG_RUNTIME_DIR values, long container bind-mount prefixes, and generated per-instance directories all hit this. The workarounds are to chdir() into the directory and bind a relative name, to open() the directory and bind via /proc/self/fd/<n>/name, or to use the abstract namespace. unix(7)’s BUGS section adds a second sharp edge: Linux appends a null terminator if you do not supply one, so binding exactly 108 non-null bytes produces an address that, when read back with getsockname(), has no terminator in sun_path — parse with strnlen(addr.sun_path, addrlen - offsetof(struct sockaddr_un, sun_path)), never with strlen().
The Connection Lifecycle, Traced Through the Kernel
For SOCK_STREAM and SOCK_SEQPACKET the shape is exactly TCP’s — socket → bind → listen → accept on the server, socket → connect on the client — but the implementation underneath has nothing in common with a three-way handshake.
sequenceDiagram autonumber participant S as "server process" participant K as "kernel: net/unix/af_unix.c" participant LSK as "listening unix_sock" participant NSK as "new server-side unix_sock" participant C as "client process" S->>K: socket(AF_UNIX, SOCK_STREAM, 0) K->>LSK: unix_create1() — sk_state = TCP_CLOSE S->>K: bind(&addr, len) K->>LSK: unix_bind_bsd(): vfs_mknod() an S_IFSOCK inode<br/>OR unix_bind_abstract(): hash the name S->>K: listen(backlog) K->>LSK: unix_listen(): requires u->addr != NULL<br/>sk_max_ack_backlog = backlog<br/>sk_state = <b>TCP_LISTEN</b> Note over LSK: AF_UNIX reuses the TCP state<br/>constants as plain integers.<br/>No TCP is involved. S->>K: accept() — blocks on an empty receive queue C->>K: socket(); connect(&addr, len) K->>K: unix_stream_connect() → unix_find_other()<br/>resolves path via VFS, or the abstract hash K->>NSK: unix_create1() — the server side is made<br/><b>by the client's connect()</b> K->>LSK: skb_queue_tail(&listener->sk_receive_queue, connection skb) Note over LSK,NSK: unix_peer(newsk) = client<br/>unix_peer(client) = newsk<br/>credentials are snapshotted here K->>S: sk_data_ready() wakes accept() K->>S: unix_accept(): dequeue the skb, take the<br/>pre-made unix_sock, graft it onto a new fd S-->>C: both ends now ESTABLISHED
The AF_UNIX stream connection sequence. What it shows: there is no handshake and no exchange of packets — connect() synchronously constructs the server’s socket, wires the two peer pointers, and drops a token onto the listener’s ordinary receive queue; accept() is just a dequeue that grafts the pre-made socket onto a descriptor. The insight to take: two consequences follow that surprise people. First, the listening socket’s “pending connection queue” is its sk_receive_queue — the same list that carries data on a connected socket — so a listener that never accept()s and a reader that never read()s hit the same backlog machinery. Second, the peer’s credentials are captured at this instant, which is precisely why SO_PEERCRED is documented as returning “the credentials that were in effect at the time of the call to connect(2), listen(2), or socketpair(2)” (unix(7)) — a snapshot, not a live query.
unix_listen() enforces two preconditions worth naming: the type must be SOCK_STREAM or SOCK_SEQPACKET (-EOPNOTSUPP otherwise — you cannot listen on a datagram socket), and the socket must already be bound (-EINVAL for “No listens on an unbound socket”). For SOCK_DGRAM there is no connection at all: each sendto() resolves the destination afresh through unix_find_other(), or you connect() once merely to set a default peer.
A datagram socket’s backlog is set from a per-network-namespace sysctl: sk->sk_max_ack_backlog = READ_ONCE(net->unx.sysctl_max_dgram_qlen), whose kernel default is net->unx.sysctl_max_dgram_qlen = 10 (af_unix.c:1036, 3664). On the Fedora host used here /proc/sys/net/unix/max_dgram_qlen reads 512, raised by the distribution. Because net->unx is a field of struct net, this knob — like the abstract namespace — is per-network namespace, so a container has its own value.
socketpair() — the Anonymous Connected Pair
When you control both ends — the canonical case being a parent that will fork() — you do not need an address at all. socketpair(AF_UNIX, type, 0, sv) returns two already-connected, unnamed sockets with no bind/listen/connect/accept dance and nothing visible in any namespace (socketpair(2)). The two descriptors are indistinguishable; what you write to one you read from the other. On Linux the only families supporting it are AF_UNIX/AF_LOCAL and AF_TIPC (since Linux 4.12).
int sv[2];
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, sv) == -1)
handle_error("socketpair");
pid_t pid = fork();
if (pid == 0) { /* child — the sandboxed worker */
close(sv[0]); /* drop the parent's end immediately */
/* sv[1] is now the ONLY channel out of the sandbox */
} else { /* parent — the trusted broker */
close(sv[1]);
/* SO_PEERCRED on sv[0] identifies the child; SCM_RIGHTS over sv[0]
can hand it an fd it could never have opened itself. */
}Line by line: SOCK_SEQPACKET gives kernel-maintained message framing so neither side needs a length-prefix parser; SOCK_CLOEXEC (OR-able into type since Linux 2.6.27, alongside SOCK_NONBLOCK) sets close-on-exec atomically, closing the race where a concurrent fork()+exec() between socketpair() and a separate fcntl(F_SETFD) would leak the descriptor into an unrelated child; each side closes the descriptor it does not own so that the peer sees a clean EOF when the other exits.
flowchart TB subgraph BEFORE["before fork() — one process"] SP["socketpair(AF_UNIX,<br/>SOCK_SEQPACKET + SOCK_CLOEXEC, 0, sv)"] SP --> S0["sv[0]"] SP --> S1["sv[1]"] S0 <-. "already connected,<br/>both UNNAMED" .-> S1 end BEFORE --> FORK["fork()"] FORK --> PARENT FORK --> CHILD subgraph PARENT["parent — trusted broker (uid 0)"] P0["keeps sv[0]<br/>close(sv[1])"] PCAP["can open files, bind ports,<br/>talk to the kernel"] end subgraph CHILD["child — sandboxed worker"] C1["keeps sv[1]<br/>close(sv[0])"] CSAND["setuid(nobody) · unshare(CLONE_NEWNET)<br/>empty mount ns · seccomp filter<br/><b>no way to open anything</b>"] end P0 == "SCM_RIGHTS: hand over an fd the<br/>worker could never obtain itself" ==> C1 C1 == "requests; EOF on parent death" ==> P0 P0 -. "SO_PEERCRED: kernel-attested<br/>pid/uid/gid of the worker" .-> C1 NONAME["No address exists anywhere.<br/>No third process can connect —<br/>the only way in is to be handed a descriptor."] CHILD -.-> NONAME
The socketpair() + fork() privilege-separation pattern. What it shows: the channel is created before the fork, so both ends are already connected and neither is reachable by name; the child then drops every other capability it has and keeps exactly one descriptor. The insight to take: this shape is secure by construction rather than by policy. There is no path, no abstract name, no port — nothing for an attacker to connect to, and therefore no access-control decision to get wrong. Everything the worker is allowed to do must arrive through the socket as an SCM_RIGHTS capability, and everything the broker knows about the worker comes from SO_PEERCRED rather than from anything the worker says. Chromium’s sandbox, OpenSSH’s privilege-separated child, and systemd’s sd_notify handoff are all variations on this diagram.
This is the backbone of privilege-separation design, and the reason is that the channel is private by construction — there is no name anywhere for a third party to connect to — while still carrying both super-powers. SO_PEERCRED works on a socketpair (returning, as measured on this host, the creating process’s own pid/uid/gid before the fork()), and so does SCM_RIGHTS. The measured 50 SOCK_SEQPACKET sockets on this desktop are overwhelmingly of exactly this shape: ss -xap shows them with * for both addresses — unnamed on both ends — held by browser sandbox processes.
Super-Power One: SCM_RIGHTS, Passing an Open File Descriptor
This is the capability that makes Unix-domain sockets structurally different from every other local transport, including loopback TCP. A sendmsg() whose msg_control buffer carries a SCM_RIGHTS control message hands the peer open file descriptions, not numbers.
The man page’s phrasing is precise and worth quoting because the common mental model is wrong: “Commonly, this operation is referred to as ‘passing a file descriptor’ to another process. However, more accurately, what is being passed is a reference to an open file description … and in the receiving process it is likely that a different file descriptor number will be used. Semantically, this operation is equivalent to duplicating (dup(2)) a file descriptor into the file descriptor table of another process” (unix(7)). The integer travels nowhere. The kernel resolves your fd to its struct file, takes a reference, carries the struct file * on the skb, and installs it into a free slot in the receiver’s descriptor table — which is why the receiver’s number differs, why the shared file offset is shared, and why the receiver gets the sender’s access mode regardless of its own permissions on the underlying object.
sequenceDiagram autonumber participant A as "sender (privileged broker)" participant SCM as "net/core/scm.c" participant SKB as "skb on the peer's<br/>receive queue" participant GC as "net/unix/garbage.c" participant B as "receiver (sandboxed worker)" Note over A: has fd 6 open on /etc/hostname<br/>(worker could never open it itself) A->>SCM: sendmsg with cmsg SOL_SOCKET / SCM_RIGHTS carrying fd 6, plus 1 byte of data SCM->>SCM: scm_fp_copy() counts the fds in the control message Note over SCM: if (num > SCM_MAX_FD) return -EINVAL;<br/><b>SCM_MAX_FD = 253</b> (255 before Linux 2.6.38) SCM->>SCM: fget_raw() resolves each fd to a struct file,<br/>bumps its refcount, stores it in scm_fp_list SCM->>GC: wait_for_unix_gc() — the fd is now in flight SCM->>SKB: unix_scm_to_skb(): attach the scm_fp_list to the skb Note over SKB: <b>at least 1 byte of real data is required</b><br/>on a stream socket, else the message<br/>carries nothing to attach to SKB->>SKB: skb_queue_tail onto the peer receive queue B->>SKB: recvmsg with msg_control sized by CMSG_SPACE SKB->>B: scm_detach_fds(): get_unused_fd_flags then<br/>fd_install for each struct file B-->>B: now holds a DIFFERENT fd number for<br/>the SAME open file description Note over B: if msg_control too small / absent:<br/>MSG_CTRUNC set and the excess fds are<br/><b>closed by the kernel</b> — they do not leak
The SCM_RIGHTS path from a sendmsg() in one process to an installed descriptor in another. What it shows: the descriptor number is resolved to a struct file * on the way in and re-allocated on the way out; between the two it lives on an skb as a reference the kernel is holding on the sender’s behalf. The insight to take: this is a capability transfer, in the security sense — the receiver gains an authority it could not have obtained by name. A broker running as root can open("/etc/shadow") and hand the fd to a worker running as nobody in an empty mount namespace, and the worker can read it, because the permission check happened once, at open(), in the broker. That is the entire architecture of privilege separation on Linux, and it is why SCM_RIGHTS is the feature that made Unix sockets rather than pipes the substrate for systemd, Wayland, Chromium’s sandbox and container runtimes.
The ancillary-data barrier, measured
A subtlety that bites real code: on a stream socket, control data acts as a frame boundary in an otherwise unframed stream. unix(7) describes the expected behaviour, and it reproduces exactly on the live kernel. Sending 4 bytes, then 1 byte carrying an fd, then 4 bytes, and reading with a 20-byte buffer:
A) recvmsg#1 with a 20-byte buffer -> 5 bytes 'AAAAB', fd=6
recvmsg#2 -> 4 bytes 'CCCC'
the received fd really is /etc/hostname: "beast"The first recvmsg() returned 5 bytes, not 9. The stream coalesced the plain 4 bytes with the 1 byte that carried ancillary data, but could not coalesce past it — the following 4 bytes waited for a second call. So although SOCK_STREAM has no message boundaries in general, attaching a control message manufactures one. This is exploitable in a good way (it is how protocols like Wayland keep an fd associated with the request that needed it) and dangerous if unnoticed (a reader that assumes “one recvmsg fills my buffer” will silently get short reads at exactly the points where fds arrive).
The complement is the truncation rule, and it contains a fact widely stated backwards. If the control buffer is too small or absent, MSG_CTRUNC is set — and the descriptors that did not fit are closed by the kernel, not leaked into your process. unix(7): “If the buffer used to receive the ancillary data containing file descriptors is too small (or is absent), then the ancillary data is truncated (or discarded) and the excess file descriptors are automatically closed in the receiving process.” Measured, by counting open descriptors around a recvmsg() with msg_control = NULL:
B) recvmsg with NO control buffer -> 1 bytes, MSG_CTRUNC=1
open fds before=7 after=7 => the passed fd was CLOSED by the kernel, not leakedThe real hazard is therefore not a descriptor leak but a silent capability loss: the peer believes it sent you a working fd, you got a byte of data and no fd, and unless you check msg_flags & MSG_CTRUNC the protocol desynchronizes with no error anywhere. The same automatic close happens if accepting the descriptors would exceed your RLIMIT_NOFILE. Always size the control buffer with CMSG_SPACE(), and always test MSG_CTRUNC.
Fds ride only on the first fragment
One more detail with no user-facing documentation, visible only in the source. unix_stream_sendmsg() splits a large write into multiple skbs — capped at min(len, (sk_sndbuf >> 1) - 64) and at SKB_MAX_HEAD(0) + UNIX_SKB_FRAGS_SZ, where UNIX_SKB_FRAGS_SZ is PAGE_SIZE << get_order(32768), i.e. 32 KiB on a 4 KiB-page machine — and attaches the ancillary data to only one of them:
/* Only send the fds in the first buffer */
err = unix_scm_to_skb(&scm, skb, !fds_sent);
if (err < 0) { kfree_skb(skb); goto out_err; }
fds_sent = true;Confirmed with a 300,000-byte sendmsg() carrying one fd, read back in 4 KiB chunks:
D) sendmsg of 300000 bytes + 1 fd -> 300000 bytes accepted
recvmsg#1 -> 4096 bytes, carries fd: YES
recvmsg#2 -> 4096 bytes, carries fd: no
recvmsg#3 -> 4096 bytes, carries fd: noPractical rule: keep the data payload of an fd-passing message tiny. One byte is enough and one byte is what nearly every real protocol sends. A large fd-carrying write is a correctness hazard, because the receiver only sees the descriptor on whichever recvmsg() happens to consume the first fragment. The full send-side and receive-side walk-through, including MSG_CMSG_CLOEXEC, lives in Passing File Descriptors with SCM_RIGHTS.
Why the kernel needs a garbage collector for this
If process A can send B a descriptor for the socket connecting A and B, then descriptors can form cycles that no reference count will ever break: a socket held only by an skb sitting in the queue of a socket held only by an skb sitting in the first socket’s queue. Both processes exit; both file descriptions have a non-zero refcount; nothing frees. Linux has carried a dedicated cycle collector for this since the 1990s, and the source file’s header comment is a 25-year archaeology of the bugs it caused — Al Viro, 11 October 1998: “Graph may have cycles. That is, we can send the descriptor of foo to bar and vice versa. Current code chokes on that.”
flowchart LR subgraph CYCLE["the unbreakable cycle"] direction LR SA["<b>socket A</b><br/>file refcount = 1"] SB["<b>socket B</b><br/>file refcount = 1"] QA["A's sk_receive_queue<br/>skb holding <i>struct file *</i> for B"] QB["B's sk_receive_queue<br/>skb holding <i>struct file *</i> for A"] SA --- QA SB --- QB QA -->|"in-flight reference"| SB QB -->|"in-flight reference"| SA end P["both processes exit,<br/>both descriptors closed"] --> CYCLE CYCLE --> DEAD["Neither refcount reaches 0.<br/>Nothing is reachable from any<br/>descriptor table. <b>Leaked forever.</b>"] DEAD --> GC["<b>net/unix/garbage.c</b><br/>models in-flight sockets as a graph:<br/>unix_vertex per socket,<br/>unix_edge per in-flight fd"] GC --> TARJAN["__unix_walk_scc(): <b>Tarjan's</b><br/>strongly-connected-components<br/>(rewritten in Linux 6.10)"] TARJAN --> TEST{"unix_vertex_dead():<br/>does any receiver outside<br/>this SCC hold a reference?"} TEST -->|yes| KEEP["live — leave alone"] TEST -->|"no — 'No receiver exists<br/>out of the same SCC'"| SWEEP["unix_collect_skb(): move the skbs<br/>to a hitlist and free them,<br/>dropping the file references"]
Why AF_UNIX is the only socket family with a garbage collector, and what the current one does. What it shows: because a socket’s descriptor can itself be sent over a socket, in-flight descriptors form a directed graph in which reference counts can be mutually satisfied by an unreachable cycle — the classic case that pure reference counting cannot collect. The insight to take: the kernel’s answer is a real cycle-collection algorithm, not a heuristic. Since Linux 6.10 it is Tarjan’s SCC algorithm over unix_vertex/unix_edge, and a component is collectable exactly when no reference into it comes from outside the component. The operational cost is that wait_for_unix_gc() runs on every sendmsg() carrying descriptors, so an fd-passing hot path can serialize against the collector — one more reason to pass fds rarely and keep bulk data in shared memory.
The algorithm was replaced wholesale in Linux 6.10 (verified by existence-checking unix_vertex in net/unix/garbage.c: absent at v6.9, present at v6.10, v6.11 and v6.12). The current implementation models in-flight sockets as a directed graph — struct unix_vertex per socket, struct unix_edge per in-flight descriptor — and runs Tarjan’s strongly-connected-components algorithm over it (__unix_walk_scc(), scc_index, unix_scc_cyclic()). A vertex is dead if no receiver outside its own strongly-connected component holds a reference:
static bool unix_vertex_dead(struct unix_vertex *vertex)
{
...
/* The vertex's file refcnt could be accounted by the receiver
* of the edge in another SCC.
*/
if (next_vertex->scc_index != vertex->scc_index)
return false;
...
/* No receiver exists out of the same SCC. */
}Two operational consequences. First, wait_for_unix_gc() is called on every sendmsg() that carries descriptors, so a workload passing fds at high rate can serialize against the collector. Second, the collector’s historical fragility is why fd-passing bugs have been a recurring source of kernel CVEs, and why the 6.10 rewrite — replacing a hand-rolled mark-and-sweep with a textbook SCC algorithm — was worth doing.
Super-Power Two: Credentials the Peer Cannot Forge
The second capability is authentication. A Unix-domain socket can tell you who is on the other end, attested by the kernel rather than claimed by the peer. There are two mechanisms and they answer different questions.
flowchart TD Q["I need to authorize an incoming local request. Who is the peer?"] --> B{"Do I need the identity of<br/>the process that <b>opened</b> the connection,<br/>or of the process that sent <b>this message</b>?"} B -->|"the connection"| PC["<b>SO_PEERCRED</b> — getsockopt(fd, SOL_SOCKET, SO_PEERCRED)<br/>returns struct ucred {pid, uid, gid}<br/>SNAPSHOT taken at connect()/listen()/socketpair()<br/>read-only, no peer cooperation needed<br/>works on connected stream sockets and socketpairs"] B -->|"this message"| CR["<b>SCM_CREDENTIALS</b> — ancillary data per message<br/>receiver must first enable <b>SO_PASSCRED</b><br/>arrives with every subsequent message<br/>works on datagram sockets too"] PC --> SNAP["<b>It is a snapshot.</b> If the peer execve()s a<br/>setuid binary after connecting, SO_PEERCRED<br/>still reports the pre-exec credentials.<br/>Correct for 'who opened this channel',<br/>wrong for 'who is asking right now'."] CR --> VAL["<b>The kernel validates what the sender claims.</b><br/>Sender must give its own pid (unless CAP_SYS_ADMIN),<br/>its real/effective/saved uid (unless CAP_SETUID),<br/>its real/effective/saved gid (unless CAP_SETGID).<br/>A lie is rejected, not forwarded."] CR --> AUTOB["Enabling SO_PASSCRED on an unbound socket<br/>silently AUTOBINDS it to an abstract name."] SNAP --> PIDR["<b>PID reuse is the residual risk.</b> A pid is not a<br/>stable identity; the process can exit and the number<br/>be recycled before you act on it. Linux 6.5 added<br/>SO_PASSPIDFD / SO_PEERPIDFD, which hand you a<br/><b>pidfd</b> instead — an unforgeable, non-recyclable<br/>handle. Implemented in af_unix.c at v6.12."]
The two credential mechanisms and the question each one answers. What it shows: SO_PEERCRED is a pull (you ask the socket, once, about the connection) and SCM_CREDENTIALS is a push (the sender attaches, the kernel validates, you receive per message). The insight to take: the security property that makes both useful is that the kernel is the notary. unix(7) states the validation rule explicitly — “The credentials which the sender specifies are checked by the kernel. A privileged process is allowed to specify values that do not match its own. The sender must specify its own process ID (unless it has the capability CAP_SYS_ADMIN) …” — so unlike an application-level “I am uid 1000” claim, an SCM_CREDENTIALS message cannot assert an identity the sender does not hold. This is why a system daemon can safely authorize local clients with no passwords, no tokens and no TLS, and it is the mechanism polkit and D-Bus are built on. Note the SO_PEERCRED caveat carefully: it is fixed at connection time, so it answers “who opened this socket,” not “who is asking now.”
Measured on a socketpair, SO_PEERCRED reports the creating process itself, since both ends were made by one socketpair() call:
C) SO_PEERCRED on a socketpair -> pid=3270052 uid=1000 gid=1000 (we are pid=3270052 uid=1000)The SO_PASSPIDFD / SO_PEERPIDFD pair is present in af_unix.c at v6.12 (nine references, including the autobind branch quoted earlier) but is not documented in the unix(7) or socket(7) pages read for this note — a genuine documentation gap, dated 2026-09-04. The mechanism, the struct ucred validation rules in scm_check_creds(), and how polkit and D-Bus use them are developed in Socket Credential Passing SCM_CREDENTIALS.
Buffering, Framing, and Why There Is No Nagle
Nagle’s algorithm exists because TCP must amortize a 40-byte IP+TCP header and a network round-trip over small writes. A Unix-domain socket has no header and no round-trip, so there is nothing to amortize and no such algorithm: TCP_NODELAY has no AF_UNIX equivalent because the delay it disables does not exist here. A write is enqueued and the reader is woken immediately; the only reason a write() ever blocks is buffer accounting.
flowchart TD W["write(fd, buf, n) on a connected stream socket"] --> CHUNK{"n > the per-skb cap?"} CHUNK -->|"cap = min of (sk_sndbuf / 2) - 64<br/>and SKB_MAX_HEAD(0) + UNIX_SKB_FRAGS_SZ,<br/>where UNIX_SKB_FRAGS_SZ = 32 KiB"| LOOP["loop until the whole write is sent"] CHUNK -->|no| LOOP LOOP --> ALLOC["sock_alloc_send_pskb()<br/><b>charged against the SENDER's sk_sndbuf</b>"] ALLOC --> FULL{"sender's sk_sndbuf exhausted?"} FULL -->|"yes, O_NONBLOCK"| EAGAIN["EAGAIN — measured: 180,224 of 212,992 bytes<br/>absorbed with no reader (the rest is skb overhead)"] FULL -->|"yes, blocking"| BLOCK["sleep until the reader drains<br/><i>this is the entire flow-control mechanism</i>"] FULL -->|no| SCM["unix_scm_to_skb(scm, skb, !fds_sent)<br/><b>fds attach to the FIRST skb only</b>"] SCM --> COPY["skb_copy_datagram_from_iter() — one copy, user → skb"] COPY --> ENQ["lock the peer, then skb_queue_tail<br/>onto its sk_receive_queue"] ENQ --> WAKE["sk_data_ready() on the peer — wake the reader <b>immediately</b>"] WAKE --> NONAGLE["<b>No coalescing timer. No Nagle.</b><br/>There is no header to amortize and<br/>no round-trip to hide, so there is<br/>nothing for a delay to buy."] WAKE --> LOOP ENQ -.-> INQ["reader's SIOCINQ reports exactly<br/>these bytes — one set of bytes,<br/>two accounting views"]
What a write() to a Unix-domain stream socket actually does, and where the back-pressure comes from. What it shows: the write is chunked, each chunk is charged to the sender’s send buffer but queued on the receiver’s queue, and the reader is woken the instant the chunk lands. The insight to take: two things people expect are simply absent. There is no delay heuristic — Nagle’s algorithm has nothing to do here because there is no header cost and no network round-trip to hide, which is why TCP_NODELAY has no AF_UNIX counterpart. And there is no separate in-flight buffer: the sender’s sk_sndbuf accounting and the receiver’s queue describe the same bytes, which is why a slow reader back-pressures the writer directly and why the measured SIOCINQ figure matches the bytes the writer could push exactly.
That accounting is worth knowing precisely, because it is the thing that actually governs throughput. Measured on the live host:
default SO_SNDBUF = 212992 SO_RCVBUF = 212992 (= net.core.wmem_default)
SOCK_STREAM: 180224 bytes buffered before EAGAIN with no reader
SIOCINQ on the reader end reports 180224 bytes readable
SOCK_DGRAM (socketpair): 278 one-byte datagrams accepted before EAGAINReading those three lines together explains the mechanism. The 180,224 bytes absorbed is less than the 212,992-byte sk_sndbuf because sock_alloc_send_pskb() charges the whole skb, headers and all, not just the payload. The identical SIOCINQ figure on the reader confirms there is no third copy in between — the sender’s buffer accounting and the receiver’s queue are two views of the same bytes. And the datagram result shows why a SOCK_DGRAM socket runs out so much sooner: each one-byte datagram costs a full skb (several hundred bytes of overhead), so 278 tiny datagrams, not 180,224 of them, fit in the same budget. Small datagrams are expensive; that is the price of kernel-maintained framing.
Two more limits fall out of the source rather than the man page:
- A datagram larger than
sk_sndbuf - 32is rejected outright.unix_dgram_sendmsg()hasif (len > READ_ONCE(sk->sk_sndbuf) - 32) goto out;returning-EMSGSIZE. There is no fragmentation: aSOCK_DGRAMorSOCK_SEQPACKETmessage either fits in oneskbor fails. RaiseSO_SNDBUF(and the peer’sSO_RCVBUF) if you need large records, or use a stream. - A stream write is chunked at roughly 32 KiB, per the
UNIX_SKB_FRAGS_SZbound quoted earlier, with the comment “Keep two messages in the pipe so it schedules better” explaining thesk_sndbuf >> 1term: the kernel deliberately limits oneskbto half the send buffer so a second can be in flight, keeping the reader fed.
A last correction to a claim unix(7) still makes. The man page says “UNIX domain sockets do not support the transmission of out-of-band data (the MSG_OOB flag for send(2) and recv(2)).” That is no longer true for SOCK_STREAM. net/unix/Kconfig at v6.12 defines config AF_UNIX_OOB with default y, and af_unix.c has ten #if IS_ENABLED(CONFIG_AF_UNIX_OOB) blocks implementing it. Tested on kernel 7.1.8:
1) AF_UNIX SOCK_STREAM send(MSG_OOB) -> 1 (OK)
recv(MSG_OOB) -> 1 byte='X'Uncertain
Verify: exactly which kernel release added
CONFIG_AF_UNIX_OOB, and whetherunix(7)has since been corrected upstream. Reason: the behaviour is confirmed two ways (the option isdefault yin the v6.12net/unix/Kconfigread here, and the syscall succeeds on the 7.1.8 kernel tested), but the man page read on 2026-09-04 still asserts the opposite, and this note did not date the introducing commit. To resolve: bisectnet/unix/Kconfigacross tags withraw.githubusercontent.comto find the first release containingAF_UNIX_OOB, and read the introducing commit viahttps://github.com/torvalds/linux/commit/<sha>.patch. Until then, treatMSG_OOBonAF_UNIXas working but undocumented, which is a poor foundation for a protocol. uncertain
Measured Against Loopback TCP
The performance claim usually made for Unix sockets is “2–3× faster than loopback TCP.” Here is that claim tested rather than repeated. The benchmark is a ping-pong round-trip — write n bytes, read n bytes back — between a parent and a forked child, TCP_NODELAY set on both TCP ends so the comparison is not merely “Nagle versus no Nagle,” compiled -O2, run on Fedora 44 / kernel 7.1.8 on 2026-09-04. Latency is the honest metric; the throughput column is derived from it and is inflated by cache residency, so read it only as a relative figure.
| Message | AF_UNIX SOCK_STREAM | SOCK_SEQPACKET | SOCK_DGRAM | TCP on 127.0.0.1 (NODELAY) | Stream advantage |
|---|---|---|---|---|---|
| 64 B | 2.37 µs | 3.45 µs | 3.36 µs | 7.76 µs | 3.3× |
| 1 KiB | 2.85 µs | 3.30 µs | 3.20 µs | 7.75 µs | 2.7× |
| 64 KiB | 7.50 µs | 8.86 µs | 7.94 µs | 14.56 µs | 1.9× |
| 1 MiB | 76.9 µs | — | — | 148.0 µs | 1.9× |
xychart-beta title "Measured round-trip latency, AF_UNIX vs loopback TCP (Fedora 44, kernel 7.1.8, 2026-09-04)" x-axis ["64 B", "1 KiB", "64 KiB"] y-axis "microseconds per round trip" 0 --> 16 bar [2.37, 2.85, 7.50] bar [3.45, 3.30, 8.86] bar [7.76, 7.75, 14.56]
Round-trip latency for the same ping-pong exchange over three transports. Bars, left to right within each group: AF_UNIX SOCK_STREAM · AF_UNIX SOCK_SEQPACKET · TCP on 127.0.0.1 with TCP_NODELAY. (Mermaid’s xychart-beta has no per-series legend, hence the ordering given here.) What it shows: the loopback TCP bar is roughly flat at ~7.8 µs for both 64 B and 1 KiB — the payload is irrelevant next to the fixed cost of the protocol stack — while the Unix-socket bars start far lower and only begin to rise when the copy itself starts to matter at 64 KiB. The insight to take: the Unix-socket advantage is a fixed-cost advantage, so it is largest exactly where most local IPC lives: small, frequent, latency-sensitive messages. As the message grows, both transports converge toward the cost of memcpy and the ratio falls from 3.3× to 1.9×.
Three findings, one of which contradicts the conventional wisdom this note previously repeated:
- The 2–3× figure holds for small messages and understates the gap at the smallest sizes — 3.3× at 64 bytes, where the fixed per-message cost dominates and TCP’s fixed cost is much larger.
- The advantage narrows with size but does not vanish, and it does not invert. At 1 MiB the Unix socket is still 1.9× faster. An earlier version of this note carried a warning that the ratio “inverts for large (≈1 MiB) messages”; that is not reproducible here and the claim has been removed. It may hold for particular kernels or benchmark shapes, but it did not on this one.
SOCK_SEQPACKETandSOCK_DGRAMcost about 1 µs more thanSOCK_STREAMat small sizes — the price of allocating and accounting oneskbper message instead of coalescing. Both are still roughly 2.3× faster than loopback TCP at 64 bytes.
The mechanical reasons, each traceable to the earlier sections: no IP or TCP header construction; no checksum; no congestion-window, sequence-number or retransmission bookkeeping; no loopback netdevice traversal and therefore no NET_RX_SOFTIRQ round trip; and no port-hash lookup — the peer is a pointer (unix_peer(sk)), not a four-tuple to be hashed.
Uncertain
Verify: whether these ratios generalize. Reason: single machine, single kernel (7.1.8), single-flow ping-pong, both processes on one host with warm caches, default buffer sizes, no concurrency. Independently published numbers differ in shape, and all three were re-read on 2026-09-04: a 1 KiB micro-benchmark reports that “for 1k packets repeated 100,000 times, Unix domain sockets beat localhost TCP sockets by about 3x” (nicmcd/uds_vs_tcp) — matching the 2.7× measured here at the same size — while application-level figures are far smaller: “a roughly 40% boost for simple GET and SET operations” on Redis, and “Bruce Momjian, of the PostgreSQL project, ran some numbers and saw a 30% improvement using Unix Sockets over a TCP/IP loopback” (revsys, 12 Days of Performance). Those are smaller precisely because the application, not the transport, dominates the request. To resolve: benchmark the actual workload. The defensible general claim is “meaningfully faster for small local messages, converging toward parity as the message grows and as application work dominates,” not a fixed multiplier. uncertain
Failure Modes
- Stale socket file →
EADDRINUSEon bind. A pathname socket’s inode is a real directory entry that survivesclose(). Measured: bind/tmp/x.sock, close the socket, bind again →Address already in use. Meanwhile aconnect()to that stale path returnsECONNREFUSED, notENOENT— the file exists, nothing is listening. Fixes, in order of preference: bind to a temporary name andrename()over the target (atomic, no race);unlink()beforebind()(racy if two instances start together — use a lock file or systemd socket activation); or use the abstract namespace, which auto-frees. ENOENT/EACCES/EROFSon bind or connect. Pathname sockets obey ordinary VFS rules: a missing directory component givesENOENT, no search or write permission on the directory givesEACCES, a read-only filesystem givesEROFS(bind(2)). Since the directory is usually the real access control,EACCEShere is often correct behaviour rather than a bug.- 108-byte
sun_pathtruncation. Not aPATH_MAX; not adjustable. LongXDG_RUNTIME_DIR, deep container mount prefixes, and per-instance generated directories all hit it. And perunix(7)’s BUGS section, a 108-byte non-null path read back has no terminator — parse withstrnlen, neverstrlen. - Assuming one
write()equals oneread()onSOCK_STREAM. It does not, measured above: two 5-byte writes came back as one 10-byte read. Either frame your messages or useSOCK_SEQPACKET. - Assuming a short
SOCK_SEQPACKETread leaves the remainder. It does not: the rest of that message is discarded andMSG_TRUNCis set. Size the buffer to your largest message, and checkmsg_flags. - Ignoring
MSG_CTRUNC. The failure is not an fd leak — the kernel closes the excess descriptors, verified above. The failure is a silent capability loss: your peer thinks it sent an fd, you got data with no fd, and nothing errors. Size the control buffer withCMSG_SPACE()and test the flag. - Passing fds on a large stream write. They ride only on the first fragment, so which
recvmsg()sees them depends on how the reader chunks. Send one byte with the fd. EMSGSIZEon a datagram. There is no fragmentation: a message must fit insk_sndbuf - 32. Raise the buffers or switch to a stream.- Trusting
SO_PEERCREDas a live identity. It is a snapshot from connect time. A peer thatexecve()s after connecting still reports its old credentials, and the pid may have been recycled by the time you act on it. UseSO_PEERPIDFDwhere available. - Abstract sockets in a container. They ignore filesystem permissions entirely and are confined only by the network namespace — so
--net=hosthands them to every container on the box. CVE-2020-15257 is the worked example. EPIPE/SIGPIPEon a dead peer. Writing to a stream socket whose peer has closed raisesSIGPIPEand returnsEPIPE, exactly as with a pipe. Usesend(..., MSG_NOSIGNAL)or ignore the signal. OnSOCK_DGRAMthere is no connection, so peer death shows up asECONNREFUSEDon send — one of several reasons to preferSOCK_SEQPACKETfor anything long-lived.
Alternatives and When to Choose Them
| Channel | Direction | Addressable by unrelated processes | Framing | Passes fds / credentials | Choose it when |
|---|---|---|---|---|---|
| Pipes and the Pipe Buffer (FIFO) | one-way | FIFOs only | none | no | one-way byte stream between related processes; simplest possible thing |
AF_UNIX SOCK_STREAM | full duplex | yes | none | yes | general local client/server where you already have a framing layer |
AF_UNIX SOCK_SEQPACKET | full duplex | yes | kernel | yes | any new message-oriented local protocol; privilege separation |
AF_UNIX SOCK_DGRAM | full duplex | yes | kernel | yes | fire-and-forget notifications with no connection state (sd_notify, /dev/log) |
TCP on 127.0.0.1 | full duplex | yes (port) | none | no | the same code must also work over a network, or a client only speaks TCP |
| Shared Memory via mmap | n/a | via a name | n/a | n/a | bulk data with zero copies — but bring your own synchronization and notification |
| POSIX Message Queues | one-way per queue | yes | kernel | no | you specifically need message priorities |
flowchart TD START["I need two processes on one host to talk."] --> Q1{"Might the same code<br/>ever need to run across<br/>a network?"} Q1 -->|yes| TCP["<b>TCP</b> (loopback locally).<br/>Accept: no peer authentication,<br/>1.9–3.3× the latency, and you<br/>must build your own auth."] Q1 -->|no| Q2{"Do I control BOTH ends,<br/>and will one fork() the other?"} Q2 -->|yes| SPAIR["<b>socketpair(AF_UNIX, SOCK_SEQPACKET)</b><br/>no name, no address, nothing to attack"] Q2 -->|no| Q3{"Do unrelated processes<br/>need to find it by name?"} Q3 -->|yes| Q4{"Must it survive being<br/>reached from another<br/>network namespace?"} Q4 -->|"no — and I want<br/>filesystem access control"| PATHN["<b>pathname socket in /run/<svc>/</b><br/>directory 0700; unlink-or-rename on start;<br/>better still, let systemd socket-activate it"] Q4 -->|"I want it invisible<br/>to the filesystem"| ABS["<b>abstract socket</b> — but understand that<br/>it has NO permission checks and its only<br/>boundary is the network namespace.<br/>See CVE-2020-15257."] Q3 -->|"no, fire-and-forget<br/>to one known receiver"| DGRAM["<b>SOCK_DGRAM pathname socket</b><br/>(the sd_notify / syslog shape)"] SPAIR --> FRAME PATHN --> FRAME ABS --> FRAME FRAME{"Are my messages<br/>discrete records?"} FRAME -->|"yes (almost always)"| SEQ["<b>SOCK_SEQPACKET</b> — kernel framing,<br/>connection semantics, SO_PEERCRED.<br/>Costs ~1 µs. Write no framing code."] FRAME -->|"no, it is genuinely<br/>a byte stream"| STREAM["<b>SOCK_STREAM</b> — you now own<br/>length prefixes and partial-read state"] SEQ --> BULK{"Moving bulk data<br/>(pixels, pages, files)?"} STREAM --> BULK BULK -->|yes| MEMFD["pass a <b>memfd</b> over the socket with<br/>SCM_RIGHTS, mmap it on both sides,<br/>keep the socket for control + wakeups"] BULK -->|no| DONE["done"]
Choosing an AF_UNIX shape, end to end. What it shows: three independent decisions — how is it addressed, how is it framed, and how does bulk data move — that people habitually collapse into one reflexive “pathname SOCK_STREAM.” The insight to take: the reflexive answer is rarely the best one. If you control both ends, a socketpair() removes the addressing question entirely and with it every access-control decision. If your protocol has messages, SOCK_SEQPACKET removes the framing question and an entire class of parser bugs. And if you are moving bulk data, the socket should be carrying a memfd rather than the bytes. The only branch that genuinely needs the classic pathname-stream shape is “unrelated processes, a real byte stream, filesystem access control.”
The comparisons that actually decide designs:
Versus loopback TCP. Choose TCP only if the same code path must work remotely, or if a client library speaks nothing else. For genuinely local IPC the Unix socket is 1.9–3.3× lower latency (measured above) and — the more important difference — it can authenticate the peer. There is no equivalent of SO_PEERCRED for a loopback TCP connection; a source port on 127.0.0.1 identifies nobody, which is why every daemon that accepts loopback TCP needs an application-level auth mechanism that the Unix-socket version can skip. PostgreSQL’s peer method is the canonical illustration: it “works by obtaining the client’s operating system user name from the kernel and using it as the allowed database user name,” and is “only supported on local connections” (PostgreSQL 18 docs, §20.9) — the kernel-attested identity a loopback TCP connection simply does not have.
Versus pipes. A pipe is cheaper and simpler, but it is one-way, anonymous pipes only connect related processes, and — decisively — a pipe cannot carry a descriptor or a credential. Any design that needs a broker to hand out capabilities needs a socket.
Versus shared memory. These are complements, not competitors, and the idiomatic combination is worth stating: create a memfd, pass it over a Unix socket with SCM_RIGHTS, mmap() it on both sides, and keep using the socket for small control messages and wakeups. That gives zero-copy bulk transfer and framing and peer authentication. It is exactly what Wayland does for pixel buffers and what Passing memfd Buffers Between Processes covers.
Versus SOCK_DGRAM for the message-boundary case. Both preserve boundaries, but a datagram socket cannot tell you the peer is gone. SOCK_SEQPACKET gives you EOF, EPIPE and SO_PEERCRED for the same framing, at about 0.1 µs. Use datagrams only where the connectionless model is what you want — one well-known receiver, many transient senders, no per-peer state — which is exactly the shape of sd_notify and /dev/log.
Production Notes
The scale is easy to under-appreciate until measured. On the single Fedora 44 desktop used for this note:
$ ss -xa | awk '{print $1}' | sort | uniq -c | sort -rn
2509 u_str # SOCK_STREAM
75 u_dgr # SOCK_DGRAM
50 u_seq # SOCK_SEQPACKET
$ ss -xa | awk '{print $5}' | grep -c '^@' # abstract
58
$ ss -xa | awk '{print $5}' | grep -c '^/' # pathname
5102,629 Unix-domain sockets, against a loopback TCP count in the low tens. Every layer of the desktop is on them, and the ones visible by name map cleanly onto the mechanisms above:
/run/user/1000/wayland-1— the compositor’s stream socket. Wayland is the archetypalSCM_RIGHTSprotocol: clients passmemfdand DMA-BUF descriptors for pixel buffers over the same socket that carries the protocol messages, which is why a display server can be a normal unprivileged process and still hand out graphics memory. Note that its permissions aresrwxr-xr-xinside adrwx------directory — the directory is the security./run/dbus/system_bus_socket(srw-rw-rw-) and/run/user/1000/bus— D-Bus. World-writable on purpose: authorization is not filesystem-based but done fromSO_PEERCRED/SCM_CREDENTIALSagainst policy, which is also how polkit decides whether a caller may perform a privileged action./run/systemd/notify,/run/user/1000/systemd/notify—SOCK_DGRAM. Thesd_notifyprotocol is fire-and-forget status text from a service to the manager; connectionless is the right model because the service must not block or care whether the manager is listening./run/systemd/journal/dev-logand/run/systemd/journal/socketare likewise datagram./run/docker.sock(srw-rw---- root docker) — the container runtime’s control API. Its file mode is the access control here, and granting a user thedockergroup grants root-equivalent power, because the API can start a privileged container./tmp/.X11-unix/X0— the X Window System display socket, sitting in adrwxrwxrwtsticky directory. It is the one entry in this list that follows none of the rules below: it is in/tmprather than/run, in a world-writable directory, and X11’s access control is therefore its own (theMIT-MAGIC-COOKIE-1token in~/.Xauthority) rather than the filesystem’s. It is a good illustration of why the/run+ private-directory convention exists. On the host measured here the display socket is pathname-only —ss -xashows no abstract counterpart — though X servers have historically also listened on an abstract name, which is exactly the surface The Abstract Socket Namespace discusses.@3d0de,@aa6f3,@503b4— autobind names, held bychrome_crashpadandspotify, produced automatically by enablingSO_PASSCREDon an unbound socket./run/user/1000/gnupg/S.gpg-agent.ssh,/run/user/1000/ssh-agent.socket— agents holding private keys. The reason an agent is safe to expose as a socket at all is that it never passes the key; it accepts signing requests, and the socket’s directory permissions plusSO_PEERCREDconstitute the entire access-control story.
The recurring operational rules distilled from those:
- Put the socket in
/run(or/run/user/$UID), never/tmp./runis atmpfs: the socket never survives a reboot, never lands on persistent storage, and is not in a world-writable sticky directory where another user can plant names. - Secure the directory, not the file. Set
0700(or0750with a group) on the containing directory. Socket-file modes are honoured on Linux but the directory is the portable, reliable control — and some historic implementations ignored the socket’s own mode entirely. - Authorize with
SO_PEERCRED/SCM_CREDENTIALS, not with reachability. Being able to connect is not authorization. Read the peer’s uid and decide; that is what D-Bus, polkit and PostgreSQL’speermethod do. - Prefer systemd socket activation over binding yourself. The manager creates and binds the listening socket, sets its ownership and mode declaratively, and hands the already-bound descriptor to the service over
SCM_RIGHTS— which eliminates the stale-socket problem, removes the start-order race between client and server, and means the service never needs privilege to bind in a protected directory. - Default new local protocols to
SOCK_SEQPACKET. The framing you do not write is the framing you cannot get wrong.
See Also
- The Abstract Socket Namespace — the
@-prefixed Linux-only addressing mode: no filesystem object, no permission checks, scoped to the network namespace - Passing File Descriptors with SCM_RIGHTS — the send/receive walk-through,
MSG_CMSG_CLOEXEC, and the in-flight garbage collector in depth - Socket Credential Passing SCM_CREDENTIALS —
struct ucredvalidation,SO_PEERCREDversusSO_PASSCRED, and how polkit and D-Bus authenticate - Network Namespaces —
net->unxis a field ofstruct net, which is why abstract names andmax_dgram_qlenare per-network-namespace - Pipes and the Pipe Buffer — the simpler unidirectional sibling that cannot pass capabilities
- Shared Memory via mmap · Passing memfd Buffers Between Processes — the zero-copy complement, set up over a Unix socket
- POSIX Message Queues — the priority-ordered alternative for framed messages
- Linux Networking Stack MOC — owns the generic socket layer (
struct socket/struct sock) this is built on - MOC: Linux IPC MOC — the parent map of the IPC catalogue