34. Горутины. Легковесная конкурентность в Go
«Управление сложностью — вот суть программирования».
— Брайан Керниган
В мире серверного программирования есть фундаментальное противоречие между физикой кремния и запросами пользователей. На физическом уровне у вашего процессора есть фиксированное число вычислительных ядер — скажем, 8, 16 или 64. А из сети к вашему сервису одновременно приходят десятки и сотни тысяч клиентов: WebSocket-соединения, HTTP/2 и gRPC стримы, фоновые задачи и таймеры.
Как распределить конечное число ядер между колоссальным океаном параллельных задач?
Исторически в бэкенд-разработке сложились две парадигмы конкурентности:
- Процессно-ориентированная (PHP-FPM, Python/uWSGI): На каждый HTTP-запрос операционная система выделяет целый процесс. Это надежно (падение одного не убьет другие), но чудовищно неэффективно по памяти и переключениям контекста. Каждому процессу нужны собственные таблицы страниц, файловые дескрипторы и изолированное адресное пространство.
- Многопоточная (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).
Если места не хватает, происходит магия:
- Вызывается специальная функция рантайма
runtime.morestack. - Рантайм выделяет в куче новый, увеличенный в 2 раза стек (например, 4 КБ вместо 2 КБ).
- Старый стек побайтово копируется в новый.
- Самое сложное: Рантайм проходит по новому стеку и обновляет все указатели, которые ссылались на переменные внутри старого стека, чтобы они указывали на новые адреса.
- Выполнение функции продолжается на новом стеке, а старый отдается сборщику мусора (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 не может собрать (удалить из памяти) горутину, пока она:
- Выполняет код.
- Заблокирована на чтении/записи в канал, сеть или мьютекс.
Если вы запустили горутину, которая ждет данных из канала, в который больше никто никогда не напишет, эта структура 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)
Итог
- Горутины — это структуры рантайма (
g), а не потоки операционной системы. Они живут в User Space. - Экстремально дешевые: Занимают от 2 КБ в памяти и переключаются за ~200 наносекунд.
- Растут динамически: Рантайм копирует стеки в новые области памяти при их нехватке (Contiguous Stacks).
- Не собираются GC при блокировке: Брошенные горутины — главная причина утечек памяти в Go-бэкендах.
Теперь мы знаем, что горутина — это просто структура g. Но кто решает, какую горутину и на каком ядре процессора выполнять в данный момент времени? Обычный планировщик Linux ничего не знает про наши горутины. Эту задачу решает одно из самых гениальных изобретений в Go — его собственный внутренний планировщик. О нем мы детально поговорим в следующей статье: 35. Scheduler Go. G, M, P и work stealing.