Learning Platform
Глоссарий Troubleshooting
Урок 05.02 · 18 мин
Начальный
ThreadsNPTLpthreadsGreen threadsLinux

Threading models — 1:1, M:N, M:1 и почему Linux выбрал NPTL

Когда вы пишете Thread::new() в Rust или new Thread() в Java — кто этот поток на самом деле планирует на CPU? Сам runtime в userspace? Или kernel? Ответ зависит от threading model — способа, которым user-level threads (то, что видит ваш код) соотносятся с kernel-level threads (то, что видит планировщик).

В этом уроке разберём три классические модели (1:1, M:N, M:1), посмотрим, почему Linux в 2003 году отказался от LinuxThreads и перешёл на NPTL (Native POSIX Thread Library), и почему сейчас все возвращаются к «зелёным потокам» через Go goroutines, Rust async, Python asyncio.

Для junior data engineer это даёт картину: когда вы запускаете 1000 Spark task’ов или 10000 connections в asyncio-сервере, важно понимать, сколько kernel threads под капотом и где это упирается.


Введение: user threads vs kernel threads

Есть две точки зрения на поток:

User-level thread — то, что видит ваша программа. pthread_t, threading.Thread, Goroutine, Future. Это объект в памяти процесса, описывающий «единицу выполнения» с точки зрения programmer’а.

Kernel-level thread — то, что планировщик ядра видит и распределяет на CPU. В Linux это task_struct с уникальным TID. Ядро ничего не знает о ваших pthread_t — оно знает только о своих task_struct.

Threading model описывает, как N user-threads мапятся на M kernel-threads.

Три классические threading models
1:1 modelКаждый user-thread соответствует одному kernel-thread. Простая модель, kernel сам всё планирует. Linux NPTL, Windows, macOS, современный pthreads везде
M:N modelM user-threads мапятся на N kernel-threads, где N меньше M. User-level scheduler принимает решения, плюс kernel scheduler. Solaris LWP, Erlang BEAM, частично Go runtime
M:1 modelМного user-threads на ОДИН kernel-thread. Все 'потоки' выполняются на одном CPU. Старые Java green threads, Python asyncio event loop, JS event loop

Каждая модель — компромисс между производительностью, простотой и реальным параллелизмом. Рассмотрим каждую.


1:1 модель — то, что использует Linux

В 1:1 модели каждый pthread_t — это отдельный task_struct в ядре. Когда вы делаете pthread_create(), библиотека через clone() создаёт новый task_struct. Когда вы делаете pthread_join(), библиотека ждёт завершения этого task_struct.

1:1 -- user thread = kernel thread
pthread_t aUserspace handle. Внутри -- указатель на TCB (thread control block)
task_struct TID 1235Структура в ядре. Управляется планировщиком kernel напрямую. CPU выделяется по правилам ядра (CFS в Linux)
pthread_t bВторой userspace handle
task_struct TID 1236Второй task_struct. Планируется независимо. На многоядерной системе может работать параллельно с TID 1235 на другом CPU

Плюсы 1:1:

  • Реальный параллелизм на многоядерных CPU. Если у вас 8 ядер — 8 потоков работают по-настоящему параллельно.
  • Просто реализовано. Библиотека тонкая, всё делает ядро.
  • Один блокирующий syscall не блокирует другие потоки. Если один thread делает read() из медленного диска — остальные продолжают работать на других ядрах.
  • Хорошая интеграция с kernel. Профилирование, ptrace, signal handling — всё работает естественно.

Минусы 1:1:

  • Дорого создавать много потоков. Каждый поток — task_struct в ядре (~10KB), стек (8MB по умолчанию). 10000 потоков = 80GB виртуальной памяти на стеки + 100MB kernel-structures.
  • Переключение — syscall. Каждое переключение проходит через ядро.
  • Один поток — одно CPU за раз. Нельзя 100 потоков одной программы запихнуть на 1 CPU так, чтобы они эффективно переключались — ядро будет переключать редко и тяжело.

Linux NPTL (Native POSIX Thread Library) — это эталонная 1:1 реализация для Linux, появилась в 2003 году. Windows, macOS, FreeBSD — тоже 1:1. Это сейчас де-факто стандарт для системных потоков.

# Проверить, что glibc использует NPTL (1:1 модель):
getconf GNU_LIBPTHREAD_VERSION
# NPTL 2.38   <- да, NPTL

# До 2003 был LinuxThreads -- кривая 1:1 реализация (issue со signals, processes vs threads):
# https://www.kernel.org/doc/man-pages/man7/pthreads.7.html

M:N модель — много user-threads на мало kernel-threads

M:N model — это когда runtime в userspace сам управляет N user-threads, шедулит их между M kernel-threads (где обычно M = число CPU).

M:N -- userspace scheduler + kernel scheduler
User thread 1Goroutine, Erlang process, или Solaris LWP. Лёгкая структура в userspace, стек 2-4KB вместо 8MB
User thread 2Второй lightweight thread
User thread 3..
User thread NМожет быть 1000+ user threads на 1 kernel thread
Userspace schedulerРешает, какой user thread выполнить сейчас на каком kernel thread. Может быть work-stealing (Go), preemptive или cooperative. Не нужен syscall для переключения
Kernel thread M1Реальный kernel-level thread (task_struct). Их обычно столько, сколько CPU cores
Kernel thread M2Второй kernel thread
Kernel thread MNN = число CPU обычно

Плюсы M:N:

  • Очень дёшево создать поток. 4-8KB на goroutine, не 8MB. 1 миллион goroutines на одной машине — норма.
  • Быстрое переключение. Без syscall, просто сохранили регистры и загрузили другие.
  • Параллелизм на CPU. Можно использовать все ядра.

Минусы M:N:

  • Сложная реализация runtime. Go-планировщик — большой и сложный код.
  • Блокирующие syscalls — проблема. Если goroutine делает синхронный read() — блокируется весь kernel thread, и planner должен «вынуть» другие goroutines с него (Go это решает, делая M динамическим).
  • Сложности с FFI и сигналами. В Go вызов в C-библиотеку (cgo) — дорогая операция: runtime должен «выпустить» kernel thread для блокировки.

Классический пример M:N — Solaris с LWP (Lightweight Processes). Сейчас Go runtime использует похожую идею: GOMAXPROCS kernel threads, на которых исполняются миллионы goroutines.

# В Go каждый goroutine - не kernel thread:
cat > /tmp/many.go << 'EOF'
package main
import "fmt"; import "runtime"; import "time"
func main() {
    for i := 0; i < 100000; i++ {
        go func() { time.Sleep(time.Hour) }()
    }
    time.Sleep(time.Second)
    fmt.Println("goroutines:", runtime.NumGoroutine())
}
EOF
# Запустить и посмотреть число потоков:
go run /tmp/many.go &
GOPID=$!
sleep 2
ls /proc/$GOPID/task/ | wc -l
# Около GOMAXPROCS (обычно 8-16) -- но goroutines 100000!
kill $GOPID

M:1 модель — все user-threads на одном CPU

M:1 — крайний случай: много user-threads на ровно один kernel-thread. Все «потоки» в одном CPU, переключаются кооперативно. Это event loop — по сути.

M:1 -- event loop, нет реального параллелизма
Task 1Coroutine, async function, Future. Можно иметь миллионы таких task'ов
Task 2Второй task
Task N..
Event loopUserspace scheduler. Выбирает следующий task, готовый к выполнению (поступили данные с сокета, истёк таймер). Cooperative scheduling: task сам отдаёт управление через await/yield
One kernel threadОдин task_struct в ядре. Один CPU за раз. Никакого реального параллелизма -- зато простота и низкий overhead

Плюсы M:1:

  • Очень мало memory overhead. Стек coroutine — сотни байт, не килобайты.
  • Нет race conditions на shared state в обычных случаях. Между await-точками только один task выполняется.
  • Идеально для I/O-bound нагрузки. 10000 одновременных сокетов — это легко в asyncio.

Минусы M:1:

  • Нет реального параллелизма. Один CPU. Если task CPU-bound — все остальные ждут.
  • Cooperative scheduling — баги. Если task забыл await и зацикливается — блокирует всех.
  • Невидимо для kernel. ps покажет один процесс, не видно нагрузки внутри.

Это модель Node.js (один event loop), Python asyncio (один event loop), Java Loom virtual threads (хотя там скорее M:N) , старые Java green threads (до Java 1.3), Lua coroutines.

Часто M:1 комбинируют с процессами: «один event loop на CPU». Nginx делает так: 8 ядер -> 8 worker-процессов, каждый — event loop в одном потоке. Получаем M:8 эффективно, через процессы а не потоки.


NPTL: эволюция Linux threading

В 2002 году Linux имел LinuxThreads — 1:1 implementation. Но кривую. Каждый поток был отдельным процессом с другим PID (!), сигналы доставлялись непредсказуемо, signal handlers разные для каждого «потока» — POSIX-несовместимо.

В 2003 году Ulrich Drepper и Ingo Molnar выкатили NPTL (Native POSIX Thread Library). Изменения были в первую очередь в ядре: новые флаги для clone(), новый syscall futex (Fast Userspace Mutex), новый механизм TLS на x86.

LinuxThreads vs NPTL
LinuxThreads (до 2003)Каждый поток -- отдельный процесс с уникальным PID. Сигналы доставляются одному потоку, getpid() возвращает разное в разных потоках. POSIX-несовместимо
NPTL (2003+)Все потоки процесса имеют один PID. getpid() возвращает одинаково. Сигналы доставляются процессу. Поддержка futex, TLS. POSIX-совместимо
Дорогие мьютексыLinuxThreads использовал signal-based синхронизацию -- дорого
futex-basedNPTL использует futex (Fast Userspace muTEX). Если нет contention -- всё в userspace, не нужно syscall. Только при сборе ожидающих -- идём в kernel

Futex (fast userspace mutex) — ключевая инновация. В обычном пути захвата мьютекса (когда никто не держит) не нужен ни один syscall — atomic CAS в userspace и всё. Syscall futex() нужен только тогда, когда поток должен подождать (мьютекс уже захвачен) или разбудить ждущих.

# Глянем futex-вызовы в реальной программе:
echo 'import threading; import time
lock = threading.Lock()
def w():
    for _ in range(10):
        with lock: time.sleep(0.001)
for _ in range(3):
    threading.Thread(target=w).start()
' > /tmp/futex.py
strace -f -e futex python3 /tmp/futex.py 2>&1 | head -20
# Увидите много futex(FUTEX_WAIT_BITSET), futex(FUTEX_WAKE_PRIVATE) -- это работа Lock внутри

Где это всё сейчас

Состояние на 2026 год:

Threading модели в современных рантаймах
C/C++ pthreadsLinux/macOS/BSD pthreads -- 1:1 через NPTL или эквивалент. Каждый pthread_t = task_struct в ядре
Java ThreadJVM Thread мапится 1:1 на kernel thread. До Java 1.3 были green threads (M:1), сейчас -- 1:1
Rust std::threadRust standard library -- 1:1 через pthreads
Go goroutineM:N. Runtime создаёт kernel threads (M штук, обычно = GOMAXPROCS), на них выполняются миллионы goroutines
Java Loom virtual threadJava 21+. Виртуальные потоки -- по сути M:N через JVM. Один платформенный thread обслуживает много virtual threads
Erlang processBEAM VM. Миллионы 'процессов' (не OS-процессов) мапятся на schedulers (=N CPU). Каждый scheduler -- kernel thread
Python asyncioEvent loop -- M:1. Все coroutines в одном потоке. Для многоядерности комбинируют с multiprocessing
Node.jsV8 + libuv. Один event loop на одном потоке. Worker threads (отдельный API) -- 1:1 но редко
Tokio (Rust async)Tokio runtime -- M:N по умолчанию. Несколько worker threads, на каждом event loop, work-stealing между ними

Тренд понятен: 1:1 для системных потоков (когда нужно реально мало и долгоживущих), M:N или M:1 (асинхронность) для high-concurrency I/O-bound нагрузок. Чистый M:1 умер на десктопах и серверах, но живёт в браузерах (JS).

Конкуррентность в сетевых серверах: где threading model встречается с socket API

Практический пример: Python threading vs asyncio

В Python есть и threading (1:1 через NPTL), и asyncio (M:1 event loop). Когда выбирать?

threading подходит для:

  • I/O-bound, но с блокирующими библиотеками (нет async версии)
  • Когда нужно интегрироваться с C-кодом, который не поддерживает async
  • Простые сценарии: 10-100 потоков на пул

asyncio подходит для:

  • I/O-bound, тысячи одновременных соединений (10000 сокетов)
  • Когда есть async-aware библиотеки (aiohttp, asyncpg)
  • Микросервисы с большим числом коротких I/O-запросов
Что такое процесс в Linux: база для сравнения с потоками

Ни то, ни то для CPU-bound в Python:

  • Из-за GIL и threading, и asyncio — однопоточны по CPU
  • Используйте multiprocessing или вынесите в C/Rust extension
# Простой тест: 10000 одновременных HTTP-запросов
# threading -- упрётся в число потоков (5000+ потоков = много RAM, переключений)
# asyncio -- легко обработает в одном потоке, 50MB RAM, миллисекунды переключений

cat > /tmp/io.py << 'EOF'
import asyncio
import aiohttp
async def fetch(s, url):
    async with s.get(url) as r:
        return await r.text()
async def main():
    async with aiohttp.ClientSession() as s:
        tasks = [fetch(s, "http://httpbin.org/get") for _ in range(100)]
        await asyncio.gather(*tasks)
asyncio.run(main())
EOF
# python3 /tmp/io.py -- работает в одном kernel-thread, 100 concurrent requests

Попробуй сам

# 1. Какая реализация pthreads у вас:
getconf GNU_LIBPTHREAD_VERSION
# NPTL X.Y -- значит 1:1 модель

# 2. Сравните число kernel threads vs userspace tasks в Go и Python:
cat > /tmp/go-many.go << 'EOF'
package main
import "runtime"; import "fmt"; import "time"
func main() {
    for i := 0; i < 1000; i++ {
        go func() { time.Sleep(time.Hour) }()
    }
    time.Sleep(time.Second)
    fmt.Println("goroutines:", runtime.NumGoroutine())
    fmt.Println("OS threads:", runtime.GOMAXPROCS(0))
    time.Sleep(time.Hour)
}
EOF
# go run /tmp/go-many.go &
# Смотрим: 1000 goroutines, но только GOMAXPROCS OS threads (8 на 8-core)

cat > /tmp/py-many.py << 'EOF'
import threading
import time
def w(): time.sleep(3600)
for _ in range(1000):
    threading.Thread(target=w, daemon=True).start()
time.sleep(3600)
EOF
# python3 /tmp/py-many.py &
# Смотрим: 1000 Python threads = 1000+ task_struct в ядре (медленно создаётся, много памяти)

# 3. Посмотрите futex-активность в нагруженной программе:
PID=$(pgrep -f my-server)
strace -e futex -p $PID -c
# Покажет статистику: сколько futex_wait/wake, среднее время

# 4. Сравните создание thread vs process:
python3 -c "
import time, threading, os
t1 = time.perf_counter_ns()
for _ in range(100):
    threading.Thread(target=lambda: None).start()
print(f'100 threads: {(time.perf_counter_ns()-t1)/1e6:.2f} ms')
t2 = time.perf_counter_ns()
for _ in range(100):
    pid = os.fork()
    if pid == 0: os._exit(0)
    else: os.waitpid(pid, 0)
print(f'100 forks: {(time.perf_counter_ns()-t2)/1e6:.2f} ms')
"
# Обычно fork в 5-20 раз дороже thread

Проверка знанийKnowledge check
Junior спрашивает: 'Я слышал, что в Go можно запустить миллион goroutines, а на pthreads -- нельзя. Почему? Это же все потоки, что им мешает?'
ОтветAnswer
Принципиальная разница в том, что pthreads -- это 1:1 модель: каждый pthread это отдельный task_struct в ядре. Goroutine -- это M:N: миллион goroutines выполняется на 8-16 kernel threads. Конкретные ограничения для pthreads (1:1): 1. Стек 8MB по умолчанию -- 1 миллион потоков = 8TB виртуальной памяти на стеки. Можно уменьшить до 64KB, но всё равно 64GB. 2. task_struct в ядре ~10KB -- миллион потоков = 10GB ядро-памяти. Лимит /proc/sys/kernel/threads-max обычно 100k-500k. 3. Создание потока -- syscall clone(), ~10 мкс -- миллион потоков = 10 секунд на создание. 4. Переключение между потоками идёт через kernel, тоже syscall. Для goroutine (M:N): 1. Стек начинается с 2-8KB и растёт по необходимости (segmented stacks) -- миллион goroutines = 2-8GB макс. 2. Goroutine -- структура в userspace ~200 байт, не задействует kernel. 3. Создание goroutine -- userspace функция, не syscall, ~1 мкс. 4. Переключение между goroutines делает Go-runtime, без syscall. Поэтому Python с threading тоже не сможет миллион потоков (упрётся в /proc/sys/kernel/threads-max). А Python с asyncio (M:1) -- сможет миллион coroutines в одном потоке. Главное: 1:1 даёт реальный параллелизм на ядрах, но дорогую структуру. M:N даёт лёгкую структуру и параллелизм, ценой сложности runtime. M:1 даёт самую дешёвую структуру, но без параллелизма.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Что такое 1:1 threading model и где она применяется?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4