21. Переполнение стека и защита памяти
Стек вызовов: фундамент исполнения
Стек (Call Stack) — это не просто «область для локальных переменных». Это фундаментальный механизм реализации вызовов функций, заложенный на уровне архитектуры процессора. При каждом вызове функции процессор сохраняет адрес возврата, состояние регистров и локальные данные в непрерывный блок памяти, управляемый специальным регистром: RSP (x86-64) или SP (ARM).
Важно понимать: стек растёт вниз по адресному пространству. Это историческое решение, позволяющее стеку и куче (Heap) развиваться навстречу друг другу, максимально эффективно используя виртуальную память процесса.
graph TD
HighAddr["Высокие адреса (0x7FFF... / Top of Stack)"]
FrameMain["Фрейм main()"]
FrameFoo["Фрейм foo()"]
FrameBar["Фрейм bar() [Текущая вершина стека: RSP]"]
FreeSpace["Свободное виртуальное пространство (Рост вниз ↓)"]
Guard["Стражевая страница: Guard Page (PROT_NONE) [Защита от переполнения]"]
HeapArea["Сегмент кучи: Heap (Рост вверх ↑)"]
LowAddr["Низкие адреса (0x0000...)"]
HighAddr --- FrameMain --- FrameFoo --- FrameBar --- FreeSpace --- Guard --- HeapArea --- LowAddr
style HighAddr fill:#1e293b,stroke:#64748b,color:#fff
style FrameMain fill:#1e3a5f,stroke:#38bdf8,color:#fff
style FrameFoo fill:#1e3a5f,stroke:#38bdf8,color:#fff
style FrameBar fill:#064e3b,stroke:#34d399,color:#fff
style FreeSpace fill:#0f172a,stroke:#334155,color:#94a3b8
style Guard fill:#4b1d1d,stroke:#f87171,color:#fff
style HeapArea fill:#2e1065,stroke:#c084fc,color:#fff
style LowAddr fill:#1e293b,stroke:#64748b,color:#fff
Механизм защиты памяти: Guard Pages и Page Fault
ОС не доверяет программе управление собственным адресным пространством. Когда процесс создаётся, ядро выделяет стек в конце виртуального адресного пространства процесса. На самом краю (вниз по адресам) размещается Guard Page (стражевая страница).
Это страница, которая:
- Не имеет физической backing-памяти (не отображена на RAM/swap).
- Имеет права доступа
PROT_NONE(ни чтение, ни запись, ни исполнение).
Что происходит, когда g.sched.sp приближается к guard page?
- CPU выполняет инструкцию записи в память (
MOV,PUSH,CALL). - MMU (Memory Management Unit) делает walk по Page Table (см.
13. Страницы памяти и Page Table). - Находит PTE с флагом
PROT_NONE. - Генерирует Page Fault (прерывание 14 в x86).
- Ядро перехватывает исключение, проверяет, является ли страница guard page.
- Если да — отправляет процессу сигнал
SIGSEGV(илиSIGBUSпри аппаратных ошибках). - Если процесс не перехватывает сигнал, он аварийно завершается.
sequenceDiagram
autonumber
participant CPU as Процессор (CPU / RSP)
participant MMU as Блок MMU (Page Table)
participant Kernel as Ядро Linux (Page Fault Handler)
participant Process as Процесс приложения
CPU->>MMU: PUSH / MOV по адресу за пределами стека
MMU->>MMU: Чтение PTE: обнаружен флаг PROT_NONE
MMU->>Kernel: Аппаратное прерывание #14 (Page Fault Exception)
Kernel->>Kernel: Проверка VMA: адрес попадает в Guard Page!
Kernel->>Process: Доставка сигнала SIGSEGV (SEGV_ACCERR)
Process->>Process: Аварийный краш (Segmentation fault)
Go runtime: Как язык «растягивает» стек динамически
В отличие от C++ или Java, где размер стека горутины/потока фиксирован при старте (обычно 1–8 МБ), Go использует технику Split Stacks (разделённые стеки, которые начиная с Go 1.4 были усовершенствованы до непрерывных динамически копируемых стеков). Это позволяет создавать миллионы горутин с минимальным потреблением памяти.
Как работает динамический рост
- Инициализация: Новая горутина получает стартовый стек размером
minStackSize(обычно 2 КБ). - Проверка перед вызовом: При каждом вызове функции компилятор вставляет в пролог функции проверку стека: она сравнивает указатель стека
SPсg.stackguard0(отдельной функции-проверки вродеgostacksplitв рантайме нет). - Триггер роста: Если расстояние меньше порога (например, 8 КБ), управление передаётся в
runtime.morestack. - Расширение:
runtime.morestackвызываетruntime.newstack:- Берёт новый блок из пулов стеков рантайма (
stackalloc:stackpool/stackcache, а крупные стеки — из кучи рантайма). Отдельногоmmapна каждое удвоение нет: память под кучу рантайма уже отображена заранее (см.18. mmap. Отображение файлов и памяти). - Копирует содержимое старого стека на новый (
copystackкопирует всегда) и поправляет указатели, которые вели внутрь стека. - Освобождает старый стек (
stackfree): блок возвращается в пул стеков рантайма, а не отдаётся ядру черезmadvise. Физическую память ядру при необходимости возвращает уже фоновый scavenger, и позже. - Обновляет
g.stack,g.sched.spи сбрасываетg.stackguard0.
- Берёт новый блок из пулов стеков рантайма (
- Возврат: рантайм возобновляет выполнение функции уже на новом стеке. (
gostartcallк этому не относится: он используется при создании горутины, а не при росте стека.)
flowchart TD
A["Вызов функции в горутине"] --> B{"g.sched.sp - g.stackguard0 < threshold?"}
B -->|"Нет"| C["Обычное выполнение"]
B -->|"Да"| D["runtime.morestack"]
D --> E["runtime.newstack"]
E --> F["Новый блок из пула стеков (stackalloc)"]
F --> G["Копирование данных стека"]
G --> H["Старый стек в пул (stackfree)"]
H --> I["Обновление g.stack и g.sched.sp"]
I --> J["Возврат в функцию с новым стеком"]
style A fill:#1e293b,stroke:#818cf8,color:#fff
style B fill:#1e3a5f,stroke:#38bdf8,color:#fff
style C fill:#064e3b,stroke:#34d399,color:#fff
style D fill:#2e1065,stroke:#c084fc,color:#fff
style E fill:#4b1d1d,stroke:#f87171,color:#fff
style F fill:#1e293b,stroke:#64748b,color:#fff
style G fill:#1e293b,stroke:#64748b,color:#fff
style H fill:#064e3b,stroke:#34d399,color:#fff
style I fill:#1e3a5f,stroke:#38bdf8,color:#fff
style J fill:#064e3b,stroke:#34d399,color:#fff
Когда Go всё же падает с stack overflow
Несмотря на динамический рост, у стека есть предел: жёсткий внутренний лимит рантайма Go на максимальный размер стека одной горутины (maxstacksize: по умолчанию 1 ГБ на 64-битных системах и 250 МБ на 32-битных; меняется через debug.SetMaxStack). Это не ulimit -v и не RLIMIT_AS. Когда стек упирается в этот лимит, происходит:
fatal error: stack overflow
runtime stack:
runtime.throw(...)
runtime.newstack()
...
Это не обычная паника, а фатальная ошибка рантайма (throw): процесс завершается с кодом 2, и никакого SIGSEGV тут нет. В production это означает, что где-то в коде есть бесконечная рекурсия или аллокация гигантских структур в стеке.
У этой картины есть оговорка про контейнеры. Стек растёт удвоением, и при последнем удвоении (например, 256 → 512 МБ) нужно одновременно держать старый и новый блоки, то есть около 768 МБ. Если у контейнера или cgroup лимит памяти меньше (например, MemoryMax=512M), процесс будет убит SIGKILL (код 137) раньше, чем рантайм успеет напечатать fatal error: stack overflow, и в логе не останется ни строчки диагностики.
Сравнение подходов: C++, Java, PHP vs Go
| Язык/Среда | Управление стеком | Лимит по умолчанию | Поведение при переполнении |
|---|---|---|---|
| C/C++ | Фиксированный (компилятор/линкер) | 1–8 МБ (ulimit -s) |
SIGSEGV, немедленный crash |
| Java | Фиксированный на поток (-Xss) |
1 МБ | StackOverflowError (перехватывается) |
| PHP | Фиксированный на процесс | 8 МБ (memory_limit не для стека) |
Segmentation fault (PHP-FPM падает) |
| Go | Динамический (непрерывный стек, копируется при росте) | 1 ГБ на горутину (maxstacksize, debug.SetMaxStack) |
fatal error: stack overflow (не panic, код выхода 2) |
Go жертвует небольшой накладной работой на копирование стека (выделение из пула + копирование) ради радикального снижения потребления памяти при высокой конкурентности.
Практика и отладка
Как отследить переполнение в production?
- pprof: профиля
stackвnet/http/pprofнет. Стеки всех горутин покажет профильgoroutine(/debug/pprof/goroutine?debug=2). - Метрики рантайма: следите за
StackInuseиStackSysвruntime.MemStats. Рост стека не виден как отдельный системный вызов (блоки берутся из пулов рантайма), поэтомуstraceздесь почти ничего не покажет. - Глобальные лимиты: В контейнерах или systemd стек ограничен не только
ulimit, но иLimitASилиMemoryMaxcgroups.
//go:nosplit: это не защита от рекурсии
Директива //go:nosplit убирает из пролога функции проверку стека, а значит, такая функция не может вызвать рост стека. Вот пример, который не стоит повторять:
//go:nosplit
func safeRecursive(n int) {
if n == 0 {
return
}
safeRecursive(n - 1)
}
Проверка роста стека в прологе не вставляется, поэтому runtime.newstack вызван не будет. Но защитой от рекурсии это не становится: линкер следит, чтобы цепочка nosplit-функций укладывалась в небольшой зарезервированный запас стека, и отвергает программу с ошибкой nosplit stack over N byte limit. Рекурсивная nosplit-функция из примера выше на линковке не пройдёт. Используйте //go:nosplit только в низкоуровневом коде вроде системных вызовов рантайма, где глубина вызовов известна заранее.
Итог
- Стек — это аппаратно-ориентированный механизм вызовов функций, растущий вниз по адресному пространству.
- Guard Page — механизм ОС на уровне MMU, который превращает выход за границы стека в
Page Fault→SIGSEGV. - Go runtime выращивает стек горутины копированием в новый блок из своих пулов; переполнение ловит проверка в прологе функции, а не MMU. Предел стека — внутренний лимит рантайма, и его превышение даёт
fatal error: stack overflow. - Лимит стека горутины в Go — внутренний
maxstacksize(1 ГБ на 64-битных системах, меняетсяdebug.SetMaxStack), а неulimit -v. - Оптимизация: Избегайте аллокации больших структур в стеке. Используйте
make(heap) илиsync.Poolдля кэш-линий и часто создаваемых объектов.
В следующей статье мы детально разберем механизм системных вызовов: как именно программа просит ОС выделить ресурсы, выполнить I/O или управлять процессами. Разберем переход между Ring 3 и Ring 0, ABI, а также почему syscall в Go — это не просто syscall.Syscall. Читайте: 22. Системные вызовы. Как программа просит ОС что-то сделать.