GraphLMS

ОС
Начать

ОС · Конкурентность · 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.
  • Дешёвое переключение убирает гонки? Нет. Общая память остаётся, нужны мьютексы/атомики/каналы.
Память в проде: mmap, page cache, OOM, контейнерыКак устроен лок: от железа к futex