Skip to content

Commit a0e9fd8

Browse files
committed
mm: per-child copy-on-write address space for fork() (opt-in, stacked)
Follow-up to the base fork() PR: gives a forked child its own address space with copy-on-write of private mappings, so a forked child has real memory isolation like Linux fork() while MAP_SHARED / shm stays shared. Also gated behind CONFIG_fork (default off): with it disabled none of this is compiled and OSv's single-address-space model and context-switch path are unchanged. With CONFIG_fork enabled: - mmu::address_space object (page-table root + vma_list); the previous global becomes 'address space 0' (kernel + init app). - Per-thread current address space; the context switch loads the target CR3 only when the address space differs (a no-op for AS0-only workloads). Kernel PML4 entries are shared across all address spaces so OSv code + the kernel heap work identically after a switch. - fork() clones the parent's vmas into a child address space: PRIVATE writable mappings are write-protected in both and copied on the first write (reusing OSv's existing COW fault machinery); MAP_SHARED / shm map the same physical pages (truly shared). execve() returns the thread to AS0. - Lock/condvar wait_records for fork-child (non-AS0) threads are allocated from the AS-shared kernel heap instead of the thread stack, so a wait_record queued on a shared kernel mutex resolves to the same physical page for any waker across address spaces (kernel-stack coherence). Validated: tst-fork-cow proves a forked child's private memory stays private while MAP_SHARED stays shared; tst-fork stays 10/10. Known limitation (documented): the child's stack is still relocated+copied rather than same-VA COW, so a child that unwinds a very deep call chain (e.g. a multi-process server forking backends) can still hit a stack-fidelity issue (tst-fork-deep); the same-VA stack fix is a further follow-up. This PR delivers memory-isolated fork for the common cases with COW proven. Copyright (C) 2026 Greg Burd
1 parent 2f125e9 commit a0e9fd8

17 files changed

Lines changed: 891 additions & 15 deletions

File tree

arch/x64/arch-switch.hh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <osv/kernel_config_preempt.h>
1414
#include <osv/kernel_config_threads_default_kernel_stack_size.h>
1515
#include <osv/kernel_config_syscall_stack_size.h>
16+
#include <osv/kernel_config_fork.h>
1617
#include <string.h>
1718
#include "tls-switch.hh"
1819

@@ -93,6 +94,16 @@ void thread::switch_to()
9394
barrier();
9495
set_fsbase(reinterpret_cast<u64>(_tcb));
9596
barrier();
97+
#if CONF_fork
98+
// Address-space (CR3) switch for fork COW. Only touch CR3 when the target
99+
// thread lives in a different address space than the outgoing one, so the
100+
// common single-address-space case pays nothing (and no TLB flush). The
101+
// kernel half of every AS is identically mapped, so the switch code, kernel
102+
// stacks and kernel heap remain valid across the write.
103+
if (_current_as != old->_current_as) {
104+
processor::write_cr3(mmu::pt_root_phys(_current_as));
105+
}
106+
#endif
96107
auto c = _detached_state->_cpu;
97108
old->_state.exception_stack = c->arch.get_exception_stack();
98109
// save the old thread SYSCALL caller stack pointer in the syscall stack descriptor

arch/x64/mmu.cc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,40 @@ void flush_tlb_all()
120120

121121
static pt_element<4> page_table_root __attribute__((init_priority((int)init_prio::pt_root)));
122122

123+
#if CONF_fork
124+
// Virtual pointer to the current thread's PML4 (child AS's private PML4 when
125+
// running in a forked child, else the kernel PML4). Defined in core/mmu.cc.
126+
pt_element<4> *current_pt_root();
127+
123128
pt_element<4> *get_root_pt(uintptr_t virt __attribute__((unused))) {
129+
return current_pt_root();
130+
}
131+
132+
// Virtual pointer to the kernel (AS0) PML4.
133+
pt_element<4> *kernel_pml4() {
124134
return &page_table_root;
125135
}
136+
#else
137+
pt_element<4> *get_root_pt(uintptr_t virt __attribute__((unused))) {
138+
return &page_table_root;
139+
}
140+
#endif // CONF_fork
126141

127142
void switch_to_runtime_page_tables()
128143
{
129144
processor::write_cr3(page_table_root.next_pt_addr());
130145
}
131146

147+
#if CONF_fork
148+
// Physical address of the kernel (AS0) PML4 -- the CR3 value that maps OSv
149+
// text/data + the identity/phys ranges. Used as the shared base for cloned
150+
// child address spaces and as AS0's pt_root.
151+
phys kernel_pt_root_phys()
152+
{
153+
return page_table_root.next_pt_addr();
154+
}
155+
#endif // CONF_fork
156+
132157
enum {
133158
page_fault_prot = 1ul << 0,
134159
page_fault_write = 1ul << 1,

conf/Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,15 @@ CONF_FILES := conf/base.mk conf/$(mode).mk conf/$(arch).mk conf/profiles/$(arch)
5656

5757
#Generate the default .config, gen/config/kernel.conf, and gen/include/osv/kernel_config.h if not generated yet
5858
$(out)/.config: $(CONF_FILES) $(out)/kbuild/kconfig/conf
59-
$(call quiet, mode=$(mode) arch=$(arch) CONFIG_=CONF_ KCONFIG_AUTOHEADER=$(out)/gen/include/osv/kernel_config.h KCONFIG_AUTOCONFIG=$(out)/gen/config/kernel.conf KCONFIG_RUSTCCFG=$(out)/gen/include/osv/rustc_cfg KCONFIG_CONFIG=$(out)/.config $(out)/kbuild/kconfig/conf -s conf/kconfig/main --alldefconfig, CONF_DEF $(out)/.config)
59+
$(call quiet, mode=$(mode) arch=$(arch) conf_fork=$(conf_fork) CONFIG_=CONF_ KCONFIG_AUTOHEADER=$(out)/gen/include/osv/kernel_config.h KCONFIG_AUTOCONFIG=$(out)/gen/config/kernel.conf KCONFIG_RUSTCCFG=$(out)/gen/include/osv/rustc_cfg KCONFIG_CONFIG=$(out)/.config $(out)/kbuild/kconfig/conf -s conf/kconfig/main --alldefconfig, CONF_DEF $(out)/.config)
6060

6161
#Generate the .config.yes, gen/include/osv/kernel_yes_config.h with all available options
6262
$(out)/.config.yes: $(CONF_FILES) $(out)/kbuild/kconfig/conf
6363
$(call quiet, mode=$(mode) arch=$(arch) CONFIG_=CONF_ KCONFIG_AUTOHEADER=$(out)/gen/include/osv/kernel_yes_config.h KCONFIG_AUTOCONFIG=$(out)/gen/config_yes/kernel_yes.conf KCONFIG_RUSTCCFG=$(out)/gen/include/osv/rustc_cfg KCONFIG_CONFIG=$(out)/.config.yes $(out)/kbuild/kconfig/conf -s conf/kconfig/main --allyesconfig, CONF_YES $(out)/.config.yes)
6464

6565
#Synchronize gen/include/osv/kernel_config.h and gen/config/kernel.conf with .config if the latter has changed maybe by mconf
6666
$(out)/gen/include/osv/kernel_config.h: $(out)/.config
67-
$(call quiet, mode=$(mode) arch=$(arch) CONFIG_=CONF_ KCONFIG_AUTOHEADER=$(out)/gen/include/osv/kernel_config.h KCONFIG_AUTOCONFIG=$(out)/gen/config/kernel.conf KCONFIG_RUSTCCFG=$(out)/gen/include/osv/rustc_cfg KCONFIG_CONFIG=$(out)/.config $(out)/kbuild/kconfig/conf -s conf/kconfig/main --syncconfig, SYNC $(out)/.config)
67+
$(call quiet, mode=$(mode) arch=$(arch) conf_fork=$(conf_fork) CONFIG_=CONF_ KCONFIG_AUTOHEADER=$(out)/gen/include/osv/kernel_config.h KCONFIG_AUTOCONFIG=$(out)/gen/config/kernel.conf KCONFIG_RUSTCCFG=$(out)/gen/include/osv/rustc_cfg KCONFIG_CONFIG=$(out)/.config $(out)/kbuild/kconfig/conf -s conf/kconfig/main --syncconfig, SYNC $(out)/.config)
6868

6969
#Generate gen/config/kernel_conf.mk AND individual option headers gen/include/osv/kernel_config_* based on the latest gen/config/kernel.conf and gen/include/osv/kernel_config.h
7070
#The gen/config/kernel_conf.mk is included by the main OSv makefile and the headers gen/include/osv/kernel_config_* included by relevant source files

conf/kconfig/threads

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ config lazy_stack
88

99
config fork
1010
prompt "Include fork()/vfork() support (per-child address space, off by default)"
11-
bool
12-
default n
11+
def_bool $(shell,[ "$conf_fork" = 1 ] && echo y || echo n)
1312
help
1413
Enable OSv's thread-backed fork()/vfork()/execve()/waitpid() emulation,
1514
including the per-child address space with copy-on-write needed to give a

core/condvar.cc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <osv/export.h>
1414
#include <osv/kernel_config_lazy_stack.h>
1515
#include <osv/kernel_config_lazy_stack_invariant.h>
16+
#include <osv/kernel_config_fork.h>
1617

1718
TRACEPOINT(trace_condvar_wait, "%p", condvar *);
1819
TRACEPOINT(trace_condvar_wake_one, "%p", condvar *);
@@ -22,7 +23,15 @@ int condvar::wait(mutex* user_mutex, sched::timer* tmr)
2223
{
2324
trace_condvar_wait(this);
2425
int ret = 0;
26+
#if CONF_fork
27+
// A fork child (non-AS0) heap-allocates its wait_record so it stays
28+
// coherent when the parent (different address space) walks this shared
29+
// condvar's queue. AS0 keeps the on-stack fast path.
30+
coherent_wait_record wr_holder(sched::thread::current());
31+
wait_record &wr = wr_holder.get();
32+
#else
2533
wait_record wr(sched::thread::current());
34+
#endif
2635

2736
_m.lock();
2837
if (!_waiters_fifo.oldest) {

core/lfmutex.cc

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@
1010
#include <osv/sched.hh>
1111
#include <osv/wait_record.hh>
1212
#include <osv/export.h>
13+
#include <osv/kernel_config_fork.h>
14+
#if CONF_fork
15+
#include <osv/mmu.hh>
16+
#endif
17+
18+
#if CONF_fork
19+
// See wait_record.hh: true iff the current thread runs in a forked-child
20+
// (non-AS0) address space, in which case a wait_record queued on a shared
21+
// kernel object must be heap-allocated to stay coherent cross-address-space.
22+
bool fork_child_needs_heap_wait_record()
23+
{
24+
auto t = sched::thread::current();
25+
return t && t->address_space() &&
26+
t->address_space() != mmu::kernel_address_space();
27+
}
28+
#endif
1329

1430
namespace lockfree {
1531

@@ -51,7 +67,15 @@ void mutex::lock()
5167
// when another thread releases the lock.
5268
// Note "waiter" is on the stack, so we must not return before making sure
5369
// it was popped from waitqueue (by another thread or by us.)
70+
#if CONF_fork
71+
// A fork child (non-AS0) gets a heap-allocated wait_record so it stays
72+
// coherent when the parent, in a different address space, dereferences it
73+
// off this shared kernel mutex. AS0 keeps the on-stack fast path.
74+
coherent_wait_record waiter_holder(current);
75+
wait_record &waiter = waiter_holder.get();
76+
#else
5477
wait_record waiter(current);
78+
#endif
5579
waitqueue.push(&waiter);
5680

5781
// The "Responsibility Hand-Off" protocol where a lock() picks from

0 commit comments

Comments
 (0)