Поток vs процесс — что общего, что разного
Если вы открыли htop и видите в колонке H (или нажали Shift+H), вдруг становится видно: в системе не 200 процессов, а 1500 потоков. Один Chrome — штук 80 потоков. Один Postgres-сервер — десятки. Что такое поток на самом деле, чем он отличается от процесса и почему OS-планировщик в Linux вообще не делает между ними разницы — разберём в этом уроке.
Это критически важно для junior data engineer. Когда вы пишете ThreadPoolExecutor в Python или включаете numThreads=8 в Spark, вы не запускаете 8 процессов. Вы запускаете 8 потоков внутри одного процесса — они делят память, файловые дескрипторы, сетевые сокеты. Когда один из них упал с segfault — упал весь процесс, все 8 потоков. Когда один пишет в shared dict без блокировки — у вас race condition, и данные испорчены незаметно.
Главная идея: процесс — это контейнер ресурсов, поток — единица выполнения
В традиционной модели (как преподают в учебниках) процесс — это контейнер, в котором живут потоки. Контейнер владеет ресурсами: памятью, открытыми файлами, сигналами, переменными окружения. Потоки внутри — это единицы, которые планировщик OS реально пускает на CPU. У каждого потока свой стек и свои регистры, но они смотрят в одну и ту же память процесса.
Что значит «один address space»? Если поток 2 записал в переменную counter = 42, поток 3 моментально это увидит — они смотрят в одну и ту же ячейку RAM. Это и преимущество (быстро обмениваться данными, не нужны pipes или sockets), и проклятие (нужны мьютексы, иначе данные будут портиться).
Что разное у потоков
Хотя потоки делят почти всё, у каждого есть приватное:
Самое важное — стек. Когда вы вызываете функцию из потока, фрейм этой функции (локальные переменные, return-адрес) кладётся на стек именно этого потока. Если другой поток одновременно вызывает ту же функцию, он работает со своим стеком — они не пересекаются. Это автоматическая «изоляция» локальных переменных. Глобальные переменные и поля объектов в куче — нет, там нужна синхронизация.
Что общее у потоков одного процесса
Всё, что не перечислено выше, — общее. Это и есть фундаментальное отличие от процессов:
Практическое следствие: если в Python-программе с threading один поток меняет os.environ['DEBUG'] = '1' — это видят все потоки. Если один поток делает os.chdir('/tmp') — работать с относительными путями становится опасно во всех потоках.
Linux: в чём разница на самом деле
Тут начинается философский момент. В Linux поток и процесс — это одна и та же сущность на уровне ядра. И то, и другое — это task_struct (структура данных, описывающая выполняемую сущность). Различие только в том, сколько ресурсов они делят с другими task_struct.
Когда вы делаете fork() — ядро создаёт новый task_struct, копирует ему address space (через copy-on-write), копирует таблицу файловых дескрипторов и т.д. Получается новый процесс с новым PID.
Когда вы делаете pthread_create() — библиотека под капотом вызывает clone() с флагами CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD. Ядро создаёт новый task_struct, но не копирует address space (CLONE_VM — share memory), не копирует таблицу файлов (CLONE_FILES — share fds), не копирует signal handlers (CLONE_SIGHAND). Это и есть «поток».
Можно увидеть это руками. Возьмём strace:
# Создаём процесс через fork:
strace -f -e clone bash -c 'sleep 0.1 & wait' 2>&1 | grep clone
# clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, ...)
# Это fork -- видите SIGCHLD без CLONE_VM/CLONE_FILES
# Создаём поток в Python:
cat > /tmp/threads.py << 'EOF'
import threading, time
def w(): time.sleep(0.1)
threading.Thread(target=w).start()
EOF
strace -f -e clone python3 /tmp/threads.py 2>&1 | grep clone | head -1
# clone(child_stack=..., flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, ...)
# Видите CLONE_VM, CLONE_FILES -- это поток
Запомните: на уровне kernel-планировщика никакой разницы между потоком и процессом нет. Планировщик видит просто список task_struct и распределяет CPU. Идея «потоки внутри процесса» — это абстракция в основном для programmer’а: «у меня одно адресное пространство, в нём несколько единиц выполнения».
TID vs PID — как Linux их различает
У каждого потока есть свой TID (Thread ID). У главного потока TID совпадает с PID процесса. У всех остальных потоков TID — отдельный, но PID одинаковый.
# Запустим программу с несколькими потоками (например, любое Java-приложение или Postgres):
ps -eLf | head -3
# UID PID PPID LWP NLWP CMD
# postgres 1234 1 1234 3 /usr/bin/postgres
# postgres 1234 1 1235 3 /usr/bin/postgres <- тот же PID, другой LWP (=TID)
# postgres 1234 1 1236 3 /usr/bin/postgres <- ещё один
# Только потоки одного процесса в /proc/[pid]/task/:
ls /proc/1234/task/
# 1234 1235 1236 <- TID'ы всех потоков процесса 1234
# Каждый TID -- такой же /proc-каталог как обычный процесс:
cat /proc/1234/task/1235/comm
# Имя именно этого потока (можно поставить через pthread_setname_np)
# Cколько потоков в системе всего:
ps -eLf | wc -l
# Может быть 1500-3000 на обычном Linux-десктопе
Когда вы шлёте сигнал через kill -SIGTERM 1234 — сигнал прилетает «процессу» 1234, и ядро доставит его в любой из его потоков (тот, у которого signal mask не блокирует SIGTERM). Если хотите конкретному потоку — используйте tkill (через syscall) или pthread_kill() в C.
Cost: потоки гораздо дешевле процессов
Когда мы говорим «дешевле», имеем в виду две вещи: создание и переключение.
Создание. fork() копирует таблицы страниц (даже с COW), копирует таблицу файловых дескрипторов, делает много работы по копированию состояния. Типичные времена — 100-500 мкс. pthread_create() — 5-20 мкс. Поток создаётся в 10-50 раз быстрее.
Переключение. Переключение между процессами требует смены адресного пространства, что включает в себя сброс TLB (Translation Lookaside Buffer — кэш переводов виртуальных адресов в физические). Это дорого: после переключения первые сотни обращений к памяти будут медленнее, потому что TLB пуст. Переключение между потоками одного процесса не требует смены address space — TLB сохраняется. Поэтому потоки переключаются дешевле.
Из-за этого многопоточные серверы (Apache worker, Postgres backends — нет, Postgres процессы; nginx worker — нет, nginx event-loop) делают thread per request быстрее, чем process per request. Тот же Java Tomcat: 200 потоков обработчиков — норма. Создавать 200 процессов было бы непрактично.
Когда выбирать потоки, когда процессы
Многопоточная программа имеет преимущества:
- Низкая стоимость создания/переключения.
- Лёгкий обмен данными. Просто общая переменная.
- Меньше памяти. Code segment, библиотеки — один раз на весь процесс.
Но и недостатки:
- Один segfault — весь процесс мёртв. Все потоки падают вместе.
- Race conditions. Нужно осваивать мьютексы, atomic, memory ordering — разберём в уроке 03.
- Один баг в одном потоке (например, утечка памяти или повреждение heap) — проблема для всех потоков.
- GIL в Python. В CPython только один поток может выполнять Python-байткод одновременно. Поэтому в Python для CPU-bound задач часто используют
multiprocessing, а неthreading.
Многопроцессная программа:
- Изоляция. Один процесс упал — другие живут.
- Параллелизм по-настоящему. В Python через
multiprocessingобходим GIL. - Безопасность. Можно запускать в разных UID, разных namespaces.
Минусы:
- Дорого создавать, переключать.
- Сложно обмениваться данными — нужны IPC (pipes, sockets, shm).
- Больше памяти.
Постгрес исторически делает process per connection (по одному backend-процессу на коннект клиента). Это даёт изоляцию: один тяжёлый запрос не сломает соседей через повреждение памяти. Но дорого: каждое соединение — 5-10MB minimum. Поэтому появились pgbouncer-style решения. nginx делает event loop в одном процессе на ядро — никаких потоков, просто async I/O.
Правило большого пальца. Нужен реальный параллелизм + low cost обмена данными — потоки. Нужна изоляция или у вас Python с GIL для CPU-bound — процессы. Нужно много соединений I/O-bound — async (event loop) в одном потоке.
Попробуй сам
# 1. Посмотреть все потоки всех процессов:
ps -eLf | head -20
# Колонка LWP -- это TID, NLWP -- сколько потоков в процессе
# 2. Сколько потоков в самом большом процессе:
ps -eLf --sort -nlwp | head -5
# Часто впереди будет Chrome, Java или какой-нибудь Postgres
# 3. Запустим многопоточный Python и посмотрим:
cat > /tmp/threads.py << 'EOF'
import threading
import time
import os
print(f"PID: {os.getpid()}")
def work(name):
while True:
time.sleep(1)
for i in range(5):
threading.Thread(target=work, args=(f"t{i}",), daemon=True).start()
time.sleep(60)
EOF
python3 /tmp/threads.py &
PYPID=$!
sleep 1
# Посмотрим потоки этого процесса:
ls /proc/$PYPID/task/
# Увидим 6 TID'ов: главный + 5 worker'ов
# Посмотрим имя главного и имена потоков:
for tid in $(ls /proc/$PYPID/task/); do
echo -n "TID $tid: "
cat /proc/$PYPID/task/$tid/comm
done
kill $PYPID
# 4. Сравните top по PID и top по TID:
top -H # с потоками
top # без потоков (только процессы)
# В режиме -H -- больше строк, видны все TID
# 5. Создадим процесс через fork и сравним стоимость:
# time-перевод примерно 100x разница (вы это увидите в реальном профилировании)