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.
Каждая модель — компромисс между производительностью, простотой и реальным параллелизмом. Рассмотрим каждую.
1:1 модель — то, что использует Linux
В 1:1 модели каждый pthread_t — это отдельный task_struct в ядре. Когда вы делаете pthread_create(), библиотека через clone() создаёт новый task_struct. Когда вы делаете pthread_join(), библиотека ждёт завершения этого task_struct.
Плюсы 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:
- Очень дёшево создать поток. 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:
- Очень мало 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.
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 год:
Тренд понятен: 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-запросов
Ни то, ни то для 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