Moving beyond fork() + exec()

Linux kernel developers discuss proposals to replace the fork()+exec() process creation model with a cleaner API built on the pidfd abstraction and a kernel-native posix_spawn().

Moving beyond fork() + exec()

Fork has been patched for fifty years — copy-on-write pages, lazy TLB flushing, vfork, CLONE_VFORK — and each patch made it cheaper without changing the fundamental awkwardness: to run a new program, you first copy the process that wants to run it, then throw most of that copy away.

Source

Moving beyond fork() + exec()

By: Jonathan Corbet / LWN.net
Published: Jun 5 2026
Type: Engineering article

Saved because it captures a moment of kernel-developer convergence on the direction for process creation — specifically the pivot away from spawn templates toward the pidfd builder API and a proper kernel-native posix_spawn(). The comments section is unusually substantive and includes the “Fork() in the Road” Microsoft Research paper thread as a reference point.

A snapshot you discard vs. a blank slate you configure

The cost of fork() in modern Linux isn’t primarily in copying pages — copy-on-write handles that — it’s in duplicating everything else: page tables, VMAs, file descriptor tables, signal handlers, memory maps, and thread state. For a large multithreaded server, that’s a substantial amount of bookkeeping to immediately discard when the child follows fork() with exec(). The problem is conceptual before it’s operational: fork() was designed for a world where processes were small and the snapshot was cheap. Today’s large server processes using fork() to spawn workers are applying a 1970s tool to a workload it wasn’t designed for.

Li Chen’s patch set proposed a targeted optimization for one specific pattern: applications that repeatedly invoke the same executable. The motivating example is a build system running git thousands of times per build. Chen’s spawn_template_create() system call would return a file descriptor representing a cached, pre-analyzed template for a given executable — the kernel opens the file and stores everything needed to execute it more quickly, so each subsequent invocation via spawn_template_spawn() doesn’t have to re-parse the ELF, re-check security policies, or redo similar setup work. Benchmark results showed roughly a 2% improvement, which matters at scale even if it looks modest in isolation. Each spawn also accepted a spawn_template_spawn_args structure carrying argv, envp, and an array of actions for file descriptor setup, signal handling, and working directory changes — a clean separation between what stays constant across invocations and what varies.

Mateusz Guzik’s review was the most technically detailed response, and it reframed the problem entirely. He acknowledged the template idea but pointed out that the patch leaves the fork portion of the process creation path untouched, which is where most of the real cost lives. His argument: optimizing the exec side while keeping the fork is fighting the abstraction. The right approach is to never create a snapshot of the parent in the first place. “Creating a pristine process is the way to go” — start with an empty process, then configure it deliberately. That is a different mental model entirely: instead of inheritance with selective overrides, you get explicit construction from a known-empty baseline.

Christian Brauner sketched what a kernel API for pristine process creation could look like, anchoring it to the pidfd abstraction that Linux already uses for race-free process management. His suggestion: extend pidfd_open() with an option to create an empty process that has no inherited state from any parent. Then configure that process through a new pidfd_config() system call, analogous to fsconfig() — the configuration interface for the filesystem mount API. You would call pidfd_config() repeatedly to set the executable path, argv, envp, file descriptor mappings, signal handlers, and working directory, then a final call would trigger execution. This builder pattern matches how modern Linux has handled other complex setup flows: don’t do everything in one monolithic call; make each step explicit, auditable, and composable with the rest of the system.

Both Chen and Brauner converged on the same user-space target: a proper native implementation of posix_spawn(). POSIX has specified posix_spawn() as the correct API for this pattern for decades, but on Linux it’s currently implemented over vfork() or clone() under the covers — it’s still doing the copy, just hiding it behind a standardized interface. A kernel-native path would let posix_spawn() skip the parent-state copy entirely, making it a true lightweight spawn primitive that matches the POSIX contract without the hidden overhead.

The vfork() comparison clarifies why this matters beyond benchmark numbers. Classic vfork() returns to the same stack frame in both parent and child — you are one compiler optimization away from the kind of stack corruption that’s hard to reproduce and harder to debug. CLONE_VFORK is safer because it requires a dedicated stack for the child, but it freezes the calling thread until the child calls exec() or exits. For a large server with hundreds of threads, pausing even one thread while waiting for a subprocess to start creates backpressure that compounds at high concurrency. The pristine-process approach avoids both problems: there is no parent state to corrupt, and there is no parent thread to freeze.

An alternative approach from Josh Triplett proposed using io_uring as the mechanism for process setup actions — create an empty process, run an io_uring in it to receive and install file descriptors, end the ring with an exec attempt. This fits naturally with applications already using io_uring heavily and extends the async I/O model into process initialization. The practical objection was seccomp integration: existing seccomp filters don’t map onto io_uring operations, so applications that rely on seccomp-based sandboxing would need to audit and update their policies before adopting this path. Per-operation BPF filtering in io_uring has been added recently, but deployment across the sandboxing ecosystem will take time.

My take

Fork isn’t a bad API — it’s an API whose default behavior assumes you want a copy of the parent, designed at a time when that assumption was almost always correct. The pidfd builder pattern inverts the default: you start with nothing and add only what you need, which matches what callers actually want when spawning subprocesses today. The reason this conversation cycles in the kernel mailing list every few years is that vfork() and CLONE_VFORK are technically adequate but so dangerous to use correctly that almost nobody does, which leaves the ecosystem stuck with hidden fork overhead inside posix_spawn(). A kernel-native solution would break that stalemate by giving library authors an implementation path that doesn’t require walking on eggshells.

Where this is useful

  • Build systems and language runtimes that spawn many short-lived subprocesses (make, Gradle, Ruby, Python subprocess pools)
  • Large server processes that fork worker processes and have profiling evidence that clone() or waitpid() time is significant
  • Container runtimes and sandbox tools that need precise, auditable control over exactly what a child process inherits
  • When NOT applicable: programs that spawn subprocesses rarely — for those, the fork() cost is a rounding error and the added API complexity adds nothing

Apply this: If profiling shows significant time in clone() or waitpid() during subprocess creation, benchmark the zygote pattern (fork one pre-warmed process, then fork from it repeatedly) as a stopgap while kernel-native spawn APIs are still under development.

What I want to remember

Remember: Every optimization to fork() is fighting the abstraction — copying a parent’s full process state that you are about to discard is inherently wasteful; the right answer is “creating a pristine process.”