Как устроен стек вызовов в Go и в чём его особенности?

Ответ

Стек вызовов (call stack) — это структура данных, которую программа использует для отслеживания вызовов функций. Она работает по принципу LIFO (Last-In, First-Out).

Общий принцип работы

  1. Вызов функции: Когда функция вызывается, в верхнюю часть стека помещается её фрейм (stack frame). Фрейм содержит аргументы функции, её локальные переменные и адрес возврата (куда вернуться после завершения функции).
  2. Завершение функции: Когда функция завершает свою работу, её фрейм удаляется из стека, и управление передаётся по адресу возврата.
  3. Переполнение стека (Stack Overflow): Если вызовы функций вкладываются друг в друга без возврата (например, при бесконечной рекурсии), стек может исчерпать выделенную ему память, что приведёт к аварийному завершению программы.
func main() { // 1. Фрейм main() попадает в стек
    foo(5)
} // 4. Фрейм main() удаляется

func foo(x int) { // 2. Фрейм foo() попадает в стек
    bar(x)
} // 3. Фрейм foo() удаляется

func bar(y int) { // ...и так далее
    // ...
}

Особенности стека в Go

Главная особенность Go — это то, как стеки работают в контексте горутин.


  1. Собственный стек у каждой горутины: В отличие от системных потоков (OS threads), у которых стеки большие и имеют фиксированный размер (например, 1-8 МБ), каждая горутина в Go имеет свой собственный, независимый стек.



  2. Динамически расширяемый стек: Стеки горутин изначально очень маленькие (около 2 КБ). Если горутине требуется больше места (например, при глубокой рекурсии), Go runtime автоматически выделяет новый, больший сегмент памяти для стека и копирует в него старые данные. Это называется копированием стека (stack copying).


Почему это важно?

  • Массовая конкурентность: Маленький начальный размер стека позволяет создавать сотни тысяч и даже миллионы горутин без огромного потребления памяти. Это делает конкурентность в Go очень "дешёвой".
  • Защита от Stack Overflow: Динамическое расширение защищает от переполнения стека в большинстве случаев. Однако при неограниченной рекурсии переполнение всё же возможно, и тогда программа завершится с паникой runtime: goroutine stack exceeds limit.
  1. Отсутствие оптимизации хвостовой рекурсии (TCO): Стандартный компилятор Go не выполняет TCO, поэтому рекурсивные вызовы всегда добавляют новый фрейм в стек. Для задач, требующих глубокой рекурсии, лучше использовать итеративные подходы.