Serving on Several Ports and Unix Sockets in asyncio¶
A service often listens in more than one place: a public TCP port, a localhost-only admin port, IPv4 and IPv6, a Unix socket for a sidecar or a reverse proxy on the same host. asyncio can serve all of them from one event loop with one handler, and the details of each listener matter. Measured on Python 3.14 with a 64-byte echo: a sequential round trip took 41.8–42.1 µs over TCP loopback and 13.1–13.7 µs over a Unix socket, and with 50 clients the Unix socket carried 101,301–104,104 round trips per second against 61,678–62,288 for TCP. One start_server call with hosts 127.0.0.1 and ::1 and port 0 bound two different ports, one per host. A Unix socket file survived SIGKILL, and the next start removed it and listened normally; but a second instance started while the first was still running also removed the path and bound its own — leaving the first server running and unreachable. A path in a deep directory failed with AF_UNIX path too long. This guide sets up several listeners and avoids those traps.
Prerequisites¶
- Python 3.11+ asyncio on Linux or macOS (Unix sockets).
- Unix socket basics, from using Unix domain sockets with asyncio.
- The topic overview, Streams, Transports & Protocols.
1. Serve one handler on several listeners¶
A handler written for start_server works unchanged with start_unix_server. Start each listener and serve them together:
async def handle(reader, writer):
... # same code for every listener
async def main():
public = await asyncio.start_server(handle, "0.0.0.0", 8000)
admin = await asyncio.start_server(admin_handle, "127.0.0.1", 9000) # localhost only
local = await asyncio.start_unix_server(handle, "/run/app/app.sock")
async with public, admin, local:
await asyncio.gather(public.serve_forever(), admin.serve_forever(), local.serve_forever())
Exiting the async with closes all three servers. When peername matters — logging, rate limiting — note that a Unix socket connection has no IP address: writer.get_extra_info("peername") is an empty string or a path, so code that parses it as (host, port) needs a branch for Unix sockets.
Verify: each listener accepts a connection in a test, and per-connection code handles both TCP and Unix peer information.
2. Bind several hosts with an explicit port¶
start_server accepts a list of hosts and creates a socket for each:
server = await asyncio.start_server(handle, ["127.0.0.1", "::1"], 8000)
print([s.getsockname()[:2] for s in server.sockets]) # [('127.0.0.1', 8000), ('::1', 8000)]
With an explicit port, every host gets that port. With port 0 — common in tests — each socket is bound separately and the kernel picks a different ephemeral port for each. Measured: one call with ["127.0.0.1", "::1"] and port 0 produced ('::1', 38933) and ('127.0.0.1', 35213), and a test that read the port from server.sockets[0] and connected to 127.0.0.1 with it was refused. Read the port from the socket whose address you intend to use, or bind a single host in tests.
Verify: tests that use port 0 with several hosts look up the port per address family.
3. Use a Unix socket for same-host clients¶
For clients on the same host — a reverse proxy, a sidecar, a local CLI — a Unix socket skips the TCP/IP stack. Measured with a 64-byte echo: 13.1–13.7 µs per sequential round trip against 41.8–42.1 µs over TCP loopback, and 101,301–104,104 round trips per second with 50 clients against 61,678–62,288. Access is controlled with file permissions rather than firewall rules:
SOCKET_PATH = "/run/app/app.sock"
async def start_local(handle):
old_umask = os.umask(0o117) # socket created as 0660
try:
server = await asyncio.start_unix_server(handle, SOCKET_PATH)
finally:
os.umask(old_umask)
return server
Created with the default umask, the socket here had mode 0o775; connecting requires write permission on the socket, so set the umask or os.chmod explicitly for the group that should connect. The path length is limited — 108 bytes on Linux, including the terminator — and a 138-character path in a deep temporary directory failed with OSError: AF_UNIX path too long; keep sockets in short paths such as /run/<app>/.
Verify: the socket's mode and group match the intended clients, and the path is well under the length limit.
4. Guard the socket path against a second instance¶
asyncio's start_unix_server removes an existing socket file at the path before binding. That makes restarts after a crash work — measured, the file survived SIGKILL, and the next start removed it and listened — but it does not check whether the old socket is still in use. Measured: starting a second instance while the first was running succeeded, replaced the path, and when the second instance exited, clients connecting to the path got ConnectionRefusedError while the first instance kept running with no way to reach it. A regular file at the path, by contrast, was refused with Address already in use. Check for a live server before binding:
async def claim_socket_path(path: str):
try:
reader, writer = await asyncio.open_unix_connection(path)
except (FileNotFoundError, ConnectionRefusedError):
return # absent or stale: safe to bind
writer.close()
await writer.wait_closed()
raise RuntimeError(f"{path} is served by a running process")
A short race remains between the check and the bind; a lock file held with fcntl.flock for the life of the process closes it, as in preventing overlapping runs of async cron scripts.
Verify: starting a second instance against the same path fails, and the first instance stays reachable.
5. Spread one port across processes with reuse_port¶
To use several cores, run several processes that each bind the same port with reuse_port=True; the kernel distributes incoming connections among them:
async def main():
server = await asyncio.start_server(handle, "0.0.0.0", 8000, reuse_port=True)
async with server:
await server.serve_forever()
Measured with four processes and 1,000 connections: 276, 254, 235 and 235 connections per process. The kernel balances by connection, not by load, so long-lived connections can leave one process busier than the others. reuse_port lets any process of the same user bind the port, including a stale or mistaken one, so combine it with a process supervisor that knows which processes belong to the service. Why each process needs its own pools is covered in why connection pools are per-process.
Verify: connection counts per process are roughly even under a load test, and only the intended processes hold the port.
Verification¶
A multi-listener service is set up correctly when:
- All listeners share one handler and close together.
- Port numbers are explicit, or looked up per address when port 0 is used.
- Unix sockets have short paths and explicit permissions, and a running instance cannot be displaced.
reuse_portprocesses receive a roughly even share of connections.
Diagnostic Hook: when a service on a Unix socket suddenly refuses connections while its process is still running, check whether another instance was started against the same path. asyncio removed the live socket file and bound a new one, leaving the first server unreachable in this test.
Pitfalls & edge cases¶
- Port 0 with several hosts. Measured: each host got a different port.
- Starting a second instance on a socket path. It took over the path silently.
- Long socket paths. 138 characters failed with
AF_UNIX path too long. - Parsing
peernameas host and port for Unix socket connections.
Frequently Asked Questions¶
Can one asyncio server listen on TCP and a Unix socket?
Yes: start each with start_server and start_unix_server using the same handler, and run their serve_forever() calls together.
Is a Unix socket faster than TCP on localhost?
In this test, a 64-byte round trip took about 13 µs against 42 µs, and 50 clients reached about 103,000 against 62,000 round trips per second.
Why does asyncio say the Unix socket path is too long?
Linux limits socket paths to 108 bytes. A 138-character path failed; keep sockets in short directories such as /run/app/.
Does asyncio remove a stale Unix socket file?
Yes, start_unix_server removes an existing socket file before binding, even one a running server still uses. Check for a live server before starting.
Related¶
- Streams, Transports & Protocols — up to the topic overview.
- Setting socket options on asyncio streams — per-connection settings for each listener.
- Network I/O & Protocol Handling — the section overview.