2. Goroutines под капотом
Как устроена горутина: от старта до завершения
В лекции 1. Scheduler Go. G-M-P модель (или 1. Scheduler Go. G-M-P модель) мы детально исследовали фундаментальную архитектуру планировщика Go и познакомились с его главной триадой: логическим процессором P, потоком операционной системы M и горутиной G. Мы узнали, что процессор P задает аппаратный лимит параллелизма, а системный поток M выполняет роль неутомимого исполнителя инструкций. Но что представляет собой сама горутина (G) на физическом уровне? Как она зарождается из простого синтаксического выражения go func(), каким образом устроен и расширяется ее стек, почему переключение между горутинами обходится процессору в считаные наносекунды и почему миллион горутин в памяти Go-сервиса — это обычный рабочий вторник, в то время как миллион потоков ядра Linux немедленно отправил бы операционную систему в нокаут?
Программисты часто называют горутины «легковесными зелеными потоками», но эта аналогия скрывает фундаментальную микроархитектурную суть: горутина — это вообще не поток в понимании операционной системы. У неё нет собственного дескриптора task_struct в адресном пространстве ядра, нет выделенной страницы в таблицах MMU, нет прямого прерывания по таймеру APIC. С точки зрения машины горутина — это компактная структура данных в памяти пользовательского пространства, непрерывный фрагмент виртуальной памяти размером всего от 2 КБ под динамический стек и скромный набор сохраненных регистров центрального процессора.
Понимание внутреннего устройства сопрограммы раскрывает истинную механическую симпатию к рантайму: зная точную цену создания go func(), физику копирования растущего стека и алгоритмы принудительного вытеснения, senior-инженер проектирует надежные конкурентные архитектуры, исключая скрытые задержки и коварные утечки памяти. В этой лекции мы заглянем в недра горутины: от исходного кода структуры g в файле runtime/runtime2.go до фаз ее жизненного цикла и микроархитектурного влияния на кэши процессора.
Структура g: что внутри горутины
В исходном коде рантайма Go горутина представлена структурой g, объявленной в файле runtime/runtime2.go. Это один из самых объемных типов данных в рантайме, содержащий более сотни полей. Однако концептуально ее можно разделить на пять логических блоков:
type g struct {
// 1. Границы динамического стека
stack stack // Структура stack: lo (нижняя граница), hi (верхняя граница)
stackguard0 uintptr // Маркер границы стека для проверки переполнения и прерываний
stackguard1 uintptr // Маркер для системного стека g0 при вызовах CGO
// 2. Сохраненный контекст процессора при переключении
sched gobuf // Регистры CPU: SP, PC, BP, ret, lr, g
// 3. Управление жизненным циклом и планированием
atomicstatus atomic.Uint32 // Атомарное состояние: _Gidle, _Grunnable, _Grunning, _Gwaiting, _Gdead
goid int64 // Уникальный 64-битный числовой идентификатор горутины
gopc uintptr // Адрес инструкции go, породившей эту горутину (Program Counter)
startpc uintptr // Адрес первой исполняемой инструкции целевой функции
parent *g // Указатель на родительскую горутину (для профилирования и трассировки)
// 4. Связь с потоками и процессорами
m *m // Системный поток M, исполняющий горутину в данный момент (если _Grunning)
lockedm *m // Привязка к конкретному потоку M при вызове runtime.LockOSThread()
// 5. Очереди ожидания и синхронизация
waiting *sudog // Дескриптор ожидания при блокировке на каналах или мьютексах
...
}
Разберём ключевые поля этой структуры:
stack— описывает непрерывный диапазон адресов виртуальной памяти[lo, hi]. Начальный размер стека новой горутины составляет ровно 2 КБ (константа_StackMin).sched— экземпляр структурыgobuf, хранящий состояние регистров центрального процессора в момент, когда горутина уступает управление. Именноg.schedпозволяет планировщику мгновенно замораживать и возобновлять выполнение с точностью до ассемблерной инструкции:sp— Stack Pointer (указатель вершины стека, регистрRSP);pc— Program Counter (PC, счетчик команд, регистрRIP);bp— Base Pointer (базовый указатель текущего стекового фрейма, регистрRBPна x86-64);lr— Link Register (регистр адреса возврата на архитектурах ARM64);g— указатель на саму структуруg.
atomicstatus— атомарный флаг состояния горутины, с помощью которого планировщик координирует переходы между очередями без дорогих системных блокировок.
Создание горутины: что происходит при go func()
Каждый раз, когда в Go-коде встречается ключевое слово go:
go processOrder(orderID)
компилятор преобразует эту конструкцию в вызов служебной функции рантайма runtime.newproc (находится в runtime/proc.go).
sequenceDiagram
autonumber
actor Parent as Родительская G
participant RT as Рантайм Go (runtime.newproc)
participant Pool as Пул свободных G (p.gFree)
participant Heap as Аллокатор памяти (malg)
participant P as Локальный процессор P
Parent->>RT: go func(args...)
RT->>Pool: Запрос переиспользуемой структуры g
alt gFree не пуст
Pool-->>RT: Возврат готовой g из пула
else gFree пуст
RT->>Heap: malg(2048): выделение новой g и стека 2 КБ
Heap-->>RT: Новая структура g
end
RT->>RT: Копирование аргументов в стек новой g
RT->>RT: Настройка gobuf: g.sched.pc = runtime.goexit
RT->>P: runqput(p, newg): помещение в runq или runnext
Note over P: newg переведена в статус _Grunnable
RT-->>Parent: Возврат управления: родитель продолжает работу!
Пошаговый конвейер инициализации:
- Получение структуры
g:
Рантайм стремится свести аллокации к нулю. Сначала он проверяет локальный список свободных структур текущего логического процессораp.gFree. Если в пуле есть свободные объектыg(оставшиеся от завершившихся ранее горутин), рантайм мгновенно берет одну из них. Если локальный пул пуст, проверяется глобальный пулsched.gFree. И только если свободных структур нет нигде, вызывается внутренняя функцияmalg, которая выделяет память в куче под дескрипторgи инициализирует непрерывный стек начальным объемом 2 КБ. - Копирование аргументов и инициализация контекста:
Параметры вызываемой функции копируются напрямую в новый стек горутины.
В структуреgobufнастраивается точка входа:- В поле
g.sched.pcпрописывается адрес функции-обёрткиruntime.goexit(а точнее, адрес возврата, смещенный относительно точки старта функции). Это гарантирует, что как только пользовательская функция выполнит последнийreturn, управление автоматически передастся вruntime.goexit, которая корректно демонтирует стек, очистит ресурсы и переведет горутину в статус_Gdead. - В поле
g.sched.spзаносится выровненный адрес вершины нового стека.
- В поле
- Регистрация в планировщике:
Статус новой горутины атомарно выставляется в_Grunnable. Она помещается либо в высокоприоритетный слотrunnextтекущего процессораP, либо в хвост локального кольцевого буфераrunq. Если локальная очередь переполнена (достигнут лимит в 256 элементов), рантайм выгружает половину очереди в глобальный пулsched.runq. - Мгновенный возврат:
Родительская горутина не ждет старта дочерней: вызовruntime.newprocзавершается за 20–40 наносекунд, и родительский поток исполнения продолжает свою работу.
Жизненный цикл: конечный автомат G
Состояние каждой горутины жестко детерминировано и отслеживается планировщиком с помощью атомарного поля atomicstatus. Переходы между состояниями образуют строгий конечный автомат:
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
Idle["_Gidle<br/>(выделена в памяти, не инициализирована)"]:::success -->|"runtime.newproc / malg"| Runnable["_Grunnable<br/>(в очереди runq или runnext, готова к запуску)"]
Runnable -->|"schedule() -> execute(g)"| Running["_Grunning<br/>(активно исполняется потоком M на CPU)"]
Running -->|"Блокировка: chan, Mutex, sleep<br/>вызов gopark()"| Waiting["_Gwaiting<br/>(заблокирована, ожидает событие)"]
Waiting -->|"Событие наступило (чтение канала, таймер)<br/>вызов goready()"| Runnable
Running -->|"Завершение функции return<br/>вызов goexit()"| Dead["_Gdead<br/>(завершена, стек готов к очистке)"]
Dead -->|"Очистка и возврат в p.gFree"| Idle
Running -.->|"Преемпция по сигналу SIGURG<br/>или вызов Gosched()"| Runnable
Running -.->|"Синхронный Syscall<br/>entersyscallblock()"| Syscall["_Gsyscall<br/>(выполняет системный вызов ядра)"]
Syscall -.->|"exitsyscall()"| Running
Syscall -.->|"exitsyscall() при занятом P"| Runnable
Разберём ключевые переходы:
_Gidle$\to$_Grunnable: Структураgинициализирована функциейnewproc, стек настроен, задача поставлена в очередьrunqпроцессораP._Grunnable$\to$_Grunning: Диспетчерscheduleвыбрал задачу, процедураexecute(g)назначилаm.curg = gи выполнила ассемблерный прыжокgogo, запустив код горутины на процессоре._Grunning$\to$_Gwaiting: Горутина обратилась к ресурсу, который сейчас недоступен (попытка чтения из пустого канала, ожидание заблокированногоsync.Mutex, системный таймерtime.Sleep, ожидание сетевого сокета в netpoller). Рантайм вызывает процедуруgopark. Контекст регистров замораживается вg.sched, потокMосвобождается и возвращается в циклscheduleдля поиска других задач._Gwaiting$\to$_Grunnable: Ожидаемое событие произошло (другая горутина отправила сообщение в канал, мьютекс освободился, сработал сетевойepoll). Сторонняя горутина или netpoller вызывает процедуруgoready, которая атомарно переводит спящую горутину в состояние_Grunnableи возвращает ее в очередь выполненияrunq._Grunning$\to$_Gdead: Функция завершила выполнение, достигнув финальногоreturn. Управление переходит вruntime.goexit, которая вызывает деструкторы, открепляет горутину, освобождает избыточную память стека и помещает структуруgв пул переиспользованияp.gFree.
Стек горутины: непрерывный, растущий, копируемый
Одним из величайших инженерных достижений рантайма Go является архитектура динамического непрерывного стека (Contiguous Stacks).
В большинстве традиционных языков программирования (C, C++, Java) каждому потоку выделяется фиксированный стек огромного размера (от 1 до 8 МБ). Это вынужденная мера: если стек переполнится, произойдет фатальная аппаратная ошибка Stack Overflow (SIGSEGV). Но выделение 8 МБ под каждую задачу делает невозможным запуск сотен тысяч потоков: 100 000 потоков по 8 МБ потребовали бы почти терабайт физической памяти!
Эволюция: от Segmented Stacks к Contiguous Stacks
В версиях Go 1.0–1.2 использовалась модель сегментированных стеков (Segmented Stacks): стек начинался с малого размера (4 КБ), а при исчерпании рантайм выделял отдельный несвязанный фрагмент памяти в куче и соединял их двусвязным списком через указатели.
У этой схемы обнаружился фатальный архитектурный недостаток — Hot Split Problem:
Если вызов функции происходил ровно на границе заполнения стека внутри горячего цикла:
for i := 0; i < 1_000_000; i++ {
step() // Стек переполняется -> аллокация сегмента в куче!
// Функция возвращается -> сегмент освобождается!
}
на каждой итерации происходила дорогостоящая аллокация и освобождение сегмента памяти в куче, обрушивая производительность в сотни раз.
Современный подход: непрерывный стек с копированием
Начиная с Go 1.3, рантайм перешел на непрерывные стеки (Contiguous Stacks).
Начальный размер стека горутины составляет всего 2 КБ (2048 байт). В прологе каждой функции компилятор размещает проверку регистра stackguard0. Если текущий фрейм не помещается в оставшееся пространство:
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
subgraph OldStack ["Старый стек (2 КБ)"]
F1["Фрейм main()"]
F2["Фрейм worker()"]
F3["Фрейм compute() -> ПЕРЕПОЛНЕНИЕ!"]
end
subgraph NewStack ["Новый непрерывный стек (4 КБ)"]
NF1["Фрейм main() (адреса пересчитаны)"]
NF2["Фрейм worker() (адреса пересчитаны)"]
NF3["Фрейм compute() (адреса пересчитаны)"]
Free["Свободное пространство (2 КБ)"]
end
OldStack -->|"copystack(): аллокация x2, побайтовое копирование, пересчет указателей через Stack Maps"| NewStack
Алгоритм расширения стека (runtime.copystack):
- Аллокация: Рантайм выделяет в пуле стеков новый непрерывный блок памяти ровно в два раза большего размера ($2 \to 4 \to 8 \to 16 \to \dots$ КБ).
- Копирование: Все данные текущего стека (локальные переменные, стековые фреймы, сохраненные адреса возврата) побайтово копируются в новый блок.
- Пересчет указателей (Pointer Adjustment): Это самая сложная фаза. Если переменная в стеке указывала на другую переменную в том же самом стеке, ее абсолютный виртуальный адрес изменился! Компилятор Go генерирует специальные битовые карты (Stack Maps), описывающие, какие ячейки стека содержат указатели. Рантайм обходит стек по этим картам и корректирует все внутренние указатели, прибавляя дельту смещения адресов.
- Утилизация: Старый блок памяти освобождается и возвращается в пул аллокатора.
Переключение контекста: что сохраняется и восстанавливается
Переключение контекста между горутинами, выполняющимися на одном логическом процессоре P (и одном машинном потоке M), осуществляется исключительно силами рантайма через функции gogo и gopark.
Сравним архитектуру переключения горутин и потоков операционной системы:
| Характеристика | Поток ОС (Linux Kernel Thread) | Горутина Go (Goroutine) |
|---|---|---|
| Пространство выполнения | Переход между User Space и Kernel Space (Ring 3 $\leftrightarrow$ Ring 0) | Исключительно User Space (без вызова системных прерываний) |
| Сохраняемый контекст | Сотни байт: все регистры общего назначения, флаги CPU, FPU, векторные регистры SSE/AVX | Минимальный набор: 14 регистров общего назначения в структуре gobuf (SP, PC, BP и др.) |
| Таблицы памяти MMU | Смена каталога страниц CR3, частичная или полная инвалидация TLB | Адресное пространство общее, регистр CR3 не меняется, TLB сохраняется |
| Длительность переключения | 1 000 – 2 500 наносекунд (тысячи тактов CPU) | 10 – 15 наносекунд (несколько десятков тактов CPU) |
Благодаря тому, что горутины делят общее виртуальное адресное пространство процесса, аппаратный блок управления памятью (MMU) не замечает переключения: кэш трансляции страниц TLB остается горячим, а конвейер процессора продолжает исполнять код без глубоких сбросов.
Преемпция: как рантайм отбирает управление
Исторически планировщик Go опирался на кооперативную многозадачность: горутина добровольно уступала процессор в точках синхронизации (Safe Points):
- вызовы функций (проверка границы стека
morestack); - выделение динамической памяти в куче (
mallocgc); - системные вызовы ввода-вывода;
- операции синхронизации с каналами и мьютексами (
gopark).
Однако этот подход порождал критическую проблему: если в коде запускалась горутина с жестким математическим циклом без вызовов функций (например, криптографический расчет или обработка пикселей):
func tightLoop() {
for {
// Нет вызовов функций, нет работы с памятью!
}
}
такая задача намертво монополизировала физическое ядро. Логический процессор P оказывался заблокирован навсегда: планировщик не мог переключить задачу, а сборщик мусора не мог войти в фазу остановки мира (STW), парализуя весь сервис.
Сигнальная невытесняющая преемпция (Go 1.14+)
В Go 1.14 инженеры реализовали революционный механизм асинхронной преемпции на базе системных сигналов:
sequenceDiagram
autonumber
participant Mon as Поток мониторинга sysmon
participant CPU as Системный поток M (исполняет G)
participant Sig as Обработчик сигнала sighandler
participant Sched as Диспетчер schedule()
Note over CPU: Горутина крутится в бесконечном цикле for {}
Mon->>Mon: Детекция: горутина занимает P > 10 мс
Mon->>CPU: Отправка сигнала ядра: tgkill(pid, tid, SIGURG)
CPU->>Sig: Ядро прерывает поток M и вызывает sighandler
Sig->>Sig: Сохранение регистров CPU в gobuf
Sig->>Sig: Помещение G в очередь runq (статус _Grunnable)
Sig->>Sched: Вызов планировщика schedule() на стеке g0
Sched->>CPU: Запуск следующей готовой горутины
- Фоновый служебный поток рантайма
sysmon(который работает без привязки кP) каждые несколько миллисекунд инспектирует состояние всех процессоров. - Если обнаруживается, что горутина непрерывно выполняется дольше 10 миллисекунд,
sysmonотправляет операционной системе команду послать системному потокуMсигналSIGURG. - Ядро Linux прерывает выполнение инструкций потока
Mи вызывает обработчик сигналов Go-рантайма (runtime.sighandler). - Обработчик корректирует адрес возврата так, чтобы горутина принудительно вызвала процедуру
runtime.asyncPreempt, которая замораживает регистры, возвращает задачу в очередьrunqи вызывает диспетчерschedule().
Привязка к потоку ОС: LockOSThread
В стандартном режиме работы планировщик Go вправе в любой момент переместить горутину с одного системного потока M на другой. Однако в некоторых сценариях требуется строгая гарантия того, что горутина будет всегда выполняться на одном и том же физическом потоке ОС:
- Работа с графическими библиотеками: Интерфейсные фреймворки (OpenGL, macOS Cocoa/Metal) жестко требуют, чтобы любые операции рендеринга выполнялись строго из главного потока процесса (Thread 0).
- Интеграция с кодом на C/C++ через CGO: Си-библиотеки часто хранят состояние в локальной памяти потока (Thread-Local Storage, переменные
__threadили вызовыpthread_setspecific). Если горутина мигрирует на другой поток, она потеряет доступ к своему контексту. - Системные вызовы привязки ядра Linux: Модификация системных пространств имен через вызов
setns(2)(например, при конфигурировании сетевых неймспейсов контейнеров в Docker/Kubernetes) применяется исключительно к текущему потоку ядра.
Для решения этих задач стандартный пакет runtime предоставляет вызов runtime.LockOSThread():
package main
import (
"runtime"
)
func init() {
// Гарантируем, что главная горутина привязана к главному потоку ОС
runtime.LockOSThread()
}
Когда горутина вызывает runtime.LockOSThread():
- Текущий поток
Mжестко привязывается к данной горутине (m.lockedg = g,g.lockedm = m). - Никакая другая горутина не сможет запуститься на этом потоке
M. - Если привязанная горутина блокируется, поток
Mзасыпает вместе с ней и не передается планировщику. - Освобождение потока выполняется парным вызовом
runtime.UnlockOSThread(). Если горутина завершится без снятия блокировки, рантайм навсегда уничтожит этот потокM, породив взамен новый.
Практические выводы и ловушки
Mechanical Sympathy: горутины и процессор
С точки зрения кремниевого кристалла процессора горутина — это непрерывный поток инструкций, загружаемый в вычислительный конвейер. Частые переключения между неродственными горутинами на одном ядре вызывают специфические аппаратные эффекты:
- Вымывание кэшей L1d и L2 (Cache Eviction):
Стек и локальные структуры одной горутины загружаются в кэш данных L1d (32–48 КБ). Когда планировщик переключает задачу на другую горутину, ее стек вытесняет предыдущие данные. При возврате к первой горутине ядро процессора фиксирует волну промахов кэша (Cache Misses), теряя сотни тактов на подкачку данных из L3 и оперативной памяти. - Сброс истории предсказателя ветвлений (Branch Target Buffer):
Каждая функция имеет собственный уникальный паттерн переходовif/else. При частой ротации горутин аппаратный блок предсказания ветвлений процессора (Branch Predictor) не успевает накапливать статистику, что приводит к частым сбросам конвейера (Pipeline Flushes). - Разделение задач по профилю нагрузки:
Для вычислительных (CPU-bound) задач количество одновременно активных горутин не должно существенно превышать аппаратное значениеGOMAXPROCS(подробнее в лекции 3. CPU bound vs IO bound задачи). Для сетевых же задач (I/O-bound), где горутины большую часть времени спят в очередях netpoller, количество параллельных задач может исчисляться сотнями тысяч без ущерба для кэша процессора.
Итог
Подведём итоги анатомии горутины:
- Горутина — это не поток ОС, а легковесная структура
gв пространстве пользователя, содержащая динамический стек (от 2 КБ), указатели контекстаgobufи атомарный статус. - Жизненный цикл управляется конечным автоматом состояний:
_Gidle$\to$_Grunnable$\to$_Grunning$\to$_Gwaiting$\to$_Gdead. - Создание горутины через
go func()занимает 20–50 наносекунд благодаря пулу переиспользованияp.gFree. - Стек горутины является непрерывным (Contiguous Stack): он динамически растет путем удвоения и копирования (
copystack) с коррекцией указателей по битовым картам компилятора (Stack Maps). Максимальный размер ограничен 1 ГБ (runtime/debug.SetMaxStack). - Переключение контекста в userspace (
gogo/gopark) обходится всего в 10–15 наносекунд, сохраняя только 14 ключевых регистров CPU без смены таблиц виртуальной памяти. - Рантайм Go сочетает кооперативные точки переключения (Safe Points) и асинхронную преемпцию по сигналу
SIGURG(начиная с Go 1.14), предотвращающую монополизацию ядер бесконечными циклами. - Функция
runtime.LockOSThread()жестко закрепляет горутину за системным потоком ОС для специфических задач (OpenGL, CGO, namespaces).
Разобравшись в том, как горутины рождаются, растут и переключаются, мы готовы перейти к механизму, благодаря которому логические процессоры P идеально распределяют миллионы задач между ядрами — к алгоритму воровства работы. Следующая статья: 3. Work stealing.