Пакет unsafe

Обход системы типов Go — конвертация между указателями разных типов и арифметика указателей.

*Tunsafe.Pointeruintptr
Что этоТипизированный указательНетипизированный указатель (void* в C)Целое число (адрес как integer)
ТипизацияСтрогая: *int нельзя кастить в *float64Нет типа: можно кастить в любой *TНе указатель вообще
Разыменование*p — даНельзя напрямую, сначала каст в *TНельзя
АрифметикаНельзяНельзяМожно (+, -)
Удерживает объект живымДаДаНет — для GC это просто число
Можно вернуть обратно в указательУже указательДа, через *TТолько в строго описанных паттернах

unsafe.Pointer — нетипизированный указатель, аналог void* в C. Позволяет кастить между любыми *T.

uintptr — обычное целое число, хранящее адрес. GC не считает его указателем и не использует как причину держать объект живым.

Правила unsafe

Цепочка конверсий

text
*T ↔ unsafe.Pointer ↔ *U // каст между типами *T ↔ unsafe.Pointer ↔ uintptr // арифметика указателей

Напрямую *int*float64 — compile error. Через unsafe.Pointer — можно:

go
var i int64 = 42 f := *(*float64)(unsafe.Pointer(&i)) // реинтерпретация тех же битов как float64

Почему uintptr опасен

uintptr не является указателем. Если адрес сохранён в uintptr, объект больше не удерживается этим значением, а компилятор и GC не обязаны сохранять связь между числом и исходным объектом.

go
// ❌ между строками объект не удерживается через addr addr := uintptr(unsafe.Pointer(&x)) // сохранили число // ... код, вызовы функций, GC, оптимизации компилятора ... p := unsafe.Pointer(addr) // dangling pointer // ✅ одно выражение — допустимый паттерн unsafe.Pointer p := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + offset)

6 легальных паттернов из документации. Всё остальное — undefined behavior.

1. Каст *T → unsafe.Pointer → *U

Реинтерпретация памяти. Оба типа должны иметь совместимый memory layout.

go
p := Point2D{X: 100, Y: 200} v := *(*Vec2)(unsafe.Pointer(&p)) // ок если layout одинаковый

2. Pointer → uintptr (только для печати)

go
import "unsafe" x := 42 fmt.Printf("addr: %x\n", uintptr(unsafe.Pointer(&x)))

Pointer → uintptr для печати — допустимо. Обратно uintptr → Pointer конвертировать нельзя (кроме паттернов 3-5).

3. Арифметика — в одном выражении

Конверсия uintptr → Pointer обязана быть в одном выражении — иначе uintptr теряет связь с объектом.

go
// ✅ одно выражение p := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.age)) // ❌ сохранил в переменную — dangling u := uintptr(unsafe.Pointer(&s)) p := unsafe.Pointer(u + offset)

С Go 1.17 есть unsafe.Add — делает то же самое безопаснее:

go
p := unsafe.Add(unsafe.Pointer(&s), unsafe.Offsetof(s.age))

4. Syscall аргументы

Конверсия в uintptr допустима только прямо в аргументе вызова. Компилятор специально удерживает объект от GC до конца вызова.

go
syscall.Syscall(SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(p)), uintptr(n))

5-6. reflect.Value и SliceHeader/StringHeader

reflect.Value.Pointer() возвращает uintptr — конвертировать обратно можно только сразу:

go
p := unsafe.Pointer(reflect.ValueOf(&x).Pointer()) // сразу в одном выражении

SliceHeader/StringHeader — deprecated с Go 1.20.

Вместо них: unsafe.String, unsafe.StringData, unsafe.Slice, unsafe.SliceData.

Почему это важно: reflect.Value.Pointer() тоже возвращает uintptr. Это удобно для логирования адресов, но опасно для обратного превращения в указатель. Если нужен живой указатель, ищи API, который возвращает unsafe.Pointer, или держи исходный объект обычной Go-ссылкой до конца работы.

go vet

go vet проверяет паттерны unsafe.Pointer и ловит нарушения правил. Если go vet ругается — код невалиден.


Три compile-time функции для получения информации о layout типов в памяти.

Функции

unsafe.Sizeof(x) — размер типа в байтах. Не учитывает данные за указателем: для слайса вернёт 24 (размер заголовка), а не размер underlying array.

unsafe.Alignof(x) — выравнивание типа в байтах. CPU читает память блоками, адрес поля должен быть кратен этому числу.

unsafe.Offsetof(s.field) — смещение поля от начала структуры в байтах. Работает только для полей структур.

Все три функции вычисляются в compile-time — это не runtime-вызовы.


Когда unsafe реально используется и когда не стоит.

Практические кейсы

Zero-copy string ↔ byte

Обычная конверсия []byte(s) копирует данные. unsafe — нет:

go
// Go 1.20+ func StringToBytes(s string) []byte { if len(s) == 0 { return nil } return unsafe.Slice(unsafe.StringData(s), len(s)) } func BytesToString(b []byte) string { if len(b) == 0 { return "" } return unsafe.String(&b[0], len(b)) }

Контракт: нельзя мутировать полученный []byte — строки иммутабельны, нарушишь — undefined behavior. Нельзя сохранять результат дольше, чем живёт исходный буфер, если буфер потом переиспользуется.

Где нужно: hot path с высоким RPS, парсинг логов/JSON, сетевые буферы.

Где не нужно: обычные handler'ы, бизнес-логика, DTO, конфиги, тестовые данные. Копия часто дешевле, чем будущая отладка случайной порчи памяти.

Каст byte → struct (бинарные протоколы)

Zero-copy парсинг бинарного протокола — приводим []byte к структуре без копирования:

go
type Header struct { Size uint32 Flags uint16 TypeId uint16 } func Parse(buf []byte) *Header { if len(buf) < int(unsafe.Sizeof(Header{})) { return nil } return (*Header)(unsafe.Pointer(&buf[0])) }

Ограничения:

  • struct должен быть без padding-сюрпризов;
  • данные должны быть в native endian;
  • адрес &buf[0] должен подходить по alignment для Header;
  • буфер должен жить дольше, чем возвращённый указатель;
  • формат должен быть стабильным между версиями сервиса.

На практике часто безопаснее разобрать поля явно:

go
func ParseSafe(buf []byte) (Header, bool) { if len(buf) < 8 { return Header{}, false } return Header{ Size: binary.LittleEndian.Uint32(buf[0:4]), Flags: binary.LittleEndian.Uint16(buf[4:6]), TypeId: binary.LittleEndian.Uint16(buf[6:8]), }, true }

Да, это копирует значения. Зато код не зависит от padding, endian и alignment.

Доступ к unexported полям

Через Offsetof можно читать/писать приватные поля чужих структур. Используется в runtime, reflect, тестах. В прикладном коде — red flag.

Когда unsafe бывает оправдан

Unsafe — не "ускоритель Go", а инструмент для случаев, где обычная система типов не выражает нужный контракт.

Оправданные сценарии:

  • runtime, компилятор, стандартная библиотека;
  • драйверы, syscall, mmap, взаимодействие с C;
  • бинарные протоколы с доказанным layout и тестами;
  • serialization/encoding библиотеки, где выигрыш измерен профилем;
  • lock-free структуры и atomic-паттерны, когда обычные mutex/channel не подходят;
  • маленький внутренний API, который закрывает unsafe внутри и отдаёт наружу обычные типы.

Перед добавлением unsafe ответь на три вопроса:

  1. Какая конкретная операция слишком дорогая: аллокация, копирование, reflect-call, bounds check?
  2. Есть ли benchmark до/после и профиль, где видно узкое место?
  3. Какой контракт должен соблюдать вызывающий код, и где он проверяется?

Если ответа нет хотя бы на один вопрос, unsafe пока рано.

Когда НЕ использовать

  • Если можно решить без unsafe — решай без unsafe.
  • Профилируй сначала: убедись что копирование реально bottleneck.
  • Layout чужих структур не является частью публичного API.
  • go vet + fuzz-тесты обязательны для кода с unsafe.

Дополнительные красные флаги:

  • unsafe используется ради "красивого" доступа к приватному полю;
  • код зависит от внутренностей string, slice, map, interface{} без жёсткой причины;
  • указатель превращается в uintptr, сохраняется в переменную, структуру, map или возвращается из функции;
  • unsafe.Pointer передаётся через goroutine без понятного ownership;
  • zero-copy строка указывает на буфер из pool, который потом возвращается обратно;
  • тесты проверяют только happy path и не гоняют -race, go vet, fuzz или разные размеры буферов.

Связь с reflect и generics

У generics, reflect и unsafe разные уровни ответственности.

ИнструментКогда выбираемЧто получаемЧем платим
ДженерикиТипы известны на compile-timeТипобезопасность, меньше дублированияСложные constraints, рост API
ИнтерфейсыНужен контракт поведенияПростая подмена реализацийДинамический dispatch, меньше информации о concrete type
ReflectТипы известны только в runtimeУниверсальность для JSON/ORM/DIПаники, аллокации, медленнее, меньше compile-time гарантий
UnsafeНужно нарушить модель типов или layoutZero-copy, низкоуровневый доступUndefined behavior при нарушении контракта

Хорошая эвристика:

  • если можно выразить задачу дженериком — начинай с дженерика;
  • если нужен runtime-анализ тегов, полей или методов — используй reflect;
  • если reflect нужен только для обхода типов, но типы известны заранее — скорее всего, нужен generic API;
  • если reflect возвращает uintptr или требует доступа к unexported данным — это уже граница с unsafe, и код должен стать маленьким и хорошо протестированным.

Пример нормального сочетания: наружу отдаём generic-функцию, внутри кешируем metadata через reflect, а unsafe используем только в одном приватном месте.

go
type fieldInfo struct { offset uintptr } func Encode[T any](dst []byte, v *T) []byte { // T даёт типобезопасный публичный API. // reflect один раз строит metadata по типу. // unsafe применим только там, где metadata уже проверена. _ = reflect.TypeOf((*T)(nil)).Elem() _ = unsafe.Pointer(v) return dst }

Такой код всё равно требует benchmark и review. Но граница риска понятна: пользователь не видит unsafe.Pointer, а инварианты проверяются рядом с местом нарушения.

Мостик к памяти и GC

Unsafe-код почти всегда касается тем из следующих двух уроков.

Escape analysis

unsafe.Pointer сам по себе не означает "уйдёт в heap", но он часто ломает простые рассуждения компилятора. Если указатель сохраняется в интерфейсе, замыкании, глобальном кеше или передаётся в неизвестную функцию, объект может escape'нуться.

Проверяй:

bash
go build -gcflags="-m=2" ./...

Ищи не только escapes to heap, но и причину: возврат указателя, interface conversion, closure capture, indirect call.

GC visibility

Главное правило: GC видит Go-указатели, но не видит адреса, спрятанные в числах.

go
type holder struct { addr uintptr // GC не считает это ссылкой } func remember(b []byte) holder { return holder{addr: uintptr(unsafe.Pointer(unsafe.SliceData(b)))} }

Такой holder не удерживает underlying array живым. Если нужен lifetime, храни обычную ссылку:

go
type holder struct { buf []byte addr unsafe.Pointer }

Иногда нужен runtime.KeepAlive(x): он явно сообщает компилятору, что x должен считаться живым до этой строки.

go
func write(fd uintptr, b []byte) { p := unsafe.Pointer(unsafe.SliceData(b)) syscall.Syscall(SYS_WRITE, fd, uintptr(p), uintptr(len(b))) runtime.KeepAlive(b) }

KeepAlive не делает плохой uintptr хорошим. Он только фиксирует lifetime исходного Go-объекта до конкретной точки.

Аллокатор и alignment

Каст []byte в *Header может быть логически верным по размеру, но неверным по alignment. На одних архитектурах unaligned access просто медленнее, на других может падать или давать некорректное поведение.

Проверяй layout явно:

go
const headerSize = unsafe.Sizeof(Header{}) const headerAlign = unsafe.Alignof(Header{}) func aligned(p unsafe.Pointer) bool { return uintptr(p)%headerAlign == 0 }

Если формат приходит из сети или файла, обычно лучше читать поля через encoding/binary, а unsafe оставить для строго контролируемого in-memory формата.

Задания на ревью кода

Ниже код, который выглядит "производительно". Найди проблемы и предложи безопасную версию.

Задание 1: string to byte

go
func Bytes(s string) []byte { return unsafe.Slice(unsafe.StringData(s), len(s)) } func Normalize(s string) string { b := Bytes(s) for i := range b { if b[i] >= 'A' && b[i] <= 'Z' { b[i] += 'a' - 'A' } } return unsafe.String(&b[0], len(b)) }

Что проверить:

  • что произойдёт с пустой строкой;
  • можно ли мутировать память строки;
  • нужна ли здесь zero-copy конверсия вообще;
  • как написать benchmark для обычного []byte(s).

Задание 2: uintptr в структуре

go
type cacheEntry struct { addr uintptr size int } func NewEntry(buf []byte) cacheEntry { return cacheEntry{ addr: uintptr(unsafe.Pointer(&buf[0])), size: len(buf), } } func (e cacheEntry) Bytes() []byte { return unsafe.Slice((*byte)(unsafe.Pointer(e.addr)), e.size) }

Что проверить:

  • кто удерживает buf живым;
  • что будет при len(buf) == 0;
  • можно ли вернуть буфер в sync.Pool;
  • почему addr uintptr хуже, чем хранить []byte или unsafe.Pointer рядом с owner.

Задание 3: binary header

go
type Header struct { Version byte Size uint32 Flags uint16 } func ParseHeader(b []byte) Header { return *(*Header)(unsafe.Pointer(&b[0])) }

Что проверить:

  • размер структуры с padding;
  • endian входных данных;
  • alignment &b[0];
  • поведение на коротком буфере;
  • нужен ли здесь pointer receiver или достаточно вернуть значения.

Чеклист собеседования

Если на интервью спрашивают про unsafe, хороший ответ не должен звучать как "можно всё". Лучше показать границы.

  • unsafe.Pointer — указатель без типа; его нельзя разыменовать напрямую.
  • uintptr — число, не ссылка; GC не удерживает объект по uintptr.
  • uintptr-арифметика допустима только в разрешённых паттернах, чаще всего в одном выражении.
  • unsafe.Add предпочтительнее ручного uintptr(...) + offset.
  • Sizeof, Alignof, Offsetof вычисляются на compile-time и описывают layout, но не делают layout переносимым протоколом.
  • SliceHeader и StringHeader не использовать для нового кода; есть unsafe.String, StringData, Slice, SliceData.
  • Zero-copy string/byte требует строгого ownership: не мутировать string, не переиспользовать буфер раньше времени.
  • reflect.Value.Pointer() возвращает uintptr; это ловушка для lifetime.
  • runtime.KeepAlive нужен, когда компилятор может посчитать объект мёртвым раньше внешнего вызова.
  • Любой unsafe-код должен быть маленьким, закрытым за обычным API, покрытым go vet, тестами, fuzz/benchmark по необходимости.

Практика

Quiz+10 XP

Почему опасно хранить адрес Go-объекта только как uintptr?

  • uintptr всегда занимает меньше байт, чем указатель
  • uintptr — число, а не ссылка; GC не обязан считать объект живым из-за такого значения
  • uintptr нельзя печатать через fmt
  • uintptr автоматически выравнивает адрес по размеру cache line
Predict+15 XP

Что выведет этот код?

go
package main import ( "fmt" "unsafe" ) type Header struct { Version byte Size uint32 } func main() { fmt.Println(unsafe.Sizeof(Header{})) }
Задача+20 XP

Перепиши чтение uint32 из байтов без unsafe. Функция должна вернуть значение и true, если в буфере хватает 4 байт, иначе 0 и false.