Ask an operating-systems course why Unix has fork and exec as two calls instead of one, and you will usually get an argument from elegance: separating “make a new process” from “run a new program” leaves a gap in which the child can rearrange its own file descriptors, so redirection and pipelines fall out of the design for free. That answer is correct about the consequences and wrong about the cause. Dennis Ritchie, who was there, gave a different reason in print — the split exists because it was the cheaper thing to build on a PDP-7 in 1969, and he says so without embarrassment.
This is the sharpest available case study of a pattern every architect eventually meets: an implementation shortcut becomes an interface, the interface becomes a standard, and the standard becomes impossible to remove. Fifty years on, fork() is a POSIX guarantee, a security boundary, a performance liability in every large process, and — per a widely cited 2019 HotOS paper — a mistake that should be deprecated and taught as history rather than as lecture one. It is still in the standard. It is still in every shipping Unix. Both of those things are the point.

Ritchie’s Own Answer: It Was Easy to Implement
The primary source is Ritchie’s “The Evolution of the Unix Time-sharing System,” first presented in Sydney in 1979 and published in the AT&T Bell Laboratories Technical Journal in 1984. Listing design decisions he considered worth explaining, he writes:
“The separation of the functions is certainly not unique to Unix, and in fact it was present in the Berkeley time-sharing system, which was well-known to Thompson. Still, it seems reasonable to suppose that it exists in Unix mainly because of the ease with which fork could be implemented without changing much else. The system already handled multiple (i.e. two) processes; there was a process table, and the processes were swapped between main memory and the disk. The initial implementation of fork required only
1) Expansion of the process table
2) Addition of a fork call that copied the current process to the disk swap area, using the already existing swap IO primitives, and made some adjustments to the process table.
In fact, the PDP-7’s fork call required precisely 27 lines of assembly code.”
Read the mechanism, not just the number. The first fork did not invent process duplication, allocate memory, or manage an address space. It reused a swapper that already existed, wrote the parent out to disk, kept the child resident, and patched a table. The PDP-7 had no virtual-memory hardware, so sharing memory between parent and child — the thing a modern reader assumes fork is for — was not available; whole-process swapping was how that machine did multiprocessing at all. fork was the shape of the hardware, exposed as a system call.
Ritchie also explains why the combined call nobody wrote would have been harder: “a combined fork-exec would have been considerably more complicated, if only because exec as such did not exist; its function was already performed, using explicit IO, by the shell.” A fused spawn() in 1969 would have meant inventing program loading as a kernel service first. Two cheap calls beat one expensive one, and the API followed the build cost.
One correction worth making loudly, because a popular summary gets it backwards. The precedent Ritchie names — “the Berkeley time-sharing system” — is Project Genie, which ran on a modified SDS 930 and shipped commercially as the SDS 940. Not the PDP-7, and not Multics. Genie’s fork was the more capable of the two: the parent could specify the address space and machine context for the child, and by default the child shared the parent’s address space, closer to a modern thread than to a Unix process. Crucially, as the HotOS authors point out, Genie had no facility to copy the address space at all — precisely the operation Unix went on to make unconditional. Unix did not inherit copying fork from Genie. It invented copying fork, because copying was what a machine without an MMU could actually do.

What the Split Bought: A Shell That Needs No Kernel Support
None of this makes the design bad in its own era. The gap between fork and exec is genuinely load-bearing, and the reason is the file-descriptor table: it is per-process, and the child inherits it wholesale. So every system call a process can use to modify its own kernel state becomes, in the window before exec, a call the parent uses to configure the child.
Redirection is then not a kernel feature. It is four ordinary calls in userland:
pid_t pid = fork();if (pid == 0) { /* child */ int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); dup2(fd, STDOUT_FILENO); /* fd 1 now names the file */ close(fd); execvp(argv[0], argv); /* image replaced; fd table survives */ _exit(127);}waitpid(pid, &status, 0);A pipeline is the same trick with pipe() and two children. The kernel has no concept of a shell, a pipeline, or a redirection operator; it exposes duplication, descriptors, and image replacement, and an ordinary unprivileged program composes them into a command language. That is mechanism-not-policy in its purest shipped form, and it is why the shell evolved from sh through csh, ksh, bash, zsh and fish across five decades without a single kernel change.
The HotOS paper concedes this cleanly: fork “needs no arguments, because it provides a simple default for all the state of a new process: inherit it from the parent.” Compare the API Windows chose — CreateProcess takes ten parameters and a struct, because it makes every one of those defaults explicit. In 1970, on a machine with eight 8 KiB pages, inherit-everything was not just cheaper to implement. It was cheaper to use.

What fork() Copies, What It Shares, and What It Silently Drops
Inherit-everything sounds like one rule. It is not a rule; it is an enumeration, and the enumeration is the whole problem. In the POSIX.1-2024 fork() specification — checked August 2026 — the DESCRIPTION runs to roughly two dozen bullet points about which pieces of process state are duplicated, which are shared, and which are silently discarded.
| Category | POSIX behaviour on fork() | The trap it sets |
|---|---|---|
| Address space | Copied (copy-on-write in every real implementation) | Cost tracks size and layout, not bytes actually written |
| File descriptors | Duplicated, but they refer to the same open file description | The child shares the parent’s file offset — interleaved writes, moving reads |
FD_CLOFORK descriptors | Closed in the child — new in POSIX.1-2024 | A flag that exists only because threads + fork leak descriptors |
MAP_SHARED mappings | Shared — writes are mutually visible | Looks like isolation, isn’t |
MAP_PRIVATE mappings | Copied | Silent memory-accounting surprise on write |
| Signal handlers | Inherited | A handler in the child that assumes parent-only invariants |
| Pending signals | Cleared — the child’s pending set starts empty | A signal in flight is lost at exactly the wrong moment |
| Record/file locks | Not inherited | The child holds the data but not the lock protecting it |
| Interval timers, alarms, POSIX timers | Reset | Watchdogs quietly stop existing in the child |
| Asynchronous I/O operations | Not inherited | In-flight AIO vanishes with no error path |
semadj values | Cleared | Semaphore cleanup accounting resets |
Memory locks (mlock, mlockall) | Not inherited | Real-time and secret-holding code loses its guarantee |
CPU-time clock, tms fields | Zeroed | Per-process accounting restarts |
| Threads | Only the calling thread exists | Every other thread’s invariant is now frozen mid-flight |
That last row is the one that ends careers. POSIX states it directly: “A process shall be created with a single thread.” Every other thread in the parent simply does not exist in the child — but every lock they held, every allocator arena they were halfway through, and every buffer they had partly filled is faithfully copied. The standard’s own advice is that “the application shall ensure that the child process only executes async-signal-safe operations” until exec succeeds, and malloc() is not on that list.
The table is also still growing, which is the ossification argument in miniature. POSIX.1-2024 — Issue 8 — added FD_CLOFORK and O_CLOFORK, a close-on-fork flag whose entire reason for existing is that one thread can fork immediately after another thread opened a descriptor, leaking it into a child with no way to know it happened. Fifty-five years in, the standard is still shipping patches to a 27-line shortcut.

The Modern Indictment: A fork() in the Road
The reference argument is “A fork() in the road” by Andrew Baumann (Microsoft Research), Jonathan Appavoo and Orran Krieger (Boston University), and Timothy Roscoe (ETH Zurich), presented at HotOS XVII in Bertinoro, 13–15 May 2019 (DOI 10.1145/3317550.3321435). Their thesis: “fork was a clever hack for machines and programs of the 1970s that has long outlived its usefulness and is now a liability.”
Their catalogue, condensed:
- Fork is no longer simple. “The POSIX specification now lists 25 special cases in how the parent’s state is copied to the child: file locks, timers, asynchronous IO operations, tracing, etc.” Every new OS facility must specify its own fork behaviour, and Linux has accumulated flags —
MADV_DONTFORK,MADV_WIPEONFORK,FD_CLOEXEC,pthread_atfork()— purely to manage it. “The simplicity and orthogonality of fork is now a myth.” - Fork isn’t thread-safe. The canonical failure: one thread holds the allocator’s heap lock while another calls
fork. The child inherits a locked mutex and no thread that will ever unlock it, so its firstmalloc()deadlocks permanently. - Fork doesn’t compose. Buffered I/O is the textbook case — flush before forking or the buffered bytes are emitted twice, once per process. More generally, “unless every layer supports fork, it cannot be used.” Cygwin’s emulation of
forkon top ofCreateProcessfails most often when address-space layout randomisation puts the parent’s and child’s mappings at different addresses. The authors note the irony: the NT kernel supports fork natively; the Win32 API Cygwin depends on does not. - Fork is insecure. Inherit-by-default inverts least privilege — the programmer must remember to remove every descriptor, secret, and namespace the child should not have. Worse, “programs that fork but don’t exec render address-space layout randomisation ineffective, since each process has the same memory layout,” the property Blind ROP attacks (Bittau et al., IEEE S&P 2014) turn into a remote exploit primitive against pre-forking servers.
- Fork is slow. Cost tracks the parent’s address space, not the child’s needs. Measuring fork+exec against
posix_spawnon Ubuntu 16.04.3 (Intel i7-6850K, 3.6 GHz), the fork+exec curves climb steadily with parent size whileposix_spawn“takes the same time (around 0.5 ms) regardless of the parent’s size or memory layout.” They cite Chrome delays of up to 100 ms insidefork, and Node.js applications blocked for seconds. - Fork doesn’t scale. Per Clements et al.’s scalable commutativity rule, “the mere specification of the fork API introduces a bottleneck, because (unlike spawn) it fails to commute with other operations on the process.” Not an implementation defect a better kernel could fix — a property of the interface.
- Fork encourages memory overcommit. A conservative implementation would reserve backing store for every copy-on-write page it might fault in, failing large
forks about toexecsomething tiny. Linux overcommits instead, relocating the failure into the out-of-memory killer — which is why Redis, which forks for persistence, advises against disabling overcommit. - Fork is hostile to modern hardware. It conflates a process with a single address space on a single core, so a process holding kernel-bypass NIC or GPU state “cannot safely fork since the OS cannot duplicate the process state on the NIC/GPU.”
Their summary lands harder than any single charge: “Fork today is a convenient API for a single-threaded process with a small memory footprint and simple memory layout that requires fine-grained control over the execution environment of its children but does not need to be strongly isolated from them. In other words, a shell.” Then the question the paper turns on — “Is it still a good idea to optimise the OS API for the shell’s convenience?”
Two honest qualifications. HotOS is a position-paper venue and this is a position paper: influential and well argued, not a consensus finding. And fork remains in POSIX.1-2024, in Linux, in every BSD, and in macOS. The argument has moved practice at the edges. It has not removed the call.

Copy-on-Write Is a Patch, Not a Fix
The usual rebuttal is that copy-on-write already solved this. It did not. It made the problem survivable, which is a different and more dangerous outcome.
By 1979 — when 3BSD introduced vfork() — fork was already understood as a performance problem, and copy-on-write is what kept it acceptable through the following decade (Smith and Maguire measured exactly this in Computing Systems 1(3), 1988). But COW does not make fork free. It replaces “copy N megabytes” with “walk the parent’s page tables, downgrade every writable mapping to read-only, and shoot down the TLB” — work proportional to how fragmented the address space is, not to how much the child will touch. A large process with a JIT, shared libraries, and ASLR pays the most and uses the least.
Then the second-order effect: because COW made fork tolerable, applications began depending on fork specifically to get copy-on-write memory. Android’s Zygote pre-initialises a runtime and forks app processes from it. Redis forks to snapshot for persistence. Reverse debuggers and fuzzers fork to checkpoint an address space. None of those call exec at all. They are not using process creation; they are using the only address-space-snapshot primitive POSIX ever shipped, and paying for a process they did not want in order to get it.
That is the shape of the trap. An optimisation that rescues a bad interface does not retire it — it entrenches it, then grows a second constituency dependent on the side effect. The paper’s own suggestion is to break the coupling: expose copy-on-write snapshots directly, through something like the checkpoint()/resume() calls Bittau proposed or the snapshot() API Xu et al. proposed for fuzzing, so that wanting a memory snapshot stops implying wanting a process.

The Alternatives, and What Each One Concedes
vfork() was BSD’s optimisation, introduced in 3BSD around 1979. It creates a child that shares the parent’s address space and blocks the parent until the child execs or exits — closer to Genie’s original behaviour than Unix’s own fork. It is fast and treacherous: anything the child does to memory or to the shared stack before exec corrupts the parent, as Rich Felker’s 2012 note “vfork considered dangerous” catalogues. The Linux man page records the standards outcome — marked obsolescent in POSIX.1-2001, removed from the specification in POSIX.1-2008. It survives in implementations anyway, because glibc uses the same trick internally, clone() with CLONE_VM|CLONE_VFORK, to make posix_spawn fast.
posix_spawn() is the standardised answer, and its origin is not what most engineers assume. The POSIX.1-2024 rationale says it exists because “the fork() function is difficult or impossible to implement without swapping or dynamic address translation” — it was designed for real-time and embedded systems with no MMU, first released in Issue 6 and derived from IEEE Std 1003.1d-1999. Its advantage on big servers was a side effect of a portability requirement. The spec notes it “is implementable as a library routine, but both posix_spawn() and posix_spawnp() are designed as kernel operations”; Solaris made it a native system call in 2018. The concessions are real: it cannot set terminal attributes or switch to an isolated namespace, its error reporting for pre-exec failures is asynchronous and indistinguishable from the child exiting, and — the deepest problem — it “is specified as if it were implemented by fork and exec, although it is not actually required” to be. The replacement is still shaped by the thing it replaces.
clone() is Linux’s generalisation, descended from Plan 9’s rfork(), underlying every process and thread creation on the platform. Its flag set — CLONE_VM, CLONE_FILES, CLONE_NEWNS, CLONE_NEWPID and the rest — makes explicit what fork leaves implicit, and it is what containers are built out of. But the HotOS authors are unsparing: for each resource there are still only two options, share or copy, so “clone suffers most of the same problems as fork.” Being explicit about a bad choice does not make it a good one.
pdfork() is FreeBSD’s contribution, part of Capsicum since FreeBSD 9.0 (TrustedBSD; Watson and Anderson at Cambridge). It returns a process descriptor — a file descriptor naming the child — so process lifetime becomes ordinary fd lifetime, and the child is waited on and killed through the descriptor rather than a globally visible PID. It fixes the naming problem, not the copying problem.
CreateProcess() made the opposite bet outright. There is no duplication step: you name an image and specify the child’s state explicitly, through ten parameters plus a STARTUPINFO struct. Inheritance is a single boolean — bInheritHandles — documented as “If this parameter is TRUE, each inheritable handle in the calling process is inherited by the new process.” Handles are opt-in at creation and filterable per process. The API is uglier at every call site, and it has no thread-safety hazard, no address-space cost, no overcommit pressure, and no ASLR bypass.
fork() + exec() | posix_spawn() | CreateProcess() | clone() | |
|---|---|---|---|---|
| Default child state | Inherit everything | Inherit, minus declared file actions | Nothing, except what you pass | Per-resource flag: share or copy |
| Where child config happens | Code running in the child | Attribute objects built before the call | Arguments to one call | Flags to one call |
| Cost vs. parent size | Scales with size and fragmentation | Flat (~0.5 ms in the HotOS measurement) | Flat | Scales, when copying |
| Safe from a threaded parent | No | Yes | Yes | Only with CLONE_VM semantics understood |
| ASLR preserved in the child | No, if it never execs | Yes | Yes | No, when sharing/copying the address space |
| Error reporting | Precise — child code returns real errnos | Asynchronous, ambiguous with child exit | Synchronous return + GetLastError | Synchronous |
| Expressiveness | Total — any syscall, between the two calls | Limited (no tty attrs, no namespace switch) | Broad but fixed at the call site | Broad, Linux-specific |
| Portability | POSIX, universal | POSIX, universal | Windows only | Linux only |

The Interface Won Anyway — Then Started Quietly Retreating
Adoption settled the argument long before the argument was made. Atlidakis et al. (EuroSys 2016) counted 1,304 Ubuntu packages — 7.2% of the total — calling fork, against 41 uses of posix_spawn. Every Unix shell, plus Apache, PostgreSQL, Oracle, Chrome, Redis and Node.js, sits on the fork side of that ledger.
The interesting movement is at the language and application layer, where the cost is felt first. Python’s multiprocessing documentation now records three separate steps back: spawn became the default on macOS in 3.8, because forking “can lead to crashes of the subprocess as macOS system libraries may start threads”; 3.12 made os.fork() raise a DeprecationWarning when Python detects multiple threads; and in 3.14, fork “is no longer the default start method on any platform” — Linux moved to forkserver, and code that needs fork must now ask for it by name. A mainstream standard library has demoted the default process-creation primitive of the platform it runs on.
PostgreSQL shows the same tension structurally. Its postmaster forks a backend per connection — which is exactly why connection poolers are standard equipment in front of it — and on Windows, where there is no fork, the same server builds each child through CreateProcess and hands over state explicitly (the EXEC_BACKEND path, which is why background workers cannot pass pointers between processes). The HotOS authors draw the obvious inference: “major applications that fork (e.g., Apache, Chrome, PostgreSQL) have Windows ports that don’t, so fork is clearly not essential.”
Their closing recommendation is pedagogical, and it is the line most worth arguing with: “Just as a programming course would not today begin with goto, we suggest teaching either posix_spawn() or CreateProcess(), and then introducing fork as a special case with its historic context.”

The Transferable Lesson: Ease of Implementation Is Distribution, Not Design
Strip the Unix specifics and the sequence is one every architect will recognise, usually too late.
Something is trivial to implement, so it ships. Because it shipped first and cost nothing, it gets adopted. Because it is adopted, it gets standardised. Once standardised, its semantics become a compatibility obligation, and every subsequent feature must define its behaviour with respect to it — which is how you get from 27 lines to a two-dozen-bullet enumeration in a specification, with new bullets still arriving in 2024. At no point does anyone re-evaluate the original trade. It was made against 1969 hardware and never reopened, because by the time reopening it was worth doing, it had become impossible.
The confusion at the root is specific: ease of implementation is a distribution advantage, not a design virtue, and the two are constantly mistaken for each other. A cheap API spreads faster than a good one, and its spread is then read backwards as evidence of quality — the HotOS authors found every OS textbook they reviewed describing fork “in uncritical or positive terms, often noting its ‘simplicity’ compared to alternatives.” Simplicity of implementation had been silently upgraded to simplicity of use, decades after the implementation in question stopped existing.
Three questions worth asking about any interface you are about to expose:
- Am I exposing this because it is right, or because it is what I happen to have? Ritchie’s answer for fork was the second, and he said so in print. Most designers do not.
- What is the enumeration? Any API whose contract is “inherit everything” has an implicit list of exceptions. That list is invisible on day one and is the entire specification on day ten thousand. Write it down at the start, and watch whether it grows.
- Would this pass review as a new proposal today? The paper’s sharpest sentence is a thought experiment: “It is hard to imagine a new proposed syscall with these properties being accepted by any sane kernel maintainer.” Run your own load-bearing interfaces through it. Some will not survive — and you will keep them anyway, which is the honest half of the lesson.
None of this means fork should never have existed. On 24K of core with no memory-translation hardware it was the right call, and the composability it accidentally unlocked is genuinely beautiful. The point is that shipping cheap is how a design wins, and winning is how it stops being changeable. Assume anything you expose because it was easy will outlive every reason you had for exposing it.

❓ Frequently Asked Questions
Why does Unix have fork and exec as two calls instead of one spawn()? Because on the PDP-7 in 1969 fork could reuse machinery the system already had — the process table and the swapper — in 27 lines of assembly, while a combined call would have meant inventing exec first, since program loading was done by the shell with explicit I/O rather than by the kernel. Ritchie states this reasoning explicitly in his 1979/1984 evolution paper. The composability benefit is real, but it arrived as a consequence, not as the motivation.
Was the Berkeley Genie system the reason Unix copies the address space? No, and this is the most common error in summaries of the topic. Project Genie ran on the SDS 930/940, not the PDP-7, and its fork was more flexible: the parent could specify the child’s address space and machine context, and by default the child shared the parent’s memory. Genie had no facility to copy an address space at all. Unconditional copying is a Unix invention, forced by a machine with no virtual-memory hardware.
Is fork() actually deprecated? Not in POSIX. It is fully specified in POSIX.1-2024 and implemented by every Unix-family kernel shipping today. What has been deprecated is vfork(), removed from the specification in POSIX.1-2008 — and, separately, fork as a default in some language runtimes: Python’s multiprocessing stopped defaulting to it on macOS in 3.8 and on every platform in 3.14. The HotOS 2019 paper argues for deprecating fork itself; that is an argument with named authors, not a standards decision.
Should I replace fork() + exec() with posix_spawn()? For the ordinary case — a program starting another program, especially from a process with threads or a large heap — yes, and the measured gap grows with your address-space size. The exceptions are specific: posix_spawn cannot set terminal attributes or move the child into an isolated namespace, and its error reporting for failures before the child starts is asynchronous and hard to distinguish from the child exiting on its own.
Why is forking from a multithreaded process unsafe? POSIX creates the child with a single thread — a copy of the one that called fork — but faithfully copies all the memory the other threads were operating on, including locks they held. If another thread held the allocator’s lock at the moment of the fork, that lock stays locked forever in the child and the first malloc() deadlocks. POSIX only guarantees async-signal-safe functions between fork and exec, and malloc() is not one of them.
Does copy-on-write make fork() cheap? It makes it survivable. The remaining cost scales with the parent’s address-space size and fragmentation — walking page tables, downgrading writable mappings, invalidating TLB entries — not with what the child touches. It also pushes systems toward memory overcommit, converting a clean fork failure into a later out-of-memory kill. And because COW made fork tolerable, Redis and Android’s Zygote came to depend on it purely as an address-space snapshot primitive, entrenching it further.
📚 Further Reading
The primary sources are all free. Ritchie’s “The Evolution of the Unix Time-sharing System” carries the 27-lines passage in full and is the single most useful document on why Unix has the shape it has; the 1974 Ritchie and Thompson CACM paper is the original system description. POSIX.1-2024 is free to read in full at The Open Group — the fork(), posix_spawn() and exec pages repay an hour each. “A fork() in the road” is nine pages and openly available, and Operating Systems: Three Easy Pieces is free online with the standard modern treatment of the process API.
Within this site: what a container actually is picks up where clone()‘s namespace flags leave off. Chromium’s architecture is one of the largest fork-dependent codebases in the world and one of the loudest sources of fork-latency complaints. Postgres as a vector database runs on the process-per-connection model this article’s cost analysis applies to directly. Anti-corruption layers are what you reach for when an interface has ossified and cannot be removed — the practical sequel to this lesson. And reading code is the new superpower skill is the general case of what makes this story possible: the 27 lines are still readable, in the archive, today.
📚 Recommended Resources
Disclosure: some links below are Amazon affiliate links. If you buy through them, I earn a small commission — at no extra cost to you — which helps keep this site running.
Operating Systems: Three Easy Pieces — free, in full, at ostep.org
Remzi H. and Andrea C. Arpaci-Dusseau · Book (free online)
Start here, and start with a specific chapter: the process API. It is the book the HotOS authors quote when they argue that teaching fork first is counterproductive — “the Unix designers simply got it right” is a line from this text. Reading the standard modern presentation of fork/exec before reading the argument against it is the fastest way to see how invisible the assumption is. There is no verified Amazon listing for it, so there is no affiliate link here — read it free at ostep.org.
Advanced Programming in the UNIX Environment, 3rd ed.
W. Richard Stevens & Stephen A. Rago · Book
The reference for everything this article touches at the call level: process control, the exact inheritance semantics across fork, exec variants, wait/waitpid, and the interaction between fork and buffered standard I/O — including the classic worked example of output appearing twice because the stream was not flushed. If you keep one systems-programming book within reach, this is it. Make sure you get the 3rd edition (2013); the 2nd is a separate listing and predates a great deal of the material.
The Linux Programming Interface
Michael Kerrisk · Book
The Linux-specific counterpart, by the long-time maintainer of the man-pages project. Its treatment of clone() and its flag set is the best book-length explanation of the mechanism that containers are built from, and it covers vfork‘s hazards and the process-creation cost model in more depth than the standard alone will give you. The man7.org pages cited in this article are the same author’s work, freely available.
The Design and Implementation of the FreeBSD Operating System, 2nd ed.
Marshall Kirk McKusick, George V. Neville-Neil & Robert N. M. Watson · Book
Read this for the implementation side of the argument: how copy-on-write address-space duplication is actually built, what vfork does to the parent’s stack, and — because Robert Watson is one of Capsicum’s authors — how process descriptors and pdfork() fit into the picture. This is the current edition; no third exists.
UNIX: A History and a Memoir
Brian Kernighan · Book
The first-hand account of the period this article opens in, by someone who was in the building. It is the right companion to the Ritchie paper: where the paper explains what was decided, this explains the conditions under which people were deciding — a scavenged PDP-7, 24K of core, and a research group with no mandate to build an operating system at all.
For the code itself rather than books about it, the early Unix sources are legally free: The Unix Heritage Society hosts V1 through V7 and 32V under the BSD-style licence Caldera International granted on 24 January 2002. The 27 lines are in the archive.