![](https://habrastorage.org/getpro/habr/upload_files/18d/c3f/884/18dc3f884e7ee886f96d9bc486cc4a26.png)
Эта статья берет за основу Go 1.12.
Go радует нас легким и умным управлением горутинами. Легким, поскольку стек горутин изначально весит всего 2 КБ, а умным, потому что горутины могут автоматически увеличиваться/уменьшаться в соответствии с нашими потребностями.
Что касается размера стека, мы можем найти его в runtime/stack.go
:
// The minimum size of stack used by Go code
_StackMin = 2048
Следует отметить, что с течением времени он менялся:
Go 1.2: стек горутины был увеличен с 4 КБ до 8 КБ.
Go 1.4: стек горутины уменьшился с 8 КБ до 2 КБ.
Размер стека изменялся из-за стратегии его аллокации (мы вернемся к этой теме чуть позже).
Иногда для работы нашей программы дефолтного размера стека недостаточно. В таком случае Go автоматически подстраивает размер стека под нужный.
Динамический размер стека
Если Go способен автоматически наращивать размер стека, то это также означает, что он может определять, когда размер выделяемой памяти менять не нужно. Давайте проанализируем, как это работает, на следующем примере:
func main() {
a := 1
b := 2
r := max(a, b)
println(`max: `+strconv.Itoa(r))
}
func max(a int, b int) int {
if a >= b {
return a
}
return b
}
Первый пример достаточно примитивен — он просто вычисляет наибольшее из двух целых чисел. Узнать, как Go управляет памятью, выделенной под стек горутины, можно взглянув на ассемблерный код Go с помощью команды: go build -gcflags -S main.go
. Вывод (я оставил только строки, связанные с аллокацией стека) дает нам несколько интересных строк, которые могут пролить свет на то, что делает Go:
"".main STEXT size=186 args=0x0 locals=0x70
0x0000 00000 (/go/src/main.go:5) TEXT "".main(SB), ABIInternal, $112-0
[...]
0x00b0 00176 (/go/src/main.go:5) CALL runtime.morestack_noctxt(SB)
[...]
0x0000 00000 (/go/src/main.go:13) TEXT "".max(SB), NOSPLIT|ABIInternal, $0-24
Среди них есть две инструкции, связанные с изменением размера стека:
- CALL runtime.morestack_noctxt
: этот метод увеличивает размер стека, если тому нужно больше памяти.
- NOSPLIT
: эта инструкция означает, что контроль переполнения стека не требуется. Она схожа с директивой компилятора //go:nosplit
.
Если мы посмотрим на метод runtime.morestack_noctxt
, то мы увидим, что он вызовет метод newstack
из runtime/stack.go
:
func newstack() {
[...]
// Выделяем больший сегмент и перемещаем стек.
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
if newsize > maxstacksize {
print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
throw("stack overflow")
}
// Для вызова newstack горутина должна выполняться,
// поэтому ее статус должен быть Grunning (или Gscanrunning).
casgstatus(gp, _Grunning, _Gcopystack)
// Параллельный сборщик мусора не будет сканировать стек, пока мы делаем копию,
// поскольку gp имеет статус - Gcopystack.
copystack(gp, newsize, true)
if stackDebug >= 1 {0
print("stack grow done\n")
}
casgstatus(gp, _Gcopystack, _Grunning)
}
Сначала вычисляется размер текущего стека на основе его границ gp.stack.hi
и gp.stack.lo
, которые указывают на его начало и конец:
type stack struct {
lo uintptr
hi uintptr
}
Затем текущий размер умножается на 2 и проверяется, не превышает ли он максимально допустимый размер — этот размер зависит от архитектуры:
// Максимальный размер стека составляет 1 ГБ для 64-битной версии и 250 МБ для 32-битной.
// Используются десятичные ГБ и МБ вместо двоичных, потому что
// они выглядят приятнее в сообщении об ошибке переполнения стека.
if sys.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
Теперь, когда мы знаем такие тонкости поведения Go, мы можем написать простой пример, чтобы все это проверить. Для дебага мы установим константу stackDebug
, которую мы видели в методе newstack
, равной 1 и запустим наш код:
func main() {
var x [10]int
a(x)
}
//go:noinline
func a(x [10]int) {
println(`func a`)
var y [100]int
b(y)
}
//go:noinline
func b(x [100]int) {
println(`func b`)
var y [1000]int
c(y)
}
//go:noinline
func c(x [1000]int) {
println(`func c`)
}
Инструкция //go:noinline
позволит избежать встраивания всех этих функций в main. Если компилятору не запретить инлайнинг, то мы не будем наблюдать динамического роста стеков в каждом прологе функции.
Вот часть результатов нашего дебага:
runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]
stack grow done
func a
runtime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]
stack grow done
runtime: newstack sp=0xc00003f888 stack=[0xc00003e000, 0xc000040000]
stack grow done
runtime: newstack sp=0xc000081888 stack=[0xc00007e000, 0xc000082000]
stack grow done
func b
runtime: newstack sp=0xc0000859f8 stack=[0xc000082000, 0xc00008a000]
func c
Здесь мы видим, что стек вырос в 4 раза. Действительно, пролог функции будет увеличивать стек настолько, насколько это необходимо для того, чтобы соответствовать его потребностям. Как мы видели в коде, размер стека определяется его границами, т.е. мы можем вычислить новый размер стека для каждого конкретного случая — инструкция newstack stack=[...]
возвращает указатели на текущие границы стека:
runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]
0xc00002e800 - 0xc00002e000 = 2048
runtime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]
0xc000077000 - 0xc000076000 = 4096
runtime: newstack sp=0xc00003f888 stack=[0xc00003e000, 0xc000040000]
0xc000040000 - 0xc00003e000 = 8192
runtime: newstack sp=0xc000081888 stack=[0xc00007e000, 0xc000082000]
0xc000082000 - 0xc00007e000 = 16384
runtime: newstack sp=0xc0000859f8 stack=[0xc000082000, 0xc00008a000]
0xc00008a000 - 0xc000082000 = 32768
Исследование внутреннего устройства показало нам, что стек горутины имеет начальный размер в 2 Кб и по необходимости увеличивается в прологе функции, добавляемом при компиляции, до тех пор, пока памяти не станет достаточно или не будет достигнут максимальный размер стека.
Управление аллокацией стека
Система динамической аллокации — не единственное, что может повлиять на наши приложения. То, каким образом была выделена память, также может оказывать существенное влияние. Давайте попробуем понять, как это происходит, на основе полной трассировки двух первых увеличений размера стека:
runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]
copystack gp=0xc000000300 [0xc00002e000 0xc00002e6e0 0xc00002e800] -> [0xc000076000 0xc000076ee0 0xc000077000]/4096
stackfree 0xc00002e000 2048
stack grow done
runtime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]
copystack gp=0xc000000300 [0xc000076000 0xc000076890 0xc000077000] -> [0xc00003e000 0xc00003f890 0xc000040000]/8192
stackfree 0xc000076000 4096
stack grow done
Как видно в коде выше, первая инструкция дает нам информацию об адресе текущего стека, stack=[0xc00002e000, 0xc00002e800]
и копирует его в новый, вдвое больший, copystack [0xc00002e000 [...] 0xc00002e800] -> [0xc000076000 [...] 0xc000077000],
4096-битного размера. Затем предыдущий стек высвобождается: stackfree 0xc00002e000
. Вот схема, которая может помочь вам визуализировать то, что происходит:
![Рост стека при непрерывном стеке Рост стека при непрерывном стеке](https://habrastorage.org/getpro/habr/upload_files/a11/a6d/497/a11a6d497e3a5d6915a04e2bb26eea1a.png)
Инструкция copystack
копирует весь стек и перемещает все адреса в новый стек. Мы можем легко в этом убедиться с помощью небольшой модификации нашего кода:
func main() {
var x [10]int
println(&x)
a(x)
println(&x)
}
Теперь он выводит адрес значения:
0xc00002e738
[...]
0xc000089f38
Адрес 0xc00002e738
располагается в пределах самого первого стека, который мы видели stack=[0xc00002e000, 0xc00002e800]
, а 0xc000089f38
уже находится в пределах финального стека stack=[0xc000082000, 0xc00008a000]
, который мы наблюдаем в дебажных трейсах. Это подтверждает, что все значения были перемещены из старого стека в новый.
Интересно отметить, что при запуске процесса сборки мусора стек будет уменьшен если это необходимо.
В нашем примере после вызова функции в стеке нет других валидных фреймов, кроме main, поэтому система будет в состоянии уменьшить его при запуске сборщика мусора. Мы можем принудительно запустить процесс сборки мусора для этого:
func main() {
var x [10]int
println(&x)
a(x)
runtime.GC()
println(&x)
}
Дебажные трейсы теперь отражают уменьшение размера стека:
func c
shrinking stack 32768->16384
copystack gp=0xc000000300 [0xc000082000 0xc000089e60 0xc00008a000] -> [0xc00007e000 0xc000081e60 0xc000082000]/16384
Можно заметить, что размер стека был уменьшен вдвое, а адрес предыдущего стека был повторно использован stack=[0xc00007e000, 0xc000082000]
. Здесь мы снова видим в runtime/stack.go — shrinkstack()
, что уменьшение размера стека всегда делит имеющийся размер на 2:
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2
Непрерывный (contiguous) стек VS сегментированный (segmented) стек
Непрерывный стек — это стратегия копирования стека в большее пространство, в противоположность сегментированному стеку. Go перешел на непрерывный стек в релизе 1.3. Чтобы увидеть разницу, давайте запустим тот же пример только на Go 1.2. И нам снова необходимо будет обновить константу stackDebug для отображения трейсов. Для этого, поскольку рантайм для этой версии был написан на C, нам придется скомпилировать исходный код. Результат следующий:
func a
runtime: newstack framesize=0x3e90 argsize=0x320 sp=0x7f8875953848 stack=[0x7f8875952000, 0x7f8875953fa0]
-> new stack [0xc21001d000, 0xc210021950]
func b
func c
runtime: oldstack gobuf={pc:0x400cff sp:0x7f8875953858 lr:0x0} cret=0x1 argsize=0x320
Текущий стек stack=[0x7f8875952000, 0x7f8875953fa0]
имеет размер 8 КБ (8192 байт + размер верхушки стека), а размер нового созданного стека составляет 18864 байт (18768 байт + размер верхушки стека). Распределение памяти следующее:
// выделяем новый сегмент.
framesize += argsize;
framesize += StackExtra; // место для дополнительных функций, Stktop.
if(framesize < StackMin)
framesize = StackMin;
framesize += StackSystem;
Что касается констант: в StackExtra
установлено значение 2048, StackMin
установлено значение 8192, а StackSystem
в зависимости от системы установлено либо в 0, либо в 512.
Таким образом, наш новый стек скомпонован следующим образом: 16016 (размер фрейма) + 800 (аргументы) + 2048 (StackExtra) + 0 (StackSystem).
После вызова всех функций новый стек высвобождается (лог runtime: oldstack
). Такое поведение было одной из причин, которая подтолкнула команду Golang к переходу на непрерывный стек:
Действующий механизм разделения стека имеет проблему «горячего разделения» — если стек почти заполнен, вызов результирует в выделении нового фрагмента стека. Когда этот вызов возвращается, новый фрагмент стека высвобождается. Если один и тот же вызов происходит неоднократно в коротком цикле, накладные расходы на аллокацию/высвобождение будут ощутимыми.
По этой причине Go пришлось увеличить минимальный размер стека в версии 1.2 до 8 КБ, а позже, после реализации непрерывного стека, его удалось уменьшить обратно до 2 КБ.
Взгляните на нашу предыдущую диаграмму только уже с сегментированным стеком:
![Рост стека при сегментированном стеке Рост стека при сегментированном стеке](https://habrastorage.org/getpro/habr/upload_files/af5/a6b/d05/af5a6bd05591ade941a5d2d15abb77a1.png)
Заключение
Управление стеком в Go является эффективным и довольно простым для понимания. Golang — не единственный язык, который предпочел не использовать сегментированный стек, Rust также приняли решение не использовать его по тем же причинам.
Если вы хотите глубже погрузится во все тонкости стека, я рекомендую вам прочитать статью Дэйва Чейни, в которой говорится о красной зоне (redzone), а также статью Билла Кеннеди, посвященную фреймам в стеке.
Материал подготовлен в рамках курса «Golang Developer. Professional».
Всех желающих приглашаем на demo-занятие «Примитивы синхронизации. Часть 1». После занятия вы сможете: пользоваться частью механизмов синхронизации в Go и бороться с «гонками» в Go. >> РЕГИСТРАЦИЯ