Привет, меня зовут Стас, и я работаю в VK Cloud над разработкой облачных сервисов в команде Data Masters. Сервисы, запрошенные клиентами, так или иначе должны развернуться в том виде, в котором клиенты их запросили, в адекватные сроки и без ошибок. Существует множество механизмов, позволяющих этого достичь, и еще больше существует ошибок и проблем, которые мешают в достижении этих целей.
Для любого специалиста, наверное, самой грустной ошибкой будет та, которая рождается из его незнания используемого инструмента. Наверняка практически каждый разработчик со сколь-нибудь большим опытом работы не раз сталкивался с такими проблемами хотя бы раз.
Единственным надежным решением таких проблем является глубокое погружение в теорию и исследования инструмента, которым решаются поставленные проблемы. В нашем случае таким инструментом является язык Go. И как же замечательно, что исследование его внутренностей — совсем легкое дело. В том числе когда дело касается использования памяти.
Введение
Язык Go до безобразия простой — опытному программисту хватит пары дней для перехода на него практически с любого другого языка. И вам совершенно не нужно знать тонкости его внутреннего устройства для успешной работы. И памяти это тоже касается. Но насколько это хороший подход?
Даже в официальной документации по языку вас встретит фраза Don't be clever, когда вы захотите глубже ознакомиться с моделью памяти. Разработчики языка сделали все, что было в их силах, чтобы скрыть от простых трудяг клавиатуры и мышки многие сложные вопросы, оставив для них только непосредственно написание логики самого приложения.
Такой подход помогает множеству новых программистов быстрее влиться в работу и приступить к нанесению пользы. Однако он же приводит к множеству проблем в будущем. Особенно когда речь заходит об оптимизации и отладке в сложных требовательных проектах.
Особенно остро это ощущается во время собеседования. Когда приходит senior Go developer, который вообще не понимает, как устроен язык, на котором он работает. В результате даже на тривиальных задачах начинаются проблемы. Крайне неприятная ситуация для обеих сторон.
Примерно из таких соображений родилась эта статья. В действительности проблема незнания не является таковой, пока не породит реальную проблему, на решение которой уйдет уйма времени и сил. Поэтому хорошо бы выделить немного времени на изучение основ внутреннего устройства инструмента, которым приходится пользоваться каждый день.
В статье большое внимание уделено практической демонстрации. Вы сможете наглядно увидеть те эффекты, о которых рассказывается во множестве чисто теоретических статей, — независимо от вашего уровня квалификации. Но и теории немного будет — куда без нее.
Инструменты для анализа работы памяти
При подготовке статьи мы столкнулись с проблемой порога входа. Какой механизм выбрать для демонстрации рассматриваемых эффектов? Следуя логике самого языка, нам стоит максимально снизить порог входа в сложный материал. Необходим какой-то механизм, который позволит любому разработчику Go сразу повторить все увиденное и прочитанное.
Существует множество способов заглянуть под капот приложения и посмотреть на устройство памяти — начиная с изучения базовой теории и построения воображаемых моделей и кончая специализированными приложениями, позволяющими искать и изменять значения в памяти.
Геймеры наверняка вспомнят ArtMoney и Cheat Engine, которые позволяют искать изменяемые места в памяти работающего приложения и править их на лету, конечно, из самых светлых читерских побуждений. Однако это все либо слишком сложно, либо требует дополнительного софта. Очевидно, это нам не особо подходит. Нужно что-то еще.
К счастью, язык Go в некотором роде делает это за нас. Как вам должно быть известно, Go написан на Go. А это значит, что все внутренние структуры языка также являются его составной частью. Более того, вы можете самостоятельно посмотреть на runtime языка и найти многие вещи прямо там.
Поэтому просто воспользуемся пакетом unsafe и возможностью конвертировать через него любой один тип в любой другой. Например, можно конвертировать структуру в байт-массив или int64 в int32 и наоборот.
Кроме того, для анализа распределения памяти будем использовать арифметику указателей, которой в Go как бы нет, но если очень нужно, то есть.
Базовая теория
Для начала синхронизируем наши знания относительно нескольких базовых аспектов, связанных с памятью в компьютерах. Обещаем: после скучной теории будет много кода и примеров, которые вы сможете повторить своими силами.
Если вы вчерашний студент-отличник технического вуза, входящего в топ-100 мировых вузов, то можете смело пропустить этот раздел. Если же университетские воспоминания уже давно вызывают у вас легкую улыбку, а знания испарились, как роса в солнечный день, — stay awhile and listen. Если же вы никогда и не были знакомы с теорией — у вас есть хороший шанс ознакомится с ней прямо сейчас.
Единицы измерения
Итак, в контексте текущей статьи нас будут интересовать в первую очередь три термина: бит, байт и машинное слово.
Бит — это минимальная единица измерения информации. Для нас это значение «ноль» или «один». Благодаря бинарной арифметике мы можем производить множество операций над битами.
Байт (октет) — также единица измерения информации, набор из восьми бит или двух тетрад. Является минимально адресуемой ячейкой памяти.
Машинное слово — основная единица информации, которой оперирует процессор. Так, например, между уровнями кэша процессор будет перемещать данные кратно размеру машинного слова.
Прочее
Выравнивание данных — механизм размещения данных в памяти. Его цель — не допустить ситуации, в которой переменная попадает на границу машинного слова. Это важная оптимизация, гарантирующая, что процессор сможет получить любое значение за константное время.
Виртуальная память — аппаратно-программный механизм, позволяющий создать виртуальное непрерывное адресное пространство. При этом различные части виртуальной памяти могут ссылаться на различные участки физической памяти — как оперативной, так и дисковой. Таким образом для прикладного приложения упрощается работа с памятью, появляется возможность работать с большими объемами памяти и скрывается проблема фрагментации физической памяти.
Фрагментация памяти — явление, при котором память заполняется отдельными фрагментами с частыми пропусками. Из-за этого может страдать производительность операций работы с памятью. А в некоторых случаях память вообще невозможно выделить до проведения операции дефрагментации.
Аллокатор — класс или алгоритм, управляющий распределением памяти в приложении. Он отвечает как за выделение памяти приложению, так и за размещение переменных в определенных местах этой памяти.
Устройство памяти
В работе с памятью одну из ключевых ролей играет выбранный аллокатор. В Go с памятью работает собственный аллокатор, основанный на tcmalloc. Одна из его особенностей — предсказуемая фрагментация. В зависимости от размера переменная может попасть в один из заранее определенных классов размеров.
В go-аллокаторе, в отличие от нативного tcmalloc, существует три варианта распределения: тини, смол и лардж. Лардж применяется для объектов размером более 32 байт. Смол для объектов менее 32 байт и более 16 байт. Ну и тини — для тех, что просто менее 16 байт.
Особенность тини-аллокатора в том, что он пытается скомпоновать в один спан (8 или 16 байт, первые 2 класса-размера) как можно больше объектов меньшего размера, соблюдая при этом правила выравнивания. Так, например, простой int64, имея размер 8 байт, целиком помещается в первый класс-размер, занимая его полностью. Две переменные int32, 4 байта каждая, также попадут в первый класс-размер и вполне могут быть упакованы в один спан.
А вот массив из 17 byte попадет в третий класс-размер, и им уже займется смол-аллокатор. И хотя третий класс-размер позволяет хранить объекты размером до 32 байт, объект на 17 байт займет его полностью. Оставшиеся 15 незанятых байтов будут потеряны — что и есть та самая управляемая фрагментация.
Детальнее ознакомиться с каждым классом размером можно прямо в рантайме.
С чистой теорией закончили — переходим к ее воспроизведению на практике.
Простые типы: byte, uint/int
Для начала разберем, пожалуй, один из самых простейших типов — byte. По задумке он является отражением байтовой системы памяти и представляет собой, как ни странно, ровно один байт. Byte является синонимом uint8 — он беззнаковый и имеет размер всего 8 бит. Посмотрим, как выглядят биты в простом байте:
func main() {
var x byte
x = 254
util.PrintAsBinary(x)
}
О том, как работает метод PrintAsBinary, мы расскажем дальше. Пока важно, что он просто печатает любую переменную в двоичном формате.
Что же мы увидим, запустив этот код?
11111110
В целом вполне ожидаемый ответ. Byte состоит из 8 бит, а значит, максимальное значение для него это 255. Мы же указываем 254, значение на единицу меньше максимального, — и в результате получаем байт, заполненный единицами полностью, кроме единственного бита. Первого бита. Который, и это важно, расположен в конце!
Дальше посмотрим на переменную большей размерности. Для простоты пусть это будет двухбайтный uint16.
func main() {
var x uint16
x = 32768
util.PrintAsBinary(x)
}
Напомню, 32768 — это двойка в пятнадцатой степени, т. е. это ровно половина от максимальной вместимости uint16. После выполнения этого куска ожидаем увидеть один байт, полностью заполненный нулями, а второй байт с одной единицей и семью нулями. Исходя из предыдущего примера, где ноль был в конце строки, в текущем примере единица должна оказаться первой.
Какой же будет результат?
00000000 10000000
Вопрос, почему байт с единицей именно второй, довольно сложный. Такой порядок байтов традиционно называют little-endian (или «от младшего к старшему»). Также его часто называют интеловским, поскольку он принят стандартом при хранении значений в памяти систем x86. Еще существует big-endian и изменяемый порядок. Если вам хочется узнать об этом больше, стоит почитать про порядок байтов.
Фрагментация, или Как работает аллокатор
В последнем разделе теоретической части мы рассказали об аллокаторе. Теперь же посмотрим на его работу изнутри. Для демонстрации мы будем использовать вот такой небольшой код:
func main() {
var b1, b2 [257]byte
e1 := unsafe.Pointer(&b1)
e2 := unsafe.Pointer(&b2)
fmt.Println(e1)
fmt.Println(e2)
fmt.Println("Size of b1:", unsafe.Sizeof(b1))
fmt.Println("Size of b2:", unsafe.Sizeof(b2))
fmt.Println("Distance b1 to b2:", uintptr(e2)-uintptr(e1))
}
Здесь мы имеем два массива с размерами 257 элементов, объявленные подряд. Получаем и выводим их адрес, затем узнаем размер и, наконец, рассчитываем расстояние от второго до первого.
В результате:
0xc0000b6000
0xc0000b6120
Size of b1: 257
Size of b2: 257
Distance b1 to b2: 288
Явно видно, что размер массивов равен объявленному нами — 257. Это на единицу больше, чем доступно в 18-м классе-размере. А вот расстояние в памяти между ними 288 байт, что как раз равно размерности 19-го класса размера.
В итоге мы заняли 257 байт из доступных 288 и получили 31 мусорный, неиспользуемый байт памяти. Это и есть ожидаемая и рассчитанная для каждого класса размера фрагментация памяти.
Создавая любые объекты, всегда стоит помнить про классы-размеры. Чем больше у вас объектов — тем потенциально больший профит вы могли бы получить от правильно подогнанных размеров. Но не стоит слишком сильно погружаться в эту оптимизацию, пока у вас достаточно других проблем.
Указатели
Каждый уважаемый go’шник обязан знать, что в Go ссылок нет — есть только указатели. Что это означает для разработчика? Что данные лежат по одному адресу, а указатель на них — по другому. Если передать указатель в функцию, передана будет копия указателя и у нее будет совсем другой адрес. Но внутри адрес будет точно такой же, как и у изначального указателя.
Это можно продемонстрировать на простом примере:
func main() {
x := 123
fmt.Println("pointer in main", &x)
p := pointTest(&x)
fmt.Println(x)
fmt.Println("Distance main to func:", uintptr(unsafe.Pointer(&x))-p)
}
// go:noinline
func pointTest(t *int) uintptr {
fmt.Println("pointer of t", &t)
fmt.Println("pointer in func before", t)
z := 321
t = &z
fmt.Println("pointer in func after", t)
return uintptr(unsafe.Pointer(&t))
}
Здесь мы рассмотрим сразу несколько интересных особенностей, с которыми часто сталкиваются даже опытные разработчики:
pointer in main 0xc00009a000
pointer of t 0xc000092020
pointer in func before 0xc00009a000
pointer in func after 0xc00009a008
123
Distance main to func: 32736
Пойдем по порядку. Создаем простую интовую переменную и передаем пойнтер на нее в функцию, затем выводим ее значение. Внутри функции создаем еще одну переменную и присваиваем ее пойнтер в полученную из мейна переменную. При этом параллельно наблюдаем за ней.
Что показывают простые наблюдения? Главное, конечно, что присвоение в функции никак не отразилось на значении переменной в мейне. Ответ на вопрос «почему так?» можно найти в наших наблюдениях.
Запишем наши наблюдения списком, чтобы немного систематизировать их:
- Переменная t типа *int (пойнтер на int) внутри функции и в мейне — это не одна и та же переменная. Расстояние между ними равно 32736 байт (при каждом запуске значение может немного колебаться).
- После присвоения пойнтера на переменную z переменной t значение хранящегося пойнтера в последней было изменено. Она больше не указывает на значение из мейна.
- Переменная z разместилась всего в 8 байтах от переменной t из мейна.
Если поведение t = &z для вас оказалось неожиданным — нужно разобраться в работе указателей. Попробуем сделать это быстро, не сходя с места.
Что же нужно, чтобы в этом примере значение в мейне стало 321? Нужно не записывать значение указателя z в переменную t, а записать значение z по адресу, который находится в переменной t.
Как? Разыменовать t и записать в него z. «*t = z» — вот так просто. Да, звездочка при объявлении типа — это сигнал, что тип является указательным, а в операциях с указателями — это сигнал разыменования переменной.
Структуры
В Go любая более-менее сложная сущность так или иначе представляет собой структуру. Поэтому, перед тем как рассмотреть комплексные типы данных, остановимся подробнее на том, что такое структура, как она хранится и какие подводные камни имеет.
Для начала рассмотрим простой пример. Возьмем простую структуру с тремя полями и попробуем преобразовать ее в массив байтов.
type struct4 struct {
b1 byte
b2 byte
i int16
}
func main() {
x := struct4{
b1: 2,
b2: 4,
i: 261,
}
fmt.Println(*(*[4]byte)(unsafe.Pointer(&x)))
}
В этом примере мы получим массив, заполненный следующим образом.
[2 4 5 1]
Это значения нашей структуры, представленные в форме массива байтов. На вопрос, почему 261 преобразилось в 5 и 1, ответ очень простой. Один заполненный байт 256 плюс 5 дает нам в сумме наши 261. А про порядок мы говорили выше, в разделе о простых типах.
Что в этом примере важного? На самом деле только то, что мы явственно увидели, что можем преобразовать любую структуру в массив байтов, а значит, вполне можем смотреть на структуры языка как на просто ограниченный участок памяти.
Выравнивание структур
Важной частью внутреннего устройства структур является выравнивание. Выравнивание — это крайне полезный для ускорения работы процессора механизм. Но в больших структурах он способен раздуть их намного больше необходимого. Зная некоторые базовые правила работы этого механизма, можно и вовсе свести вероятность этого к минимуму.
Для примера возьмем код, похожий на предыдущий. Ключевым отличием будет только то, что интовое поле теперь не двухбайтное, а четырехбайтное и располагается между двумя однобайтовыми. Также заполним все поля максимальными для типов значениями. Это позволит нам явно идентифицировать заполненные байты и отличить их от мусорных и неиспользуемых.
type alignmentStruct12 struct {
b1 byte
i uint32
b2 byte
}
func main() {
x := alignmentStruct8{
b1: util.MaxByte,
i: util.MaxUint32,
b2: util.MaxByte,
}
fmt.Println(unsafe.Sizeof(x))
fmt.Println(*(*[12]byte)(unsafe.Pointer(&x)))
}
В результате видим, что размер структуры равен 12 байтам! А в выводе есть 6 нулей, которые как раз и демонстрируют выравнивание.
12
[255 0 0 0 255 255 255 255 255 0 0 0]
В этом примере мы получили внутреннюю фрагментацию структуры, равную 50%. Это очень много! В реальном мире, если такая структура окажется нагруженной и приложение будет порождать множество ее копий, половина памяти будет занята ничем.
Для уменьшения негативного эффекта достаточно просто поменять местами поля в структуре: так, чтобы маленькие однобайтовые поля были в начале, а большой, четырехбайтный int — в самом конце:
type alignmentStruct8 struct {
b1 byte
b2 byte
i uint32
}
Получим совсем другую картину! Размер структуры сократился до 8 байт! А значит, и потери памяти сократились в 3 раза. Ну и в выводе стало явно меньше нулей.
8
[255 255 0 0 255 255 255 255]
Внимание! Не спешите! Существует большой соблазн броситься и сразу начать править все свои структуры, чтобы они занимали как можно меньше места. Но это темный путь, и не стоит становиться на эту дорожку раньше срока.
Прежде чем начинать оптимизировать размер структуры — задумайтесь, а действительно ли вам это необходимо. В некоторых случаях перестановка полей структуры местами может ухудшить ее читаемость; стоит дважды подумать, прежде чем приступать к оптимизации.
Просто оцените объем теряемой памяти. Если ваша структура не использует значительной памяти из-за выравнивания, но вы создаете ее лишь единожды — не стоит ее оптимизировать.
Интерфейсы
Интерфейс — это в первую очередь структура. И да, средствами самого языка мы можем на нее взглянуть. Начиная с этого раздела в примерах будут весьма странные преобразования, но мы постараемся раскрыть и описать их как можно понятнее.
Раз уж интерфейс является структурой, давайте ее создадим.
type iface struct {
t unsafe.Pointer
value unsafe.Pointer
}
Структура интерфейса имеет всего два поля: указатель на тип и указатель на само значение. Именно отсюда растут корни множества ошибок, при которых интерфейс неожиданно для разработчика не равен nil.
Теперь попробуем заглянуть внутрь интерфейса.
func main() {
var x any
x = 10
i := *(*iface)(unsafe.Pointer(&x))
v := *(*int)(i.value)
fmt.Println(i)
fmt.Println(v)
}
Здесь мы создаем переменную x с типом any, фактически пустой интерфейс, и записываем в нее простое интовое значение. Затем преобразуем x в структуру iface и помещаем в переменную i. А затем нехитрыми манипуляциями с конвертацией пойнтера в инт-пойнтер и разыменованием получаем значение.
В итоге получим следующий результат:
{0x1094fa0 0x10c5800}
10
В первой строке у нас значения указателей. Они ведут в совершенно разные места, что нас не особенно интересует. Вторая же строка содержит, как ни странно, само записанное значение.
Таким же образом мы можем заглянуть и внутрь пойнтера на тип. Но там все немного сложнее, поскольку тип представляет собой еще одну, уже не такую простую, структуру itab. При желании вы всегда можете самостоятельно получить гораздо больше информации напрямую из кода рантайма: https://go.dev/src/runtime/runtime2.go.
Слайс и его устройство
Как и в случае с интерфейсами, рассматривать устройство слайса начнем с объявления структуры, в которую будем его преобразовывать.
type slice struct {
array unsafe.Pointer
len int
cap int
}
Весьма простая и показательная структура — она содержит указатель на массив с данными и значения длины и вместимости слайса. Это отличает слайс от простого неизменяемого массива.
Для начала посмотрим, как представляется предзаполненный слайс. Преобразуем слайс в структуру и выведем ее на экран. Следом преобразуем указатель на массив из слайса в массив интов. Его также выведем на экран:
func main() {
x := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := unsafe.Pointer(&x)
fmt.Println(*(*slice)(s1))
fmt.Println(*(*[9]int)((*(*slice)(s1)).array))
}
Результат не должен быть неожиданным. Видим адрес массива в памяти, длину и вместимость, равную девяти. Легко выводим значения массива:
{0xc0000240a0 9 9}
[1 2 3 4 5 6 7 8 9]
Теперь рассмотрим более подробно вопрос «как работает получение кусочка слайса?». Этот вопрос часто возникает даже у опытных разработчиков, которые ранее никогда не вникали в суть работы нативных структур языка.
Для демонстрации работы создадим слайс длиной 9 и от него получим еще один слайс с третьего по шестой элементы.
type slice struct {
array unsafe.Pointer
len int
cap int
}
func main() {
x1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
x2 := x1[3:6]
s1 := unsafe.Pointer(&x1)
fmt.Println("x1 pointer", s1)
fmt.Println("x1 slice", *(*slice)(s1))
fmt.Println("x1 array", *(*[9]int)((*(*slice)(s1)).array))
s2 := unsafe.Pointer(&x2)
fmt.Println("x2 pointer", s2)
fmt.Println("x2 slice", *(*slice)(s2))
fmt.Println("x2 array", *(*[6]int)((*(*slice)(s2)).array))
}
Для демонстрации того, что происходит в памяти и что там хранится, выведем указатель на слайс, его внутренние значения и, собственно, сами данные. Проделаем такие операции для обоих слайсов и посмотрим результат. Для большего интереса выведем шесть элементов из массива второго слайса:
x1 pointer 0xc000122018
x1 slice {0xc000138000 9 9}
x1 array [1 2 3 4 5 6 7 8 9]
x2 pointer 0xc000122030
x2 slice {0xc000138018 3 6}
x2 array [4 5 6 7 8 9]
В массиве второго слайса видим что-то странное. Откуда там значения после 6? Ведь мы явно задали, что новый слайс должен был быть создан из элементов с третьего по шестой. Там должно было быть всего три элемента.
И да и нет. Новый слайс действительно содержит всего три элемента — это явно указано в его значении длины. Но, как видно из пойнтера на массив, он ссылается практически на то же самое место в памяти, что и пойнтер исходного массива. Применив нехитрую математику и преобразовав шестнадцатеричные 18 в десятичные 24, получаем расстояние между началами массивов.
Но ведь тогда выходит, что массив второго слайса перекрывает значения в массиве первого! И действительно, 24 байта — это ровно 3 интовых значения, на столько сдвинут массив второго слайса. Просто второй слайс использует ту же область памяти, что и первый слайс.
Именно поэтому его вместимость равна шести, а не трем. И именно поэтому мы можем наблюдать значения исходного слайса как бы внутри дочернего. И изменения значений второго слайса, конечно же, повлияют и на первый.
Мапа и ее содержимое
Теперь рассмотрим одну из самых сложных нативных структур языка — мапу, или карту. Гоферы, пришедшие из других языков, наверняка сравнивали мапу с чем-то вроде ассоциативного массива в своем языке и, скорее всего, были правы. Внутри мапа реализует алгоритм хешмап и позволяет хранить пары «ключ — значение». Доступ к элементам осуществляется за О(1) в идеальном случае и все такое.
Структура мапы весьма большая. Ее полный обзор может потянуть на отдельную статью. В рамках этой статьи мы рассмотрим только некоторые ее элементы. Поэтому возьмем структуру попроще:
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
}
Посмотреть детальнее на реальную структуру мапы можно в самом рантайме языка https://go.dev/src/runtime/map.go.
С точки зрения темы статьи нас больше всего интересует, где и как хранятся элементы мапы. И что происходит в памяти в момент роста мапы и эвакуации элементов.
Для этого напишем следующий код:
func main() {
x := make(map[int]int)
for i := 0; i < 8; i++ {
x[50+i] = 10 + i
}
s1 := unsafe.Pointer(&x)
hm1 := *(*hmap)(*(*unsafe.Pointer)(s1))
fmt.Printf("hash map 1 %+v", hm1)
fmt.Println(*(*[17]int)(hm1.buckets))
fmt.Println(*(*[16]int)(unsafe.Pointer(uintptr(hm1.buckets) + uintptr(0x8))))
x[5] = 2 // тут мапа будет расти
s2 := unsafe.Pointer(&x)
hm2 := *(*hmap)(*(*unsafe.Pointer)(s2))
fmt.Printf("hash map 2 %+v", hm2)
fmt.Println(*(*[16]int)(unsafe.Pointer(uintptr(hm2.buckets) + uintptr(0x8))))
}
Что здесь происходит? Создаем мапу, заполняем ее элементами, затем выводим интересующие нас значения. После добавляем новый элемент в мапу и снова выводим содержимое.
После запуска этого кода получаем довольно большой и несколько запутанный вывод. Зато какой интересный!
hash map 1 {count:8 flags:0 B:0 noverflow:0 hash0:3182635999 buckets:0xc000104000 oldbuckets:<nil> nevacuate:0}
[-2034221874750625488 50 51 52 53 54 55 56 57 10 11 12 13 14 15 16 17]
[50 51 52 53 54 55 56 57 10 11 12 13 14 15 16 17]
hash map 2 {count:9 flags:0 B:1 noverflow:0 hash0:3182635999 buckets:0xc00010a000 oldbuckets:<nil> nevacuate:1}
[51 53 54 55 56 57 0 0 11 13 14 15 16 17 0 0]
Приступим к разбору.
В первую очередь посмотрим на поле buckets до и после роста мапы. Как ни странно, адрес изменился. А еще поля nevacuate и В после роста стали единичками. При этом oldbuckets в обоих случаях остается пустым. Почему же?
На самом деле все не так уж сложно. Поле B представляет собой степень двойки и отвечает за вместимость мапы — сколько бакетов в нее может влезть. А nevacuate — это счетчик эвакуации. Бакеты под номером меньше счетчика уже были эвакуированы. Это прямо в runtime написано, так что не стесняемся почаще туда заглядывать.
Обязательно обратим внимание и на вывод самих бакетов. Первое, что могло показаться странным, — в первый раз выводим 17 элементов, а затем 16. Но начало массива сдвинуто на 8 байт. Ответ кроется в бакетах, здесь мы лишь немного приоткроем тему.
Все потому, что первым элементом в бакете идет tophash, массив из 8 байт необходим для работы хеша. Далее идет массив ключей и следом массив значений. Суммарно ключей и значений получается как раз 16 штук.
Если очень захотеть — можно и на содержимое tophash посмотреть, хоть там и нет ничего очень уж интересного. Например, вот так:
fmt.Println(*(*[8]uint8)(unsafe.Pointer(&(*[17]int)(hm1.buckets)[0])))
В качестве вывода получим просто массив.
Кажется, все просто. Но хотелось бы еще взглянуть на мапу непосредственно в момент эвакуации! Возможно ли это? Да, конечно, ведь сам механизм эвакуации был создан именно с целью позволить работать с мапой, несмотря на работу эвакуации.
Для этого просто сделаем мапу побольше, чтобы успеть посмотреть на нее, пока эвакуация еще идет. Изменяем значение в цикле с 8 на 48 и запускаем выполнение снова.
{count:49 flags:0 B:4 noverflow:0 hash0:2105338655 buckets:0xc000110000 oldbuckets:0xc000108000 nevacuate:1}
В этот раз второй вывод будет уже содержать указатель в oldbuckets. Он, как ни странно, равен указателю buckets из первого вывода. Кстати, иногда может так получиться, что oldbuckets будет и в первом выводе! Это потому, что мы заполняем мапу в цикле, она несколько раз переживает процесс эвакуации и вполне может не успеть завершить его.
Внутренности канала
Последней нативной структурой, которую мы рассмотрим, будет канал. Как и в прошлые разы, призываю смотреть, читать и изучать runtime языка, канал же можно найти тут https://go.dev/src/runtime/chan.go.
Мы рассмотрим только часть полей структуры канала и не будем погружаться в нее слишком уж сильно. Поэтому для нас будет достаточно такой структуры:
type hchan struct {
qcount uint
dataqsiz uint
buf unsafe.Pointer
elemsize uint16
closed uint32
elemtype unsafe.Pointer
sendx uint
recvx uint
}
Исходя из одних только названий полей уже можно найти как минимум 3 интересных поля:
- qcount — количество элементов в канале;
- buf — ссылка на буфер с элементами;
- closed — флаг закрытости канала.
Если у вас зачесалось в одном месте и захотелось воспользоваться этими значениями напрямую, призываю вас подумать еще раз! А потом еще раз — и все же отказаться от этой идеи.
Теперь код для демонстрации поведения:
func main() {
ch := make(chan int, 100)
ch <- 3
ch <- 2
ch <- 1
<-ch
ch <- 4
s1 := unsafe.Pointer(&ch)
fmt.Println(s1)
fmt.Printf("chan %+v\n", *(*hchan)(*(*unsafe.Pointer)(s1)))
p := *(*hchan)(*(*unsafe.Pointer)(s1))
fmt.Println(*(*[10]int)(p.buf))
}
Что здесь происходит? Создаем буферизированный канал и записываем в него три элемента, а считываем всего один. В самом конце запишем еще один элемент. Таким образом, в буфере канала должно остаться три элемента. И после всех мероприятий с каналом преобразуем его и выводим содержимое.
На выходе получим вот такой результат:
0xc000012028
chan {qcount:3 dataqsiz:100 buf:0xc000112060 elemsize:8 closed:0 elemtype:0x483dc0 sendx:4 recvx:1}
[0 2 1 4 0 0 0 0 0 0]
Из интересного стоит обратить внимание на буфер. Видно, что элементы добавляются по порядку, а считанный первый элемент был обнулен. Из этого следует, что в моменте элементы буфера не сдвигаются — вместо этого двигается начало. В таком случае возникает вопрос — а как поведет себя канал, когда добавлять элементы будет уже невозможно?
Проверить это довольно просто. Уменьшаем размер буфера до 3 элементов и запускаем код повторно. Конечно, для лучшего вида стоит еще и размер массива, в который кастится буфер, уменьшить до 3.
0xc000042020
chan {qcount:3 dataqsiz:3 buf:0xc00007a060 elemsize:8 closed:0 elemtype:0x483d80 sendx:1 recvx:1}
[4 2 1]
Элементы начали добавляться в начало буфера. Но ведь при следующем считывании мы точно получим 2 и никак не 4. Для этого немного внимательнее взглянем на элементы структуры, которые мы еще не рассматривали, — sendx и recvx. Они отвечают за позицию в буфере, в которую будет производиться запись и считывание следующего элемента при соответствующем действии.
Вместо вывода
Мы рассмотрели основные структуры языка. Хотя многие тонкости внутреннего устройства остались за скобками, вы узнали об одном из существующих инструментов, используя который можно свободно исследовать внутренний мир самостоятельно.
В Go существует еще множество интересных моментов, которые полезно изучить. Например, escape analysis, отвечающий за попадание значения в стек или в кучу. Но формат статьи не позволяет затронуть все интересные темы, поэтому мы просто советуем погрузиться в эту тему самостоятельно.
Изучайте тонкости устройства и работы Go и приходите работать к нам, в VK Cloud! У нас вы сможете найти, где применить ваши знания лучшим образом.
Комментарии (6)
SabMakc
07.12.2023 15:13Все было хорошо и понятно, пока не дошли до мап. Стоит немного рассказать, что за эвакуация и зачем она нужна. И в контексте хеш-таблиц насколько вообще общепринят термин эвакуации? Обычно говорят о росте хеш-таблицы и о переносе ее элементов (как раз эвакуации).
И такой вопрос: в go рост таблицы асинхронен? Зачем хранить старый bucket, если рост при вставке элемента происходит синхронно для хеш-таблиц обычно?stkevich Автор
07.12.2023 15:13+1Да, согласен, немного не корректно получилось. Наверное, стоило явно писать "эвакуация данных" когда это подразумевалось, а то местами выглядит будто эвакуация мапы.
По росту - да, в go он асинхронный. Из-за этого могут возникать ситуации когда при попытке доступа к данным часть бакетов уже переехала, а часть нет. Но, благодаря этому не происходит просадок во время роста большой мапы.
maxim_ge
07.12.2023 15:13Но, благодаря этому не происходит просадок во время роста большой мапы.
А если все ядра уже заняты полезной вычислительной работой, разве асинхронная обработка не повлияет на производительность?
maxim_ge
Лучше делать наборот - маленькие в конце, иначе может случиться padding:
anonymous
НЛО прилетело и опубликовало эту надпись здесь
maxim_ge
Пожалуй. Это стоило бы осветить в статье, КМК.