Skip to main content

TECH VEDA

Embedded Linux on Edge-AI 23rd Sept 2026 enrollingLinux kernel & Device drivers starts on 24th Oct 2026 enrollingCorporate on-site training - Submit proposal Pick your modulesSharpen your kernel skills: deep dives, drivers, Yocto, CVEs, careers — updated daily. Read the blog →Embedded Linux fast track starts 23rd sept 2026 enrollingEmbedded Linux Mastery track starts 23rd sept 2026 enrollingLinux systems engineering starts 23rd sept 2026 enrolling
News

What fork() Actually Costs, and Why the Process-Builder RFC Does Not Fix It Yet

A process-builder API is proposed for Linux to replace fork()+exec(). We check the performance claim against kernel source and ask if it matters on embedded.

What fork() Actually Costs, and Why the Process-Builder RFC Does Not Fix It Yet

A process-builder API is proposed for Linux — pidfd_open(0, PIDFD_EMPTY), pidfd_config(), pidfd_spawn_run() — to construct a process incrementally instead of copying the parent with fork() and discarding the copy with exec(). We traced the cost argument through the kernel source at v7.1.7, and it has three parts: the mapping walk, the page-table copy for anonymous memory, and a descriptor-table copy sized by your highest descriptor number. posix_spawn() already removes the first two and not the third. In its current RFC form the proposed API does not improve on that: it uses CLONE_VM | CLONE_VFORK internally too, so it avoids the same two costs and still copies the descriptor table.

Status: RFC. First series 28 May 2026 (13 patches, superseded by a redesign); second series 16 July 2026 (24 patches, against linux-next). No pidfd or exec maintainer had replied to the second series at the time of writing. Nothing described here is in a released or -rc kernel; you would have to apply the series to linux-next yourself to try it.

What happened

The practical conclusion first, because it decides whether the rest of this concerns you: this interface is not in any released kernel, and in its current form it is not faster than what posix_spawn() already gives you. The useful question is not whether to wait for it, but where process creation actually costs you time and latency today. That is what most of this article is about.

In May 2026, Li Chen posted a patch series to speed up a specific pattern: runtimes that repeatedly launch the same short-lived helpers. The stated target was coding agents that start rg, git, sed and python over and over. The design cached executable metadata behind a template file descriptor and spawned from that.

Reviewers rejected the shape while accepting the goal. Mateusz Guzik argued it optimised the wrong end — “Most of this shaves off a tiny amount of work, while retaining the primary avoidable reason for bad performance: the very fact that fork is part of the picture, especially the part mucking with mm” — and Andy Lutomirski wrote that it was “a lot of complexity in the kernel for a teeny tiny gain”. Christian Brauner proposed building on pidfds instead, modelled on fsconfig(), and said that “any implementation should also allow userspace to implement posix_spawn() on top of it”. Kees Cook agreed.

Chen accepted the redirection and, in July, posted a 24-patch replacement following Brauner’s sketch. It is a single-descriptor model:

    int fd = pidfd_open(0, PIDFD_EMPTY);

    /* Optional executable-path staging. */
    pidfd_config(fd, PIDFD_CONFIG_SET_STRING,
                 PIDFD_CONFIG_KEY_PATH, path, 0);

    pidfd_spawn_run(fd, &args, sizeof(args));

The empty pidfd is “a taskless future pidfd with a stable pidfs inode, but no task, PID, or process-count charge”. Live-task operations return -ESRCH until the run step creates one; afterwards the same descriptor is the child’s pidfd. LWN has covered both rounds, and the mailing-list threads are linked in full at the end. The rest of this article checks the performance claim against the kernel source and works out who it applies to.

What we checked, and what it changes

The usual one-line summary is that fork() is expensive because it copies the process, including its memory. We traced the paths in the v7.1.7 tree. There are three separate costs, they scale with different things, and each one changes what you would do about it.

First, the mapping walk; second, the page-table copy. These are usually described as one cost and they are not. Copy-on-write means page contents are not duplicated. But page tables are, for some mappings, and that is the part usually left out. The walk in dup_mmap() costs one structure per virtual memory area, so it is proportional to the number of mappings. Then, per mapping, copy_page_range() decides whether to copy page tables at all. Its gate, vma_needs_copy(), returns false — skipping the work entirely — for mappings a fault can refill later; the comment there reads “Fork becomes much lighter when there are big shared or private readonly mappings.” But it returns true whenever the source mapping has an anon_vma, which every private mapping acquires as soon as it is written. For those, every present page table entry is copied.

So the first cost tracks the number of mappings and the second tracks resident anonymous pages. A large read-only file mapping is close to free, and a large heap is not. A process with twelve mappings and a two-gigabyte touched heap is twelve lines in /proc/PID/maps and roughly half a million page-table entries to copy. Measure both terms: wc -l < /proc/PID/maps for the first, and AnonRSS in /proc/PID/status for the second. Note also that a library’s writable data segment acquires an anon_vma at relocation time, so “read-only libraries are free” applies to their text segments and not their data.

A side effect of the walk is worth knowing if you read traces: the parent’s mmap_lock is held for write throughout, and each mapping is also write-locked individually as it is walked. Under per-VMA locking it is that per-mapping lock, rather than mmap_lock alone, that stalls a concurrent fault in another thread.

The second cost has a delayed part. Setting up copy-on-write write-protects the page-table entries of both processes, not just the child’s. The line in __copy_present_ptes() is commented “If it’s a COW mapping, write protect it both processes”, and it calls wrprotect_ptes() on the source mm. So after fork() returns, the parent’s own writable private pages are write-protected, and its next write to each one takes a minor fault.

Whether that fault also copies the page depends on who else holds it. do_wp_page() reuses the page in place when the folio is exclusively owned, so once the child has exec’d or exited the parent usually pays one fault per page and no copy; it pays the copy as well while the page is genuinely still shared. Either way the cost is deferred and does not appear in a benchmark that times the fork() call, and the write-protect pass itself, including its TLB flush, is paid before fork() returns.

Third, the descriptor table, sized by your highest descriptor number rather than the count. The extent of the walk in dup_fd() comes from sane_fdtable_size(), which is built on find_last_bit() over the open-descriptor bitmap and rounds up to a multiple of BITS_PER_LONG. A process holding descriptors 0, 1, 2 and one stray at 100000 walks 100032 slots and allocates a table for them, against 64 slots and no allocation without the stray. Two processes with identical open-file counts can differ by three orders of magnitude on the descriptor-copying step. This is the cost that produces behaviour with no obvious cause, and it is also the one you can fix today.

What posix_spawn() already avoids

One further check, because it surprises people who have read the POSIX page: there is no spawn system call in Linux. We grepped every architecture syscall table at v7.1.7 to confirm it. glibc implements posix_spawn() in user space, and its own source comment says how — it “uses the clone syscall directly with CLONE_VM and CLONE_VFORK flags and an allocated stack”, which today means clone3() with a fallback for older kernels.

That matters for a reason the RFC discussion does not draw out. CLONE_VM collapses both memory-side costs to a single mmget(), and CLONE_VFORK is what makes sharing the address space safe, by suspending the calling thread until the child execs or exits. But glibc does not pass CLONE_FILES, so posix_spawn() still copies the descriptor table. Of the three costs above, the C library already removes the first two and leaves the third to you.

The three shapes side by side, which is the comparison the discussion rarely shows:

/* 1. fork() + exec(): pays all three costs */
pid_t pid = fork();
if (pid == 0) {
        execve(path, argv, envp);
        _exit(127);
}

/* 2. posix_spawn(): CLONE_VM | CLONE_VFORK in the C library.
      Removes the mapping walk and the page-table copy.
      Still copies the descriptor table. */
pid_t pid;
posix_spawn(&pid, path, NULL, NULL, argv, envp);

/* 3. The proposed builder. Not in any released kernel.
      Today it is CLONE_VM | CLONE_VFORK internally, so it
      pays what posix_spawn() pays. */
int fd = pidfd_open(0, PIDFD_EMPTY);
pidfd_spawn_run(fd, &run, sizeof(run));

What this costs on an embedded target

The code above is the same on every architecture Linux runs on. dup_mmap(), copy_page_range() and dup_fd() do not vary by platform. What varies is the size of each input, and which consequence you care about — and on one point, whether the system call exists at all.

Without an MMU there is no fork(). On a CONFIG_MMU=n build, fork() returns -EINVAL; the kernel source comments it “can not support in nommu mode”. vfork() carries no such guard, so on no-MMU targets vfork() and posix_spawn() are what you have. For a large class of embedded systems, then, avoiding the fork() cost is not an optimisation to consider; it is already the only path available.

On PREEMPT_RT the cost is jitter, not throughput. The question on a real-time system is not whether a process launches a few percent faster. It is whether a fork() elsewhere on the system injects latency into unrelated real-time work. It does: the fork holds the parent’s mmap_lock for write, allocates, and write-protects page-table entries, which requires TLB shootdown across CPUs. The RFC’s throughput numbers say nothing about this, and the shootdown cost scales with the number of pages being write-protected.

A large daemon spawning small helpers. Media and camera frameworks are the common case: a long-running process with a large heap and many buffer mappings launching short-lived codec or processing helpers. The heap, not the framework, is what makes it expensive. For the large device and dma-buf mappings these processes hold, MADV_DONTFORK is the direct remedy — it sets VM_DONTCOPY so the mapping is skipped entirely. It applies to distinct mmap() regions, so it is a remedy for buffer mappings and not for a large heap.

Descriptor tables are relatively more expensive on small systems. The high-water-mark cost is the same code everywhere, but a descriptor table sized for number 100000 is several hundred kilobytes of pointer array plus bitmaps. On a server that is noise. On a 128 MB target already balancing media buffers, DMA heaps and graphics memory, it is not.

Two things cut the other way, and they are the reason the agent-runtime framing does not transfer directly. Guzik’s main objection to building the child’s state in the parent is that the allocations happen on the parent’s node, so a child intended to run elsewhere starts with its memory on the wrong node; on a single-node SoC that objection does not apply at all. And on a small target booting from eMMC, NAND or SPI-NOR, spawn latency is dominated by ELF loading, dynamic-linker relocation and cold-cache demand paging — the exec half. Even where fork cost is measurable, it is often not the term that decides how long a spawn takes.

The general shape, then: on a BusyBox shell, an init or supervisor, or a watchdog respawning a service, all three cost terms are short and there is nothing here to fix. The cost becomes real when the spawning process is large, when descriptor numbers have drifted high, or when the system has latency requirements rather than throughput requirements. The Android zygote shows the first case is solvable in user space. It starts early, loads the shared libraries and runtime that every application will need, and then waits. Applications are forked from that process rather than from a large parent, so each fork copies a small, known address space instead of whatever the parent has grown into.

The seccomp question nobody has answered

One item in the July series deserves separate attention from anyone shipping a sandboxed product, and the author raised it himself rather than being caught out on it. Seccomp sees pidfd_spawn_run() and cannot dereference the user pointer to inspect the path or the action records behind it. An exec-only denylist that permits unknown syscalls would therefore not block this exec. The cover letter puts it as “policy must filter the builder syscall as a unit”, and ends the paragraph with an open question to reviewers rather than a decision.

The operational consequence is what matters: if a builder syscall is merged, seccomp policies will have to filter it explicitly. A policy that reasons about execve() today will not automatically extend to it, because seccomp cannot inspect the staged path and action data behind the user pointer. That inability is long-standing and documented, so this follows from how the interface is shaped rather than from a bug in the implementation. No reviewer has responded to the question.

What the process-builder API does not do yet

The most useful sentence in the July cover letter is the author’s own assessment of what he has built: “The implementation still uses CLONE_VM | CLONE_VFORK plus exec internally”, and it “does not yet construct a pristine target process without first inheriting source state”. The elided sentence between those two makes clear this was deliberate — Guzik had suggested starting with vfork to begin the implementation, and that is what the RFC does.

Read against the cost model above, that means the process-builder API currently does what posix_spawn() already does, with a cleaner interface: it removes the two memory-side costs and not the descriptor-table copy. The pristine-process work — the part that would remove the remaining cost, argued for by Guzik and by John Ericson, who described a partially initialised process that stays unscheduled while callers install its state — is listed as follow-up. Anyone reading the headline as “process creation is about to get faster” is describing work that has not been written yet.

It is also not close to replacing posix_spawn() functionally. The series lists what is missing: “open and close file actions, resetids, signal masks/defaults, process groups, sessions, scheduler attributes, affinity, cgroup placement, PATH lookup/posix_spawnp(), and exec by fd.”

What we could not verify

Three things here are reported rather than checked, and should be read that way.

We did not measure any of this. The cost model above comes from reading the code at a pinned tag, not from timing it on hardware. Where we say a cost scales with something, that is what the source does, not a curve we plotted.

The benchmark is the author’s own. The first series reported between +2.00% and +4.99% higher tool-call throughput on his own harness against a Python subprocess baseline, with a later thread remark of “about +14% for printf-style work” for very short single-tool runs. We have not reproduced it. No reviewer reproduced it either, and two of them argued the gain was small regardless of the measurement.

The review status is the claim most likely to be out of date by the time you read this. At the time of writing, neither Brauner nor Kees Cook had replied to the July series. The two responses on the list were from Justin Suess, who argued the executable should be passed as a descriptor rather than a path string and questioned whether pidfd_spawn_run() should exist rather than extending execveat(), and Andy Lutomirski, replying to one patch, who flagged that checking whether a task is embryonic must happen before reading its credentials or the read is a security-relevant data race.

On authorship: the July series discloses substantial LLM assistance in its cover letter and carries Assisted-by: trailers on its patches. The May series did not disclose it up front — it came out when Guzik asked directly whether the work was “vibe-coded”, meaning produced from an LLM prompt rather than from a working understanding of the subsystem, and Chen answered “Partly, yes”. We note it because it is on the record and became part of the review discussion. It does not change the engineering question, which is whether the series can justify its interface, its security model and its performance value upstream.

What to do today

None of the proposed interface is available in a released kernel. The cost model is, and it points at six things that do not depend on this RFC landing.

  1. Prefer posix_spawn() over fork() plus exec() when the child setup fits within spawn attributes and file actions. It removes the mapping walk and the page-table copy, which are the two largest costs for most processes. glibc and musl both implement it with CLONE_VM | CLONE_VFORK; check your C library if you ship uClibc-ng or an old Bionic. Two caveats: CLONE_VFORK suspends the calling thread until the child execs or exits, which moves ELF loading and relocation onto your critical path and can be the worse trade on slow flash; and you cannot run arbitrary code between spawn and exec.
  2. Find out what your runtime actually does. strace -f -e trace=clone,clone3,vfork,execve on the spawning process settles in one run whether you are paying for a full fork() or not.
  3. Measure both memory cost terms. wc -l < /proc/PID/maps gives the mapping count and AnonRSS in /proc/PID/status gives the anonymous footprint. A few dozen mappings and a few megabytes of anonymous memory means you have no problem. A few dozen mappings and a gigabyte heap means you do.
  4. Keep descriptor numbers dense. The table is sized by the highest number you hold, not by how many. Close what you do not need before spawning, which close_range() does in one call, and do not raise RLIMIT_NOFILE beyond what you use. This is the one cost posix_spawn() does not remove.
  5. Use MADV_DONTFORK for large inherited mappings that should not cross the spawn boundary. It applies to distinct mmap() regions, so it is a remedy for device and dma-buf buffers rather than for a large heap.
  6. On PREEMPT_RT, evaluate jitter rather than average throughput. The cost that matters there is the latency a spawning process injects into unrelated real-time tasks, which no throughput benchmark will show you.

If a large daemon must launch helpers and none of the above is enough, the structural fix is the zygote pattern described earlier: fork a small helper early and let it do the launching.

What to watch upstream

The signal that this is moving is a non-RFC posting, or an ack from the pidfd or exec maintainers on the lore thread linked below. A reply from Christian Brauner in particular would settle whether the pidfd-based shape survives. The thing to watch for after that is whether the pristine-process work appears, because that, and not the interface, is what would change the numbers.

Was this worth your time?

Frequently asked questions

What is the process-builder API?
It is a proposed Linux interface for constructing a new process step by step and then running it, instead of copying the parent with fork() and replacing that copy with exec(). The current RFC uses pidfd_open with the PIDFD_EMPTY flag to create an empty process handle, pidfd_config() to configure it, and pidfd_spawn_run() to start it.

Can I use the process-builder API today?
Not in a released kernel. It is an unmerged RFC patch series and the design has already changed once, so trying it means applying the patches to linux-next yourself.

Will it make process creation faster?
Not in its current form. The cover letter states the implementation still uses CLONE_VM and CLONE_VFORK plus exec internally and does not yet construct a pristine target process, so it performs about as posix_spawn() already does. The work that would remove the remaining cost is listed as follow-up.

Is fork() slow because it copies all the parent’s memory?
Page contents are not copied, but page tables are, for any private mapping that has been written. So fork cost has two terms: one proportional to the number of mappings, and one proportional to the number of resident pages in anonymous mappings. A large read-only file mapping is nearly free; a large heap is not.

Which of these costs does posix_spawn() actually remove?
Two of the three. CLONE_VM removes the mapping walk and the page-table copy. It does not pass CLONE_FILES, so the descriptor table is still copied and the cost of a high descriptor number remains. Close unneeded descriptors before spawning to deal with that separately.

Does fork cost differ by architecture?
The code does not. dup_mmap(), copy_page_range() and dup_fd() are the same everywhere. What differs is the size of each input and which consequence matters. One real exception: on a CONFIG_MMU=n build fork() returns -EINVAL, so no-MMU targets use vfork() or posix_spawn() instead.

When does this actually cost me on an embedded target?
When the spawning process is large, such as a media daemon with a big heap launching codec helpers; when descriptor numbers have drifted high, since the table is sized by the highest number in use; and on PREEMPT_RT systems, where the TLB shootdown a fork requires injects latency into unrelated tasks on other cores. On a BusyBox shell or a small supervisor all three cost terms are short.

Does Linux have a posix_spawn() system call?
No. There is no spawn system call in the kernel. glibc and musl implement posix_spawn() in user space using the clone family of system calls with CLONE_VM and CLONE_VFORK, which is why it avoids the address space duplication that fork() performs.

References

— Raghu Bharadwaj

RB
Raghu Bharadwaj

Founder, TECH VEDA — 20+ years teaching the Linux kernel, device drivers and embedded systems.

Follow on LinkedIn

Get new posts by email

Kernel, embedded Linux and AI-era engineering — a few sharp reads a month. No spam.

We email occasionally and never share your address.