Operating Systems Module 3 – Threads and concurrency models
Threads and threading models
What the threads of a process share and what each owns, how user and kernel threads map, how Linux makes threads with clone, and what one costs.
What you will learn
- List what the threads of one process share and what each thread owns, and predict what a change in one thread affects
- Compare user-level and kernel-level threads and the many-to-one, one-to-one and many-to-many models
- Explain how Linux makes a thread as a task that clone lets share its parent's address space and thread group
- Measure the cost of creating a thread against creating a process, and weigh it against isolation
Before you start
What you’ll learn · Before you start
What you will learn
- List what the threads of one process share and what each thread owns, and predict what a change in one thread affects
- Compare user-level and kernel-level threads and the many-to-one, one-to-one and many-to-many models
- Explain how Linux makes a thread as a task that clone lets share its parent's address space and thread group
- Measure the cost of creating a thread against creating a process, and weigh it against isolation
Before you start
On this page
In this lesson’s recorded run, CPython took a median of 70 µs to start and join a thread and 1,952 µs to fork and collect a process: about 28 times as long. Threads are the cheap way to do several things at once in one program, because they share almost everything a process owns.
This lesson takes a process apart into what its threads share and what each one keeps for itself, then looks at the three ways a system can map the threads a program sees onto the threads the kernel schedules. It shows how Linux makes a thread with the very call it uses for a process, in a model in 14 languages, and measures what the difference costs.
The threads of a process share its memory and files, and each has its own stack
A thread: One flow of control inside a process, with its own program counter, registers and stack. The threads of one process share its address space and its open files. More in “The process: address space, PCB and states” is one flow of control: a program counter, registers and a stack. Every thread of a
process runs in the same address space, so they “share the same global memory (data and heap segments)”, in
pthreads(7)’s words, and the same descriptor table, signal handlers and current directory. What each keeps for itself
is short: its stack, its registers, its thread ID, its signal mask and its own errno. This program starts three
threads that each touch one thing, then reports from main:
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>
enum { THREADS = 3 };
static int shared_global; // one copy, in the process's data segment
static int log_fd; // one descriptor table for the whole process
static atomic_int arrived;
typedef struct {
int number;
pid_t pid;
uint64_t tid;
uintptr_t local_at; // where this thread's local variable lives
uintptr_t errno_at; // where this thread's errno lives
int errno_seen;
} Report;
static uint64_t thread_id(void) {
#ifdef __APPLE__
uint64_t id = 0;
pthread_threadid_np(NULL, &id);
return id;
#else
return (uint64_t)gettid();
#endif
}
static void *run(void *arg) {
Report *r = arg;
int local = r->number; // on this thread's own stack
errno = 0;
r->pid = getpid();
r->tid = thread_id();
r->local_at = (uintptr_t)&local;
r->errno_at = (uintptr_t)&errno;
if (r->number == 1) {
shared_global = 42;
} else if (r->number == 2) {
open("no-such-file", O_RDONLY); // fails: sets this thread's errno
} else if (write(log_fd, "hello\n", 6) != 6) {
return NULL;
}
atomic_fetch_add(&arrived, 1);
while (atomic_load(&arrived) < THREADS) { // all three stay alive together
}
r->errno_seen = errno;
return NULL;
}
int main(void) {
log_fd = open("threads.log", O_CREAT | O_TRUNC | O_WRONLY, 0600);
Report reports[THREADS];
pthread_t threads[THREADS];
for (int i = 0; i < THREADS; i++) {
reports[i] = (Report){.number = i + 1};
pthread_create(&threads[i], NULL, run, &reports[i]);
}
for (int i = 0; i < THREADS; i++) {
pthread_join(threads[i], NULL);
}
bool same_pid = true, own_tid = true, own_stack = true, own_errno = true;
for (int i = 0; i < THREADS; i++) {
same_pid = same_pid && reports[i].pid == getpid();
own_tid = own_tid && reports[i].tid != thread_id();
for (int j = 0; j < i; j++) {
own_tid = own_tid && reports[i].tid != reports[j].tid;
own_stack = own_stack && reports[i].local_at != reports[j].local_at;
own_errno = own_errno && reports[i].errno_at != reports[j].errno_at;
}
}
printf("getpid() in main and the 3 threads, all the same: %s\n",
same_pid ? "yes" : "no");
printf("thread IDs of main and the 3 threads, all different: %s\n",
own_tid ? "yes" : "no");
printf("a global that thread 1 set, read in main: %d\n", shared_global);
printf("each thread's local variable at its own address: %s\n",
own_stack ? "yes" : "no");
printf("errno: a variable of its own in each thread: %s; thread 2 saw "
"%s, thread 1 saw %d\n",
own_errno ? "yes" : "no",
reports[1].errno_seen == ENOENT ? "ENOENT" : "something else",
reports[0].errno_seen);
printf("thread 3 wrote 6 bytes; main's descriptor is now at offset %lld\n",
(long long)lseek(log_fd, 0, SEEK_CUR));
pthread_attr_t attributes;
size_t stack = 0;
pthread_attr_init(&attributes);
pthread_attr_getstacksize(&attributes, &stack);
printf("a new thread's stack on this system: %zu KiB by default\n",
stack / 1024);
close(log_fd);
unlink("threads.log");
return 0;
} Output
getpid() in main and the 3 threads, all the same: yes thread IDs of main and the 3 threads, all different: yes a global that thread 1 set, read in main: 42 each thread's local variable at its own address: yes errno: a variable of its own in each thread: yes; thread 2 saw ENOENT, thread 1 saw 0 thread 3 wrote 6 bytes; main's descriptor is now at offset 6 a new thread's stack on this system: 512 KiB by default
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined shared_or_own.c -o shared_or_own && ./shared_or_own
All four threads saw one process ID, and each had a thread ID of its own. The global that thread 1 set was there for
main, because there is one copy of it. Each thread’s local variable lived at an address of its own, on its own stack,
and so did errno: thread 2’s failed open left ENOENT in its own errno while thread 1 still read 0. Thread 3’s
6-byte write moved the offset of descriptor 3 for everyone, since the descriptor, like every open file, belongs to the
process. Step through the same run:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One thread. The process starts with one thread, main. It owns a stack and a set of registers; the code, the globals, the heap and the descriptor table belong to the process. main opens threads.log as descriptor 3.
- Three more threads. pthread_create starts three threads. Each gets a new stack, 512 KiB by default on this Mac, and its own registers, but no new code, globals, heap or descriptors: those stay the process's, shared by all four.
- A global changes for all. Thread 1 sets shared_global to 42. There is one copy of the variable, so when main reads it after the join, it reads 42.
- errno stays private. Thread 2's open of a missing file fails and sets errno to ENOENT. errno is per thread, at a different address in each, so thread 1 still reads 0.
- One open file for all. Thread 3 writes 6 bytes through descriptor 3. The descriptor and its file offset belong to the process, so main's lseek finds the offset at 6.
- What main sees. After the joins, main reads 42 from the global and offset 6 from the descriptor, and finds that each thread's local variable and errno lived at an address of its own.
| Shared by every thread of the process | Owned by each thread |
|---|---|
| Code, global variables and the heap | Its stack, with its local variables |
| Open file descriptors and their offsets | Its registers and program counter |
| Signal handlers, the current directory, the user ID | Its thread ID, its signal mask and its errno |
The last line of the run is a reminder that a stack is a fixed reservation: 512 KiB by default for a new thread on this Mac. On Linux the default is the stack size limit that was in force when the program started, pthread_create(3) says.
Key idea
Threads share everything that belongs to the process, memory, files and signal handlers, so a change one thread makes there is seen by all of them; only the stack, the registers, errno and the signal mask are a thread’s own.
Checkpoint
Check: What threads share, and what each owns
User threads and kernel threads can be mapped three ways
The threads a program creates are not always the threads the kernel schedules. A runtime can switch between threads of its own inside the process, without the kernel, and a system then has two kinds: user-level thread: A thread that a library or runtime inside the process creates and switches between itself, without the kernel; the kernel runs it on one of its own threads., which the program sees, and kernel-level thread: A thread that the kernel itself knows about and schedules onto a core. A blocking system call stops only that thread., which the kernel runs on cores. JEP 444, which brought virtual threads to Java, names the three ways to map one onto the other:
- Many-to-one, M:1. All user threads run on one kernel thread. Switching between them is cheap, but one blocking system call stops every thread, and the program never uses a second core. Early Java’s green threads “all shared one OS thread”, JEP 444 recalls.
- One-to-one, 1:1. Each user thread is a kernel thread. A blocking call stops only its own thread, and threads run on every core, but each one costs a kernel object and switches go through the kernel. Linux’s NPTL is a 1:1 implementation, pthreads(7) says, and on macOS and Windows too every thread is scheduled by the kernel; Windows’ fibers, which “must be manually scheduled by the application”, are the M:1 exception there.
- Many-to-many, M:N. A runtime spreads many user threads over a few kernel threads. Effective Go describes goroutines as spread over several OS threads, so that when one blocks, waiting for I/O for instance, the rest keep running, and the JDK schedules virtual threads on as many carrier threads as there are processors, by default.
Step through the three mappings, and the blocking call that separates them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Many-to-one, M:1. A library inside the process runs three user threads on one kernel thread, switching between them without any system call, which is fast. The kernel sees a single thread.
- One blocking call. A calls read and blocks in the kernel. The kernel thread sleeps, so B and C, although ready, cannot run either: in M:1 one blocking call stops every thread, and a second core is never used.
- One-to-one, 1:1. Each user thread is a kernel thread of its own, as with Linux's NPTL and the threads of macOS and Windows. A blocking read stops only A; the kernel runs B and C, on other cores if there are any.
- The price of 1:1. Every thread is now a kernel object with its own stack and a place in the scheduler, and starting or switching one goes through the kernel. That is why programs with tens of thousands of tasks pool their threads.
- Many-to-many, M:N. A runtime spreads many user threads over a few kernel threads, as Go does with goroutines and the JDK with virtual threads. Here C shares kernel thread 2 with B.
- Blocked, but not stuck. When a virtual thread blocks on I/O it unmounts and frees its kernel thread for the others, JEP 444 says; Go keeps other goroutines running when one blocks. M:N keeps cheap switches without M:1's stall.
An M:N runtime has a scheduler of its own, with its own rules. Before Go 1.14 a goroutine running a loop without
function calls could keep the others waiting; since then goroutines are “asynchronously preemptible”, its release notes
say. Until JDK 24, a Java virtual thread that blocked inside a synchronized block stayed pinned to its carrier, so a
blocking call there held a platform thread after all; JEP 491 let it unmount, and only a few cases, such as blocking in
a class initializer, still pin it. The history, in order:
- Early Java
Green threads on one OS thread M:1
Early versions of Java ran user-mode 'green threads', and JEP 444 recalls that they all shared one OS thread. Platform threads, each a wrapper for an OS thread, later outperformed them.
- LinuxThreads
Linux's first POSIX threads library 1:1
Every thread was a kernel task made with clone, plus a manager thread that created and ended threads. pthreads(7) notes that its threads did not even share one process ID.
- NPTL, Linux 2.6
NPTL replaces LinuxThreads 1:1
Still one kernel task per thread, but with no manager thread, one process ID for all the threads and futexes for locking. It needs Linux 2.6 and glibc 2.3.2, and LinuxThreads lost support in glibc 2.4.
- Go goroutines
Goroutines on a few OS threads M:N
Effective Go says goroutines are "multiplexed onto multiple OS threads": a goroutine that waits for I/O leaves the rest running on the other threads, and each stack starts small and grows when it must.
- Go 1.14
Goroutines become preemptible M:N
The Go 1.14 release notes made goroutines asynchronously preemptible, so a loop without function calls can no longer hold up the scheduler.
- JDK 21
Virtual threads, final in JEP 444 M:N
The JDK schedules many virtual threads on a few platform threads, by default as many as there are processors, and a virtual thread blocked on I/O unmounts and frees its carrier.
- JDK 24
synchronized no longer pins M:N
JEP 491 lets a virtual thread that blocks inside synchronized unmount and free its carrier, which it could not do in JDK 21. A few cases, such as blocking in a class initializer, still pin it.
The timeline has 7 events, from Early Java to JDK 24.
Checkpoint
Check: User threads, kernel threads and the three models
Linux makes a thread with the same call as a process
Linux has no separate object for a thread. Every thread and every process is a task, and one system call, clone, makes
both; its flags decide what the new task shares with its parent. CLONE_VM shares the address space, CLONE_FILES the
descriptor table, CLONE_SIGHAND the signal handlers, and CLONE_THREAD puts the new task in its parent’s thread
group, clone(2) says, so that getpid() returns the group’s ID while gettid() returns the task’s own. A thread is a
task with all four flags; a process made by fork is a task with none. The flags depend on each other: CLONE_THREAD
requires CLONE_SIGHAND, which requires CLONE_VM. The model below plays both calls, in 14 languages:
Python 3.14 · main.py
from dataclasses import dataclass
THREAD = {"CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD"}
@dataclass
class Task: # what Linux keeps for every thread and every process
tid: int # gettid()
tgid: int # getpid(): the ID of the thread group
memory: dict[str, int] # the address space: shared with CLONE_VM, else copied
@dataclass
class Kernel:
next_tid: int = 101
def clone(self, parent: Task, flags: set[str]) -> Task:
tid = self.next_tid
self.next_tid += 1
memory = parent.memory if "CLONE_VM" in flags else dict(parent.memory)
tgid = parent.tgid if "CLONE_THREAD" in flags else tid
return Task(tid, tgid, memory)
kernel = Kernel()
main = Task(100, 100, {"counter": 0})
thread = kernel.clone(main, THREAD) # what pthread_create asks for
child = kernel.clone(main, set()) # what fork asks for
print("task getpid gettid")
for name, task in [("main", main), ("thread", thread), ("child", child)]:
print(f"{name:<7}{task.tgid:>7}{task.tid:>8}")
thread.memory["counter"] = 1
print(f"thread sets counter to 1: main reads {main.memory['counter']}")
child.memory["counter"] = 2
print(f"child sets counter to 2: main reads {main.memory['counter']}") In Python: The thread receives the parent's own dict, while dict(parent.memory) makes a new one: that is the whole difference between sharing and copying an address space here.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const THREAD = new Set(["CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD"]);
// What Linux keeps for every thread and every process: tid is gettid(), tgid is getpid(),
// and memory is the address space, shared with CLONE_VM, else copied.
class Kernel {
nextTid = 101;
clone(parent, flags) {
const tid = this.nextTid++;
const memory = flags.has("CLONE_VM") ? parent.memory : new Map(parent.memory);
const tgid = flags.has("CLONE_THREAD") ? parent.tgid : tid;
return { tid, tgid, memory };
}
}
const kernel = new Kernel();
const main = { tid: 100, tgid: 100, memory: new Map([["counter", 0]]) };
const thread = kernel.clone(main, THREAD); // what pthread_create asks for
const child = kernel.clone(main, new Set()); // what fork asks for
console.log("task getpid gettid");
for (const [name, task] of [
["main", main],
["thread", thread],
["child", child],
]) {
console.log(name.padEnd(7) + String(task.tgid).padStart(7) + String(task.tid).padStart(8));
}
thread.memory.set("counter", 1);
console.log(`thread sets counter to 1: main reads ${main.memory.get("counter")}`);
child.memory.set("counter", 2);
console.log(`child sets counter to 2: main reads ${main.memory.get("counter")}`); In JavaScript: A Map is held by reference, so the thread receives the parent's Map itself, and new Map(parent.memory) makes the child a copy of its own.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class Main {
static final Set<String> THREAD =
Set.of("CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD");
/**
* What Linux keeps for every thread and every process: tid is gettid(), tgid is getpid(), and
* memory is the address space, shared with CLONE_VM, else copied.
*/
record Task(int tid, int tgid, Map<String, Integer> memory) {}
static int nextTid = 101;
static Task cloneTask(Task parent, Set<String> flags) {
int tid = nextTid++;
var memory = flags.contains("CLONE_VM") ? parent.memory() : new HashMap<>(parent.memory());
int tgid = flags.contains("CLONE_THREAD") ? parent.tgid() : tid;
return new Task(tid, tgid, memory);
}
static String padded(Object value, int width) {
String text = String.valueOf(value);
return " ".repeat(width - text.length()) + text;
}
public static void main(String[] args) {
Task main = new Task(100, 100, new HashMap<>(Map.of("counter", 0)));
Task thread = cloneTask(main, THREAD); // what pthread_create asks for
Task child = cloneTask(main, Set.of()); // what fork asks for
System.out.println("task getpid gettid");
String[] names = {"main", "thread", "child"};
Task[] tasks = {main, thread, child};
for (int i = 0; i < tasks.length; i++) {
String name = names[i] + " ".repeat(7 - names[i].length());
System.out.println(name + padded(tasks[i].tgid(), 7) + padded(tasks[i].tid(), 8));
}
thread.memory().put("counter", 1);
System.out.println("thread sets counter to 1: main reads " + main.memory().get("counter"));
child.memory().put("counter", 2);
System.out.println("child sets counter to 2: main reads " + main.memory().get("counter"));
}
} In Java: Task is a record that holds a reference to its map, so the thread and main point at one HashMap, while new HashMap<>(...) copies it for the child.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <map>
#include <memory>
#include <print>
#include <set>
#include <string>
using Memory = std::map<std::string, int>;
// What Linux keeps for every thread and every process.
struct Task {
int tid; // gettid()
int tgid; // getpid(): the ID of the thread group
std::shared_ptr<Memory> memory; // the address space: shared with CLONE_VM
};
const std::set<std::string> thread_flags = {"CLONE_VM", "CLONE_FILES",
"CLONE_SIGHAND", "CLONE_THREAD"};
class Kernel {
public:
Task clone(const Task &parent, const std::set<std::string> &flags) {
const int tid = next_tid_++;
auto memory = flags.contains("CLONE_VM")
? parent.memory
: std::make_shared<Memory>(*parent.memory);
return {tid, flags.contains("CLONE_THREAD") ? parent.tgid : tid, memory};
}
private:
int next_tid_ = 101;
};
int main() {
Kernel kernel;
const Task main_task{100, 100,
std::make_shared<Memory>(Memory{{"counter", 0}})};
const Task thread = kernel.clone(main_task, thread_flags); // pthread_create
const Task child = kernel.clone(main_task, {}); // fork
std::println("task getpid gettid");
for (const auto &[name, task] :
{std::pair{"main", main_task}, {"thread", thread}, {"child", child}})
std::println("{:<7}{:>7}{:>8}", name, task.tgid, task.tid);
(*thread.memory)["counter"] = 1;
std::println("thread sets counter to 1: main reads {}",
main_task.memory->at("counter"));
(*child.memory)["counter"] = 2;
std::println("child sets counter to 2: main reads {}",
main_task.memory->at("counter"));
} In C++: std::shared_ptr<Memory> lets two tasks own one map: copying the pointer shares it, and std::make_shared<Memory>(*parent.memory) copies the map itself.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
var kernel = new Kernel();
var main = new TaskEntry(100, 100, new() { ["counter"] = 0 });
var thread = kernel.Clone(main, Kernel.ThreadFlags); // what pthread_create asks for
var child = kernel.Clone(main, []); // what fork asks for
Console.WriteLine("task getpid gettid");
foreach (var (name, task) in new[] { ("main", main), ("thread", thread), ("child", child) })
{
Console.WriteLine($"{name,-7}{task.Tgid,7}{task.Tid,8}");
}
thread.Memory["counter"] = 1;
Console.WriteLine($"thread sets counter to 1: main reads {main.Memory["counter"]}");
child.Memory["counter"] = 2;
Console.WriteLine($"child sets counter to 2: main reads {main.Memory["counter"]}");
// What Linux keeps for every thread and every process: Tid is gettid(), Tgid is getpid(),
// and Memory is the address space, shared with CLONE_VM, else copied.
sealed record TaskEntry(int Tid, int Tgid, Dictionary<string, int> Memory);
sealed class Kernel
{
public static readonly HashSet<string> ThreadFlags =
["CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD"];
private int nextTid = 101;
public TaskEntry Clone(TaskEntry parent, HashSet<string> flags)
{
var tid = nextTid++;
var memory = flags.Contains("CLONE_VM") ? parent.Memory : new(parent.Memory);
return new TaskEntry(tid, flags.Contains("CLONE_THREAD") ? parent.Tgid : tid, memory);
}
} In C#: The record is TaskEntry because Task is already .NET's own type for asynchronous work, and new(parent.Memory) copies the dictionary.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
#include <stdlib.h>
enum { CLONE_VM = 1, CLONE_FILES = 2, CLONE_SIGHAND = 4, CLONE_THREAD = 8 };
typedef struct {
int counter;
} Memory; // the address space, reduced to one variable
// What Linux keeps for every thread and every process.
typedef struct {
int tid; // gettid()
int tgid; // getpid(): the ID of the thread group
Memory *memory; // shared with CLONE_VM, else copied
} Task;
static int next_tid = 101;
static Task clone_task(const Task *parent, int flags) {
Task task = {next_tid++, parent->tgid, parent->memory};
if (!(flags & CLONE_THREAD)) {
task.tgid = task.tid;
}
if (!(flags & CLONE_VM)) {
task.memory = malloc(sizeof(Memory));
*task.memory = *parent->memory;
}
return task;
}
int main(void) {
Memory memory = {0};
const Task main_task = {100, 100, &memory};
const Task thread = clone_task(&main_task, CLONE_VM | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD);
const Task child = clone_task(&main_task, 0); // what fork asks for
printf("task getpid gettid\n");
const char *names[] = {"main", "thread", "child"};
const Task *tasks[] = {&main_task, &thread, &child};
for (int i = 0; i < 3; i++) {
printf("%-7s%7d%8d\n", names[i], tasks[i]->tgid, tasks[i]->tid);
}
thread.memory->counter = 1;
printf("thread sets counter to 1: main reads %d\n", memory.counter);
child.memory->counter = 2;
printf("child sets counter to 2: main reads %d\n", memory.counter);
free(child.memory);
return 0;
} In C: Sharing is copying the pointer, copying is malloc and a struct assignment, and the flags are a bit mask, as the real clone takes them.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
const THREAD = ['CLONE_VM', 'CLONE_FILES', 'CLONE_SIGHAND', 'CLONE_THREAD'];
// The address space, as an object: tasks that hold the same object share it.
final class Memory
{
/** @param array<string, int> $values */
public function __construct(public array $values) {}
}
// What Linux keeps for every thread and every process.
final class Task
{
public function __construct(
public readonly int $tid, // gettid()
public readonly int $tgid, // getpid(): the ID of the thread group
public readonly Memory $memory, // shared with CLONE_VM, else copied
) {}
}
final class Kernel
{
private int $nextTid = 101;
/** @param list<string> $flags */
public function clone(Task $parent, array $flags): Task
{
$tid = $this->nextTid++;
$memory = in_array('CLONE_VM', $flags, true) ? $parent->memory : clone $parent->memory;
$tgid = in_array('CLONE_THREAD', $flags, true) ? $parent->tgid : $tid;
return new Task($tid, $tgid, $memory);
}
}
$kernel = new Kernel();
$main = new Task(100, 100, new Memory(['counter' => 0]));
$thread = $kernel->clone($main, THREAD); // what pthread_create asks for
$child = $kernel->clone($main, []); // what fork asks for
echo "task getpid gettid\n";
foreach (['main' => $main, 'thread' => $thread, 'child' => $child] as $name => $task) {
printf("%-7s%7d%8d\n", $name, $task->tgid, $task->tid);
}
$thread->memory->values['counter'] = 1;
echo "thread sets counter to 1: main reads {$main->memory->values['counter']}\n";
$child->memory->values['counter'] = 2;
echo "child sets counter to 2: main reads {$main->memory->values['counter']}\n"; In PHP: PHP copies arrays on assignment, so the address space is an object: tasks that hold the same object share it, and clone makes the child a copy.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
const THREAD: [&str; 4] = ["CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD"];
/// What Linux keeps for every thread and every process.
struct Task {
tid: u32, // gettid()
tgid: u32, // getpid(): the ID of the thread group
memory: Rc<RefCell<BTreeMap<String, i32>>>, // the address space: shared with CLONE_VM
}
struct Kernel {
next_tid: u32,
}
impl Kernel {
fn clone_task(&mut self, parent: &Task, flags: &[&str]) -> Task {
let tid = self.next_tid;
self.next_tid += 1;
let memory = if flags.contains(&"CLONE_VM") {
Rc::clone(&parent.memory)
} else {
Rc::new(RefCell::new(parent.memory.borrow().clone()))
};
let tgid = if flags.contains(&"CLONE_THREAD") {
parent.tgid
} else {
tid
};
Task { tid, tgid, memory }
}
}
fn main() {
let mut kernel = Kernel { next_tid: 101 };
let memory = BTreeMap::from([("counter".to_string(), 0)]);
let main = Task {
tid: 100,
tgid: 100,
memory: Rc::new(RefCell::new(memory)),
};
let thread = kernel.clone_task(&main, &THREAD); // what pthread_create asks for
let child = kernel.clone_task(&main, &[]); // what fork asks for
println!("task getpid gettid");
for (name, task) in [("main", &main), ("thread", &thread), ("child", &child)] {
println!("{name:<7}{:>7}{:>8}", task.tgid, task.tid);
}
thread.memory.borrow_mut().insert("counter".to_string(), 1);
println!(
"thread sets counter to 1: main reads {}",
main.memory.borrow()["counter"]
);
child.memory.borrow_mut().insert("counter".to_string(), 2);
println!(
"child sets counter to 2: main reads {}",
main.memory.borrow()["counter"]
);
} In Rust: Rc<RefCell<...>> gives two tasks one map: Rc::clone shares it, borrow_mut changes it, and cloning the borrowed map makes the copy.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
const THREAD = new Set(["CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD"]);
// What Linux keeps for every thread and every process.
interface Task {
tid: number; // gettid()
tgid: number; // getpid(): the ID of the thread group
memory: Map<string, number>; // the address space: shared with CLONE_VM, else copied
}
class Kernel {
nextTid = 101;
clone(parent: Task, flags: Set<string>): Task {
const tid = this.nextTid++;
const memory = flags.has("CLONE_VM") ? parent.memory : new Map(parent.memory);
const tgid = flags.has("CLONE_THREAD") ? parent.tgid : tid;
return { tid, tgid, memory };
}
}
const kernel = new Kernel();
const main: Task = { tid: 100, tgid: 100, memory: new Map([["counter", 0]]) };
const thread = kernel.clone(main, THREAD); // what pthread_create asks for
const child = kernel.clone(main, new Set()); // what fork asks for
console.log("task getpid gettid");
const tasks: [string, Task][] = [
["main", main],
["thread", thread],
["child", child],
];
for (const [name, task] of tasks) {
console.log(name.padEnd(7) + String(task.tgid).padStart(7) + String(task.tid).padStart(8));
}
thread.memory.set("counter", 1);
console.log(`thread sets counter to 1: main reads ${main.memory.get("counter")}`);
child.memory.set("counter", 2);
console.log(`child sets counter to 2: main reads ${main.memory.get("counter")}`); In TypeScript: Task is an interface for the plain object that clone returns; the rest is the JavaScript version.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"maps"
"slices"
)
var threadFlags = []string{"CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD"}
// task is what Linux keeps for every thread and every process.
type task struct {
tid int // gettid()
tgid int // getpid(): the ID of the thread group
memory map[string]int // the address space: shared with CLONE_VM, else copied
}
type kernel struct{ nextTid int }
func (k *kernel) clone(parent *task, flags []string) *task {
t := &task{tid: k.nextTid, tgid: parent.tgid, memory: parent.memory}
k.nextTid++
if !slices.Contains(flags, "CLONE_THREAD") {
t.tgid = t.tid
}
if !slices.Contains(flags, "CLONE_VM") {
t.memory = maps.Clone(parent.memory)
}
return t
}
func main() {
k := &kernel{nextTid: 101}
mainTask := &task{tid: 100, tgid: 100, memory: map[string]int{"counter": 0}}
thread := k.clone(mainTask, threadFlags) // what pthread_create asks for
child := k.clone(mainTask, nil) // what fork asks for
fmt.Println("task getpid gettid")
names := []string{"main", "thread", "child"}
for i, t := range []*task{mainTask, thread, child} {
fmt.Printf("%-7s%7d%8d\n", names[i], t.tgid, t.tid)
}
thread.memory["counter"] = 1
fmt.Printf("thread sets counter to 1: main reads %d\n", mainTask.memory["counter"])
child.memory["counter"] = 2
fmt.Printf("child sets counter to 2: main reads %d\n", mainTask.memory["counter"])
} In Go: Maps are references in Go, so assigning the map shares it, and maps.Clone copies it for the child.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
let threadFlags: Set = ["CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD"]
// The address space. A dictionary is a value in Swift, so it lives in a class: tasks that
// hold the same object share it.
final class Memory {
var values: [String: Int]
init(_ values: [String: Int]) { self.values = values }
}
// What Linux keeps for every thread and every process.
struct Task {
let tid: Int // gettid()
let tgid: Int // getpid(): the ID of the thread group
let memory: Memory // shared with CLONE_VM, else copied
}
struct Kernel {
var nextTid = 101
mutating func clone(_ parent: Task, flags: Set<String>) -> Task {
let tid = nextTid
nextTid += 1
let memory = flags.contains("CLONE_VM") ? parent.memory : Memory(parent.memory.values)
return Task(tid: tid, tgid: flags.contains("CLONE_THREAD") ? parent.tgid : tid, memory: memory)
}
}
func padded(_ value: Int, _ width: Int) -> String {
let text = String(value)
return String(repeating: " ", count: max(0, width - text.count)) + text
}
var kernel = Kernel()
let main = Task(tid: 100, tgid: 100, memory: Memory(["counter": 0]))
let thread = kernel.clone(main, flags: threadFlags) // what pthread_create asks for
let child = kernel.clone(main, flags: []) // what fork asks for
print("task getpid gettid")
for (name, task) in [("main", main), ("thread", thread), ("child", child)] {
let label = name + String(repeating: " ", count: 7 - name.count)
print(label + padded(task.tgid, 7) + padded(task.tid, 8))
}
thread.memory.values["counter"] = 1
print("thread sets counter to 1: main reads \(main.memory.values["counter"] ?? 0)")
child.memory.values["counter"] = 2
print("child sets counter to 2: main reads \(main.memory.values["counter"] ?? 0)") In Swift: Swift dictionaries are values that copy on assignment, so the address space is a final class: tasks that hold the same object share it.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
THREAD = %w[CLONE_VM CLONE_FILES CLONE_SIGHAND CLONE_THREAD].freeze
# What Linux keeps for every thread and every process: tid is gettid(), tgid is getpid(), and
# memory is the address space, shared with CLONE_VM, else copied.
Task = Struct.new(:tid, :tgid, :memory)
# Hands out thread IDs and makes new tasks.
class OsKernel
def initialize
@next_tid = 101
end
def clone_task(parent, flags)
tid = @next_tid
@next_tid += 1
memory = flags.include?('CLONE_VM') ? parent.memory : parent.memory.dup
Task.new(tid, flags.include?('CLONE_THREAD') ? parent.tgid : tid, memory)
end
end
kernel = OsKernel.new
main = Task.new(100, 100, { 'counter' => 0 })
thread = kernel.clone_task(main, THREAD) # what pthread_create asks for
child = kernel.clone_task(main, []) # what fork asks for
puts 'task getpid gettid'
{ 'main' => main, 'thread' => thread, 'child' => child }.each do |name, task|
puts name.ljust(7) + task.tgid.to_s.rjust(7) + task.tid.to_s.rjust(8)
end
thread.memory['counter'] = 1
puts "thread sets counter to 1: main reads #{main.memory['counter']}"
child.memory['counter'] = 2
puts "child sets counter to 2: main reads #{main.memory['counter']}" In Ruby: The class is OsKernel, because Kernel is Ruby's own module of global methods such as puts; dup copies the hash for the child.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
val threadFlags = setOf("CLONE_VM", "CLONE_FILES", "CLONE_SIGHAND", "CLONE_THREAD")
// What Linux keeps for every thread and every process: tid is gettid(), tgid is getpid(), and
// memory is the address space, shared with CLONE_VM, else copied.
class Task(
val tid: Int,
val tgid: Int,
val memory: MutableMap<String, Int>,
)
class Kernel {
private var nextTid = 101
fun clone(
parent: Task,
flags: Set<String>,
): Task {
val tid = nextTid++
val memory = if ("CLONE_VM" in flags) parent.memory else parent.memory.toMutableMap()
return Task(tid, if ("CLONE_THREAD" in flags) parent.tgid else tid, memory)
}
}
fun main() {
val kernel = Kernel()
val main = Task(100, 100, mutableMapOf("counter" to 0))
// What pthread_create asks for, then what fork asks for.
val thread = kernel.clone(main, threadFlags)
val child = kernel.clone(main, emptySet())
println("task getpid gettid")
for ((name, task) in listOf("main" to main, "thread" to thread, "child" to child)) {
println(name.padEnd(7) + task.tgid.toString().padStart(7) + task.tid.toString().padStart(8))
}
thread.memory["counter"] = 1
println("thread sets counter to 1: main reads ${main.memory["counter"]}")
child.memory["counter"] = 2
println("child sets counter to 2: main reads ${main.memory["counter"]}")
} In Kotlin: toMutableMap() copies the map for the child, while the thread gets the parent's MutableMap itself.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
const threadFlags = {
'CLONE_VM',
'CLONE_FILES',
'CLONE_SIGHAND',
'CLONE_THREAD',
};
// What Linux keeps for every thread and every process: tid is gettid(), tgid is getpid(), and
// memory is the address space, shared with CLONE_VM, else copied.
typedef Task = ({int tid, int tgid, Map<String, int> memory});
class Kernel {
int _nextTid = 101;
Task clone(Task parent, Set<String> flags) {
final tid = _nextTid++;
return (
tid: tid,
tgid: flags.contains('CLONE_THREAD') ? parent.tgid : tid,
memory: flags.contains('CLONE_VM')
? parent.memory
: Map.of(parent.memory),
);
}
}
void main() {
final kernel = Kernel();
final Task main = (tid: 100, tgid: 100, memory: {'counter': 0});
// What pthread_create asks for, then what fork asks for.
final thread = kernel.clone(main, threadFlags);
final child = kernel.clone(main, {});
print('task getpid gettid');
for (final (name, task) in [
('main', main),
('thread', thread),
('child', child),
]) {
print(
name.padRight(7) + '${task.tgid}'.padLeft(7) + '${task.tid}'.padLeft(8),
);
}
thread.memory['counter'] = 1;
print('thread sets counter to 1: main reads ${main.memory['counter']}');
child.memory['counter'] = 2;
print('child sets counter to 2: main reads ${main.memory['counter']}');
} In Dart: Task is a record type with named fields, and Map.of copies the map for the child, while the thread gets the same map.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
task getpid gettid main 100 100 thread 100 101 child 102 102 thread sets counter to 1: main reads 1 child sets counter to 2: main reads 1
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 13 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 153 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 4 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 23 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 719 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 192 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 17 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
The thread got its own ID, 101, but the group’s process ID, 100, and the child got 102 for both. When the thread set
the counter to 1, main read 1, because both held the same address space; when the child set it to 2, main still read
1, because the child’s address space was a copy. On a real system the copy is made lazily, page by page, with
copy-on-write: Sharing memory pages between processes until one of them writes to a page, when the kernel copies that page for the writer. fork uses it, so the child does not copy all of its parent's memory at once. More in “Creating processes: fork, exec and wait”, as the fork lesson showed. What POSIX calls a process is, on Linux, a
thread group: On Linux, the tasks that make up one process: the first task and every task cloned from it with CLONE_THREAD. getpid returns the group's ID, gettid each task's own. of tasks, and each of them is listed under /proc/<pid>/task.
macOS and its thread IDs
macOS builds its threads on Mach threads, whose IDs come from pthread_threadid_np, the call shared_or_own.c used
there; gettid() is the Linux call. The model follows Linux, the system whose kernel interface this lesson shows.
Checkpoint
Check: Linux threads as tasks made by clone
Creating a thread costs far less than creating a process
A thread needs a stack and a task; a process made with fork also needs an address space of its own, with page tables
copied from its parent and every writable page marked copy-on-write. This program measures both in CPython: 1,000
threads started and joined, then 1,000 children forked and collected, with the median of each:
"""Times creating a thread against creating a process, in CPython: 1,000 of each."""
import os
import statistics
import threading
import time
ROUNDS = 1000
def nothing() -> None:
pass
def thread_cost() -> float:
"""Microseconds to start a thread that does nothing and join it."""
samples = []
for _ in range(ROUNDS):
start = time.perf_counter_ns()
thread = threading.Thread(target=nothing)
thread.start()
thread.join()
samples.append(time.perf_counter_ns() - start)
return statistics.median(samples) / 1000
def process_cost() -> float:
"""Microseconds to fork a child that exits at once and wait for it."""
samples = []
for _ in range(ROUNDS):
start = time.perf_counter_ns()
pid = os.fork()
if pid == 0:
os._exit(0)
os.waitpid(pid, 0)
samples.append(time.perf_counter_ns() - start)
return statistics.median(samples) / 1000
threads = thread_cost() # first, so that no thread is alive when fork runs
processes = process_cost()
print(f"start and join a thread: median {threads:6.0f} µs")
print(f"fork and wait for a process: median {processes:6.0f} µs")
print(f"a process cost about {processes / threads:.0f} times as much as a thread") Output
start and join a thread: median 70 µs fork and wait for a process: median 1952 µs a process cost about 28 times as much as a thread
This output changes from run to run: the times depend on the machine and on what else it is running
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 spawn_cost.py
The process cost about 28 times as much as the thread here. Python’s own work is in both numbers, so the ratio says more than the microseconds do; the threads first so that no thread is alive when the program forks. The saving has a price. Threads share one fate: a thread that is ended by a fault, such as SIGSEGV, ends the whole process, because the default action of a signal belongs to the process. Separate processes keep a crash, and a memory error, to the process where it happened.
Threads for speed, processes for safety
Use threads for work that cooperates closely and trusts itself, such as the workers of one server. Use processes for parts that must survive each other’s failures, such as code written by other teams or plugins.
Checkpoint
Check: What creating a thread costs
Practise: predict what getpid and gettid return
The exercise takes the clone model one step further: given a list of threads and forks, each started by an existing task, return the pair of IDs each task would see. Its sample tests run in your browser, threads of threads and forks of threads included:
Exercise · Easy · Python
Predict what getpid and gettid return
On Linux every thread and every process is a task with its own ID, which gettid() returns, and getpid() returns the ID of the task's thread group. A process starts as one task, main, with ID 100, and every new task takes the next free ID: 101, 102 and so on. Each step is a string:
"thread NAME in PARENT": PARENT callspthread_create, a clone with CLONE_THREAD, so the new task joins PARENT's thread group;"fork NAME in PARENT": PARENT callsfork, a clone without it, so the new task starts a thread group of its own.
Write task_ids(steps) in ids.py. It returns a dictionary from each task's name, main included, to the pair (getpid(), gettid()) that the task would see.
Starter code · ids.py
def task_ids(steps):
"""The (getpid(), gettid()) pair of each task after the steps."""
# Replace this line with your code.
return {"main": (100, 100)} The sample tests · test_ids.py
from ids import task_ids
def test_one_thread():
"""gives a new thread its own gettid and its creator's getpid"""
assert task_ids(["thread worker in main"]) == {"main": (100, 100), "worker": (100, 101)}
def test_one_fork():
"""gives a forked child a thread group of its own"""
assert task_ids(["fork child in main"]) == {"main": (100, 100), "child": (101, 101)}
def test_thread_inside_a_child():
"""puts a thread started by a forked child into the child's group"""
steps = ["fork child in main", "thread helper in child"]
assert task_ids(steps) == {"main": (100, 100), "child": (101, 101), "helper": (101, 102)}
def test_thread_of_a_thread():
"""keeps a thread started by another thread in the same group"""
steps = ["thread a in main", "thread b in a", "fork c in b"]
expected = {"main": (100, 100), "a": (100, 101), "b": (100, 102), "c": (103, 103)}
assert task_ids(steps) == expected A hint
Hand out IDs in the order the steps create tasks. A new task's gettid is always its own new ID; its getpid is either its parent's getpid, for a thread, or its own new ID, for a forked process.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then the self-check below, which starts with the concept your answers have shown to be weakest and covers all four:
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
The warm-up below has its complete answer:
Two harder questions come from this lesson: a threaded server whose forked helpers sometimes hang before they run their program, and a design choice between threads and processes for plugins. Pick a first step for each; the feedback says what every choice gets right or misses:
Checkpoint
Your first decision
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A server with 16 threads forks a helper for each job; the child sets up its environment, logs a line and calls exec. Under load, a few children hang for ever before exec, using no CPU. Explain why, and fix it.
-
Mid to staff
Your application loads plugins written by other teams, and some of them crash now and then. A colleague wants each plugin to run as a thread because threads are cheaper. Compare threads and processes for this, with numbers, and recommend a design.
Key takeaways
- The threads of a process share its address space, its descriptor table, its signal handlers and its current directory; each owns only its stack, its registers, its thread ID, its signal mask and its errno.
- User threads map onto kernel threads three ways: many-to-one is cheap but stalls on one blocking call, one-to-one is today’s default with Linux’s NPTL, and many-to-many runtimes such as Go’s and Java’s virtual threads combine the two.
- Linux makes threads and processes with the same call, clone: a thread shares the address space, files, signal handlers and thread group, so getpid returns the group’s ID and gettid the task’s own.
- A thread costs far less to create than a process, 28 times less in the recorded CPython run, but it shares the process’s fate, so isolation still calls for processes.
Terms in this lesson
- kernel-level thread
- A thread that the kernel itself knows about and schedules onto a core. A blocking system call stops only that thread.
- thread group
- On Linux, the tasks that make up one process: the first task and every task cloned from it with CLONE_THREAD. getpid returns the group's ID, gettid each task's own.
- user-level thread
- A thread that a library or runtime inside the process creates and switches between itself, without the kernel; the kernel runs it on one of its own threads.
References
- pthreads(7): POSIX threads (Linux man-pages project (man7.org))
- clone(2): create a child process (CLONE_VM, CLONE_FILES, CLONE_SIGHAND, CLONE_THREAD) (Linux man-pages project (man7.org))
- futex(7): fast user-space locking (NPTL is built on futexes) (Linux man-pages project (man7.org))
- pthread_create(3): create a new thread (Linux man-pages project (man7.org))
- POSIX.1-2024: pthread_create, thread creation (The Open Group and IEEE)
- JEP 444: Virtual Threads (OpenJDK)
- JEP 491: Synchronize Virtual Threads without Pinning (OpenJDK)
- POSIX.1-2024: fork, create a new process (a multithreaded parent) (The Open Group and IEEE)
- Effective Go: goroutines (The Go Authors)
- Go 1.14 release notes (runtime) (The Go Authors)
- Threading Programming Guide: about threaded programming (Apple)
- About processes and threads (Win32) (Microsoft)
Report a problem with this lesson
Kept only in this browser. Your Learn progress