12. Как создается и завершается goroutine
В предыдущих главах мы подробно исследовали архитектуру планировщика (9. Scheduler Go. G, M, P и work stealing) и динамическую организацию стековой памяти (11. Стек горутины. Рост и shrink стека). Теперь у нас есть все необходимые инженерные блоки, чтобы соединить их воедино и проследить полный драматический жизненный цикл горутины: от момента, когда компилятор встречает лаконичное ключевое слово go, до секунды, когда отработавшая задача бесследно исчезает из памяти.
Для бэкенд-инженера ясное понимание этого пути — не просто академическое любопытство, а практический щит от скрытых деградаций производительности и самого коварного врага распределенных Go-систем: утечек горутин (Goroutine Leaks).
Рождение: Синтаксический сахар и runtime.newproc
Ключевое слово go — это синтаксический сахар для программиста. Вы не найдете его в скомпилированном бинарнике. На этапе генерации SSA (о котором мы говорили в начале раздела) компилятор транслирует вызов вида go myFunction(arg1, arg2) (или go processUser(userID)) в вызов функции рантайма — runtime.newproc.
// Ваш исходный код:
go processUser(userID)
// Что генерирует компилятор под капотом (концептуально):
runtime.newproc(sizeOfArgs, funcPC, argData)
Функция runtime.newproc (исходный код в src/runtime/proc.go) выполняет ювелирную работу по инициализации новой единицы исполнения. Этот процесс строго оптимизирован, чтобы минимизировать накладные расходы на аллокацию памяти.
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
GO_KW["Ключевое слово go"]:::system -->|"Компиляция (SSA)"| NEWPROC["runtime.newproc"]
NEWPROC --> CHECK_GFREE{"Есть ли свободная G<br>в p.gFree?"}
CHECK_GFREE -->|"Да"| REUSE_G["Берем G из локального кэша p.gFree<br>(Без аллокаций памяти)"]
CHECK_GFREE -->|"Нет"| MALG["Функция malg<br>Аллокация структуры G и стека 2 КБ"]
REUSE_G --> SETUP_G["Настройка дескриптора G"]
MALG --> SETUP_G
SETUP_G -->|"1. Копируем аргументы на стек<br>2. Подставляем runtime.goexit<br>3. Устанавливаем PC на вашу функцию"| SET_RUNNABLE["Статус: _Grunnable"]
SET_RUNNABLE --> PUT_RUNNEXT{"Слот runnext свободен?"}
PUT_RUNNEXT -->|"Да"| RUNNEXT["Кладем в P.runnext (приоритет L1/L2)"]
PUT_RUNNEXT -->|"Нет"| RUNQ["Кладем в P.runq или Глобальную очередь"]
1. Поиск в кэше (gFree)
Выделение новой памяти в общей куче (Heap) — дорогостоящая операция. Создатели Go руководствовались принципом Mechanical Sympathy: самая быстрая аллокация памяти — та, которой удалось избежать.
Поэтому горутины в Go агрессивно переиспользуются.
Когда горутина завершает выполнение, ее структура g и выделенный стек не уничтожаются. Рантайм помещает ее в пул свободных горутин — gFree.
Первым делом newproc обращается к текущему логическому процессору P и проверяет его локальный список p.gFree. Если локальный кэш пуст, рантайм захватывает порцию свободных структур из глобального репозитория sched.gFree. Это позволяет создавать тысячи кратковременных горутин с нулевыми аллокациями оперативной памяти!
2. Аллокация новой G (malg)
Если пул gFree исчерпан (например, при холодном старте сервиса или резком всплеске трафика), рантайм вызывает служебную функцию malg (Memory Allocate G).
Функция malg запрашивает у аллокатора памяти новую структуру g и выделяет под нее начальный непрерывный стек размером ровно 2 КБ.
3. Настройка контекста (gobuf)
Получив чистую структуру g, рантайм инициализирует ее внутреннее состояние:
- Аргументы вызываемой функции копируются на стек новорожденной горутины;
- В структуру сохраненного контекста
g.sched(типgobuf) записывается указатель на целевую функцию — программный счетчикPC(Program Counter); - В регистр указателя стека
SP(Stack Pointer) записывается адрес вершины выделенного 2-килобайтного фрейма.
4. Подмена адреса возврата
Здесь кроется один из самых остроумных трюков рантайма.
В классической архитектуре процессора при вызове функции адрес возврата сохраняется на стеке инструкцией CALL, и по завершении инструкция RET возвращает управление вызывающей стороне.
Но кто вызвал функцию новой горутины? Никто! Она запускается асинхронно в изолированном контексте. Куда должен прыгнуть процессор, когда функция дойдет до финальной фигурной скобки }?
Чтобы разрешить эту дилемму, рантайм искусственно подделывает адрес возврата на стеке.
На самое дно стека новой горутины компилятор и рантайм вручную помещают адрес системной функции runtime.goexit. Когда прикладная функция завершает работу и выполняет инструкцию RET, процессор считывает этот поддельный адрес и автоматически проваливается в процедуру корректной утилизации горутины.
5. Отправка в очередь
Настройка завершена. Рантайм атомарно выставляет горутине статус _Grunnable (готова к выполнению) и передает ее планировщику:
- Сначала делается попытка поместить горутину в привилегированный слот
runnextтекущего процессораP(для мгновенного запуска на теплом L1/L2 кэше процессора); - Если слот занят, горутина отправляется в локальную очередь
P.runq; - Если локальная очередь (256 элементов) переполнена, половина задач вместе с новой горутиной вытесняется в глобальную очередь
sched.runq.
Жизнь: Выполнение и Handoff
Когда свободный рабочий поток операционной системы (M) забирает горутину из очереди, он вызывает низкоуровневую ассемблерную процедуру gogo.
Процедура gogo восстанавливает регистры CPU из структуры g.sched, переключает указатель стека процессора на стек горутины и совершает аппаратный прыжок по адресу функции. Статус горутины меняется на _Grunning.
В течение своей жизни горутина постоянно взаимодействует со средой:
- При обращении к сети она может быть усыплена Netpoller'ом со статусом
_Gwaiting; - При блокирующем системном вызове к диску планировщик совершит Handoff, передав контекст
Pдругому потокуM; - При передаче данных через каналы или блокировке на мьютексе горутина уступает место в потоке
Mдругим задачам.
Смерть: runtime.goexit и очистка
Когда пользовательская функция выполняет return или доходит до своего естественного конца, процессор совершает возврат по ранее подставленному адресу и передает управление функции runtime.goexit1 (которая немедленно переключается на стек системной горутины g0 и вызывает runtime.goexit0).
Процедура смерти — это четко выверенный ритуал очистки ресурсов:
- Исполнение отложенных вызовов (
defer):
Рантайм раскручивает стек горутины и последовательно выполняет все зарегистрированные функцииdeferв порядке LIFO. Если в ходе исполненияdeferвозникла паника, активируется механизмrecover(подробнее см. 39. defer, panic и recover под капотом). - Смена статуса: Горутина бесповоротно переходит в терминальный статус
_Gdead. - Очистка ссылок: Рантайм обнуляет внутренние поля структуры
g(указатели на каналы, структуры ожиданияsudog, таймеры).
Это критически важный шаг для сборщика мусора: если оставить в дескрипторе мертвой горутины старые указатели на пользовательские данные, GC сочтет эти объекты живыми, что приведет к скрытой утечке памяти. - Возврат в пул переиспользования (
gFree):
Освобожденная «оболочка» структурыgпомещается в локальный списокp.gFreeтекущего процессораP. Физический стек горутины не освобождается в ОС — он сохраняется для следующей горутины, гарантируя мгновенный старт новых задач. - Освобождение исполнителя: Поток ОС
Mвозвращается в диспетчерский циклruntime.schedule(), немедленно забирая следующую готовую задачу из очереди.
Итог
- Оператор
go func()транслируется компилятором в вызовruntime.newproc. - Рантайм агрессивно кэширует дескрипторы завершившихся задач через списки
gFree, предотвращая дорогостоящие аллокации памяти в куче. - Компилятор и рантайм искусственно подставляют адрес
runtime.goexitна дно стека новой горутины, обеспечивая автоматический вызов процедуры утилизации при завершении функции. - Процедура смерти гарантирует вызов отложенных функций
defer, зануление ссылок на объекты и возврат дескриптораgв локальный пул процессораP. - Горутину невозможно принудительно убить снаружи: зависшая в
_Gwaitingгорутина порождает неустранимую утечку памяти (Goroutine Leak).
Мы проследили путь одиночной горутины от рождения до утилизации. Но истинная выразительность Go раскрывается в коллективном взаимодействии горутин через каналы по завету Тони Хоара (CSP): «Не общайтесь, разделяя память; разделяйте память, общаясь».
В следующей лекции мы препарируем каналы Go на уровне структур данных C и ассемблера:
Переходим к: 13. Каналы под капотом. hchan, sudog, sendq, recvq.