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

Поток 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, много threads
Process (PID 1234)Контейнер ресурсов. Владеет address space, file descriptors, signal handlers, environment, UID/GID. Не выполняется сам -- выполняются его потоки
Thread 1 (TID 1234)Главный поток. TID совпадает с PID. У него свой стек (обычно 8MB на Linux), свои регистры, свой program counter
Thread 2 (TID 1235)Worker. Свой стек, свои регистры. Но heap, .data, .bss, открытые файлы -- общие с другими потоками процесса
Thread 3 (TID 1236)Ещё один worker. Может одновременно с Thread 2 модифицировать одну и ту же переменную -- отсюда race conditions
Shared: heap, code, data, file descriptorsHeap (malloc), сегмент кода (.text), глобальные переменные (.data/.bss), открытые файлы и сокеты -- одни и те же для всех потоков

Что значит «один address space»? Если поток 2 записал в переменную counter = 42, поток 3 моментально это увидит — они смотрят в одну и ту же ячейку RAM. Это и преимущество (быстро обмениваться данными, не нужны pipes или sockets), и проклятие (нужны мьютексы, иначе данные будут портиться).


Что разное у потоков

Хотя потоки делят почти всё, у каждого есть приватное:

Private state каждого потока
StackСобственный стек на ~8MB (Linux default). Сюда кладутся локальные переменные, аргументы функций, return-адреса. Стеки разных потоков лежат в разных областях памяти процесса, не пересекаются
RegistersRAX, RBX, RCX, RDX, R8-R15, флаги, segment registers. Когда планировщик переключает поток, он сохраняет регистры в task_struct, загружает регистры следующего потока
Program counter (RIP)Где именно в коде сейчас выполняется этот поток. Один поток может быть в строке 50 функции foo, другой -- в строке 200 функции bar
errnoГлобальная переменная для системных ошибок. В однопоточной программе одна на всю программу. В многопоточной -- thread-local, иначе ошибки бы перетирали друг друга
signal maskКакие сигналы разблокированы для этого конкретного потока. Можно делать так, чтобы SIGINT обрабатывал только главный поток, а worker'ы его игнорировали
TLSThread Local Storage. Глобальная переменная, у которой у каждого потока свой экземпляр. Объявляется через __thread в C или threading.local() в Python

Самое важное — стек. Когда вы вызываете функцию из потока, фрейм этой функции (локальные переменные, return-адрес) кладётся на стек именно этого потока. Если другой поток одновременно вызывает ту же функцию, он работает со своим стеком — они не пересекаются. Это автоматическая «изоляция» локальных переменных. Глобальные переменные и поля объектов в куче — нет, там нужна синхронизация.


Что общее у потоков одного процесса

Всё, что не перечислено выше, — общее. Это и есть фундаментальное отличие от процессов:

Shared между потоками процесса
Address spaceОдна виртуальная память. Указатель из потока 1 валиден в потоке 2 (если оба смотрят в эту память). Можно передавать pointer между потоками просто как аргумент
Heapmalloc-память. Если поток 1 сделал malloc и сохранил указатель в shared переменную -- поток 2 может прочитать и записать туда же
.data, .bssСегменты для глобальных переменных. Если в C объявили int counter; -- все потоки видят одну и ту же ячейку
File descriptorsОткрытые файлы и сокеты. Если поток 1 открыл /var/log/app.log fd=5, поток 2 может писать в fd=5 одновременно. Ядро сериализует write(), но порядок не гарантирован
Signal handlersУстановленные обработчики сигналов общие на весь процесс. Сигнал может прилететь в любой поток (если signal mask не блокирует)
Working dir, envgetcwd(), переменные окружения -- одно на весь процесс. Если один поток сделал chdir() -- меняется для всех потоков
PID, UID, GIDВсе потоки процесса имеют один PID (идентификатор процесса), один UID (под каким пользователем запущен), одни группы

Практическое следствие: если в 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). Это и есть «поток».

fork() vs pthread_create() через clone()
fork()Под капотом clone() без флагов sharing -- всё копируется. Новый PID, новое address space (COW), новые fds. Дорогая операция при большом RSS
clone(...)Универсальный syscall. fork == clone без флагов sharing. pthread_create == clone с CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND, CLONE_THREAD
task_structСтруктура в ядре, ~10KB. Один task_struct на одну выполняемую сущность (поток или процесс)
pthread_create()POSIX threads API. Высокоуровневая обёртка над clone() для создания потока внутри процесса. Использует NPTL (Native POSIX Thread Library) в glibc
clone(CLONE_VM | CLONE_FILES | ...)Те же task_struct, но share практически всё. Дешевле fork() -- не нужно копировать таблицы страниц

Можно увидеть это руками. Возьмём 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.

ps: смотрим на процессы и потоки через LWP

Cost: потоки гораздо дешевле процессов

Когда мы говорим «дешевле», имеем в виду две вещи: создание и переключение.

Создание. fork() копирует таблицы страниц (даже с COW), копирует таблицу файловых дескрипторов, делает много работы по копированию состояния. Типичные времена — 100-500 мкс. pthread_create() — 5-20 мкс. Поток создаётся в 10-50 раз быстрее.

Переключение. Переключение между процессами требует смены адресного пространства, что включает в себя сброс TLB (Translation Lookaside Buffer — кэш переводов виртуальных адресов в физические). Это дорого: после переключения первые сотни обращений к памяти будут медленнее, потому что TLB пуст. Переключение между потоками одного процесса не требует смены address space — TLB сохраняется. Поэтому потоки переключаются дешевле.

Сравнение стоимости
fork()Создание процесса. Дорого: копирование page tables, fd table, signal table. Типично 100-500 мкс
pthread_create()Создание потока. Дёшево: новый task_struct, новый стек, share всего остального. Типично 5-20 мкс
exec()Загрузка новой программы. Дорогая операция: парсинг ELF, загрузка библиотек, релокации. Типично 1-5 мс
Process switchПереключение между процессами. Требует смены CR3 register (PML4), частичного flush TLB. Реальная стоимость с учётом cache miss -- 5-30 мкс
Thread switchПереключение между потоками одного процесса. CR3 не меняется, TLB сохраняется. Часто 1-5 мкс

Из-за этого многопоточные серверы (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.

TIP

Правило большого пальца. Нужен реальный параллелизм + low cost обмена данными — потоки. Нужна изоляция или у вас Python с GIL для CPU-bound — процессы. Нужно много соединений I/O-bound — async (event loop) в одном потоке.

Конкуррентность в сетевых серверах: threads, poll/epoll, async Namespaces и cgroups: как clone() лежит в основе контейнеров

Попробуй сам

# 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 разница (вы это увидите в реальном профилировании)

Проверка знанийKnowledge check
Junior спрашивает: 'Если в моём Java-приложении 200 потоков, и я делаю kill 1234 (PID процесса) -- что произойдёт? Сигнал прилетит во все 200 потоков или в один?'
ОтветAnswer
Сигнал прилетит в процесс как целое, не в каждый поток. Ядро доставит SIGTERM ОДНОМУ из потоков -- тому, у которого этот сигнал не заблокирован в signal mask. Какому конкретно -- не определено, ядро выбирает. Обычно это главный поток (если он не заблокировал SIGTERM), но не всегда. Поток-получатель вызовет signal handler (если установлен) или произойдёт действие по умолчанию (для SIGTERM -- terminate процесса). Но термин 'terminate процесса' означает: процесс завершится целиком, со всеми 200 потоками сразу. Невозможно убить один поток и оставить остальные через сигнал процессу -- сигнал на уровне процесса всегда влияет на весь процесс. Если нужно убить конкретный поток (что обычно плохая идея), есть pthread_kill(tid) -- но это шлёт сигнал именно тому TID, и если поток умрёт -- это часто оставит мьютексы заблокированными и испорченное состояние процесса. Поэтому правильный паттерн graceful shutdown: signal handler устанавливает атомарный флаг 'shutdown_requested = true', все потоки регулярно его проверяют и сами корректно завершаются. kill 1234 шлёт сигнал процессу -> главный поток ловит -> ставит флаг -> все потоки видят его в общей памяти (потому что у них один address space) -> завершаются по очереди.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Что НЕ является общим (shared) между потоками одного процесса?

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

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

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

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