---
title: "Потоки vs процессы vs горутины"
description: "Этой главой заканчивается блок о виртуализации и начинается конкурентность. До сих пор у нас была картина «один процесс — одна линия исполнения»: программа идёт по коду сверху…"
canonical: https://graphlms.ru/os/book/threads-intro
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Потоки vs процессы vs горутины

> Источник: https://graphlms.ru/os/book/threads-intro — курс «Операционные системы», GraphLMS.

## Зачем вообще потоки

Этой главой заканчивается блок о виртуализации и начинается конкурентность. До сих
пор у нас была картина «один процесс — одна линия исполнения»: программа идёт по
коду сверху вниз, у неё один счётчик команд и один стек.

Но что если работы много и её можно делать параллельно? Например, веб-сервер
обрабатывает сотни запросов одновременно. Можно на каждый запрос плодить новый
**процесс** (см. [процессы](/os/book/process)) — но это дорого: у процесса своё
адресное пространство (см. [адресное пространство](/os/book/address-space)), своя
таблица страниц, своё всё. Создавать и переключать такое — накладно, а делиться
данными между процессами тяжело (нужны трубы, разделяемая память, сериализация).

**Поток (thread)** — это способ иметь несколько линий исполнения **внутри одного
процесса**. Аналогия: процесс — это квартира, а потоки — жильцы в ней. Кухня,
холодильник и гостиная (память) общие, но у каждого жильца своя кровать (стек) и
своя голова с текущими мыслями (регистры). Жить вместе дёшево, но за общий
холодильник можно и подраться — отсюда все проблемы гонок, о которых дальше.

## Что общее, а что своё

Ключ к пониманию потоков — чётко знать, что они **делят**, а что у каждого **своё**.

```text
            ПРОЦЕСС (одно адресное пространство)
  ┌───────────────────────────────────────────────┐
  │  Код (.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) из главы про [процессы](/os/book/process).
Если PCB описывает весь процесс (память, открытые файлы, права), то TCB описывает
одну линию исполнения: сохранённые регистры, указатель стека, состояние
(работает/ждёт/готов).

В Linux это разделение спрятано. И процесс, и поток ядро описывает одной и той же
структурой `task_struct` и создаёт одним системным вызовом `clone()`. Разница в
флагах: поток создаётся с флагами «делить с родителем память, таблицу файлов,
обработчики сигналов», процесс — без них. Поэтому планировщик Linux планирует не
процессы, а потоки, и в `top -H` или в `/proc/<PID>/task/` видно потоки
Go-сервиса по отдельности.

| Ресурс | Процессы между собой | Потоки одного процесса |
|---|---|---|
| Адресное пространство | у каждого своё | **общее** |
| Куча / глобальные | изолированы | **общие** |
| Открытые файлы | у каждого свои | общие |
| Стек | свой | **у каждого свой** |
| Регистры, PC, SP | свои | **у каждого свои** |
| Цена создания | высокая | низкая |
| Цена переключения | высокая (смена таблицы страниц, сброс TLB) | ниже (адр. пространство то же) |

Эта таблица — суть главы. Потоки дешевле процессов именно потому, что переключение
между ними **не требует** менять таблицу страниц и сбрасывать [TLB](/os/book/tlb).

## Почему общая память — это и сила, и беда

Общая куча — удобно: потоки обмениваются данными напрямую, без копирования. Но та
же общая память порождает **гонки (race conditions)** — ситуации, когда результат
зависит от того, в каком порядке планировщик переключал потоки.

Самый классический пример — обычный инкремент `count++`. Кажется, что это одна
операция, но процессор делает её в **три шага**:

```text
   count++   расшифровывается как:
   ┌──────────────────────────────────────────┐
   │ 1. load   R ← память[count]   (прочитать) │
   │ 2. add    R ← R + 1           (прибавить)  │
   │ 3. store  память[count] ← R   (записать)   │
   └──────────────────────────────────────────┘
```

Что нарисовано: одна строчка кода = три шага. Между любыми двумя из них таймер
может прервать поток, и планировщик пустит другой (глава
[прямое исполнение с ограничениями](/os/book/limited-direct-execution)). А на
многоядерной машине второй поток может выполнять те же шаги одновременно на
соседнем ядре CPU.

На x86-64 компилятор иногда выдаёт для инкремента одну инструкцию вида
`add [mem], 1`. Это не спасает: внутри процессора она всё равно читает, складывает
и пишет, и другое ядро может вклиниться между чтением и записью. Атомарной её
делает только префикс `lock`, его и использует `sync/atomic`.

## Потерянный инкремент: пошагово

Пусть `count = 5`, и два потока одновременно делают `count++`. Ожидаем `7`.
Посмотрим, как планировщик может всё испортить.

```text
 Время │ Поток 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`. Это и есть
**потерянное обновление** — фундамент всех проблем конкурентности.

### Проверим на Go

Четыре горутины по сто тысяч раз увеличивают общий счётчик:

```go
package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var wg sync.WaitGroup
	for g := 0; g < 4; g++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := 0; i < 100_000; i++ {
				count++ // гонка: чтение, сложение, запись без защиты
			}
		}()
	}
	wg.Wait()
	fmt.Println("ожидали 400000, получили", count)
}
```

Три запуска подряд на 8-ядерном ноутбуке:

```text
ожидали 400000, получили 216272
ожидали 400000, получили 182189
ожидали 400000, получили 172582
```

Потеряна почти половина инкрементов, и каждый раз по-разному. Детектор гонок Go
находит проблему сразу: `go run -race .` печатает

```text
WARNING: DATA RACE
Read at 0x00c000010158 by goroutine 10:
  main.main.func1()
      .../main.go:16 +0x88

Previous write at 0x00c000010158 by goroutine 8:
  main.main.func1()
      .../main.go:16 +0x98
```

Строка 16 — это `count++`: две горутины читают и пишут один адрес без
синхронизации. Исправления: `sync.Mutex` вокруг инкремента или
`atomic.Int64` с методом `Add(1)`. Запускайте тесты с `-race` в CI: гонка может
месяцами не проявляться на проде и потом испортить данные.

Участок кода, где идёт работа с общими данными и который нельзя «переплетать»,
называется **критической секцией**. Чтобы её защитить, нужна **взаимная
исключительность** — гарантия, что в критической секции в каждый момент только
один поток. Как это реализуют на уровне железа (test-and-set, CAS) — тема
следующей главы [блокировки: железо](/os/book/locks-hardware). В Go тот же эффект
описывается через [модель памяти](/go/book/memory-model), а защита — через
`sync.Mutex` и каналы.

## Kernel threads vs user threads: кто кого планирует

Поток можно «видеть» на двух уровнях: знает ли о нём ядро ОС, или он существует
только внутри программы. Отсюда три модели связи между **пользовательскими**
потоками (которыми оперирует ваш код/рантайм) и **ядерными** потоками (которые
реально ставит на ядра CPU планировщик ОС).

```text
   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**:

```text
   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-курсе:
[горутины](/go/book/goroutines) и [планировщик](/go/book/scheduler-deep).

А как Go обходит главную беду модели N:1, блокирующий системный вызов? Если
горутина вызвала, например, `read` у файла, поток M уходит в ядро вместе с ней и
блокируется. Рантайм замечает это и отдаёт P другому потоку (при необходимости
создаёт новый), чтобы остальные горутины продолжали работать. Когда вызов
вернётся, горутина встанет в очередь заново. Для сетевых сокетов рантайм делает
ещё лучше: переводит их в неблокирующий режим и ждёт готовности через netpoller
(epoll на Linux), так что поток вообще не блокируется. Поэтому число потоков ОС у
Go-сервиса может быть больше `GOMAXPROCS`: часть из них стоит в системных вызовах.

## Цена переключения: потоки ОС vs горутины

Зачем вообще нужны user-потоки? Из-за стоимости **переключения контекста**.

```text
  Переключение между потоками ОС (kernel switch):
  user → [trap в ядро] → сохранить регистры в TCB →
        планировщик ОС → загрузить регистры другого →
        [возможно сброс TLB] → return в user
        ≈ сотни–тысячи наносекунд + промахи кэша

  Переключение между горутинами (на одном M):
  сохранить пару регистров → подменить указатель стека →
  прыгнуть в другую горутину   (всё в user space)
        ≈ десятки наносекунд
```

Что нарисовано: переключение потоков ОС обязательно проходит через ядро (через
`trap` — см. [ограниченное прямое исполнение](/os/book/limited-direct-execution)),
а если меняется адресное пространство, ещё и страдает [TLB](/os/book/tlb).
Переключение горутин на одном потоке ОС ядро вообще не трогает — поэтому оно на
порядок-другой дешевле. Это и есть главная причина, по которой Go может позволить
себе сотни тысяч «потоков».

Важно не путать: горутины дают дешёвую **конкурентность** (много задач в работе),
а реальный **параллелизм** (одновременное счёт на нескольких ядрах) ограничен
числом потоков ОС / ядер — то есть `GOMAXPROCS`. Дешёвое переключение не отменяет
гонок: общая память у горутин ровно та же, и `count++` из двух горутин теряет
инкремент так же, как из двух потоков. Защита — `sync.Mutex`, атомики, каналы
([модель памяти Go](/go/book/memory-model)).

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/threads-intro

## Частые ошибки

- Думать, что гонка возможна только на многоядерной машине. На одном ядре её
  создаёт вытеснение по таймеру между load и store.
- Считать, что `count++` атомарен, потому что это «одна строчка» или даже одна
  инструкция процессора.
- Считать горутину потоком ОС. Горутина — задача рантайма; потоков ОС у
  Go-сервиса обычно немного больше `GOMAXPROCS`.
- Думать, что каналы и горутины отменяют необходимость синхронизации. Если две
  горутины трогают одну переменную без мьютекса, атомика или канала, это гонка.

## Проверьте себя

1. Что у двух потоков одного процесса общее, а что своё: куча, стек, глобальные
   переменные, регистр PC, открытые файлы?
2. `count = 10`, три потока одновременно делают `count++`. Какое наименьшее
   значение может получиться?
3. В какой модели (1:1, N:1, M:N) блокирующий `read` останавливает все
   пользовательские потоки?
4. Что делает рантайм Go, когда горутина блокируется в системном вызове чтения
   файла?

Ответы. (1) Общие: куча, глобальные, открытые файлы. Свои: стек и регистры, включая
PC. (2) 11: все трое прочитали 10 и записали 11. (3) N:1. (4) Поток M остаётся
заблокированным в ядре, а P передаётся другому потоку, чтобы остальные горутины
продолжали работать.

## Что спрашивают на собесе

- **Чем поток отличается от процесса?** Потоки одного процесса делят адресное
  пространство (код, глобальные, кучу, открытые файлы), но у каждого свой стек и
  регистры (TCB). Процессы изолированы, переключение между ними дороже (смена
  таблицы страниц, сброс TLB).
- **Почему `count++` не атомарен?** Это три инструкции load-add-store; между ними
  возможно вытеснение, поэтому два потока могут прочитать одно значение и потерять
  инкремент. Уметь нарисовать переплетение по шагам.
- **Модели 1:1, N:1, M:N** — в чём разница, у кого блокирующий syscall морозит
  всех (N:1), почему M:N сложнее, но масштабируемее.
- **Что такое горутина и почему их можно миллионы?** Лёгкий user-поток (~2 КБ
  стек), мультиплексируется на потоки ОС через GMP; переключение в user space без
  захода в ядро.
- **Конкурентность vs параллелизм:** горутины дают дешёвую конкурентность;
  параллелизм ограничен числом ядер / `GOMAXPROCS`.
- **Дешёвое переключение убирает гонки?** Нет. Общая память остаётся, нужны
  мьютексы/атомики/каналы.

## Вопросы для самопроверки

**Квиз «Потоки и горутины»**

1. Что потоки одного процесса ДЕЛЯТ между собой?
   - Открытые файлы (верно)
   - Глобальные переменные (верно)
   - Регистры и PC
   - Стек
   - Кучу (heap) (верно)
   - Пояснение: Общие: код, глобальные, куча, открытые файлы. Своё у каждого потока: стек и регистры (включая PC и SP). Поэтому через кучу/глобальные легко обмениваться данными — и легко словить гонку.
2. На сколько машинных инструкций раскладывается count++ на типичном CPU?
   - Ответ: 3
   - Пояснение: Три: load (прочитать из памяти в регистр), add (прибавить), store (записать обратно). Между ними возможно вытеснение — отсюда потерянный инкремент.
3. count=5, два потока делают count++. Какое МИНИМАЛЬНОЕ значение может получиться при неудачном переплетении?
   - Ответ: 6
   - Пояснение: Оба читают 5, оба прибавляют до 6, оба записывают 6. Один инкремент теряется — итог 6 вместо ожидаемых 7.
4. В какой модели один блокирующий системный вызов может заморозить ВСЕ пользовательские потоки?
   - M:N
   - N:1 (верно)
   - 1:1
   - Пояснение: В N:1 все user-потоки сидят на одном ядерном. Блокирующий syscall усыпляет этот единственный kernel-поток, а с ним и всех. В M:N рантайм может снять горутину и подставить другую.
5. Почему горутин можно создавать сотнями тысяч, а потоков ОС — нет?
   - Ядро ОС не ограничивает число потоков
   - Стартовый стек горутины ~2 КБ и растёт по мере надобности, а у потока ОС стек — мегабайты, плюс запись в планировщике ядра (верно)
   - Горутины не используют память вообще
   - Горутины выполняются на видеокарте
   - Пояснение: Горутина лёгкая: маленький растущий стек и переключение в user space без захода в ядро. Поток ОС — тяжёлый ресурс ядра (большой стек, структура в планировщике).
6. Что из перечисленного ВЕРНО про горутины?
   - Реализуют модель M:N через GMP (верно)
   - Дают конкурентность, но параллелизм ограничен GOMAXPROCS (верно)
   - Переключаются в user space дешевле, чем потоки ОС (верно)
   - Полностью устраняют гонки данных
   - Пояснение: Горутины — M:N (GMP), дешёвое переключение в user space, конкурентность дешева, а параллелизм ограничен числом потоков ОС/ядер. Но общая память остаётся — гонки никуда не делись, нужны мьютексы/атомики/каналы.
