|
| 1 | +# fork() on OSv |
| 2 | + |
| 3 | +OSv is a single-address-space unikernel: all threads of all applications share |
| 4 | +one address space, with no MMU-enforced process isolation. This is deliberate, |
| 5 | +it is where OSv's performance comes from. It also means Linux `fork()`, which |
| 6 | +gives the child a **private copy-on-write duplicate of the parent's entire |
| 7 | +address space**, cannot be implemented literally. |
| 8 | + |
| 9 | +OSv instead provides a **thread-backed fork() emulation** that supports the |
| 10 | +common, compatible subset of fork semantics. This document describes what works, |
| 11 | +what does not, and why. |
| 12 | + |
| 13 | +## Off by default: the `fork` configure flag |
| 14 | + |
| 15 | +All of the fork() machinery - the fork()/vfork()/execve()/waitpid() |
| 16 | +implementations, the per-child address space, and the copy-on-write changes - |
| 17 | +is gated behind the `CONFIG_fork` kconfig option (make variable `conf_fork`), |
| 18 | +which defaults to **n**. When it is disabled (the default), NONE of this code |
| 19 | +is compiled into the kernel: the fork object files are excluded from the build, |
| 20 | +fork()/vfork() return ENOSYS as before, and OSv behaves exactly as it did with |
| 21 | +no change to its single-address-space model or performance. Build with |
| 22 | +`conf_fork=1` (or enable "Include fork() support" in `make menuconfig`) only for |
| 23 | +workloads that need fork(). This keeps the default OSv unchanged while offering |
| 24 | +fork() to those who require it. |
| 25 | + |
| 26 | +## What works |
| 27 | + |
| 28 | +- **`fork()` twin return.** `fork()` creates a new OSv thread (the "child") that |
| 29 | + resumes at the `fork()` call site. `fork()` returns the child's pid to the |
| 30 | + parent and `0` to the child, exactly like Linux. The child runs on a **private |
| 31 | + copy of the parent's user stack**, so parent and child have independent local |
| 32 | + variables and call chains after the return. |
| 33 | + |
| 34 | +- **`fork()` + `execve()`.** The child calls `execve()`, which launches the |
| 35 | + requested program as a fresh OSv application (its own ELF namespace) and makes |
| 36 | + the child thread that program's driver; a successful `execve()` never returns, |
| 37 | + as on Linux. The parent is a separate thread and is unaffected. |
| 38 | + |
| 39 | +- **`waitpid()` / `wait4()` / `wait()`.** The parent reaps a child's exit status |
| 40 | + (Linux-encoded, use `WIFEXITED`/`WEXITSTATUS`). `WNOHANG` is supported. |
| 41 | + `SIGCHLD` is raised to the parent when a child exits. |
| 42 | + |
| 43 | +- **`vfork()`.** Maps to `fork()`. Because the child already shares the parent's |
| 44 | + address space, this actually matches vfork's contract ("the child borrows the |
| 45 | + parent's memory until it execs or exits") more faithfully than it matches |
| 46 | + fork's copy contract. |
| 47 | + |
| 48 | +- **`_exit()` / `exit()` in a child** ends only that child "process" (thread / |
| 49 | + app) and records its status for the parent, rather than shutting down the |
| 50 | + whole unikernel (which is what a top-level `exit()` still does). |
| 51 | + |
| 52 | +## What does NOT work (and why) |
| 53 | + |
| 54 | +- **Shared TLS (thread-local storage) - only for apps that install their own |
| 55 | + TCB.** A forked child is a real OSv thread, so it gets its OWN fresh, private |
| 56 | + OSv TLS block (its own `errno` and libc `__thread` state) automatically. For a |
| 57 | + program that uses OSv's (musl-derived) libc the normal way, fork() TLS "just |
| 58 | + works" - the child does not share the parent's TLS. The exception is a program |
| 59 | + that installs its OWN thread pointer via `arch_prctl(ARCH_SET_FS)` (e.g. a |
| 60 | + glibc-ABI binary's `__libc_setup_tls`): OSv records that as `app_tcb`, and the |
| 61 | + fork child currently inherits the SAME `app_tcb` (shared), which collides. The |
| 62 | + fix for such binaries is to build them against OSv's own musl libc instead of |
| 63 | + glibc, so they take the clean per-thread-TLS path. (This was the wall stock |
| 64 | + glibc-built PostgreSQL hit; a musl build avoids it.) |
| 65 | + |
| 66 | +- **Memory isolation.** The child shares the parent's heap and global variables |
| 67 | + (only the stack is copied). A child that **writes** to shared globals or |
| 68 | + heap-allocated data expecting a private copy will affect the parent. Code that |
| 69 | + only reads shared state and writes to its own fds or freshly-`malloc`'d memory |
| 70 | + before `exec`/`_exit` is fine; code that mutates shared state after fork is |
| 71 | + not. This cannot be fixed without adding process isolation to OSv. |
| 72 | + |
| 73 | +- **`fork()` as a memory snapshot** (e.g. Redis `BGSAVE`, a GC that forks to walk |
| 74 | + a frozen heap). These rely on the child seeing a *frozen* copy of the parent's |
| 75 | + memory at fork time. On OSv the child sees live, shared memory. This is a |
| 76 | + silent behavioral difference (it cannot be detected at the syscall boundary) |
| 77 | + and is **unsupported**. |
| 78 | + |
| 79 | +- **Stack-internal pointers.** Because the child's stack is a byte copy of the |
| 80 | + parent's biased to a new address, a pointer stored on the stack that points |
| 81 | + *into the same stack* still points at the parent's stack in the child. Short |
| 82 | + child code paths (the fork+exec and fork+work-then-_exit patterns) do not hit |
| 83 | + this; long-lived divergent children can. A future refinement could scan and |
| 84 | + fix up such pointers. |
| 85 | + |
| 86 | +- **`clone()` with namespace-unshare flags** (`CLONE_NEWNS`, `CLONE_NEWPID`, |
| 87 | + etc.) returns `ENOSYS` — there are no namespaces to unshare. |
| 88 | + |
| 89 | +- **aarch64.** Implemented and validated: the stack-copy + `br` resume |
| 90 | + trampoline works on aarch64 as well as x86-64. `tst-fork` passes |
| 91 | + 10/10 on both architectures. |
| 92 | + |
| 93 | +## Implementation |
| 94 | + |
| 95 | +- `libc/process/fork.cc` — `fork()`/`vfork()`, the child registry, and the |
| 96 | + `waitpid()` backend + `SIGCHLD` notification. |
| 97 | +- `arch/x64/fork.cc` — `fork_thread()`: allocates a fresh stack, copies the |
| 98 | + parent's current user stack into it, and returns a child thread that installs |
| 99 | + the copied stack and returns `0` from `fork()`. Reuses the register/ |
| 100 | + continuation approach of `clone_thread()` (used by `pthread_create`). |
| 101 | +- `libc/process/execve.cc` — `execve()` via `osv::application::run()`. |
| 102 | +- `libc/process/waitpid.cc` — `wait`/`waitpid`/`wait4`. |
| 103 | +- `runtime.cc` — `exit()` ends a child rather than shutting down OSv. |
| 104 | +- `linux.cc` — `sys_clone()` routes the non-`CLONE_THREAD` (fork) case here. |
| 105 | + |
| 106 | +## Guidance |
| 107 | + |
| 108 | +For spawning helper programs, prefer `posix_spawn()` or `system()` (which route |
| 109 | +straight to `osv::application::run()` and skip the stack copy entirely) over |
| 110 | +`fork()`+`exec()` where you control the code. Use `fork()` for compatibility |
| 111 | +with existing Linux programs that expect it, within the limitations above. |
0 commit comments