Linux clone3: Negotiate the Versioned Process-Creation ABI
Use Linux clone3() safely by negotiating clone_args, distinguishing syscall errors, and validating process, stack, pidfd, and cgroup choices at runtime.
Linux clone3() replaces the older system call’s long positional argument list with a size-versioned struct clone_args. That change makes the interface easier to extend, but it also creates a compatibility boundary: headers can describe fields that the running kernel does not implement, seccomp can reject a syscall that the kernel supports, and combinations of flags can change process, thread, namespace, descriptor, and signal behavior.
Treat clone3() as a Linux-specific process-creation ABI, not as a drop-in replacement for fork() or the portable POSIX fork() interface. It was added in Linux 5.3. The glibc API documentation states that glibc does not provide a clone3() wrapper, so callers use syscall(2) or a maintained runtime that handles the ABI and its fallbacks. For ordinary child creation, fork() remains simpler. For threads, use a threading library rather than inventing a clone3() lifecycle.
Understand the versioned structure
clone_args groups the flags, exit signal, stack information, TLS, child and parent TID locations, optional pidfd output, and newer features such as PID namespace selection and cgroup placement. The argument size lets the kernel distinguish the part of the structure that a caller knows. For example, set_tid fields arrived in Linux 5.5 and the cgroup field for CLONE_INTO_CGROUP arrived in Linux 5.7; compiling against a recent header does not make those features available on an older kernel.
Initialize the complete structure to zero, set only fields required by the requested operation, and pass the size of the structure represented by the build headers. The kernel can accept a larger userspace structure when bytes beyond its own known size are zero; nonzero unknown trailing data can produce E2BIG. A size larger than the system page size is also invalid. This forward-compatibility rule is not permission to set fields speculatively: a program should request only features it can verify and should keep a correct fallback when an optional feature is absent.
The fields are 64-bit ABI values, including fields that carry userspace addresses. Use the definitions from the target architecture’s Linux UAPI headers; do not hand-roll a layout from a blog post or copy a structure from an unrelated architecture. The ordering of arguments and flag encoding in legacy clone() differs from clone3(): clone3() places the exit signal in its own field rather than packing it into the low byte of the flags argument.
Test the simplest fork-like case
This bounded program asks for a child with SIGCHLD exit notification and then waits for it. It uses no namespace or shared-memory flags, so it does not require a separately allocated child stack. Build and run it only in a disposable Linux test environment with headers that define struct clone_args and SYS_clone3.
#define _GNU_SOURCE
#include <errno.h>
#include <linux/sched.h>
#include <signal.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
#ifndef SYS_clone3
fputs("build headers do not define SYS_clone3\n", stderr);
return 77;
#else
struct clone_args args = {0};
args.exit_signal = SIGCHLD;
long result = syscall(SYS_clone3, &args, sizeof(args));
if (result == -1) {
int error = errno;
perror("clone3");
return error == ENOSYS ? 77 : 1;
}
if (result == 0)
_exit(23);
int status;
if (waitpid((pid_t)result, &status, 0) == -1) {
perror("waitpid");
return 1;
}
if (!WIFEXITED(status) || WEXITSTATUS(status) != 23)
return 1;
puts("clone3 child created and reaped");
return 0;
#endif
}
The parent receives the child’s PID; the child sees a return value of zero and exits through _exit(). The parent remains responsible for reaping the child. A successful call therefore validates one narrow creation-and-wait path, not namespace setup, pidfd delivery, cgroup placement, or thread semantics. The example deliberately does not use CLONE_VM: with a shared address space, the caller must provide a valid independent stack and coordinate memory lifetime with the child.
Diagnose errors without hiding the cause
Read errno before logging or making another library call. ENOSYS means the syscall is not available through that execution path, but a seccomp filter can deliberately return an error for a supported system call, including ENOSYS. EPERM or EACCES can point to a policy or cgroup-permission boundary. E2BIG is a structure-size or unsupported-field signal; EINVAL can indicate invalid flag combinations or argument relationships. Preserve the kernel release, architecture, exact clone_args size, requested nonzero fields, and seccomp/container context with the failure report.
Fallback policy must preserve the original semantics. If the code requested placement into a cgroup as part of creation, falling back to a plain fork() and moving the process afterward is not equivalent: the child can run before the move. If it requested a pidfd to avoid a PID-reuse race, a PID-only fallback changes the process-reference guarantee. Either fail closed for a required property or implement and test a fallback that establishes the same invariant before the child can perform work.
Use clone3() when a specific field or creation-time operation provides a real capability that simpler APIs do not. Keep flags minimal, test each feature on the oldest kernel you support, and verify the resulting process from the parent and child. A successful syscall is a creation event; it is not evidence that the requested service policy, resource membership, or isolation boundary is correct.
Related:
- Fixing Zombie Processes That Won’t Clear from the Process Table
- Linux Namespaces: The Kernel Primitive Behind Every Container
Sources: