ОС · Конкурентность · 14 мин
Потоки vs процессы vs горутины
Зачем вообще потоки
Раньше у нас была картина «один процесс — одна линия исполнения». Программа шла по коду сверху вниз, как один человек читает книгу: одна пара глаз, одна закладка.
Но что если работы много и её можно делать параллельно? Например, веб-сервер обрабатывает сотни запросов одновременно. Можно на каждый запрос плодить новый процесс (см. процессы) — но это дорого: у процесса своё адресное пространство (см. адресное пространство), своя таблица страниц, своё всё. Создавать и переключать такое — накладно, а делиться данными между процессами тяжело (нужны трубы, разделяемая память, сериализация).
Поток (thread) — это способ иметь несколько линий исполнения внутри одного процесса. Аналогия: процесс — это квартира, а потоки — жильцы в ней. Кухня, холодильник и гостиная (память) общие, но у каждого жильца своя кровать (стек) и своя голова с текущими мыслями (регистры). Жить вместе дёшево, но за общий холодильник можно и подраться — отсюда все проблемы гонок, о которых дальше.
Что общее, а что своё
Ключ к пониманию потоков — чётко знать, что они делят, а что у каждого своё.
ПРОЦЕСС (одно адресное пространство)
┌───────────────────────────────────────────────┐
│ Код (.text) ← общий │
│ Глобальные/static (.data, .bss) ← общие │
│ КУЧА (heap, malloc) ← ОБЩАЯ │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Поток 1 │ │ Поток 2 │ │
│ │ свой стек │ │ свой стек │ │
│ │ свои регистры│ │ свои регистры│ │
│ │ свой PC/SP │ │ свой PC/SP │ │
│ └─────────────┘ └─────────────┘ │
└───────────────────────────────────────────────┘Что нарисовано: два потока живут в одном процессе. Код, глобальные переменные и
куча — общие: если поток 1 положил что-то в malloc-нутую структуру, поток 2
сразу это видит. А вот стек (локальные переменные, кадры вызовов функций) и
регистры (включая PC — указатель на текущую инструкцию, и SP — вершину
стека) у каждого потока свои.
Состояние потока ОС хранит в структуре TCB (Thread Control Block) — это младший брат PCB (Process Control Block) из главы про процессы. Если PCB описывает весь процесс (память, открытые файлы, права), то TCB описывает одну линию исполнения: сохранённые регистры, указатель стека, состояние (работает/ждёт/готов).
| Ресурс | Процессы между собой | Потоки одного процесса |
|---|---|---|
| Адресное пространство | у каждого своё | общее |
| Куча / глобальные | изолированы | общие |
| Открытые файлы | у каждого свои | общие |
| Стек | свой | у каждого свой |
| Регистры, PC, SP | свои | у каждого свои |
| Цена создания | высокая | низкая |
| Цена переключения | высокая (смена таблицы страниц, сброс TLB) | ниже (адр. пространство то же) |
Эта таблица — суть главы. Потоки дешевле процессов именно потому, что переключение между ними не требует менять таблицу страниц и сбрасывать TLB.
Почему общая память — это и сила, и беда
Общая куча — удобно: потоки обмениваются данными напрямую, без копирования. Но та же общая память порождает гонки (race conditions) — ситуации, когда результат зависит от того, в каком порядке планировщик переключал потоки.
Самый классический пример — обычный инкремент count++. Кажется, что это одна
операция, но процессор делает её в три шага:
count++ расшифровывается как:
┌──────────────────────────────────────────┐
│ 1. load R ← память[count] (прочитать) │
│ 2. add R ← R + 1 (прибавить) │
│ 3. store память[count] ← R (записать) │
└──────────────────────────────────────────┘Что нарисовано: одна строчка кода = три машинные инструкции. Между любыми двумя из них планировщик может вытеснить поток и пустить другой. И вот тут начинается беда.
Потерянный инкремент: пошагово
Пусть count = 5, и два потока одновременно делают count++. Ожидаем 7.
Посмотрим, как планировщик может всё испортить.
Время │ Поток A │ Поток B │ count в памяти
──────┼───────────────────┼───────────────────┼──────────────
t1 │ load R=5 │ │ 5
t2 │ add R=6 │ │ 5
│ ─── вытеснение, переключаемся на B ─── │
t3 │ │ load R=5 (!) │ 5
t4 │ │ add R=6 │ 5
t5 │ │ store count=6 │ 6
│ ─── возвращаемся к A, его R всё ещё 6 ─│
t6 │ store count=6 (!) │ │ 6
──────┴───────────────────┴───────────────────┴──────────────
Итог: count = 6, а должно быть 7. Инкремент ПОТЕРЯН.Что нарисовано: поток B прочитал count (5) до того, как A успел записать
своё значение. У каждого потока свой регистр R, поэтому B не увидел, что A уже
прибавил единицу. В итоге две операции +1 дали только +1. Это и есть
потерянное обновление — фундамент всех проблем конкурентности.
Участок кода, где идёт работа с общими данными и который нельзя «переплетать»,
называется критической секцией. Чтобы её защитить, нужна взаимная
исключительность — гарантия, что в критической секции в каждый момент только
один поток. Как это реализуют на уровне железа (test-and-set, CAS) — тема
следующей главы блокировки: железо. В Go тот же эффект
описывается через модель памяти, а защита — через
sync.Mutex и каналы.
Kernel threads vs user threads: кто кого планирует
Поток можно «видеть» на двух уровнях: знает ли о нём ядро ОС, или он существует только внутри программы. Отсюда три модели связи между пользовательскими потоками (которыми оперирует ваш код/рантайм) и ядерными потоками (которые реально ставит на ядра CPU планировщик ОС).
1:1 (каждый user-поток = свой kernel-поток)
U1 U2 U3
│ │ │
K1 K2 K3 ← ядро видит и планирует каждый
(POSIX threads / Linux, std::thread)
N:1 (много user-потоков на один kernel-поток)
U1 U2 U3
\ | /
K1 ← ядро видит ОДИН поток; рантайм сам жонглирует
(старые «зелёные потоки»; блокирующий syscall морозит всех)
M:N (M user-потоков на N kernel-потоков)
U1 U2 U3 U4 U5
\ \ | / /
K1 K2 ← рантайм мультиплексирует M на N ядерных
(модель горутин в Go)Что нарисовано: три способа «приземлить» пользовательские потоки на ядерные.
- 1:1 — самый прямой. Каждый ваш поток — отдельный поток ОС. Так работают
POSIX threads (pthreads) и
std::thread. Плюс: настоящий параллелизм, блокировка одного не морозит других. Минус: каждый поток — это ресурс ядра (память под стек ~1–8 МБ, запись в планировщике), создавать их тысячами дорого. - N:1 — все пользовательские потоки крутятся на одном ядерном. Рантайм сам переключает их в user space (быстро, без захода в ядро). Минус смертельный: один блокирующий системный вызов (например, чтение с диска) усыпляет весь ядерный поток — а значит, и все user-потоки на нём. И параллелизма по ядрам нет.
- M:N — золотая середина: рантайм держит пул из
Nядерных потоков и размазывает по нимMпользовательских. Дорого создавать толькоNштук, аMмогут быть лёгкими и многочисленными. Именно так устроены горутины.
Горутины: M:N и модель GMP
Горутина — это пользовательский («зелёный») поток рантайма Go. Создать её почти бесплатно: стартовый стек всего ~2 КБ (и растёт по мере надобности), тогда как у потока ОС стек — мегабайты. Поэтому миллион горутин — норма, а миллион потоков ОС положит машину.
Рантайм Go реализует модель M:N через диспетчер GMP:
G G G G G G G G ← Goroutine: ваши горутины (их тысячи)
\ \ | run-queue | /
┌───────┐ ┌───────┐
│ P │ │ P │ ← Processor: логический «слот» исполнения
└───┬───┘ └───┬───┘ (их GOMAXPROCS штук)
│ │
[M] [M] ← Machine: поток ОС (kernel thread)
│ │
ядро1 ядро2 ← реальные ядра CPUЧто нарисовано: G (goroutine) — единица работы; M (machine) — настоящий поток ОС; P (processor) — логический контекст с локальной очередью готовых горутин, который нужно «занять», чтобы выполнять Go-код. Планировщик Go сам снимает горутину с потока, когда она блокируется (например, ждёт канал или сетевой ответ), и ставит на этот поток другую готовую горутину — всё в user space, без дорогого захода в ядро. Подробности — в Go-курсе: горутины и планировщик.
Цена переключения: потоки ОС vs горутины
Почему вообще весь сыр-бор с user-потоками? Из-за стоимости переключения контекста.
Переключение между потоками ОС (kernel switch):
user → [trap в ядро] → сохранить регистры в TCB →
планировщик ОС → загрузить регистры другого →
[возможно сброс TLB] → return в user
≈ сотни–тысячи наносекунд + промахи кэша
Переключение между горутинами (на одном M):
сохранить пару регистров → подменить указатель стека →
прыгнуть в другую горутину (всё в user space)
≈ десятки наносекундЧто нарисовано: переключение потоков ОС обязательно проходит через ядро (через
trap — см. ограниченное прямое исполнение),
а если меняется адресное пространство, ещё и страдает TLB.
Переключение горутин на одном потоке ОС ядро вообще не трогает — поэтому оно на
порядок-другой дешевле. Это и есть главная причина, по которой Go может позволить
себе сотни тысяч «потоков».
Важно не путать: горутины дают дешёвую конкурентность (много задач в работе),
а реальный параллелизм (одновременное счёт на нескольких ядрах) ограничен
числом потоков ОС / ядер — то есть GOMAXPROCS. Дешёвое переключение не отменяет
гонок: общая память у горутин ровно та же, и count++ из двух горутин теряет
инкремент так же, как из двух потоков. Защита — sync.Mutex, атомики, каналы
(модель памяти Go).
Что потоки одного процесса ДЕЛЯТ между собой?
На сколько машинных инструкций раскладывается count++ на типичном CPU?
count=5, два потока делают count++. Какое МИНИМАЛЬНОЕ значение может получиться при неудачном переплетении?
В какой модели один блокирующий системный вызов может заморозить ВСЕ пользовательские потоки?
Почему горутин можно создавать сотнями тысяч, а потоков ОС — нет?
Что из перечисленного ВЕРНО про горутины?
Что спрашивают на собесе
- Чем поток отличается от процесса? Потоки одного процесса делят адресное пространство (код, глобальные, кучу, открытые файлы), но у каждого свой стек и регистры (TCB). Процессы изолированы, переключение между ними дороже (смена таблицы страниц, сброс TLB).
- Почему
count++не атомарен? Это три инструкции load-add-store; между ними возможно вытеснение, поэтому два потока могут прочитать одно значение и потерять инкремент. Уметь нарисовать переплетение по шагам. - Модели 1:1, N:1, M:N — в чём разница, у кого блокирующий syscall морозит всех (N:1), почему M:N сложнее, но масштабируемее.
- Что такое горутина и почему их можно миллионы? Лёгкий user-поток (~2 КБ стек), мультиплексируется на потоки ОС через GMP; переключение в user space без захода в ядро.
- Конкурентность vs параллелизм: горутины дают дешёвую конкурентность;
параллелизм ограничен числом ядер /
GOMAXPROCS. - Дешёвое переключение убирает гонки? Нет. Общая память остаётся, нужны мьютексы/атомики/каналы.