Устройство типов в памяти
Зачем знать устройство типов
Большинство Go-программ прекрасно работают без этих знаний. Но три сценария, где это становится критично:
1. Объяснение неочевидного поведения. Почему nil-интерфейс не равен интерфейсу с nil-указателем? Почему после b := a[1:3] запись в b[0] меняет a? Ответы — в структуре памяти.
2. Оптимизация struct. Переставить поля в правильном порядке — одна строчка кода, которая может уменьшить размер структуры в полтора раза. В горячих путях это прямо влияет на GC-паузы и cache locality.
3. Работа с unsafe и CGo. Без понимания layout'а невозможно писать zero-copy сериализацию или вызывать C-библиотеки через cgo.
Интерфейс: два указателя
Каждый интерфейс в памяти — это всегда ровно 16 байт: два машинных слова.
// Интерфейс с методами (io.Reader, error, fmt.Stringer...)
type iface struct {
tab *itab // тип + таблица методов
data unsafe.Pointer // указатель на значение
}
// Пустой интерфейс (interface{} / any)
type eface struct {
_type *_type // только описание типа
data unsafe.Pointer
}itab — это кэшированная пара (интерфейс, конкретный тип). Он создаётся один раз и переиспользуется:
type itab struct {
inter *interfacetype // какой интерфейс
_type *_type // какой конкретный тип
hash uint32 // копия _type.hash для type switch
fun [1]uintptr // начало vtable — адреса методов
}Вызов метода через интерфейс — это call *itab.fun[i]. Один дополнительный уровень косвенности. На современных CPU с предсказателем ветвлений это почти бесплатно.
Ловушка №1: nil-интерфейс ≠ интерфейс с nil-указателем
Это самая частая нетривиальная ошибка в Go. Вот классический пример:
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func getError(fail bool) error {
var err *MyError // nil указатель на MyError
if fail {
err = &MyError{"что-то пошло не так"}
}
return err // ОШИБКА ДИЗАЙНА
}
func main() {
err := getError(false)
if err != nil {
fmt.Println("ошибка:", err) // этот код выполнится!
}
}Что происходит в памяти:
err (error interface):
tab → itab{inter: error, _type: *MyError} ← НЕ nil!
data → nil
nil-интерфейс:
tab → nil
data → nil
Функция вернула интерфейс error, в котором tab указывает на тип *MyError. Интерфейс не nil — даже хотя указатель внутри него nil.
Правильное решение:
func getError(fail bool) error {
if fail {
return &MyError{"что-то пошло не так"}
}
return nil // возвращаем nil-интерфейс напрямую
}Правило: никогда не возвращай конкретный nil-указатель через интерфейс. Возвращай nil напрямую.
Ловушка №2: присваивание интерфейса не копирует значение
x := 42
var i interface{} = x // копия 42 уходит в heap, i.data → копия
x = 100
fmt.Println(i) // 42 — i хранит копию, не ссылку на xДля больших структур это важно — каждое присваивание в interface{} аллоцирует копию в heap. Именно поэтому горячий код избегает interface{} в пользу дженериков (Go 1.18+) или конкретных типов.
// Дорого: аллокация на каждый вызов
func logValue(v interface{}) { fmt.Println(v) }
// Дёшево: без аллокации, компилятор знает тип
func logInt(v int) { fmt.Println(v) }String: почему копирование строки — O(1)
type stringHeader struct {
Data unsafe.Pointer // указатель на байты (read-only)
Len int
}Строка занимает 16 байт в стеке, независимо от содержимого. Данные лежат в отдельном блоке памяти — неизменяемом.
a := "hello world" // 16 байт на стеке, 11 байт в heap
b := a // скопировано 16 байт, данные общие
unsafe.Sizeof(a) // 16
unsafe.Sizeof(b) // 16Срезы строк — тоже O(1):
s := "Hello, World!"
sub := s[7:12] // новый 16-байтовый заголовок, Data сдвинут на 7
// sub.Data = s.Data + 7
// sub.Len = 5
// никакого копирования байт!Конкатенация — другое дело:
a + b // всегда аллоцирует новый блок и копирует обаИменно поэтому конкатенация в цикле — O(n²), а strings.Builder — O(n).
Slice: когда append создаёт новый массив
type sliceHeader struct {
Data unsafe.Pointer
Len int
Cap int
}Слайс — 24 байта. Ключ к пониманию append — это поле Cap.
Случай 1: есть место (Len < Cap)
a := make([]int, 3, 6) // Len=3, Cap=6, есть 3 свободных слота
b := a // b.Data == a.Data — один массив!
b = append(b, 99) // пишет в a[3], Data не меняется
fmt.Println(a[:4]) // [0 0 0 99] — a тоже изменился!Это тихая ошибка. b выглядит как копия, но backing array общий.
Случай 2: места нет (Len == Cap)
a := []int{1, 2, 3} // Len=3, Cap=3
b := append(a, 4)
// Cap исчерпан → новый массив (примерно ×2 по размеру)
// b.Data ≠ a.Data
b[0] = 99
fmt.Println(a[0]) // 1 — a не тронут, разные массивыСтратегия роста capacity
Go не просто удваивает — алгоритм более тонкий:
// Упрощённо (реальный код в runtime/slice.go):
// cap < 256 → newcap = oldcap * 2
// cap >= 256 → newcap = oldcap + (oldcap + 3*256) / 4s := make([]int, 0)
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// len=1 cap=1
// len=2 cap=2
// len=3 cap=4
// len=4 cap=4
// len=5 cap=8
// ...Совет: если знаешь финальный размер — передавай capacity в make:
result := make([]Item, 0, len(input)) // ноль аллокаций при appendБезопасное копирование слайса
original := []int{1, 2, 3, 4, 5}
// НЕПРАВИЛЬНО: b и original делят массив
b := original[:]
// ПРАВИЛЬНО: независимая копия
c := make([]int, len(original))
copy(c, original)
// Или:
c2 := append([]int{}, original...)Map: почему всегда по ссылке
var m map[string]int // m — это *hmap, т.е. указательunsafe.Sizeof(m) == 8 всегда. m — это просто адрес структуры hmap в heap. Именно поэтому:
func addItem(m map[string]int, key string, val int) {
m[key] = val // модифицирует оригинал без &m
}
func main() {
scores := map[string]int{}
addItem(scores, "Alice", 100)
fmt.Println(scores["Alice"]) // 100
}Сравни со срезом: append возвращает новый заголовок, map — нет. Это разные модели.
Внутреннее устройство: buckets
type hmap struct {
count int // текущее кол-во элементов
B uint8 // log₂ кол-ва buckets
hash0 uint32 // случайный seed (защита от DoS)
buckets unsafe.Pointer // указатель на массив bucket'ов
oldbuckets unsafe.Pointer // при перехэшировании
}Каждый bucket хранит до 8 пар ключ-значение. При load factor > 6.5 карта начинает incremental rehashing: новые buckets создаются вдвое большего размера, данные переносятся по несколько штук при каждой операции записи.
// hash0 — ключевая защита:
// без рандомного seed атакующий может подобрать ключи
// с одним hash, вызвав деградацию до O(n) на каждый lookupИтерация по map недетерминирована
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v)
}
// порядок разный каждый раз — намеренно рандомизированGo специально рандомизирует порядок итерации (используя hash0), чтобы код не мог случайно начать зависеть от него.
Struct: padding и выравнивание
Каждый тип имеет alignment — по какому адресу он может начинаться:
| Тип | Alignment |
|---|---|
bool, int8, uint8, byte | 1 байт |
int16, uint16 | 2 байта |
int32, uint32, float32, rune | 4 байта |
int64, uint64, float64, pointer, uintptr | 8 байт |
string (ptr+len) | 8 байт |
slice (ptr+len+cap) | 8 байт |
Компилятор вставляет padding, чтобы каждое поле лежало на своей границе.
Пример: 24 байта вместо 10
type Wasteful struct {
a bool // offset 0, size 1
// padding 7 байт (до выравнивания int64)
b int64 // offset 8, size 8
c bool // offset 16, size 1
// padding 7 байт (выравнивание всей структуры = 8)
}
// unsafe.Sizeof(Wasteful{}) = 24
// реальных данных: 1+8+1 = 10 байт
// потери: 14 байт (58%!)Оптимизация: от большего к меньшему
type Efficient struct {
b int64 // offset 0, size 8
a bool // offset 8, size 1
c bool // offset 9, size 1
// padding 6 байт
}
// unsafe.Sizeof(Efficient{}) = 16
// сэкономили 8 байт — 33% памятиРеальный пример: структура HTTP-запроса
// Плохо — 72 байта
type RequestBad struct {
method string // 16
isDone bool // 1 + 7 pad
path string // 16
isHTTPS bool // 1 + 7 pad
code int32 // 4 + 4 pad
}
// Хорошо — 56 байт
type RequestGood struct {
method string // 16
path string // 16
code int32 // 4
isDone bool // 1
isHTTPS bool // 1 + 2 pad
}При 1 миллионе объектов в памяти это разница в 16 МБ — и это напрямую влияет на сколько объектов помещается в CPU cache.
Инструменты
import "unsafe"
fmt.Println(unsafe.Sizeof(Wasteful{})) // 24
fmt.Println(unsafe.Sizeof(Efficient{})) // 16
fmt.Println(unsafe.Offsetof(Wasteful{}.b)) // 8
fmt.Println(unsafe.Alignof(Wasteful{}.b)) // 8Или используй go vet и линтер fieldalignment:
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment ./...
# struct with 72 pointer bytes could be 56Unsafe: когда и зачем
unsafe обходит систему типов Go. Используй только когда без него не обойтись.
Читать байты любого значения
import "unsafe"
x := float64(1.5)
// смотрим на сырые байты float64
bits := *(*uint64)(unsafe.Pointer(&x))
fmt.Printf("%064b\n", bits)
// 0 01111111111 1000000000000000000000000000000000000000000000000000
// знак=0, экспонента=1023, мантисса=0.5Zero-copy преобразование []byte ↔ string
// Стандартный способ: аллоцирует копию
s := string(bytes)
b := []byte(s)
// Unsafe: zero-copy, но UB если bytes мутируется
func bytesToString(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
// Безопаснее через reflect:
import "reflect"
func bytesToStringReflect(b []byte) string {
return *(*string)(unsafe.Pointer(&reflect.SliceHeader{
Data: (*reflect.SliceHeader)(unsafe.Pointer(&b)).Data,
Len: len(b),
}))
}Арифметика указателей
type Point struct{ X, Y int32 }
p := Point{1, 2}
ptr := unsafe.Pointer(&p)
// сдвигаемся на 4 байта (sizeof int32) к полю Y
yPtr := (*int32)(unsafe.Pointer(uintptr(ptr) + unsafe.Offsetof(p.Y)))
fmt.Println(*yPtr) // 2Важно: uintptr — это просто число, не указатель. GC не знает о нём и может переместить объект между двумя строками. Конвертируй обратно в unsafe.Pointer в той же инструкции.
Когда unsafe оправдан
| Ситуация | Оправдан? |
|---|---|
| Zero-copy сериализация (protobuf, flatbuffers) | Да |
| CGo, взаимодействие с C-библиотекой | Да |
| Атомарные операции над кастомными типами | Иногда |
| Обход системы типов из лени | Нет |
| Чтение приватных полей чужого пакета | Нет |
Итог: размеры типов
var (
i int
s string
sl []int
m map[string]int
ch chan int
p *int
fn func()
ifc interface{}
)
fmt.Println(unsafe.Sizeof(i)) // 8
fmt.Println(unsafe.Sizeof(s)) // 16
fmt.Println(unsafe.Sizeof(sl)) // 24
fmt.Println(unsafe.Sizeof(m)) // 8 (указатель)
fmt.Println(unsafe.Sizeof(ch)) // 8 (указатель)
fmt.Println(unsafe.Sizeof(p)) // 8 (указатель)
fmt.Println(unsafe.Sizeof(fn)) // 8 (указатель на closure)
fmt.Println(unsafe.Sizeof(ifc)) // 16 (два указателя)Запомни эту таблицу. Когда будешь проектировать структуры для горячих путей — она поможет сразу оценить цену решения.
// интерфейс с методами
type iface struct {
tab *itab // тип + таблица методов
data unsafe.Pointer // указатель на значение
}
type itab struct {
inter *interfacetype
_type *_type
hash uint32 // для type switch
fun [1]uintptr // таблица методов (vtable)
}var r io.Reader = os.Stdin
// r.tab → itab{inter: io.Reader, _type: *os.File}
// r.data → указатель на os.Stdin
// type assertion — проверяет r.tab._type:
f, ok := r.(*os.File) // ok=true
// type switch — использует itab.hash: