34. Горутины. Легковесная конкурентность в Go

«Управление сложностью — вот суть программирования».
— Брайан Керниган

В мире серверного программирования есть фундаментальное противоречие между физикой кремния и запросами пользователей. На физическом уровне у вашего процессора есть фиксированное число вычислительных ядер — скажем, 8, 16 или 64. А из сети к вашему сервису одновременно приходят десятки и сотни тысяч клиентов: WebSocket-соединения, HTTP/2 и gRPC стримы, фоновые задачи и таймеры.

Как распределить конечное число ядер между колоссальным океаном параллельных задач?

Исторически в бэкенд-разработке сложились две парадигмы конкурентности:

  1. Процессно-ориентированная (PHP-FPM, Python/uWSGI): На каждый HTTP-запрос операционная система выделяет целый процесс. Это надежно (падение одного не убьет другие), но чудовищно неэффективно по памяти и переключениям контекста. Каждому процессу нужны собственные таблицы страниц, файловые дескрипторы и изолированное адресное пространство.
  2. Многопоточная (Java, C++, C#): На каждый запрос выделяется поток ОС (OS Thread). Это легче процессов, но имеет свой предел — знаменитую «проблему C10K» (10 000 одновременных соединений). Операционная система начинает тратить больше времени на переключение потоков, чем на полезную работу. (Современные платформы решают ту же задачу и иначе: Java 21 добавила виртуальные потоки — по сути аналог горутин, а C# и Kotlin используют async/await и корутины.)

Бэкенд на Go проектировался для эпохи облаков и микросервисов, где сервер должен держать сотни тысяч постоянных соединений (например, WebSockets). Создатели Go отвергли классические потоки ОС в пользу Горутин (Goroutines) — легковесных потоков, управляемых не операционной системой, а самим рантаймом Go (в User Space).

В этой главе мы заглянем под капот горутин с позиций «механического сочувствия» (Mechanical Sympathy): разберем их анатомию в исходниках рантайма, выясним, почему их стек стартует с крошечных 2 КБ и как он растет на лету, а также разберем коварные ловушки утечек горутин.


1. Mechanical Sympathy: Горутины против Потоков ОС

Почему обычные потоки ОС (Threads) не подходят для высокой конкурентности? Проблема кроется в архитектуре ядра Linux и железа.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph OSModel["Модель 1:1 (Потоки ОС — Java, C++)"]
        T1["Поток ОС 1 (Стек 2 MB)"]:::system --> K["Ядро Linux (Ring 0)<br>Планировщик CFS/EEVDF"]
        T2["Поток ОС 2 (Стек 2 MB)"] --> K
        T3["Поток ОС 3 (Стек 2 MB)"] --> K
        K --> CPU1["Ядро CPU"]
    end

    subgraph GoModel["Модель M:N (Горутины — Go Runtime)"]
        G1["Горутина G1 (Стек 2 KB)"]:::entry --> R["Go Runtime Scheduler<br>(User Space)"]
        G2["Горутина G2 (Стек 2 KB)"] --> R
        G3["Горутина G3 (Стек 2 KB)"] --> R
        G4["Горутина G4 (Стек 2 KB)"] --> R
        R --> M1["Поток ОС M1"] --> CPU2["Ядро CPU"]
    end
  

Проблема 1: Размер стека (Memory Footprint)

Когда вы создаете поток в Java или C++, операционная система сразу резервирует для него непрерывный кусок виртуальной памяти под стек — обычно от 1 до 8 Мегабайт (зависит от ОС и настроек: в glibc — по ulimit -s, обычно 8 МБ; в JVM по умолчанию -Xss — 1 МБ). К этому добавляется защитная страница памяти (Guard Page) для отлова Stack Overflow.

Если вы захотите держать 100 000 активных WebSocket-соединений, где на каждое выделен поток, вам потребуется: 100 000 * 2 МБ = 200 Гигабайт адресного пространства только под пустые стеки! Физически заняты будут лишь затронутые страницы, но и это сотни мегабайт — гигабайты памяти, плюс нагрузка на планировщик ОС и системные лимиты числа потоков (threads-max, pids.max).

Решение Go: Горутина стартует с крошечным стеком — всего 2 Килобайта.
100 000 горутин займут около 200 Мегабайт памяти. Разница в тысячу раз. Благодаря этому микросервис на Go спокойно поднимает миллион горутин на стандартном сервере с 16 ГБ RAM, даже не приближаясь к лимитам OOM.

Проблема 2: Цена переключения контекста (Context Switch)

Чтобы переключить выполнение с одного потока ОС на другой (даже внутри одного процесса), процессор должен совершить переход в режим ядра (Ring 0), сохранить в память все 16+ регистров общего назначения, векторные регистры (AVX/SSE), состояние FPU и выбрать новый поток через планировщик ядра ОС (например, CFS в Linux), а при смене потоков разных процессов — также переключить таблицы виртуальной памяти (CR3) со сбросом записей TLB. Это занимает от 1000 до 2000 тактов процессора (~1-2 микросекунды).

Решение Go: Переключение горутин происходит полностью в пространстве пользователя (User Space) силами рантайма Go. Рантайму нужно сохранить лишь минимальный контекст выполнения: PC (Program Counter — где мы остановились), SP (Stack Pointer — где вершина стека) и BP (Base Pointer). Это занимает около 200 наносекунд — на порядок быстрее системного Context Switch. Для процессора это обычная логика в пространстве пользователя, не требующая входа в Ring 0, системных вызовов (syscall) и работы диспетчера ядра ОС.


2. Под капотом: Структура g

Горутина — это не магия, а просто структура данных в памяти рантайма. В исходниках Go (в файле src/runtime/runtime2.go) она называется структурой g.

Состояния горутины (atomicstatus) управляются конечным автоматом рантайма:

  • _Gidle: структура только что выделена и ещё не инициализирована.
  • _Grunnable: горутина готова к исполнению и ждет свободного процессора P в локальной или глобальной очереди.
  • _Grunning: код горутины прямо сейчас исполняется на ядре процессора потоком M.
  • _Gwaiting: горутина заблокирована (ждет чтения из канала, сетевого сокета, мьютекса или завершения time.Sleep).
  • _Gdead: горутина не используется — завершила работу или лежит в пуле свободных структур; её стек освобождается или кэшируется для повторного использования.

(Есть и другие состояния: _Gsyscall — горутина в системном вызове, _Gpreempted, _Gcopystack — стек копируется, флаги _Gscan* для сборщика мусора, а в Go 1.27 — _Gleaked для утёкших горутин.)


3. Магия растущего стека (Contiguous Stacks)

Возникает резонный инженерный вопрос: если стек горутины всего 2 КБ, что произойдет, если мы сделаем глубокую рекурсию или создадим массив на 4 КБ прямо внутри функции? В C++ это привело бы к переполнению стека (Stack Overflow) и падению программы (SIGSEGV).

Go использует механизм Contiguous Stacks (Непрерывные стеки).

В ранних версиях Go (до 1.3) использовался механизм сегментированных стеков (Segmented Stacks), где новые стековые фреймы выделялись отдельными блоками и связывались в двусвязный список. Однако это приводило к проблеме «горячего разбиения» (hot split problem): если функция на границе стека вызывалась в плотном цикле, рантайм бесконечно выделял и освобождал память.

Начиная с Go 1.3, язык перешел на непрерывные перемещаемые стеки:

Компилятор Go вставляет небольшую проверку (пролог) в начало каждой функции. Эта проверка сравнивает текущий указатель стека (SP) с нижней границей стека текущей горутины (g.stack.lo).

Если места не хватает, происходит магия:

  1. Вызывается специальная функция рантайма runtime.morestack.
  2. Рантайм выделяет в куче новый, увеличенный в 2 раза стек (например, 4 КБ вместо 2 КБ).
  3. Старый стек побайтово копируется в новый.
  4. Самое сложное: Рантайм проходит по новому стеку и обновляет все указатели, которые ссылались на переменные внутри старого стека, чтобы они указывали на новые адреса.
  5. Выполнение функции продолжается на новом стеке, а старый отдается сборщику мусора (GC).
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    A["Старый стек<br>Размер: 2 KB"]:::process -->|"Вызов morestack<br>Удвоение размера"| B["Новый стек<br>Размер: 4 KB"]
    C["Рантайм"] -.->|"Побайтовое копирование<br>и обновление указателей"| B
  

Когда глубина вызовов уменьшается, рантайм во время работы сборщика мусора (GC) проверяет, не используется ли меньше четверти выделенного стека, и при необходимости сжимает его обратно, экономя оперативную память.


4. Ловушки (Gotchas) и утечки памяти

Горутины дешевые, но они не бесплатные. Одна из главных проблем новичков — Goroutine Leaks (Утечки горутин).

Сборщик мусора (GC) в Go не может собрать (удалить из памяти) горутину, пока она:

  1. Выполняет код.
  2. Заблокирована на чтении/записи в канал, сеть или мьютекс.

Если вы запустили горутину, которая ждет данных из канала, в который больше никто никогда не напишет, эта структура g и её стек останутся в памяти навсегда. Вместе с ней останутся все переменные, на которые ссылается ее стек. (Освобождать такие горутины рантайм не умеет, но с Go 1.27 помогает их найти: профиль goroutineleak — подробнее в 43. Deadlock, Livelock и типичные ошибки конкурентности.)

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Leak["Утечка горутины (Goroutine Leak)"]
        G["Горутина g<br>(Статус: _Gwaiting)"]:::accent -->|"Заблокирована навсегда"| Ch["Небуферизованный канал<br>(Никто не читает)"]
        GC["Сборщик мусора (GC)"] -.->|"Не может собрать активный корень!"| G
        G -->|"Удерживает ссылки"| Obj["Объекты в куче<br>(Буферы, структуры, соединение)"]
    end
  

Замыкания в циклах (Loop Variables Closure Trap)


Итог

  1. Горутины — это структуры рантайма (g), а не потоки операционной системы. Они живут в User Space.
  2. Экстремально дешевые: Занимают от 2 КБ в памяти и переключаются за ~200 наносекунд.
  3. Растут динамически: Рантайм копирует стеки в новые области памяти при их нехватке (Contiguous Stacks).
  4. Не собираются GC при блокировке: Брошенные горутины — главная причина утечек памяти в Go-бэкендах.

Теперь мы знаем, что горутина — это просто структура g. Но кто решает, какую горутину и на каком ядре процессора выполнять в данный момент времени? Обычный планировщик Linux ничего не знает про наши горутины. Эту задачу решает одно из самых гениальных изобретений в Go — его собственный внутренний планировщик. О нем мы детально поговорим в следующей статье: 35. Scheduler Go. G, M, P и work stealing.