The idea: same API, two kinds of address
A program that talks to a server on the same machine, such as psql to
PostgreSQL or the docker command to dockerd, has two choices. It can open a
TCP connection to 127.0.0.1:5432, the loopback address. Or it
can open a unix domain socket (AF_UNIX) at a path such as
/var/run/postgresql/.s.PGSQL.5432. The calls are the same, socket(),
connect(), send(), recv(); only the address changes. What the kernel
does with the bytes is very different.
The canvas runs the same client and server both ways on one clock. One tick is one hop of one message from one stop to the next: from the program into its socket, from the socket into TCP, from TCP into IP, and so on. The unix side usually finishes first and waits while the TCP side is still climbing.
What loopback TCP still does
Packets to 127.0.0.1 never touch a network card, but they go through every layer as if they
would. send() copies the bytes into an skb (the kernel's packet buffer). TCP adds a
header with the sequence and acknowledgment numbers. IP adds its header, looks up the route
(dev lo) and runs the netfilter hooks. The loopback device lo hands the
packet back with netif_rx(), and it waits for the NET_RX softirq, just like a packet
from a real NIC. Then it climbs IP and TCP again on the server side: socket lookup by the 4-tuple, sequence
check, an ACK owed, the bytes queued, the server woken. That is 8 hops one way, 16 for a
request and its reply (Demo: One query). The checksum is the one thing skipped: lo marks
its packets as already checked. The receive half is the same climb as on
How Linux Receives a Packet, without the ring and the interrupt.
What a unix socket does instead
A unix stream socket has no headers, no ports, no sequence numbers and no device. The kernel already knows
the peer socket, so unix_stream_sendmsg() copies the bytes into an skb and links it
straight onto the peer's receive queue, then wakes the peer. recv() copies the bytes
out. That is 3 hops one way. The data is still copied twice, user space to kernel and
kernel to user space, exactly as with TCP. The saving is the layers in between, not the copies. Ten queries make
the difference plain: 160 hops against 60, and 40 copies on each side (Demo: Ten queries). Real
benchmarks usually show unix sockets clearly ahead for small requests; for large messages the copying dominates
and the gap shrinks.
Connecting: handshake vs path lookup
A TCP connect() is the 3-way handshake: SYN, SYN-ACK, ACK, each
going down one stack and up the other, then the new socket waits on the listener's accept queue until the server
calls accept(). On the canvas that is 16 ticks before a single byte is sent. A unix
connect() looks the path up like any file name, checks write permission on the socket
file, finds the listening socket through the inode and queues a connected socket on its accept queue. No packet
is sent; connect() returns at once. On the canvas that is 4 ticks
(Demo: Connect).
Who may connect: file permissions and SO_PEERCRED
A TCP port has no owner and no permission bits: any process on the machine can connect to
127.0.0.1:2375. A unix socket is a file, so the usual permission check applies. Docker's socket is
srw-rw---- root docker: root and members of group docker may connect, nobody else.
In Demo: Docker socket, bob (not in the group) gets EACCES on the unix socket after 3 ticks,
but the TCP port lets him straight in. Since the Docker API can start a privileged container, that TCP port
would make every local user root. This is why dockerd listens on the socket by default and warns
against -H tcp:// without TLS.
The server can also ask the kernel who is on the other end: getsockopt(fd, SOL_SOCKET,
SO_PEERCRED) returns the peer's pid, uid and gid, recorded at connect() and
impossible to fake. PostgreSQL's peer authentication (local all all peer in
pg_hba.conf) uses it to let OS user alice in as role alice with no password. Over TCP the server only
sees 127.0.0.1:54012, which says nothing about the user, so pg_hba.conf usually asks for
a password there (scram-sha-256). How PostgreSQL forks a backend per connection is on
PostgreSQL Process Architecture.
Passing a file descriptor with SCM_RIGHTS
A file descriptor is just an index into one process's fd table; the number 4 means nothing to another
process. A unix socket can still hand an open file across: sendmsg() with a control message of type
SCM_RIGHTS makes the kernel take a reference to the open file itself, and
recvmsg() installs it in the receiver as a new descriptor (fd 7 on the canvas). Both now share one
open file, offset included (Demo: Pass an open file). On a TCP socket the same call fails with
EINVAL. Real uses: systemd hands listening sockets to the services it starts, container runtimes pass
a terminal (runc --console-socket), Wayland clients pass graphics buffers, and some servers pass
listening sockets to their new version during a reload. More on fds and sockets:
A TCP Socket in Linux.
Side by side
| TCP on 127.0.0.1 | Unix domain socket | |
|---|---|---|
| Address | IP and port, 127.0.0.1:5432 | file path, /var/run/postgresql/.s.PGSQL.5432 |
| Hops one way (canvas) | 8: socket, TCP, IP, lo, IP, TCP, socket | 3: socket, peer's queue |
| Copies per message | 2 | 2 |
connect() | 3-way handshake, 16 ticks | path lookup, 4 ticks |
| Who may connect | any local user | who may write the socket file |
| Who is the peer | an IP and a port | SO_PEERCRED: pid, uid, gid |
| Pass open files | no (EINVAL) | yes, SCM_RIGHTS |
| Reach another machine | yes, change the address | no, same kernel only |
So the rule of thumb: use the unix socket when client and server share a kernel (the default for
psql, the Docker CLI, MySQL's localhost, nginx to a local PHP-FPM or gunicorn), and TCP when
they might not. Containers are the usual catch: two containers in different network namespaces cannot reach each
other's 127.0.0.1, but they can share a unix socket file through a mounted volume, and mounting
/var/run/docker.sock into a container hands it full control of Docker
(What a Container Really Is,
How Docker Networking Works on One Host).
What the page leaves out
- Real timing: a hop here is one tick, but the TCP and IP hops are not equal in cost to the unix ones, and the wake-up and context switch cost the same on both sides.
- Delayed ACKs, the receive window, Nagle and
TCP_NODELAY, segmentation of large writes, GSO/GRO. - Datagram (
SOCK_DGRAM) andSOCK_SEQPACKETunix sockets,socketpair(), and abstract socket names (a path starting with a zero byte, no file). SCM_CREDENTIALS, passing several fds at once, and the limits on fds in flight.- The PostgreSQL protocol itself (startup message, authentication exchange, RowDescription, DataRow) and the HTTP request
dockersends. - Faster local paths: shared memory, pipes, and TCP over loopback with zero-copy or io_uring.