Содержание урока · 13 разделов
- unsafe в Go: договор на память, который читает компилятор
- unsafe.Sizeof: сколько весит значение
- Выравнивание и padding: почему структуры толстеют
- Представление string и slice: заголовок вместо содержимого
- unsafe.Pointer: пять паттернов из спецификации
- Зачем это знать: драйверы, zero-copy и границы системы
- Когда unsafe не нужен
- Шпаргалка: unsafe из этого урока (64-битная платформа)
- Правила гигиены unsafe
- Что дальше: от байтов к файлам
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
unsafe в Go: договор на память, который читает компилятор#
Пока вы пишете обычный Go, компилятор стоит между вами и памятью: он знает размер каждого типа, расставляет поля структур по адресам и следит, чтобы вы не читали чужие байты. Пакет unsafe — это лазейка в этом договоре, и само название пакета — предупреждение, а не призыв. Из него вы не импортируете библиотечные функции, а берёте три метрических инструмента — unsafe.Sizeof, unsafe.Alignof и unsafe.Offsetof — и один тип-посредник unsafe.Pointer, через который указатели одного типа можно переклеить в указатели другого. Всё остальное в языке устроено так, чтобы вам это никогда не понадобилось.
Зачем тогда об этом целый урок? Затем, что запрос «unsafe go» приходит не из любопытства: «память golang» перестаёт быть магией ровно в момент, когда вы видели размеры своими глазами. Сорок мегабайт вместо двенадцати на срезе из миллиона структур — это паддинг, и он предсказуем. Заявление «len работает за O(1)» — это устройство заголовка строки, а не добродетель разработчиков. Код на encoding/binary, syscall, cgo и в драйверах баз — это unsafe.Pointer с дисциплиной из пяти правил спецификации. Даже рефлексия из прошлого урока внизу читает те же самые смещения полей: reflect.Offsetof и unsafe.Offsetof смотрят на одну и ту же раскладку. Понимать этот этаж нужно каждому, кто пишет на Go дольше месяца; трогать его руками — почти никому.
Договоримся о правилах урока. Мы измеряем и объясняем — Sizeof, Alignof, Offsetof, заголовки, смещения; паттерны с unsafe.Pointer разбираем обзорно, читая их как документацию, а не как рецепт. Это честно отражено в интерактиве: метрические функции раннеры исполняют (и вывод у них детерминированный, как у настоящего Go на 64-битной платформе), а вот конверсии указателей песочница не исполняет вовсе — что фолбэк-движок, что основной: у первого пакет запрещён целиком, у второго сломана именно переклейка типов, и мы зафиксируем точные тексты отказов. И ещё одна оговорка заранее: все числа урока — для 64-битной платформы, где машинное слово равно восьми байтам. На 32-битных архитектурах int, uintptr и указатель худеют до четырёх, и часть раскладок поедет — это не помеха пониманию, а лучшая иллюстрация того, что размеры не вшиты в язык.
unsafe.Sizeof: сколько весит значение#
unsafe.Sizeof(x) принимает операнд любого типа и возвращает размер его представления в байтах как uintptr — константу, которую компилятор знает уже на этапе сборки, до всякого запуска. Важная тонкость, на которой спотыкаются: Sizeof измеряет не «логическое содержимое», а значение-заголовок переменной этого типа. Для строки ответ всегда один и тот же — 16 байт на 64-битной платформе — независимо от того, пустая она или на гигабайт: Sizeof("abc") равен 16, а Sizeof("") тоже 16, потому что измеряется заголовок (указатель на байты плюс длина), а не содержимое. Байты содержимого считает len: len("abc") — 3. Это различие «вес ручки против веса чемодана» — главный навык урока.
Базовая шкала для 64-бит. Числа фиксированной разрядности честны: int8 — 1, int16 — 2, int32 — 4, int64 и uint64 — 8, float32 — 4, float64 — 8, complex128 — 16. bool и byte — по одному байту. А вот int, uint и uintptr — это машинное слово: 8 байт на 64-битных платформах и 4 на 32-битных. Отсюда практическое правило, которое стоит запомнить быстрее всех прочих: когда вам важен размер в сериализации или в протоколе — берите типы с явной разрядностью (int64, uint32), а int оставляйте для счётчиков и индексов, где платформозависимость безразлична. Указатель — слово: *int — 8 байт, каким бы тяжёлым ни было то, на что он смотрит.
Составные типы складываются из заголовков. Срез — три слова: указатель на массив, длина, ёмкость — итого 24 байта, опять же без учёта самих элементов. Карта и канал — по одному слову (8): внутри — указатели на рантайм-структуры, наружу торчит только ручка. Функция — слово. Интерфейс — два слова (16): типовой компонент и содержимое, та самая пара «какой тип — какое значение», из которой reflect.TypeOf доставал паспорт в прошлом уроке. Структура — не сумма полей, а сумма полей ПЛЮС выравнивание, и это следующий блок; но даже до него вы уже можете предсказывать размеры: []int из десяти элементов весит 24 байта заголовка плюс 80 байт массива — и ни байтом больше или меньше.
Отдельно — про именованные типы. type Celsius float64 не создаёт новое представление: Sizeof(Celsius(0)) равен 8, как у float64. Определение типа заводит новую личность в системе типов (мы это видели через reflect в прошлом уроке), но байты под значениями остаются родительскими. Поэтому «сколько весит» всегда спрашивается у базового представления — и поэтому же перевод между ними не требует перекопирования памяти.
Размеры скаляров, строк и заголовков
Раннер нативный: резервный движок откажет с текстом «пакет "unsafe" недоступен в песочнице» — переключитесь на основной движок. Запустите и сверьте с прогнозом: 1 4 8, потом 8 8, 1 8, 16 16, 8 16 и 24. Эксперимент: поменяйте "abc" на строку из вашего имени — Sizeof останется 16, потому что измеряется заголовок, а не байты; добавьте unsafe.Sizeof(int16(0)) и проверьте двойку. Обратите внимание, что «пустая» строка и срез без элементов всё равно имеют вес: заголовок существует, даже когда содержимого нет.
Запустите код, чтобы увидеть вывод.
Проверьте себя
Почему unsafe.Sizeof(int64(0)) равен 8 на любой платформе, а unsafe.Sizeof(int(0)) — 8 только на 64-битных?
Выравнивание и padding: почему структуры толстеют#
Процессор не читает память по одному байту: он забирает машинные слова, и обращение к int64 по адресу, не кратному восьми, на многих архитектурах либо медленнее, либо вовсе невозможно. Поэтому компилятор Go раскладывает поля структур по адресам, кратным их собственному выравниванию, а дырки заполняет нулями. Эти дырки называются padding (паддинг, выравнивающее заполнение) — и они же причина главного сюрприза урока: размер структуры не равен сумме размеров полей.
Правило раскладки простое, и его стоит сформулировать один раз. Каждое поле ложится на ближайшее свободное смещение от начала структуры, кратное его выравниванию (Alignof): int64 требует кратности 8, int32 — 4, int16 — 2, байты и bool кладутся на любой адрес. Если до кратности не хватает места — вставляется pad. В конце структуры компилятор добивает размер так, чтобы СЛЕДУЮЩАЯ структура в массиве тоже начиналась с выравненного адреса: размер всей структуры кратен максимальному выравниванию её полей. Отсюда классический пример: struct { A byte; B int64 } занимает 16 байт — один байт поля, семь байт pad, восемь байт int64. Переставим поля: struct { B int64; A byte } — тоже 16, потому что теперь семь байт pad висят хвостом, ради следующего элемента массива. Порядок полей меняет, где дырка, но не факт, что убирает её.
Откуда дырки убираются совсем: когда поля идут по убыванию разрядности. struct { int64; int32; byte } — 8 + 4 + 1 + 3 хвоста = 16, а struct { int64; int32; int32 } — ровно 16 без единого паддинга: 8 + 4 + 4. А вот пара байт и int32 struct { X, Y byte; I int32 } весит 8: 1 + 1 + 2 pad + 4 — выравнивание int32 отработало. Смысл для практики — экономия на массивах: структура из байта и int64 в срезе на миллион элементов — это 16 мегабайт, из которых шесть — чистые дырки; перестановка полей сжимает то же самое до восьми. encoding/json вас не спросит о порядке полей, а память — спросит: это единственное место в языке, где порядок объявлений имеет наблюдаемый эффект на размер.
Учитесь читать раскладку глазами, прежде чем мерить: увидев структуру, пройдите по полям сверху вниз и нарисуйте смещения — byte на 0, pad до 8, int64 на 8, int32 на 16, хвост до кратности 8. Этот навык — ручная репетиция того, что делает Alignof и Offsetof, к которым мы перейдём после раннера; а пока проверьте себя на структурах попроще.
Паддинг: поля меняют размер структуры
Раннер нативный: на резервном движке — отказ пакета. Запустите: A и B по 16 (pad спереди или хвостом — размер один), C — 8, D — ровно 16 без паддинга, массивы — 12 и 2. Эксперимент: добавьте в A поле-байт P после I и проверьте, что размер остался 16 (байт влез в существующий хвостовой pad); переставьте C в порядок {I int32; X, Y byte} — размер не изменится, потому что хвост добивается до кратности четырёх. Так порядок полей действительно становится вопросом памяти, а не стиля.
Запустите код, чтобы увидеть вывод.
Что напечатает len для строк?
Прочитайте код и выберите, что он выведет:
package main
import "fmt"
func main() {
fmt.Println(len("привет"))
fmt.Println(len("hi"))
}Представление string и slice: заголовок вместо содержимого#
Теперь можно назвать вещи, которые Sizeof только что показал пальцем. Строка — это заголовок из двух машинных слов: указатель на байты содержимого и длина в байтах. Весь тип string — 16 байт ручки; сами байты лежат где-то в памяти, чаще всего в read-only сегменте, если строка литеральная, или в куче, если собрана в рантайме. Отсюда всё поведение строк, которое вы уже набили до автоматизма: len(s) — чтение второго поля заголовка, поэтому O(1); s[0:2] — новый заголовок с тем же указателем и другой длиной, поэтому срез строки не копирует содержимое; конкатенация копирует байты, потому что собирает новый буфер. Два заголовка могут указывать на один и тот же байтовый массив — «копия строки» стоит 16 байт и ноль копирования, пока вы не пишете в неё — а писать в строку нельзя вовсе: содержимое иммутабельно, и это защита не абстракции, а памяти.
Срез — заголовок из трёх слов: указатель на первый элемент массива, длина len и ёмкость cap. Двадцать четыре байта ручки — и вся механика срезов выводится из этого представления, как из аксиомы. len и cap — чтения полей заголовка: O(1). Срез s[1:3] — новый заголовок с указателем, сдвинутым на размер элемента, len 2 и cap, уменьшенной на единицу: содержимое не копируется, что объясняет, почему подсрезы «делят» массив и правки через один видны через другой. append при наличии ёмкости пишет в тот же массив за заголовком; когда ёмкость кончилась — аллокация нового массива больше и копирование, поэтому старая присказка «append может оторваться от оригинала» — это буквально история про то, что заголовки перестали указывать на один массив. В уроках про срезы и строки вы проходили это на поведении; здесь видно, почему поведение именно такое.
Третьим лицом этого семейства стоит map и канал: по одному слову наружу, рантайм-структуры внутри — поэтому nil-карта читается, но не пишется (заголовок пуст, писать некуда), и поэтому же их нельзя сравнивать на равенство кроме как с nil: два заголовка указывают на внутренности, про которые язык не знает структуры сравнения. Но главные герои урока — строка и срез, потому что именно их заголовки являются целью законных unsafe-паттернов: zero-copy разбор строки в []byte и обратно возможен ровно потому, что у обоих под ручкой байтовый массив, и отличается только форма ручки.
Заголовки стоит удерживать в голове при каждой оценке «сколько это стоит в памяти». Срез из тысячи строк — это 24 тысячи байт заголовков плюс сумма длин строк: заголовки и содержимое живут в разных местах и считаются по-разному. Копирование среза в функцию копирует 24 байта, не элементы: тот факт, что срез «дорогой на вид», но дешёвый в передаче, — прямое следствие представления. А string-ключи в картах сравниваются за O(len) по байтам, хотя сам ключ — ручка: заголовок делает дешёвым хранение, но не сравнение.
Листинг: заголовки строки и среза изнутри
GO// headers_demo.go — так string и slice устроены под ручкой.
// Листинг для чтения: в песочнице он не исполнится (почему — в callout ниже),
// но локально он печатает ровно то, что описывает.
package main
import (
"fmt"
"unsafe"
)
func main() {
// Заголовок строки: указатель на байты + длина. Размер совпадает.
s := "привет"
strHdr := (*struct {
Data *byte
Len int
})(unsafe.Pointer(&s))
fmt.Println(strHdr.Len, len(s), unsafe.Sizeof(s)) // 12 12 16
// Заголовок среза: указатель + len + cap. Три слова — 24 байта.
sl := []int{7, 8, 9}
slHdr := (*struct {
Data *int
Len int
Cap int
})(unsafe.Pointer(&sl))
fmt.Println(slHdr.Len, slHdr.Cap, len(sl), cap(sl)) // 3 3 3 3
// Срез строки: новый заголовок, тот же массив — ноль копий байт.
part := s[0:4]
fmt.Println(len(part), unsafe.Sizeof(part)) // 4 16
}Каст строки к анонимной структуре с полями Data/Len — канонический способ показать представление: поля совпадают с рантайм-описанием stringHeader, и Len обязан сойтись с len(s). Листинг намеренно не исполнится песочницей: переклейка string в struct через unsafe.Pointer — это тот самый паттерн T1 → T2, который основной движок не исполняет (точный текст отказа — в callout ниже), а фолбэк не знает unsafe вовсе. Локально код честен на 64-битной платформе: 12 12 16 и 3 3 3 3. Читать его стоит как документацию: после него len(s) за O(1) перестаёт быть обещанием и становится устройством.
Alignof и Offsetof: карта полей структуры
Раннер нативный (фолбэк не знает unsafe). Запустите и прочитайте как карту: выравнивания 1 8 4 — каждое поле требует адрес, кратный своему размеру; смещения 0 8 16 — byte на нуле, int64 втиснуто в кратность восьми с семью байтами pad, int32 — сразу за ним; размер 24 — хвост добит до кратности восьми. Эксперимент: переставьте поля в порядок {B int64; C int32; A byte} и запустите снова — смещения станут 0 8 12, размер ужмётся до 16: два байта pad исчезли вместе с хвостовыми четырьмя. Вы только что вручную повторили работу, которую компилятор делает с каждой структурой.
Запустите код, чтобы увидеть вывод.
unsafe.Pointer: пять паттернов из спецификации#
unsafe.Pointer — тип-посредник, «универсальная ручка»: любой указатель T можно превратить в unsafe.Pointer и обратно в U другого типа. В этом весь механизм; вся сложность — в дисциплине его применения. Спецификация Go (раздел Package unsafe в go.dev/ref/spec) фиксирует, какие преобразования с unsafe.Pointer законны, — их ровно пять, и мы пройдём их обзорно, как экскурсию по забору с табличкой «не влезай — убьёт».
*Паттерн 1: T1 → unsafe.Pointer → T2*, когда типы занимают одинаковую память. Это reinterpret cast: те же байты читаются другой интерпретацией. Классика — посмотреть на float64 как на uint64 и увидеть его биты: преобразование легально, потому что оба типа восьмибайтные; читать через тип меньшего размера уже нельзя. На этом паттерне стоит бинарный разбор форматов, энкодеры и заголовки из листинга выше.
Паттерн 2: unsafe.Pointer → uintptr → unsafe.Pointer — круглый рейс, разрешённый ТОЛЬКО как одно выражение: uintptr(p) вычисляет адрес как число, и если между получением и обратным превращением GC не может сдвинуть или освободить объект — вы вернётесь по тому же адресу. Целевое применение — арифметика адресов в следующем паттерне. Держать uintptr в переменной, в поле структуры, передавать его между функциями — запрещено: uintptr не считается указателем, сборщик мусора про него не знает.
Паттерн 3: uintptr(p) + смещение → unsafe.Pointer — адресная арифметика для полей структуры: адрес поля равен адресу структуры плюс Offsetof. Формула unsafe.Pointer(uintptr(p) + unsafe.Offsetof(s.Field)) законна, потому что вся она — одно выражение. Так syscall-функции и cgo собирают указатели на вложенные поля чужих структур. Тот же механизм — единственный способ посчитать адрес элемента массива по индексу, не зная типа.
Паттерны 4 и 5 — про uintptr, который уже хранится где-то в памяти: конвертировать обратно в Pointer можно только uintptr, пришедший из reflect.Value.Pointer() или reflect.Value.UnsafeAddr(), и uintptr, выданный Syscall-вызовом — и только немедленно, в том же выражении. Оба случая — мосты к API, которые адреса принимают как числа, и оба существуют ради совместимости, а не для повседневной работы. Пять правил сжимаются в одну фразу: Pointer честен, пока uintptr живёт внутри одного выражения; всё, что пережило выражение, — мусорный адрес, по которому рантайм ничего не обещает.
Нарушение этих правил не похоже на обычную ошибку Go: программа собирается, работает, проходит тесты — и падает раз в неделю, когда GC перенёс объект, а вы держали старый uintptr. Это самый коварный жанр багов в экосистеме, поэтому ревьюеры в Go-проектах реагируют на unsafe.Pointer как на кнопку с красной пломбой: допустимо, но объясни в комментарии, какой из пяти паттернов и почему он здесь законен. Go-флаг -race и проверка go vet частично ловят злоупотребления — но не все, и ставка на «протестируется» здесь не работает.
Листинг: паттерны unsafe.Pointer в одной рамке
GO// patterns_demo.go — пять законных паттернов из спецификации.
// Читается как документация: песочница конверсии не исполняет,
// локально же каждый фрагмент соответствует разделу go.dev/ref/spec.
package main
import (
"fmt"
"unsafe"
)
type Meter struct {
Hits int64
Path string
}
func main() {
// Паттерн 1: *T1 -> Pointer -> *T2 при равном размере памяти.
f := 3.14
bits := *(*uint64)(unsafe.Pointer(&f))
fmt.Printf("биты float64: %064b\n", bits)
// Паттерн 2: Pointer -> uintptr -> Pointer, строго одним выражением.
m := Meter{Hits: 1}
p := unsafe.Pointer(&m)
q := unsafe.Pointer(uintptr(p)) // туда и обратно без шагов между
fmt.Println((*Meter)(q).Hits)
// Паттерн 3: адресная арифметика с Offsetof — тоже одно выражение.
ph := unsafe.Pointer(uintptr(unsafe.Pointer(&m)) + unsafe.Offsetof(m.Hits))
*(*int64)(ph) = 99 // пишем в поле по адресу
fmt.Println(m.Hits)
// Паттерны 4-5 (reflect.Value.Pointer / Syscall) — см. текст:
// конвертировать в Pointer можно только их uintptr и немедленно.
}Каждый фрагмент — одна строка спецификации вживую. Паттерн 1 объясняет, почему энкодеры видят биты чисел; паттерн 3 — как syscall и cgo достают поля структур по смещению. Обратите внимание на аннотации «одним выражением»: как только uintptr(p) присвоен переменной вне выражения, паттерн 2 перестаёт быть законным — GC ничего не должен переменной типа uintptr. В песочнице листинг не исполняется: основной движок спотыкается уже на первой переклейке типов (точный текст — в callout), фолбэк не знает пакет unsafe. Локальный запуск ожидаемо печатает биты 3.14 и Hits 99 — но в реальном коде вместо паттерна 3 почти всегда честнее обычный доступ к полю.
Правила unsafe.Pointer
Какой фрагмент соответствует законному паттерну из спецификации Go?
Резервный движок пакет unsafe не знает: import "unsafe" даёт отказ с точным текстом «пакет "unsafe" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)» — поэтому все раннеры с unsafe помечены нативными, а quiz, predict, fill и order написаны без unsafe и гоняются обоими движками. Основной движок (yaegi) исполняет метрическую половину пакета честно: unsafe.Sizeof, unsafe.Alignof и unsafe.Offsetof работают и дают детерминированные значения, совпадающие с настоящим Go на 64-битной платформе. А вот переклейка указателей не исполняется: p := unsafe.Pointer(&x) падает с ошибкой «ошибка выполнения: reflect.Value.Convert: value of type int32 cannot be converted to type unsafe.Pointer» (то же для string и любых других *T), а unsafe.Slice и unsafe.Add в нативе вообще не содержатся символов («package unsafe "unsafe" has no symbol Slice»). Поэтому паттерны unsafe.Pointer в этом уроке живут в листингах и тексте — они прочитаны как документация, и исполнять их песочница не обещает. Байтовые факты, не требующие unsafe, — len("привет") == 12 — честны в обоих движках.
Зачем это знать: драйверы, zero-copy и границы системы#
Есть жанр кода, который без unsafe.Pointer не существует, — и он окружает вас в каждом продакшн-проекте, оставаясь невидимым. Драйверы баз (pq, mysql-драйверы, pgx) разбирают сетевые пакеты: байты, пришедшие по сокету, нужно прочитать как числа и строки, не копируя их по сто раз, — там живут reinterpret-паттерны и адресная арифметика по смещениям. encoding/binary сериализует целые в байты и обратно; под капотом стандартной библиотеки unsafe встречается ровно там, где измерения и разбор должны быть быстрыми. syscall и cgo — полностью построены на пятом паттерне: операционная система принимает адреса как числа, и языковой мост обязан конвертировать их по правилам, которые мы перечислили. Рефлексия из прошлого урока внизу читает те же смещения: reflect.Type.Offsetof и unsafe.Offsetof считают одно и то же — просто рефлексия даёт ручку через валидацию, а unsafe — без.
Второй жанр — zero-copy: работа с байтами без копирования. Канонический пример — перевод string в []byte без аллокации: обычное преобразование []byte(s) копирует содержимое, а через unsafe.Pointer можно собрать заголовок среза, указывающий на байты строки, — и разбор мегабайтного ответа API не удвоит память. Обратная сторона там же: нельзя писать в байты строки (иммутабельность — часть контракта рантайма, её нарушит только паттерн, который и нарушать не надо), и нельзя держать такой срез дольше жизни строки. Тот же жанр — парсеры протоколов: взять буфер целиком и читать числа по смещениям, как мы читали поля структуры через Offsetof. Если ваш профиль показывает, что время уходит на копирования, — это место, где память golang начинают считать не в интуициях, а в байтах, и zero-copy — легальный ответ.
Третий жанр — оптимизация памяти структур, то есть паддинг из середины урока, применённый осознанно. В горячих сервисах структуры по миллиону элементов — это мегабайты, и перестановка полей по убыванию разрядности — самая дешёвая оптимизация из существующих: ноль строк логики, минус десятки процентов RSS. Заметьте иерархию: сначала считается профиль и GC-давление (урок про бенчмарки даёт инструменты), потом проектируется раскладка структур, и только потом — если куда-то не хватило всех обычных средств — открывается unsafe. Большинству разработчиков хватает первых двух шагов всю карьеру.
И во всех трёх жанрах общая черта: unsafe сидит на ГРАНИЦЕ системы — там, где код встречается с сетью, с ОС, с форматом файла, с чужой библиотекой. Внутри ядра приложения ходят обычные типы. Это тот же принцип границы, что у рефлексии в прошлом уроке: дорогой и опасный механизм допустим на низкой частоте и подконтрольной периферии, и запрещён в горячем цикле по своим же данным. Когда вы читаете чужой код и видите import "unsafe", вопрос всегда один: какая граница за этим импортом — сетевой протокол, системный вызов, формат? Если ответа нет — небрежность, и её место в ревью с пометкой.
Когда unsafe не нужен#
Начнём с самого частого случая — unsafe ради скорости на ровном месте. Интуиция «через указатели быстрее» в Go обычно ложь: компилятор уже размещает поля, уже знает смещения, уже разворачивает методы, и прямая запись m.Hits = 99 компилируется в одну инструкцию по известному адресу. Переход на unsafe.Pointer с арифметикой добавляет проверки, ломает встраивание и инлайн-оптимизации, а выигрыш есть только в узких местах, которые вы ВИДЕЛИ в профиле. Правило из урока про бенчмарки переносится дословно: нет бенчмарка — нет и права на unsafe.
unsafe ради «типизировать чужие байты» — второй кандидат на отказ. Если данные пришли по сети, язык даёт encoding/binary с ByteOrder, да срезы с бинарными протоколами; reinterpret через Pointer без пяти правил превращает программу в лотерею с GC. Вариант «мне просто посмотреть содержимое» решается %v, fmt.Printf с %#x, hex-дампами и отладчиком — все они безопасны и дают больше, чем сырой указатель. Чтение байт как байт — это не unsafe, это обычная работа с []byte, и она не требует переклейки типов вовсе.
unsafe ради структур данных с хранением адресов — классическая ловушка новичков: поле uintptr в структуре как «быстрый указатель». uintptr не ссылка для GC: объект, на который он указывает, может быть собран или перемещён, и поле однажды укажет в чужую память. Хотите адрес в структуре — храните честный указатель или индекс. Это правило стоит повторить: ССЫЛКИ В ДАННЫХ — ТОЛЬКО УКАЗАТЕЛИ; uintptr — тип для одного выражения, не для хранения.
Unsafe ради массивов переменной длины — тоже мимо: в современном Go есть unsafe.Slice, но чаще всего задача «буфер с длиной» — это обычный make([]T, n), а «стек без аллокаций» — фиксированный массив с индексом. Наконец, отдельно: не трогайте неэкспортируемые поля чужих структур через Pointer — это не «хак», а прямой путь к несовместимости: внутреннее представление типов стандартной библиотеки меняется между версиями Go без предупреждения, и код, читавший чужие байты, тихо ломается при обновлении тулчейна. Единственная легальная дверь к чужим внутренностям — API, которое авторы для этого оставили: reflect с его проверками или документированные конвертеры. Если двери нет — значит, за ней не для вас построено.
Экономия памяти: перестановка полей
Раннер нативный: фолбэк не знает пакет unsafe. Запустите: Bad — 32 байта на элемент, Good — 24, разница 8 байт, а на миллионе — восемь миллионов байт, то есть восемь мегабайт чистой экономии нулём строк логики. Эксперимент: переставьте в Good поля в порядок {A byte; C byte; B int64; D int64} — размер останется 24: байты, прижатые друг к другу, дают pad всего шесть байт; а теперь вставьте между байтами один int64 — размер снова 32. Поле порядка объявлений — единственное место в Go, где стиль буквально весит мегабайты.
Запустите код, чтобы увидеть вывод.
Ручной расчёт layout
Восстановите ручной расчёт смещений для структуры {A byte; B int64; C int32} — той же, что в раннере с Alignof и Offsetof: считаем без unsafe, вручную, как это делает компилятор.
Соберите программу-раскладку
Соберите программу, которая вручную печатает карту смещений структуры {A byte; B int64; C int32} — тот же расчёт, что делает компилятор, только словами.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Шпаргалка: unsafe из этого урока (64-битная платформа)#
| Инструмент / тип | Что даёт | Как выглядит | Осторожно |
|---|---|---|---|
| unsafe.Sizeof | размер представления значения в байтах | unsafe.Sizeof(int64(0)) — 8 | заголовок, а не содержимое: строка всегда 16 |
| unsafe.Alignof | требуемое выравнивание типа/поля | unsafe.Alignof(r.B) — 8 | равно размеру базового типа |
| unsafe.Offsetof | смещение поля от начала структуры | unsafe.Offsetof(r.C) — 16 | меняется от перестановки полей |
| int / uint / uintptr | машинное слово: 8 на 64-бит, 4 на 32-бит | unsafe.Sizeof(0) — 8 | для протоколов берите int64/uint32 |
| string | 16 байт: указатель на байты + длина | len(s) — O(1), чтение поля | len считает байты UTF-8, не символы |
| []T (срез) | 24 байта: указатель + len + cap | s[1:3] — тот же массив, новый заголовок | подсрезы делят массив с оригиналом |
| map / chan / func | 8 байт: ручка на рантайм-структуру | unsafe.Sizeof(map[string]int{}) | nil-карта читается, но не пишется |
| интерфейс any | 16 байт: тип + значение | unsafe.Sizeof(interface{}(nil)) | пара, из которой reflect.TypeOf читает тип |
| unsafe.Pointer | универсальная ручка между T1 и T2 | (*uint64)(unsafe.Pointer(&f)) | только паттерны из спецификации; GC не знает uintptr |
| Паттерн 1 | reinterpret равных по памяти типов | float64 → uint64: биты числа | чтение типом меньшего размера — уже нарушение |
| Паттерны 2-3 | адресная арифметика одним выражением | Pointer(uintptr(p) + Offsetof(f)) | uintptr в переменной/поле — запрещено |
| Паттерны 4-5 | uintptr из reflect.Value.Pointer / Syscall | немедленная конверсия в Pointer | мосты к системным API, не для повседневного кода |
Правила гигиены unsafe#
- Сначала измерьте, потом меняйте: размер структуры смотрят Sizeof'ом, а не интуицией; нет профиля и бенчмарка — нет и повода трогать раскладку.
- Поля структур располагайте по убыванию разрядности: int64/float64 первыми, байты и bool последними — паддинг минимален автоматически.
- Размер структуры кратен максимальному выравниванию поля: хвостовое заполнение тоже память, на массивах оно умножается на число элементов.
- Sizeof строки — всегда 16 на 64-бит: он меряет заголовок; содержимое меряет len, и len для строк — байты, а не символы.
- Срез — 24 байта заголовка: передача в функцию дешёвая, но подсрезы делят массив — правки через один срез видны в другом.
- unsafe.Pointer применяйте только к пяти паттернам спецификации; uintptr живёт внутри одного выражения и никогда не хранится в полях.
- Именованные типы не меняют представление: type Celsius float64 весит столько же, сколько float64, — меряйте базовый тип.
- Для сериализации и протоколов — типы фиксированной разрядности (int64, uint32) и encoding/binary: int платформозависим.
- На код-ревью import "unsafe" требует комментария с номером паттерна и причиной; небрежный unsafe — красная пломба, а не стиль.
Что дальше: от байтов к файлам#
Соберём урок в одну карту. unsafe.Sizeof измеряет представление: скаляры — по разрядности, int и uintptr — по машинному слову, string — 16 (указатель плюс длина), срез — 24 (указатель, len, cap), интерфейс — 16, указатель — 8. Выравнивание раскладывает поля структур по кратным адресам, padding — цена этой раскладки, и порядок полей — единственный рычаг, которым вы её регулируете: по убыванию разрядности — минимальные дырки. Alignof и Offsetof делают раскладку наблюдаемой: смещения 0, 8, 16 из раннера — это то, что компилятор делает с каждой структурой молча. unsafe.Pointer — посредник с пятью паттернами из спецификации: reinterpret равных типов, круглый рейс через uintptr одним выражением, адресная арифметика по Offsetof и два мостовых случая для reflect и syscall. Всё это — язык границы: драйверы, binary-протоколы, zero-copy, cgo; внутри приложения обычным типам есть что сказать и без него, и когда НЕ трогать — главный вывод урока: нет профиля, нет бенчмарка, нет границы — нет и unsafe.
Двадцатый урок — файлы и io: мы выйдем из памяти наружу, к диску и потокам — os.Open, чтение файлов, Writer и Reader, и увидим, что байтовое мышление из этого урока там работает напрямую: заголовки срезов — это буферы, строки из байтов — это содержимое файлов, и размер имеет значение, когда файл на гигабайт. Рефлексия отвечала «какой это тип», unsafe — «сколько он весит и как лежит», файлы покажут, «откуда он приходит».
До встречи на странице курса. Если захочется перечитать смежное — урок про рефлексию: reflect.Type.Offsetof и unsafe.Offsetof смотрят на одну раскладку с разных этажей; а если захочется измерить, что паддинг действительно стоит памяти, — каркас бенчмарков ждёт в уроке про бенчмарки: BenchmarkBad против BenchmarkGood на срезах из миллиона структур — упражнение на десять минут, которое раз и навсегда превращает раскладку из теории в цифры.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- unsafe — пакет-лазейка: три метрических инструмента (Sizeof, Alignof, Offsetof) и тип-посредник unsafe.Pointer; обычному коду туда хода нет, но понимать этаж обязан каждый.
- unsafe.Sizeof измеряет представление значения: int64 — 8 везде, int/uint/uintptr — машинное слово (8 на 64-бит, 4 на 32-бит), string — 16 (заголовок), []T — 24 (ptr+len+cap), интерфейс — 16, *T — 8, map/chan/func — 8.
- Sizeof строки — всегда 16 независимо от содержимого: меряется заголовок, а байты считает len; len("привет") — 12, потому что строки хранятся в UTF-8.
- Структура — не сумма полей: поля ложатся на смещения, кратные выравниванию, дырки — padding, размер добивается до кратности максимального выравнивания; struct{byte; int64} — 16, а не 9.
- Порядок полей — единственный рычаг раскладки: по убыванию разрядности паддинг минимален; на срезе из миллиона структур перестановка полей экономит мегабайты.
- unsafe.Alignof и unsafe.Offsetof делают раскладку наблюдаемой: для Row{byte; int64; int32} — выравнивания 1 8 4, смещения 0 8 16, размер 24.
- string — заголовок из двух слов (указатель + длина), срез — из трёх (указатель + len + cap): отсюда O(1) у len/cap, дешёвая передача заголовков и «деление» массива подсрезами.
- unsafe.Pointer законен только в пяти паттернах спецификации: reinterpret равных типов, round-trip через uintptr одним выражением, адресная арифметика с Offsetof и два мостовых случая (reflect, Syscall); uintptr вне выражения — мусорный адрес.
- Безопасен unsafe на границах системы: драйверы, binary-протоколы, syscall, cgo, zero-copy; «ускорение» без профиля, хранение адресов в полях uintptr и чтение чужих приватных полей — жанр запрещённый.
- Песочница честна вдвойне: фолбэк не знает unsafe вовсе, основной движок исполняет Sizeof/Alignof/Offsetof с детерминированными значениями, но конверсии unsafe.Pointer не исполняет — паттерны читаются как документация.
Домашнее задание
Часть первая — этюд о размерах: наберите раннер с размерами скаляров и дополните его unsafe.Sizeof(int16(0)), unsafe.Sizeof(complex128(0)) и unsafe.Sizeof(uint64(0)); выпишите таблицу «тип — размер» и рядом напишите по памяти, сколько весит заголовок строки и среза, прежде чем смотреть в шпаргалку. Часть вторая — раскладка: возьмите структуру из своего прошлого проекта (или любую на пять-шесть полей разных типов), сначала НА ЛИСТЕ РУЧКОЙ посчитайте смещения и размер по правилам выравнивания, потом проверьте себя раннером с Alignof/Offsetof/Sizeof; если расхождение — объясните его хвостовым выравниванием. Часть третья — экономия: возьмите Bad и Good из раннера, добавьте третье семейство структур с полями в порядке {byte; byte; int64; int64; int32} и посчитайте его размер; затем напишите бенчмарк из урока про бенчмарки: аллокация среза из миллиона Bad и миллиона Good (make + заполнение), запустите с -benchmem и сравните B/op — экономия должна совпасть с разницей Sizeof, умноженной на миллион. Часть четвёртая — правила: по памяти напишите пять паттернов unsafe.Pointer одной строкой каждый и к каждому — пример нарушения; объясните письменно, почему uintptr в поле структуры опаснее, чем тот же адрес в *T, и что сделает GC с объектом, на который ссылается только uintptr. Комментарии в коде — по-русски.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему unsafe.Sizeof строки — 16, а не длина строки?
Sizeof измеряет представление ЗНАЧЕНИЯ типа, а строка в Go — заголовок из двух машинных слов: указатель на байты содержимого и длина в байтах. Поэтому Sizeof("abc") == Sizeof("") == 16 на 64-битной платформе. Байты содержимого измеряет len(s) — для "abc" это 3, для "привет" — 12 (шесть кириллических букв по два байта UTF-8). Формула-подсказка: Sizeof — вес ручки, len — вес чемодана.
Как убрать padding из структуры и всегда ли это нужно?
Располагайте поля по убыванию разрядности: int64/float64 первыми, затем int32, затем байты и bool — тогда дырок почти не остаётся, а хвостовое заполнение минимально. Проверять — unsafe.Sizeof и unsafe.Offsetof. Нужна ли перестановка — решает масштаб: на срезе из миллиона элементов разница между 32 и 24 байтами на элемент — восемь мегабайт живой памяти; на конфиге из одного экземпляра — байты, о которых не стоит думать. Меряйте профиль и бенчмарком из урока 17, а не интуицией.
Что делает unsafe.Pointer и почему вокруг него столько правил?
unsafe.Pointer — посредник, через который указатель одного типа переклеивается в указатель другого: *T1 → unsafe.Pointer → *T2. Спецификация разрешает ровно пять паттернов: reinterpret равных по памяти типов; round-trip Pointer→uintptr→Pointer одним выражением; адресную арифметику uintptr(p) + unsafe.Offsetof(f) → Pointer; и два мостовых случая для uintptr из reflect.Value.Pointer и Syscall — тоже немедленно. Правила существуют из-за GC: uintptr — обычное целое, сборщик мусора не считает его ссылкой, и адрес, поживший в uintptr дольше одного выражения, может указывать на собранный или перемещённый объект. Такие баги не ловятся компилятором и проявляются редко.
Работает ли unsafe в песочнице самоучителя?
Частично, и это зафиксировано честно. Резервный движок пакет unsafe не поддерживает: import "unsafe" даёт отказ «пакет "unsafe" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)». Основной движок (yaegi) исполняет метрическую половину: unsafe.Sizeof, unsafe.Alignof, unsafe.Offsetof — их вывод детерминирован и совпадает с настоящим Go на 64-битной платформе. Конверсии указателей основной движок не исполняет: unsafe.Pointer(&x) падает с «reflect.Value.Convert: value of type *int32 cannot be converted to type unsafe.Pointer», а unsafe.Slice и unsafe.Add недоступны вовсе. Поэтому раннеры урока меряют размеры, а паттерны unsafe.Pointer даны листингами для чтения.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией