Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Clang 21.0.0 and GCC 16.2.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

What three threads share, and what each keeps for itself C · shared_or_own.c
#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:

Interactive · step by step Three threads in one process, and what each one touches Step through shared_or_own.c. The left column is the process's, shared by every thread; the right is the threads.
Three threads in one process, and what each one touches, step 6 of 6. Showing Code and globals (shared_global = 42), Heap, Descriptor table (fd 3: offset 6), Main thread, Thread 1, Thread 2 (errno = ENOENT), Thread 3. In focus: Main thread.writes 42writes 6 bytesreads 42Code and globalsshared_global = 42Heapone for the processDescriptor tablefd 3: offset 6Main threadits stack and registersThread 1its own stackThread 2errno = ENOENTThread 3its own stack

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  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

  1. Question 1 of 2 Which of these do all the threads of one process share?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The global variables and the heap
    • The signal handlers
    • The open file descriptors

    pthreads(7) lists the process-wide attributes, among them the process ID, the descriptors, the signal dispositions and the current directory, and the per-thread ones: the thread ID, the signal mask, errno and the alternate signal stack.

  2. Question 2 of 2 In shared_or_own.c, thread 3 wrote 6 bytes through descriptor 3. Suppose thread 1 had then written 4 more bytes through the same descriptor. At what offset would main's lseek have found it?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 10 bytes

    There is one descriptor table and one open file description with one offset for the whole process, so both writes move the same offset: 6 + 4 = 10.

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:

Interactive · step by step Three user threads on kernel threads, mapped three ways Step through the models. User threads are on the left, the kernel threads that really run them on the right.
Three user threads on kernel threads, mapped three ways, step 6 of 6. Showing User thread A, User thread B, User thread C, Kernel thread 1 (A blocked in read()), Kernel thread 2. In focus: Kernel thread 2.User thread AUser thread BUser thread CKernel thread 1A blocked in read()Kernel thread 2

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Timeline · 7 events in order How runtimes moved between the models Walk through the changes in order. Each one names the model it used, M:1, 1:1 or M:N.
  1. 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.

  2. 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.

  3. 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.

  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.

  5. 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.

  6. 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.

  7. 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

  1. Question 1 of 2 A runtime runs 50 user threads on one kernel thread, many-to-one. One thread calls read() on a network socket that has no data yet. What happens to the other 49?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: They stop too, because the only kernel thread is blocked in the kernel

    That stall, and the use of a single core, is why the one-to-one model replaced many-to-one green threads, as JEP 444 recalls for early Java.

  2. Question 2 of 2 Which model do Java's virtual threads, introduced in JDK 21, use?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: M:N, many virtual threads scheduled on a few platform threads

    Go's goroutines use the same model, multiplexed onto multiple OS threads, Effective Go says.

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:

One program · 14 languages One call for threads and processes, in a model of clone

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

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

  1. Question 1 of 2 On Linux, what does getpid() return in a thread made by pthread_create?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The thread group's ID, the same value as in the main thread

    The model printed 100 for getpid in main and in the thread, and 101 for the thread's gettid; shared_or_own.c found the same process ID in all four threads on macOS.

  2. Question 2 of 2 In the model, the child made like fork set counter to 2. Why did main still read 1?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Without CLONE_VM the child got a copy of the address space, so its write changed only its copy

    On a real system the copy is made lazily with copy-on-write, as the fork lesson showed, but the effect is the same: a forked child's writes stay its own.

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:

A thread against a process: 1,000 of each, timed Python · spawn_cost.py
"""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

  1. Question 1 of 2 In the recorded run of spawn_cost.py, how did forking a process compare with starting a thread?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The process cost many times as much as the thread

    The exact numbers change from run to run and machine to machine, which is why the example's output is matched by a pattern; the ratio is the lesson.

  2. Question 2 of 2 One thread of a server dereferences a null pointer and gets SIGSEGV, which it does not handle. What happens to the server's other threads?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They die with it, because the default action of SIGSEGV ends the whole process

    That is the price of cheap threads: they share memory and fate. Programs that must survive a crash in one part put the parts in separate processes.

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 calls pthread_create, a clone with CLONE_THREAD, so the new task joins PARENT's thread group;
  • "fork NAME in PARENT": PARENT calls fork, 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.

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.

  1. Question 1 of 12 Why must every thread have a stack of its own?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Each thread calls its own functions in its own order, so each needs its own frames for locals and return addresses

    shared_or_own.c found each thread's local variable at an address of its own, on a stack of 512 KiB by default on this Mac.

  2. Question 2 of 12 Thread A's read fails and sets errno to EBADF. Just before A looks at errno, thread B's open succeeds. What does A read in errno?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: EBADF, because errno is per thread, so B's call cannot touch A's

    errno is defined as a per-thread value precisely so that error handling keeps working in threaded programs.

  3. Question 3 of 12 A worker thread calls chdir("/tmp") to make its relative paths shorter. What does that do to the other threads of the process?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Their relative paths change too, because the current directory belongs to the process

    Threaded code should open files relative to a directory descriptor, with openat, instead of changing the shared current directory.

  4. Question 4 of 12 Which model do Linux's NPTL threads use?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: 1:1, every thread one kernel scheduling entity

    macOS and Windows threads are also one kernel thread each, which is why the lesson calls 1:1 the default of today's systems.

  5. Question 5 of 12 Which two problems of many-to-one threading does the one-to-one model solve?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: One blocking call no longer stops every thread, and threads can run on several cores at once

    M:N runtimes keep those two gains while getting back some of M:1's cheap switches, at the cost of a scheduler in the runtime.

  6. Question 6 of 12 A Java service on JDK 21 runs on virtual threads. It holds a synchronized lock while it waits for a slow database call, and throughput drops sharply under load. What is happening?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Inside synchronized the virtual thread is pinned to its carrier, so the blocking call holds a platform thread too

    With few carriers, as many as there are processors by default, a handful of pinned threads can stall all the others, the M:1 problem returning inside an M:N runtime. JEP 491 removed this pinning in JDK 24, so on newer JDKs the same code unmounts while it waits.

  7. Question 7 of 12 What is a thread group on Linux?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: The tasks made with CLONE_THREAD from one first task; they form one process, and getpid returns the group's ID

    Linux has no separate thread object: what POSIX calls a process is a thread group of tasks, and what it calls a thread is one task.

  8. Question 8 of 12 A program calls clone with CLONE_THREAD but without CLONE_SIGHAND. What does clone(2) say happens?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: It is refused, because CLONE_THREAD requires CLONE_SIGHAND, which in turn requires CLONE_VM

    The flags fit together for a reason: a thread group shares one set of signal actions, and those actions are addresses of functions in one address space.

  9. Question 9 of 12 A Linux server has 40 threads. Its process ID is 7000. How many entries does it have in /proc/7000/task, and what does each thread's getpid() return?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: 40 entries, one per task, and every thread's getpid() returns 7000

    The model's table shows the same: main and the thread share getpid 100 and differ in gettid, 100 and 101.

  10. Question 10 of 12 Why is creating a thread cheaper than creating a process with fork?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: A thread needs only a new stack and a task; a process needs its own address space, with page tables copied from the parent

    The recorded run measured the difference: forking cost many times as much as starting a thread, even in CPython.

  11. Question 11 of 12 A program loads plugins written by other teams, some of which crash now and then. Should each plugin run as a thread or as a process?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: As a process, so that a crash ends only the plugin and the program can start it again

    The measured cost of a process, a couple of milliseconds in CPython here, is small next to a crash of the whole program.

  12. Question 12 of 12 A service starts one 1:1 thread per connection. At 20,000 connections, pthread_create starts failing with EAGAIN. Which explanation does pthread_create(3) support?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: A limit on resources or on the number of threads was reached, such as RLIMIT_NPROC or the kernel's limit on tasks

    Each thread also reserves a stack, 512 KiB by default on this Mac and on Linux the size of RLIMIT_STACK when the program started, so designs with tens of thousands of tasks use a pool, an event loop or an M:N runtime.

Interview questions

The warm-up below has its complete answer:

  1. Fresher · Warm-up

    What do the threads of one process share, and what does each thread have of its own?

    They share everything that belongs to the process: the address space with its code, global variables and heap, the open file descriptors with their offsets, the signal handlers, the current directory and the user and group IDs. So a global that one thread changes, or a file offset one thread moves, changes for all of them. Each thread owns only what it needs to run on its own: its stack, with its local variables, its registers and program counter, its thread ID, its signal mask and its own errno. In the lesson's run, a global set by one thread was seen by main, while each thread's local variable and errno lived at addresses of their own, and one thread's failed open left ENOENT in its errno only.

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

  1. Question 1 of 2 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 before exec and use no CPU. What do you suspect first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: fork copied the address space with a lock that another thread held at that moment, and the child's logging waits for it

    A thread is a task that shares its parent's address space; a forked child is a task with a copy of it, locks and all, but with a single thread. That is why POSIX lets such a child call only async-signal-safe functions until an exec succeeds.

  2. Question 2 of 2 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. What do you weigh first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: What a crash does, since a fault in a plugin thread ends the whole application while a plugin process can crash alone

    The lesson measured a process at about 28 times the cost of a thread in CPython, and still gave the rule: parts that must survive each other's failures belong in separate processes.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.