В Go нет неинициализированных переменных. Объявили var x int, и там гарантированно ноль. Это одна из тех гарантий, которые настолько удобны, что перестаёшь их замечать. Но нули не берутся из воздуха. Кто-то должен физически записать их в память, и за это кто-то платит. В статье разбираю, кто именно: где в компиляторе и рантайме Go находится код обнуления, какие системные вызовы за этим стоят и сколько это стоит в наносекундах. Начинаю с C, чтобы было видно, чего стоит гарантия, которой там нет. Если хочется сразу к Go, первые два раздела можно пролистать. Все замеры сделаны на моей машине, все ссылки на исходники проверены на Go 1.24.10.
Коротко
Если вы пришли за определением, вот оно.
| Тип | Zero value |
|---|---|
Числовые: int, float64, complex128 и другие | 0 |
bool | false |
string | "" |
| Указатель, функция, интерфейс, слайс, map, канал | nil |
| Массив | массив, каждый элемент которого равен своему zero value |
| Структура | структура, каждое поле которой равно своему zero value |
Переменная получает zero value в десяти случаях:
- объявление через
varбез инициализатора; - вызов
new; - создание через
make; - незаданные поля композитного литерала;
- именованные возвращаемые значения функции;
- чтение отсутствующего ключа из map;
- чтение из закрытого канала;
- неудачное приведение типа с
, ok; - очистка через встроенный
clear; - хвост ёмкости при росте слайса через
append: его видно, если сделатьs = s[:cap(s)].
Всё остальное в статье про то, откуда эти нули физически берутся.
Как бывает без zero value
Чтобы понять цену гарантии, полезно посмотреть на язык, где её нет. Вот программа на C. Функция fill записывает в свой кадр стека 64 байта 0xAA и выходит. Функция leak объявляет такой же массив, ничего в него не пишет и печатает содержимое.
#include <stdio.h>
#include <string.h>
// Записывает в свой кадр 64 байта 0xAA и выходит.
void fill(void) {
unsigned char buf[64];
memset(buf, 0xAA, sizeof(buf));
}
// Объявляет такой же массив, ничего в него не пишет и печатает.
void leak(void) {
unsigned char buf[64];
printf("buf[0..15] =");
for (int i = 0; i < 16; i++) {
printf(" %02X", buf[i]);
}
printf("\n");
}
int main(void) {
printf("до вызова fill(): ");
leak();
fill();
printf("после вызова fill():");
leak();
return 0;
}
Собираем без оптимизаций, иначе компилятор выбросит бесполезный memset:
$ gcc -O0 -o dirty_stack dirty_stack.c
$ ./dirty_stack
до вызова fill(): buf[0..15] = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
после вызова fill():buf[0..15] = AA AA AA AA AA AA AA AA AA AA AA AA AA AA AA AA
Здесь сразу два наблюдения, и оба важны.
Первое: при первом вызове массив полон нулей. Не потому, что C так гарантирует. Просто эта страница стека ещё ни разу не использовалась, и ядро отдало её процессу чистой. Второе: после fill тот же самый неинициализированный массив содержит чужие данные. Оба вызова получили один и тот же участок стека, и второй увидел то, что оставил первый.
Посмотрим на пролог leak в дизассемблере. Пролог это несколько инструкций в начале функции, которыми она отводит себе место на стеке под локальные переменные:
$ objdump -d --no-show-raw-insn dirty_stack | sed -n '/<leak>:/,/^$/p' | head -6
00000000004011a2 <leak>:
4011a2: push %rbp
4011a3: mov %rsp,%rbp
4011a6: sub $0x60,%rsp
4011aa: mov %fs:0x28,%rax
4011b3: mov %rax,-0x8(%rbp)
Пролог сдвигает указатель стека на 0x60 байт и на этом с памятью заканчивает. Две последние инструкции к массиву отношения не имеют: это канарейка -fstack-protector, которую GCC кладёт между локальными переменными и адресом возврата. Обнуления нет. Выделение памяти на стеке в C стоит одну инструкцию вычитания. Именно поэтому оно такое дешёвое, и именно поэтому содержимое произвольное.
То же самое в куче
Со стеком понятно. Проверим кучу.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Печатаем с 16-го байта: первые 16 занимает служебная
// структура tcache, которую glibc пишет в освобождённый блок.
static void dump(const char *label, unsigned char *p) {
printf("%s", label);
for (int i = 16; i < 28; i++) {
printf(" %02X", p[i]);
}
printf("\n");
}
int main(void) {
unsigned char *a = malloc(64);
dump("malloc(64), первый раз: ", a);
memset(a, 0xBB, 64);
free(a);
unsigned char *b = malloc(64);
dump("malloc(64) после free: ", b);
free(b);
unsigned char *big = malloc(10 << 20);
dump("malloc(10 МБ): ", big);
memset(big, 0xCC, 10 << 20);
free(big);
unsigned char *big2 = malloc(10 << 20);
dump("malloc(10 МБ) после free:", big2);
return 0;
}
$ gcc -O0 -o heap_dirty heap_dirty.c
$ ./heap_dirty
malloc(64), первый раз: 00 00 00 00 00 00 00 00 00 00 00 00
malloc(64) после free: BB BB BB BB BB BB BB BB BB BB BB BB
malloc(10 МБ): 00 00 00 00 00 00 00 00 00 00 00 00
malloc(10 МБ) после free: 00 00 00 00 00 00 00 00 00 00 00 00
Маленький блок после free вернулся с моими байтами 0xBB внутри. Аллокатор просто положил его в свой список свободных блоков и выдал обратно как есть. А вот блок на 10 МБ в обоих случаях пришёл чистым. Разница видна в системных вызовах:
$ strace -e trace=mmap,munmap,brk ./heap_dirty
...
mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f009a3ff000
munmap(0x7f009a3ff000, 10489856) = 0
brk(0x2c7ec000) = 0x2c7ec000
Большой запрос glibc не стал обслуживать из своей кучи, а сходил за памятью к ядру. Первый раз через mmap с флагом MAP_ANONYMOUS, второй раз через brk. Смена системного вызова не случайна. Порог, после которого glibc идёт в mmap, динамический: освобождение mmap-блока поднимает его до размера этого блока. Второй запрос под новый порог уже попал, и его обслужили расширением кучи. И в том, и в другом случае ядро обязано отдать обнулённые страницы. Это требование безопасности: без него ваш процесс читал бы обрывки чужой памяти. В man 2 mmap про MAP_ANONYMOUS написано прямо: «its contents are initialized to zero».
Отсюда главный вывод раздела. Нули в C иногда есть, но приходят они не от языка, а от ядра, и только на памяти, которую процесс получает впервые. Внутри процесса не зануляет никто.
Сколько стоит обнуление
Теперь измерим. Все числа дальше сняты на одной машине: AMD Ryzen 5 PRO 4650U, 30 ГБ памяти, Linux 6.12, Go 1.24.10. Возьмём гигабайт и посмотрим, во что обходятся нули.
Первая попытка провалилась неожиданно. Я написал замер malloc плюс memset против calloc, и memset показал ноль миллисекунд. Разгадка нашлась в дизассемблере: в исполняемом файле не оказалось ни одного вызова malloc.
$ objdump -d calloc_cost | grep -oE "<(malloc|calloc|memset)@plt>" | sort | uniq -c
2 <calloc@plt>
GCC на -O2 распознаёт пару «выделили память, тут же обнулили» и заменяет её на calloc. Сам компилятор считает обнуление достаточно дорогой операцией, чтобы держать для неё отдельную оптимизацию. Снять замену можно флагом -fno-builtin-memset, но он действует на всю единицу трансляции. Точечнее работает барьер:
// Барьер: компилятор перестаёт понимать, откуда взялся указатель,
// и не может свернуть malloc+memset обратно в calloc.
#define OPAQUE(p) __asm__ volatile("" : "+r"(p) : : "memory")
С барьером измерения выглядят так. Гигабайт, страничные ошибки считаются через getrusage.
Страничная ошибка (page fault) это прерывание процессора. Оно возникает при обращении к адресу, за которым ещё не стоит физическая страница. Ядро перехватывает его, подбирает страницу и возвращает управление в ту же инструкцию. Дальше в тексте различаются три состояния памяти. Свежая: адреса выданы, физических страниц нет, каждое первое обращение стоит страничной ошибки. Резидентная: физические страницы уже есть. Прогретая: то же самое, что резидентная, память недавно трогали, и она к тому же лежит в кэшах процессора.
| Операция | Время | ГиБ/с | Страничных ошибок |
|---|---|---|---|
calloc(1 ГБ) | 0.0 мс | нет | 3 |
| Первый проход, чтение | 302.7 мс | 3.3 | 262 143 |
| Первый проход, запись | 876.5 мс | 1.1 | 262 143 |
malloc + memset по свежей памяти | 802.5 мс | 1.2 | 262 145 |
memset по прогретой памяти | 178.7 мс | 5.6 | 0 |
В таблице пять строк, и каждая про свою работу.
calloc на гигабайт отработал за время, неотличимое от нуля, и вызвал три страничных ошибки. Никакой памяти в этот момент не существует. Есть только запись в таблице отображений.
Первый проход на чтение стоил 302 мс и 262 143 страничных ошибки. В гигабайте 262 144 страницы по 4 КБ, то есть по одной ошибке на страницу. При этом ядро не выделило ни одной реальной страницы и не записало ни одного нуля. На чтение анонимной памяти оно подставляет одну общую страницу нулей. Эти 302 мс есть чистая цена самих ошибок, примерно 1.15 микросекунды на страницу.
Первый проход на запись стоил 876 мс при том же числе ошибок. Разница с чтением, около 570 мс, и есть счёт за обнуление, который выставляет ядро. На запись общей нулевой страницей не обойтись: нужно выделить настоящую страницу и обнулить её, прежде чем отдать процессу.
Последняя строка показывает цену обнуления в чистом виде, без участия ядра. Все страницы уже физически существуют, memset только пишет нули: 5.6 ГиБ/с. Здесь и дальше все пропускные способности двоичные: гигабайт это 1 << 30 байт.
От прогона к прогону абсолютные числа гуляют. На этом ноутбуке memset по прогретой памяти показывал от 104 до 180 мс в зависимости от того, успел ли процессор разогнаться. Соотношения при этом держатся.
Итог по C: гигабайт нулей стоит от 0.18 до 0.9 секунды в зависимости от того, кто их пишет и существуют ли уже страницы. Ноль не бесплатен нигде. Вопрос только в том, кому выставят счёт.
Что гарантирует спецификация Go
Теперь к Go. Гарантия записана в спецификации, в разделе The zero value:
When storage is allocated for a variable, either through a declaration or a call of new, or when a new value is created, either through a composite literal or a call of make, and no explicit initialization is provided, the variable or value is given a default value. Each element of such a variable or value is set to the zero value for its type: false for booleans, 0 for numeric types, "" for strings, and nil for pointers, functions, interfaces, slices, channels, and maps. This initialization is done recursively, so for instance each element of an array of structs will have its fields zeroed if no value is specified.
Ключевое слово в конце: рекурсивно. Массив структур, содержащих массивы структур, будет обнулён целиком, на любую глубину вложенности.
В этом списке есть закономерность. false, 0, "", nil. Выглядит как перечисление разных вещей. На самом деле это одна и та же вещь, записанная четырьмя способами.
| Тип | Как устроен внутри | Байты zero value |
|---|---|---|
bool | один байт | 00 |
int64 | 8 байт, дополнительный код | 00 × 8 |
float64 | IEEE-754 | 00 × 8, это и есть +0.0 |
| Указатель | адрес | 00 × 8, это и есть nil |
string | структура {ptr, len} | 00 × 16 |
| Слайс | структура {ptr, len, cap} | 00 × 24 |
| Интерфейс | структура {tab, data} | 00 × 16 |
| map, канал | указатель на структуру рантайма | 00 × 8 |
Zero value любого типа в Go это область памяти, заполненная нулевыми байтами. Ни одного исключения. Пустая строка это нулевой указатель плюс нулевая длина, о внутреннем устройстве строк я писал подробнее в статье про строки. Нулевой интерфейс это нулевой указатель на таблицу методов плюс нулевой указатель на данные, подробности в статье про интерфейсы.
Из этого следует важная вещь для всей оставшейся статьи. Раз zero value везде одинаковый по байтам, то и реализация везде одна: заполнить область памяти нулями. Никакой зависящей от типа инициализации, никакого обхода полей структуры. Один memclr.
Зачем это сделано
Две причины, и вторая менее очевидна.
Первая причина, безопасность памяти. Чтение неинициализированной переменной в C это undefined behaviour и канал утечки данных, что мы и увидели в первом разделе. В Go этого класса ошибок нет по построению.
Вторая причина, корректность сборщика мусора. GC может увидеть объект в любой момент, в том числе между аллокацией и первой записью в него. Если бы там лежал мусор, сборщик интерпретировал бы случайные байты как указатели и пошёл бы по ним. Это не теоретическое рассуждение, оно прямо записано в комментарии рантайма. Вот runtime/slice.go, функция growslice:
// Note: can't use rawmem (which avoids zeroing of memory),
// because then GC can scan uninitialized memory.
p = mallocgc(capmem, et, true)
rawmem здесь это быстрый путь без обнуления. Для типов с указателями им пользоваться нельзя именно из-за сборщика.
Три места, где рождается ноль
Обнулением в Go занимаются три разные сущности, в трёх разных областях памяти, разными механизмами.
глобальные переменные → секция .bss → зануляет ядро при первом обращении
локальные переменные → стек горутины → зануляет код компилятора
всё, что убежало → куча → зануляет рантайм
Разберём по очереди.
Глобальные переменные: секция .bss
Начнём с самого дешёвого случая. Объявим глобальный массив на 64 мегабайта нулей и посмотрим на размер исполняемого файла.
package main
// 64 МБ нулей. В бинарь не попадает ни один байт.
var buf [64 << 20]byte
func main() { println(buf[0]) }
$ ls -l bin_empty bin_zeroed
-rwxr-xr-x 1 brualan users 1575671 bin_empty
-rwxr-xr-x 1 brualan users 1575792 bin_zeroed
Разница между пустой программой и программой с 64 мегабайтами нулей составила 121 байт. Массив в файл не попал. Зато он есть в таблице секций:
$ readelf -S bin_zeroed | grep -E "bss|noptrdata|\.data"
[ 9] .noptrdata PROGBITS 00000000004f41c0 000f41c0
[10] .data PROGBITS 00000000004f4bc0 000f4bc0
[11] .bss NOBITS 00000000004f7b20 000f7b20
[12] .noptrbss NOBITS 00000000005178e0 001178e0
Тип секции NOBITS означает буквально «в файле байтов нет». Есть только запись о том, что при запуске в этом месте адресного пространства должно оказаться столько-то нулей.
Нагляднее это видно на сегментах: ядро при запуске читает не секции, а program headers. У сегмента там два разных размера, сколько он занимает в файле и сколько должен занять в памяти.
$ readelf -lW bin_empty | grep -E "^ Type|LOAD.*RW"
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x0f4000 0x00000000004f4000 0x00000000004f4000 0x003b20 0x0272e0 RW 0x1000
$ readelf -lW bin_zeroed | grep -E "LOAD.*RW"
LOAD 0x0f4000 0x00000000004f4000 0x00000000004f4000 0x003b20 0x40272e0 RW 0x1000
FileSiz одинаковый, MemSiz отличается на 0x4000000, ровно на 64 мегабайта. Разница между этими двумя числами и есть обещанные нули: в файле их нет, в памяти они обязаны быть.
Обратите внимание на две отдельные секции: .bss и .noptrbss. Обычные компоновщики обходятся одной. Go разделяет глобальные переменные на содержащие указатели и не содержащие, чтобы сборщик мусора сканировал только первую секцию и не тратил время на вторую.
Для контраста возьмём тот же массив, но с ненулевой статической инициализацией:
// Статический инициализатор: компилятор обязан положить
// эти 4 МБ в файл, потому что они не нули.
var buf = [4 << 20]byte{0: 1, 1: 2, 2: 3}
$ ls -l bin_empty bin_static
-rwxr-xr-x 1 brualan users 1575671 bin_empty
-rwxr-xr-x 1 brualan users 5770096 bin_static
Четыре мегабайта ненулевых данных стоят четыре мегабайта в файле. Шестьдесят четыре мегабайта нулей стоят 121 байт. Разница ровно в том, что нули не надо хранить, их достаточно пообещать.
Кто выполняет обещание. При execve загрузчик ELF отображает сегменты файла в память. Хвост, на который MemSiz больше FileSiz, он добирает анонимным отображением. Ровно тем же, что делает mmap с MAP_ANONYMOUS из первого раздела. Ядро гарантирует нули. Работы на старте нет вообще, счёт приходит позже, страничными ошибками при первом обращении к массиву.
Это самый выгодный из трёх механизмов. Программа не платит ничего ни за размер файла, ни за время запуска. Платит она только за те страницы, которых реально коснулась.
Стек: работа компилятора
С глобальными переменными помог загрузчик. Со стеком не поможет никто.
Стеки горутин Go выделяет сам, функцией stackalloc в runtime/stack.go. Она берёт память у аллокатора спанов через allocManual и отдаёт как есть. Спан (span) это непрерывный участок кучи из нескольких страниц, который рантайм Go нарезает на объекты одного размерного класса. Это единица учёта памяти в аллокаторе. На рабочем пути в этом файле нет ни одного обнуления. Единственный memclr во всём stack.go спрятан в stackfree под условием stackDebug >= 1, с комментарием «for testing, clobber stack data». Стек горутины ровно такой же грязный, как стек в C: там лежит то, что оставила предыдущая горутина, которой этот кусок памяти принадлежал.
Значит, обнулением занимается компилятор. Причём он старается делать это как можно реже, потому что каждый обнулённый байт это записанный байт.
Что компилятор зануляет обязательно
Первое, это именованные возвращаемые значения. Код в cmd/compile/internal/ssagen/ssa.go, функция zeroResults, комментарий объясняет причину:
// zeroResults zeros the return values at the start of the function.
// We need to do this very early in the function. Defer might stop a
// panic and show the return values as they exist at the time of
// panic. For precise stacks, the garbage collector assumes results
// are always live, so we need to zero them before any allocations,
// even allocations to move params/results to the heap.
Две причины в одной. defer может перехватить панику и прочитать именованные результаты в любой момент выполнения функции, включая самое начало. И сборщик мусора считает результаты живыми всегда, значит они обязаны быть корректными указателями с первой инструкции.
Второе, это «неоднозначно живые» переменные. Флаг ставится в cmd/compile/internal/liveness/plive.go, код генерируется в функции defframe там же в ssa.go:
// Insert code to zero ambiguously live variables so that the
// garbage collector only sees initialized values when it
// looks for pointers.
Это переменные, про которые компилятор не смог доказать, что к моменту возможной остановки на сборку мусора они уже проинициализированы. Их зануляют на всякий случай.
Всё остальное компилятор зануляет, только если переменную реально читают до записи. Если он видит, что в переменную сначала пишут, обнуление выбрасывается целиком, а сама переменная чаще всего живёт в регистре и до стека не доходит.
Четыре стратегии
Когда обнулять всё-таки надо, инструкции зависят от размера. Возьмём четыре функции с буферами разной длины:
//go:noinline
func sink(b []byte) byte { return b[0] }
//go:noinline
func buf8() byte { var b [8]byte; return sink(b[:]) }
//go:noinline
func buf64() byte { var b [64]byte; return sink(b[:]) }
//go:noinline
func buf1024() byte { var b [1024]byte; return sink(b[:]) }
//go:noinline
func buf8192() byte { var b [8192]byte; return sink(b[:]) }
//go:noinline нужен, чтобы компилятор не подставил тела функций по месту вызова. Обращение b[:] заставляет компилятор положить массив в кадр, а не разложить по регистрам. Смотрим, что получилось.
8 байт дают одну инструкцию записи непосредственного значения:
$ go tool objdump -s 'main\.buf8$' stackzero
MOVQ $0x0, 0x18(SP)
64 байта дают четыре записи по 16 байт:
$ go tool objdump -s 'main\.buf64$' stackzero
MOVUPS X15, 0x18(SP)
MOVUPS X15, 0x28(SP)
MOVUPS X15, 0x38(SP)
MOVUPS X15, 0x48(SP)
На этой строчке стоит задержаться. Регистр X15 в Go на amd64 зарезервирован: в нём всегда лежат нули, рантайм поддерживает это соглашение. Обнуление превращается в простую запись регистра в память, константу не нужно ни загружать, ни держать.
1024 байта дают вызов устройства Даффа:
$ go tool objdump -s 'main\.buf1024$' stackzero
LEAQ 0x18(SP), DI
CALL runtime.duffzero(SB)
runtime.duffzero из runtime/duff_amd64.s это развёрнутый цикл: 16 блоков по четыре MOVUPS подряд. Обнуление произвольной длины делается прыжком в середину этой ленты, на нужное расстояние от конца. Ни счётчика, ни проверки условия на каждой итерации. Сам файл сгенерирован программой runtime/mkduff.go.
8192 байта дают строковую инструкцию процессора. Строковыми в x86 называют инструкции, которые сами в цикле проходят по области памяти, к строкам языка они отношения не имеют:
$ go tool objdump -s 'main\.buf8192$' stackzero
LEAQ 0x18(SP), DI
MOVL $0x400, CX
XORL AX, AX
REP; STOSQ AX, ES:0(DI)
REP STOSQ записывает AX в память CX раз по 8 байт. 0x400 это 1024 повторения, ровно 8192 байта.
Пороги переключения между стратегиями лежат в правилах генератора кода, файл cmd/compile/internal/ssa/_gen/AMD64.rules:
// Medium zeroing uses a duff device.
(Zero [s] destptr mem)
&& s > 64 && s <= 1024 && s%16 == 0 && !config.noDuffDevice =>
(DUFFZERO [s] destptr mem)
// Large zeroing uses REP STOSQ.
(Zero [s] destptr mem)
&& (s > 1024 || (config.noDuffDevice && s > 64 || !config.useSSE && s > 32))
&& s%8 == 0 =>
(REPSTOSQ destptr (MOVQconst [s/8]) (MOVQconst [0]) mem)
До 64 байт включительно развёрнутая цепочка записей, от 64 до 1024 байт устройство Даффа, дальше REP STOSQ. Ровно то, что мы увидели в дизассемблере. Решает не только размер: обе строчки требуют кратности, 16 байт для устройства Даффа и 8 для REP STOSQ. Некратный хвост дописывают отдельными инструкциями по другим правилам.
Отдельно замечу, что обнуление кадра в прологе, то самое для неоднозначно живых переменных, идёт другим путём, через функцию zerorange в cmd/compile/internal/amd64/ggen.go. Пороги там свои, но настроены точно так же: cnt <= 8*RegSize даёт цепочку MOVUPS, cnt <= 128*RegSize даёт DUFFZERO, дальше REP STOSQ. При размере слова в 8 байт это те же самые 64 и 1024.
Если хотите убедиться, что стек действительно грязный, в компиляторе есть отладочный флаг -gcflags=-clobberdead. Он забивает мёртвые указательные слоты кадра значением 0xdeaddead. Константа объявлена в runtime/mbitmap.go как clobberdeadPtr, и рантайм падает, если такой указатель где-то всплывёт.
Всё перечисленное про amd64. На arm64 смысл тот же, инструкции другие. Роль X15 играет аппаратный регистр нулей ZR. А runtime/duff_arm64.s это лента из 64 записей STP (ZR, ZR): пара нулевых регистров за раз, по 16 байт, всего те же 1024. Первые 63 записи делает STP.P (ZR, ZR), 16(R20): адрес сдвигает сама инструкция. Последняя пишет без сдвига. Пороги совпадают: правило в cmd/compile/internal/ssa/_gen/ARM64.rules берёт устройство Даффа при s > 64 && s <= 16*64, то есть до тех же 1024 байт. Строковых инструкций вроде REP STOSQ в arm64 нет, поэтому выше порога компилятор разворачивает обычный цикл, LoweredZero.
Сколько стоит кадр
Теперь цена трёх стратегий в наносекундах. Каждая функция объявляет буфер и читает из него два байта:
│ sec/op │
StackZero/64B_MOVUPS-12 3.280n ± 3%
StackZero/1KB_DUFFZERO-12 27.96n ± 5%
StackZero/8KB_REPSTOSQ-12 87.05n ± 5%
Буфер в 1 КБ на стеке стоит 28 наносекунд, буфер в 8 КБ стоит 87 наносекунд. Само по себе немного. Но это цена каждого вызова. Функция с буфером в 8 КБ, вызываемая миллион раз в секунду, тратит 87 миллисекунд процессорного времени в секунду только на запись нулей. Почти девять процентов ядра, и в профиле это будет выглядеть как размазанное время внутри самой функции, без отдельной строчки. Как такое ловить, разбирал в статье про pprof.
У этой истории есть потолок. Переменная больше ir.MaxStackVarSize, а это 128 килобайт, на стек не попадёт в любом случае. Escape-анализ отправит её в кучу независимо от того, убегает она или нет. Для неявных объектов, new и указателей на составные литералы, порог вдвое ниже, ir.MaxImplicitStackVarSize, 64 килобайта. Оба лежат в cmd/compile/internal/ir/cfg.go. Так что буфер на мегабайт это уже не разговор про кадр, это разговор про mallocgc из следующего раздела.
Куча: работа рантайма
Третий случай. Всё, что escape-анализ не оставил на стеке, попадает в кучу, и обнулением занимается рантайм.
Точка входа простая. Оператор new компилятор превращает в вызов newobject из runtime/malloc.go:
// implementation of new builtin
// compiler (both frontend and SSA backend) knows the signature
// of this function.
func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.Size_, typ, true)
}
Третий аргумент mallocgc называется needzero. Здесь он true. Дальше mallocgc разветвляется по размеру и типу объекта на пять функций: mallocgcTiny, mallocgcSmallNoscan, mallocgcSmallScanNoHeader, mallocgcSmallScanHeader и mallocgcLarge. В трёх из них, во всех вариантах для мелких объектов, стоит одна и та же пара строк:
if needzero && span.needzero != 0 {
memclrNoHeapPointers(x, size)
}
Вот и всё обнуление кучи в Go. Одно условие и один вызов. Два оставшихся варианта отличаются, и оба отличия любопытные.
mallocgcTiny не проверяет needzero вообще. Свежий блок в 16 байт зануляется безусловно, двумя записями:
x := unsafe.Pointer(v)
(*[2]uint64)(x)[0] = 0
(*[2]uint64)(x)[1] = 0
Две записи дешевле, чем ветвление, которое их сэкономит.
mallocgcLarge зовёт не memclrNoHeapPointers, а его версию с нарезкой:
if needzero && span.needzero != 0 {
// N.B. size == fullSize always in this case.
memclrNoHeapPointersChunked(size, x) // This is a possible preemption point: see #47302
}
Внутри неё обычный цикл по кускам:
func memclrNoHeapPointersChunked(size uintptr, x unsafe.Pointer) {
v := uintptr(x)
// got this from benchmarking. 128k is too small, 512k is too large.
const chunkBytes = 256 * 1024
vsize := v + size
for voff := v; voff < vsize; voff = voff + chunkBytes {
if getg().preempt {
// may hold locks, e.g., profiling
goschedguarded()
}
// clear min(avail, lump) bytes
n := vsize - voff
if n > chunkBytes {
n = chunkBytes
}
memclrNoHeapPointers(unsafe.Pointer(voff), n)
}
}
Причина в задержках. Ниже я меряю аллокацию на 256 мегабайт, её обнуление занимает 45 миллисекунд. Одним вызовом это были бы десятки миллисекунд, в течение которых горутину нельзя вытеснить: ни планировщик, ни сборщик мусора к ней не подступятся. Нарезка по 256 килобайт даёт точку вытеснения примерно каждые 45 микросекунд, то есть в тысячу раз чаще. Бесплатно это не даётся, ниже я покажу, сколько именно стоит.
Условие из двух частей
Интересно здесь второе условие. needzero пришёл от вызывающего, а span.needzero это свойство спана, из которого выдали объект. Если спан помечен как уже нулевой, обнуление пропускается целиком, даже когда его просили.
Логика объяснена в комментарии в начале runtime/malloc.go:
// If mspan.needzero is false, then free object slots in the mspan are
// already zeroed. Otherwise if needzero is true, objects are zeroed as
// they are allocated. There are various benefits to delaying zeroing
// this way:
//
// 1. Stack frame allocation can avoid zeroing altogether.
//
// 2. It exhibits better temporal locality, since the program is
// probably about to write to the memory.
//
// 3. We don't zero pages that never get reused.
Третий пункт и есть суть оптимизации. Память, которую рантайм только что получил от ядра, уже нулевая. Обнулять её второй раз бессмысленно.
Откуда рантайм знает, что конкретный участок ещё девственно чист. Через отметку на каждой арене кучи, в исходниках она называется watermark. Это поле zeroedBase структуры heapArena в runtime/mheap.go:
// zeroedBase marks the first byte of the first page in this
// arena which hasn't been used yet and is therefore already
// zero. zeroedBase is relative to the arena base.
// Increases monotonically until it hits heapArenaBytes.
//
// This field is sufficient to determine if an allocation
// needs to be zeroed because the page allocator follows an
// address-ordered first-fit policy.
zeroedBase uintptr
Одно число на арену в 64 мегабайта. Всё, что за этой отметкой, ещё ни разу не выдавалось, значит там по-прежнему нули от ядра. Проверяет это функция allocNeedsZero, и одного числа хватает ровно потому, что аллокатор страниц выдаёт адреса по возрастанию.
Обратная сторона: zeroedBase только растёт и никогда не сбрасывается. Как только участок арены хоть раз выдали, он считается грязным навсегда. Спан помечается грязным при подметании, фазе sweep сборщика мусора: runtime/mgcsweep.go, одна строка s.needzero = 1.
Убедиться, что освобождённая память действительно остаётся грязной, можно не читая исходники. С GODEBUG=clobberfree=1 сборщик забивает каждый освобождаемый объект константой 0xdeadbeef, функция clobberfree в runtime/mgcsweep.go. Если после этого кто-то в программе читает освобождённый объект, это видно сразу.
Возникает вопрос: а если рантайм вернул страницы ядру, почему бы не сбросить отметку? Ответ в runtime/mem_linux.go, функция sysUnusedOS. Скавенджер (scavenger) это часть рантайма, которая возвращает ядру давно не используемые страницы кучи. Отдаёт он их через madvise, и вот каким флагом:
advise := atomic.Load(&adviseUnused)
if debug.madvdontneed != 0 && advise != madviseUnsupported {
advise = _MADV_DONTNEED
}
switch advise {
case _MADV_FREE:
if madvise(v, n, _MADV_FREE) == 0 {
break
}
Флагов два, и выбор между ними не фиксирован. По умолчанию на Linux debug.madvdontneed равен единице, это выставляется в parsedebugvars в runtime/runtime1.go с комментарием «Hence, default to MADV_DONTNEED». То есть обычно идёт MADV_DONTNEED, после которого ядро при следующем обращении действительно отдаст чистые страницы. Но с GODEBUG=madvdontneed=0 рантайм переключается на MADV_FREE, а он нулей не гарантирует: ядро вправе вернуть старое содержимое, если под давлением памяти так и не забрало страницу.
Так что первая причина не сбрасывать отметку в том, что на одном из двух путей сбрасывать её было бы просто неверно. Вторая причина в устройстве самой отметки. zeroedBase это одно монотонно растущее число на арену, и оно умеет выражать только «всё, что дальше вот этой границы, ещё ни разу не выдавалось». Страницы, которые скавенджер вернул ядру, лежат где угодно внутри арены, вперемешку с занятыми. Такую дырявую картину одним числом не описать, а отслеживать её отдельно значит платить за учёт больше, чем сэкономишь на нулях.
Откуда берётся свежая память
Тот же системный вызов, что и в первом разделе. Функция sysAllocOS в runtime/mem_linux.go:
func sysAllocOS(n uintptr) unsafe.Pointer {
p, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
MAP_ANON плюс MAP_PRIVATE, ровно то, что glibc делала для malloc(10 << 20). Круг замкнулся: и в C, и в Go источник нулей один и тот же, ядро Linux.
Проверяем needzero на практике
Теория красивая, проверим её измерением. Программа семь раз аллоцирует 256 мегабайт. Первые два раза память свежая, дальше каждый раз перед аллокацией предыдущий буфер отпускается и вызывается сборка мусора.
const size = 256 << 20
var keep []byte
func alloc(label string) {
t := time.Now()
keep = make([]byte, size)
d := time.Since(t)
gib := float64(size) / (1 << 30) / d.Seconds()
fmt.Printf("%-40s %7.1f мс %5.1f ГиБ/с\n", label, float64(d.Nanoseconds())/1e6, gib)
}
func main() {
alloc("1. первая аллокация, свежая память")
alloc("2. вторая, первая ещё жива")
for i := 3; i <= 7; i++ {
keep = nil
runtime.GC()
alloc(fmt.Sprintf("%d. после GC, span переиспользован", i))
}
}
$ ./needzero
1. первая аллокация, свежая память 0.8 мс 305.5 ГиБ/с
2. вторая, первая ещё жива 1.6 мс 160.9 ГиБ/с
3. после GC, span переиспользован 217.4 мс 1.1 ГиБ/с
4. после GC, span переиспользован 47.7 мс 5.2 ГиБ/с
5. после GC, span переиспользован 46.1 мс 5.4 ГиБ/с
6. после GC, span переиспользован 45.6 мс 5.5 ГиБ/с
7. после GC, span переиспользован 47.0 мс 5.3 ГиБ/с
Разница между первой и третьей строкой в 270 раз. Сравнение неравное: в быстром пути не записывается ни одного байта.
Первые две аллокации по 256 мегабайт заняли меньше двух миллисекунд. Пропускная способность в 300 ГиБ/с, разумеется, фикция: ничего не записывалось. Спан пришёл из свежей арены, span.needzero равен нулю, memclr не вызвался ни разу.
Третья строка показывает первую аллокацию из переиспользованного спана. 217 миллисекунд, 1.1 ГиБ/с. Здесь и обнуление, и страничные ошибки: скавенджер успел вернуть часть страниц ядру. Обратите внимание на цифру 1.1 ГиБ/с. Ровно её же показал первый проход на запись в C-замере из первого раздела. Это одна и та же работа: ядро выделяет страницу и обнуляет её.
Строки с четвёртой по седьмую показывают установившийся режим. 45–48 миллисекунд, 5.2–5.5 ГиБ/с. Страницы резидентны, работает только memclr. И снова совпадение с C: memset по прогретой памяти дал 5.6 ГиБ/с.
Три замера, два языка, один порядок величины. Обнуление гигабайта на этой машине стоит примерно секунду, если страниц ещё нет, и порядка 180 миллисекунд, если они уже есть. К совпадению до второй значащей цифры относитесь спокойно. Ниже будет видно, что абсолютные числа на этом ноутбуке гуляют вдвое. Та же самая запись нулей умеет и 11 ГиБ/с.
Как устроен сам memclr
memclrNoHeapPointers написан на ассемблере, runtime/memclr_amd64.s. Для мелких размеров там дерево сравнений с отдельной веткой почти на каждый размер до 256 байт. Дальше начинается интересное:
CMPB internal∕cpu·X86+const_offsetX86HasERMS(SB), $1
JNE skip_erms
// If the size is less than 2kb, do not use ERMS as it has a big start-up cost.
CMPQ BX, $2048
JAE loop_preheader_erms
От двух килобайт, если процессор поддерживает ERMS, используется строковая инструкция. Ниже этого порога у неё слишком большая стоимость запуска. Сама возможность называется «enhanced REP MOVSB/STOSB», но пишет рантайм всё равно REP STOSQ, то есть словами по 8 байт. Комментарий в этой ветке объясняет, что запись целого слова размером с указатель должна быть видна подсистеме памяти целиком, этого требует сборщик мусора.
Ещё дальше есть третий режим:
loop_preheader_avx2:
VPXOR X0, X0, X0
// For smaller sizes MOVNTDQ may be faster or slower depending on hardware.
// For larger sizes it is always faster, even on dual Xeons with 30M cache.
CMPQ BX, $0x2000000
JAE loop_preheader_avx2_huge
0x2000000 это 32 мегабайта. Выше этого порога рантайм переходит на MOVNTDQ, запись мимо кэша (non-temporal store). Обычная запись в память, которой нет в кэше, требует сначала прочитать строку кэша из памяти. Для обнуления это чистые потери: читаем то, что немедленно затрём. Запись мимо кэша этот шаг пропускает.
Порог видно на замерах. Меряю clear на буферах вокруг границы:
$ ./clearbw
размер пропускная способность
8 МБ 5.8 ГиБ/с
16 МБ 5.8 ГиБ/с
24 МБ 5.8 ГиБ/с
31 МБ 5.7 ГиБ/с
33 МБ 11.8 ГиБ/с
40 МБ 11.8 ГиБ/с
64 МБ 11.8 ГиБ/с
Между 31 и 33 мегабайтами скорость удваивается. Столько и должно получиться: вдвое падает трафик к памяти.
Здесь нужна оговорка про методику. Абсолютные числа на этом ноутбуке нестабильны: от запуска к запуску они скачут между двумя уровнями: ниже порога 5.8 и 9.6 ГиБ/с, выше порога 11.8 и 29 ГиБ/с. Похоже на управление частотой процессора и парковку ядер. А вот скачок на границе 32 МБ воспроизводится в каждом без исключения прогоне, и отношение держится между двумя и тремя. Верить здесь стоит отношению, а не абсолютным цифрам.
Отсюда же ответ на вопрос, который мог возникнуть двумя разделами выше. Там make на 256 мегабайт из переиспользованного спана давал 5.2–5.5 ГиБ/с, здесь clear на буфере того же порядка даёт 11.8. Дело как раз в этом пороге. mallocgcLarge не зовёт memclr на весь блок, он зовёт memclrNoHeapPointersChunked, а тот режет работу на куски по 256 килобайт. Каждый кусок приходит в memclr отдельным вызовом со своим размером. 256 килобайт до 0x2000000 не дотягивают, и запись мимо кэша не включается ни разу.
Проверить это можно напрямую. Зануляю один и тот же буфер на 256 МБ, меняя только размер куска:
$ ./chunkbw
chunk 256 КБ 44.6 мс 5.6 ГиБ/с
chunk 1024 КБ 44.5 мс 5.6 ГиБ/с
chunk 8192 КБ 44.2 мс 5.7 ГиБ/с
chunk 32768 КБ 21.5 мс 11.6 ГиБ/с
chunk 65536 КБ 21.5 мс 11.6 ГиБ/с
chunk 262144 КБ 21.5 мс 11.6 ГиБ/с
Работы одинаково, записанных байт одинаково, скачок ровно на 32 мегабайтах. Значит, нарезка на куски стоит примерно двух крат на больших аллокациях. Это и есть цена за возможность вытеснить горутину посреди обнуления. Обмен разумный: 21 миллисекунда, в течение которой горутину нельзя тронуть, хуже, чем 45 с точкой вытеснения каждые 45 микросекунд. Но бесплатным его называть нельзя.
Где Go обнуление пропускает
Раз обнуление стоит денег, рантайм старается его избегать везде, где может доказать, что память немедленно перезапишут.
Самый наглядный пример это рост слайса. runtime/slice.go, функция growslice:
if !et.Pointers() {
p = mallocgc(capmem, nil, false)
// The append() that calls growslice is going to overwrite from oldLen to newLen.
// Only clear the part that will not be overwritten.
// (здесь опущены две строки комментария про reflect_growslice)
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
}
Третий аргумент mallocgc здесь false. Память выдаётся как есть, грязная. Затем зануляется только хвост от новой длины до конца ёмкости. Участок, который append всё равно перезапишет, не трогают вообще. Работает это только для типов без указателей, потому что для остальных сборщик мусора не должен увидеть мусор.
Похожим образом устроены rawstring и rawbyteslice в runtime/string.go. Обе берут память через mallocgc(size, nil, false), потому что заполнят её сразу же. rawstring не зануляет вообще ничего. rawbyteslice зануляет только хвост от запрошенной длины до ёмкости, которую округлил размерный класс.
Обнуление живой памяти
Всё сказанное про один memclr относится к свежей памяти, которую сборщик мусора ещё ни разу не видел. С памятью, которая уже живёт, дороже. Возьмём три функции: сброс структуры с указателями, очистку слайса указателей и очистку слайса байт.
type T struct {
a *int
b []byte
c string
}
//go:noinline
func resetStruct(p *T) { *p = T{} }
//go:noinline
func clearPtrs(s []*int) { clear(s) }
//go:noinline
func clearBytes(s []byte) { clear(s) }
Смотрим, во что каждая превращается:
$ go tool objdump -s 'main\.resetStruct$' tmc | grep -oE "runtime\.\w+" | sort -u
runtime.morestack_noctxt
runtime.wbZero
runtime.writeBarrier
$ go tool objdump -s 'main\.clearPtrs$' tmc | grep -oE "runtime\.\w+" | sort -u
runtime.memclrHasPointers
runtime.morestack_noctxt
$ go tool objdump -s 'main\.clearBytes$' tmc | grep -oE "runtime\.\w+" | sort -u
runtime.memclrNoHeapPointers
runtime.morestack_noctxt
Чистый memclr только в последней строке. Слайс указателей идёт через memclrHasPointers из runtime/mbarrier.go:
func memclrHasPointers(ptr unsafe.Pointer, n uintptr) {
// Pass nil for the type since we don't have one here anyway.
bulkBarrierPreWrite(uintptr(ptr), 0, n, nil)
memclrNoHeapPointers(ptr, n)
}
Сначала барьер записи, потом обнуление. Во время сборки мусора bulkBarrierPreWrite проходит по указательным словам области и складывает старые значения в буфер, чтобы сборщик не потерял объекты, на которые они ссылались. У сброса структуры то же самое. Нули компилятор пишет по месту, а в рантайм уходит wbZero. Он сам ничего не зануляет, только делает тот же барьер.
Практический вывод для пулов. Сброс объекта с указателями перед возвратом в sync.Pool стоит дороже, чем такой же по размеру clear на слайсе байт. Разница вылезает именно во время сборки мусора.
Заодно деталь для коллекционеров. У memclrHasPointers в исходниках висит //go:linkname и список тех, кто дотягивается до неё в обход экспорта. В списке github.com/bytedance/sonic, про баг в котором я писал отдельно.
Сколько это стоит в Go
Соберём цену обнуления в одну картину. Стенд тот же. Шесть прогонов на конфигурацию, разброс посчитан benchstat, про саму методику замеров писал в статье про бенчмарки.
Три бенчмарка на одних и тех же размерах. Make создаёт слайс заново, то есть платит и за аллокацию, и за обнуление. Reuse переиспользует буфер и не делает ничего. ReuseClear переиспользует буфер и зануляет его через clear, то есть платит только за обнуление.
│ sec/op │ B/s │
Make/64B-12 58.39n ± 6% 1.021Gi ± 6%
Make/1KB-12 379.1n ± 3% 2.516Gi ± 3%
Make/16KB-12 4.750µ ± 1% 3.213Gi ± 1%
Make/256KB-12 79.43µ ± 13% 3.074Gi ± 15%
Make/4MB-12 638.5µ ± 8% 6.118Gi ± 9%
Make/64MB-12 7.921m ± 3% 7.890Gi ± 3%
Reuse/64B-12 0.7856n ± 4%
Reuse/1KB-12 0.7938n ± 5%
Reuse/16KB-12 0.8007n ± 5%
Reuse/256KB-12 0.7615n ± 7%
Reuse/4MB-12 0.7984n ± 5%
Reuse/64MB-12 0.7734n ± 3%
ReuseClear/64B-12 2.419n ± 2% 24.64Gi ± 2%
ReuseClear/1KB-12 10.35n ± 7% 92.12Gi ± 6%
ReuseClear/16KB-12 138.6n ± 5% 110.1Gi ± 5%
ReuseClear/256KB-12 2.286µ ± 5% 106.8Gi ± 5%
Начнём с середины таблицы. Reuse показывает 0.78 наносекунды на любом размере, от 64 байт до 64 мегабайт. Это ноль работы, просто накладные расходы цикла. Отсюда следует, что вся стоимость make состоит из аллокации и обнуления, больше там ничего нет.
Теперь ReuseClear, чистое обнуление резидентной памяти. Кривая пропускной способности показывает иерархию памяти как на ладони. На 64 байтах 24.6 ГиБ/с, здесь всё съедают накладные расходы вызова. На 16 и 256 килобайтах 110 и 107 ГиБ/с, буфер целиком помещается в кэш. Строк на 4 и 64 мегабайта в таблице нет намеренно. Разброс там доходил до 44 процентов, а результат зависел от числа итераций. Доверять таким числам нельзя. Отдельным замером выше эти размеры дали 5–12 ГиБ/с, и это ожидаемо: буфер перестаёт помещаться в кэш.
И Make сверху. На 64 байтах 58 наносекунд при том, что обнуление 64 байт стоит 2.4. Почти вся цена здесь это бухгалтерия аллокатора, а не нули. С ростом размера соотношение переворачивается: на 64 мегабайтах make работает со скоростью 7.9 ГиБ/с, и это уже почти целиком запись нулей плюс страничные ошибки.
Практический вывод: для мелких объектов обнуление не имеет значения, всё съедает аллокатор. Начиная примерно с килобайта нули становятся заметной статьёй расходов, а с мегабайта основной.
Неожиданный случай
Есть популярный совет: не пишите make([]T, n), пишите make([]T, 0, n) и добавляйте через append. Я проверил его на буфере в мегабайт и получил обратный результат.
// make зануляет буфер, copy тут же его перезаписывает.
func BenchmarkMakeThenCopy(b *testing.B) {
for i := 0; i < b.N; i++ {
dst := make([]byte, n)
copy(dst, src)
sink = dst
}
}
// Здесь работает growslice, которая тоже не зануляет то,
// что будет перезаписано.
func BenchmarkAppendNil(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = append([]byte(nil), src...)
}
}
// make с нулевой длиной, но полной ёмкостью.
func BenchmarkMakeCapThenAppend(b *testing.B) {
for i := 0; i < b.N; i++ {
dst := make([]byte, 0, n)
dst = append(dst, src...)
sink = dst
}
}
│ sec/op │
MakeThenCopy-12 174.2µ ± 21%
AppendNil-12 161.9µ ± 13%
MakeCapThenAppend-12 354.7µ ± 23%
Вариант с cap и append оказался вдвое медленнее. Разгадка в том, какие функции рантайма вызывает каждый вариант:
$ go tool objdump -s 'bench\.BenchmarkMakeThenCopy$' bench.test | grep -oE "runtime\.\w+" | sort -u | grep -v morestack
runtime.makeslicecopy
$ go tool objdump -s 'bench\.BenchmarkMakeCapThenAppend$' bench.test | grep -oE "runtime\.\w+" | sort -u | grep -v morestack
runtime.makeslice
runtime.growslice
runtime.memmove
Пару «выделили слайс, тут же скопировали в него» компилятор распознаёт и заменяет на runtime.makeslicecopy. Внутри неё то же самое, что в growslice:
to = mallocgc(tomem, nil, false)
if copymem < tomem {
memclrNoHeapPointers(add(to, copymem), tomem-copymem)
}
Аллокация без обнуления, зануляется только хвост за пределами копируемых данных. Мегабайт нулей не пишется вообще.
А make([]byte, 0, n) идёт через makeslice, и там обнуление безусловное:
func makeslice(et *_type, len, cap int) unsafe.Pointer {
// ...
return mallocgc(mem, et, true)
}
Обнуляется вся ёмкость, независимо от длины. То есть make([]byte, 0, n) записывает ровно столько же нулей, сколько make([]byte, n). Дальше append поверх этих нулей копирует данные. Мегабайт памяти проходится дважды, отсюда и двукратная разница.
Совет про cap и append остаётся верным там, где он и был придуман: он спасает от того, что растущий в цикле слайс несколько раз переедет на новое место. Но если данные копируются одним куском, make плюс copy быстрее.
Что из этого следует
Механика разобрана, соберём выводы.
Полезное нулевое значение это следствие, а не фича. Раз рантайм всё равно обязан обнулять память, язык извлекает из этого пользу: нулевое значение делают рабочим. var buf bytes.Buffer, var mu sync.Mutex, var wg sync.WaitGroup готовы к работе сразу, без конструктора. Проектируя свои типы, стоит держаться той же линии: тип, у которого zero value осмыслен, удобнее в использовании.
Обратная сторона тоже есть. Zero value безопасен, но не всегда пригоден к работе. Нулевой map читается, но паникует на запись. Нулевой указатель паникует на разыменование. Гарантия защищает от чтения чужих данных, а не от логических ошибок.
Обнуление становится заметным примерно с килобайта. Ниже этого порога о нём можно не думать. Выше стоит посмотреть, не пишете ли вы нули, которые тут же перезапишете.
Большие буферы на стеке недёшевы. Массив в 8 КБ добавляет к каждому вызову функции 87 наносекунд, и в профиле это время не выделено отдельной строкой.
Переиспользование буфера окупается не всегда. Если после взятия из пула вы всё равно делаете clear, вы сэкономили только на аллокации, а не на обнулении. Экономия есть, но вдвое меньше ожидаемой. А если в объекте есть указатели, во время сборки мусора к обнулению добавится ещё и барьер записи.
Инструменты, чтобы проверить всё это на своём коде:
| Команда | Что показывает |
|---|---|
go build -gcflags=-S | ассемблер с обнулением кадров |
go tool objdump -s 'pkg\.Func$' bin | дизассемблер конкретной функции |
go build -gcflags=-m | что убежало в кучу |
GODEBUG=clobberfree=1 | забивает освобождённые объекты мусором |
go build -gcflags=-clobberdead | забивает мёртвые слоты кадра |
readelf -S bin | размеры .bss и .noptrbss |
strace -e trace=mmap,madvise | как рантайм говорит с ядром |
Итог
Zero value в Go это не абстракция уровня языка, а вполне физическая запись нулевых байтов, которую выполняют три разных механизма.
Глобальные переменные попадают в секцию .bss типа NOBITS. Нули для них не хранятся в файле и не пишутся при старте, их предоставляет ядро анонимным отображением. Шестьдесят четыре мегабайта нулей обошлись в 121 байт файла.
Локальные переменные живут на стеке горутины, который рантайм не обнуляет вообще. За них отвечает компилятор, и он зануляет минимум необходимого: именованные результаты, неоднозначно живые переменные и то, что читают до записи. В зависимости от размера получается цепочка MOVUPS, вызов duffzero или REP STOSQ.
Объекты в куче обнуляет mallocgc, и то лишь когда спан помечен грязным. Память, только что полученная от ядра через mmap с MAP_ANONYMOUS, уже нулевая, и рантайм отслеживает это отметкой zeroedBase на каждую арену. Аллокация из свежей арены вышла в 270 раз быстрее аллокации из переиспользованного спана, но это не ускорение обнуления: в быстром пути его просто нет.
Общая цена на этом железе: от 90 до 180 миллисекунд на гигабайт, если страницы резидентны, и около секунды, если их ещё нужно получить у ядра. Столько же стоит memset в C. Гарантия Go не бесплатна, просто счёт за неё выставляют не вам, а компилятору и рантайму, и они торгуются за каждый байт.
Теги: