Skip to content

Subprocesses and pipes

process = await asyncio.create_subprocess_exec(
    "cat", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE
)
stdout, _ = await process.communicate(b"round trip")

create_subprocess_exec and create_subprocess_shell follow asyncio's public contract, including env, cwd, pass_fds and start_new_session where the host platform supports them.

How it is put together

On macOS and Windows, the child is spawned with uv_spawn and its stdio runs over native pipe transports. Linux uses subprocess.Popen so its race-free close_fds implementation preserves Python's descriptor-inheritance contract; non-empty pass_fds also selects that path on Unix. asyncio's own subprocess transport drives both implementations, so protocol callbacks and exit bookkeeping stay identical.

libuv reaps children it spawns and reports status through its exit callback. The Linux stdlib path uses one bounded, process-wide reaper thread for all children rather than a thread per child.

On the libuv path, signals go through uv_process_kill rather than the pid. The stdlib path delegates signalling to its Popen object.

Pipes

read_transport, protocol = await loop.connect_read_pipe(Protocol, pipe)
write_transport, protocol = await loop.connect_write_pipe(Protocol, pipe)

Pipes reuse the stream transport — libuv drives FIFOs through the same handle it drives Unix sockets through. A write pipe never starts a read on its descriptor, because a write-only descriptor cannot be read and trying would fail the transport rather than simply deliver nothing.

The descriptor is checked the way asyncio checks it: FIFOs, sockets and character devices are accepted, and anything else — a regular file, say — raises ValueError.

Who closes what

The transport owns the pipe you hand it, and closes it. Internally the descriptor is duplicated before libuv adopts it, which keeps the two owners apart: libuv closes its own descriptor, the transport closes your pipe object, and neither can close the other's.