Пакет unsafe
Обход системы типов Go — конвертация между указателями разных типов и арифметика указателей.
*T | unsafe.Pointer | uintptr | |
|---|---|---|---|
| Что это | Типизированный указатель | Нетипизированный указатель (void* в C) | Целое число (адрес как integer) |
| Типизация | Строгая: *int нельзя кастить в *float64 | Нет типа: можно кастить в любой *T | Не указатель вообще |
| Разыменование | *p — да | Нельзя напрямую, сначала каст в *T | Нельзя |
| Арифметика | Нельзя | Нельзя | Можно (+, -) |
| Удерживает объект живым | Да | Да | Нет — для GC это просто число |
| Можно вернуть обратно в указатель | Уже указатель | Да, через *T | Только в строго описанных паттернах |
unsafe.Pointer — нетипизированный указатель, аналог void* в C. Позволяет кастить между любыми *T.
uintptr — обычное целое число, хранящее адрес. GC не считает его указателем и не использует как причину держать объект живым.
Правила unsafe
Цепочка конверсий
*T ↔ unsafe.Pointer ↔ *U // каст между типами
*T ↔ unsafe.Pointer ↔ uintptr // арифметика указателей
Напрямую *int → *float64 — compile error. Через unsafe.Pointer — можно:
var i int64 = 42
f := *(*float64)(unsafe.Pointer(&i)) // реинтерпретация тех же битов как float64
Почему uintptr опасен
uintptr не является указателем. Если адрес сохранён в uintptr, объект больше не удерживается этим значением, а компилятор и GC не обязаны сохранять связь между числом и исходным объектом.
// ❌ между строками объект не удерживается через 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.
p := Point2D{X: 100, Y: 200}
v := *(*Vec2)(unsafe.Pointer(&p)) // ок если layout одинаковый
2. Pointer → uintptr (только для печати)
import "unsafe"
x := 42
fmt.Printf("addr: %x\n", uintptr(unsafe.Pointer(&x)))
Pointer → uintptr для печати — допустимо. Обратно uintptr → Pointer конвертировать нельзя (кроме паттернов 3-5).
3. Арифметика — в одном выражении
Конверсия uintptr → Pointer обязана быть в одном выражении — иначе uintptr теряет связь с объектом.
// ✅ одно выражение
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 — делает то же самое безопаснее:
p := unsafe.Add(unsafe.Pointer(&s), unsafe.Offsetof(s.age))
4. Syscall аргументы
Конверсия в uintptr допустима только прямо в аргументе вызова. Компилятор специально удерживает объект от GC до конца вызова.
syscall.Syscall(SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(p)), uintptr(n))
5-6. reflect.Value и SliceHeader/StringHeader
reflect.Value.Pointer() возвращает uintptr — конвертировать обратно можно только сразу:
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 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 к структуре без копирования:
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; - буфер должен жить дольше, чем возвращённый указатель;
- формат должен быть стабильным между версиями сервиса.
На практике часто безопаснее разобрать поля явно:
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 ответь на три вопроса:
- Какая конкретная операция слишком дорогая: аллокация, копирование, reflect-call, bounds check?
- Есть ли benchmark до/после и профиль, где видно узкое место?
- Какой контракт должен соблюдать вызывающий код, и где он проверяется?
Если ответа нет хотя бы на один вопрос, 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 | Нужно нарушить модель типов или layout | Zero-copy, низкоуровневый доступ | Undefined behavior при нарушении контракта |
Хорошая эвристика:
- если можно выразить задачу дженериком — начинай с дженерика;
- если нужен runtime-анализ тегов, полей или методов — используй
reflect; - если
reflectнужен только для обхода типов, но типы известны заранее — скорее всего, нужен generic API; - если
reflectвозвращаетuintptrили требует доступа к unexported данным — это уже граница с unsafe, и код должен стать маленьким и хорошо протестированным.
Пример нормального сочетания: наружу отдаём generic-функцию, внутри кешируем metadata через reflect, а unsafe используем только в одном приватном месте.
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'нуться.
Проверяй:
go build -gcflags="-m=2" ./...
Ищи не только escapes to heap, но и причину: возврат указателя, interface conversion, closure capture, indirect call.
GC visibility
Главное правило: GC видит 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, храни обычную ссылку:
type holder struct {
buf []byte
addr unsafe.Pointer
}
Иногда нужен runtime.KeepAlive(x): он явно сообщает компилятору, что x должен считаться живым до этой строки.
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 явно:
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
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 в структуре
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
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 по необходимости.
Практика
Почему опасно хранить адрес Go-объекта только как uintptr?
Что выведет этот код?
package main
import (
"fmt"
"unsafe"
)
type Header struct {
Version byte
Size uint32
}
func main() {
fmt.Println(unsafe.Sizeof(Header{}))
}
Перепиши чтение uint32 из байтов без unsafe. Функция должна вернуть значение и true, если в буфере хватает 4 байт, иначе 0 и false.