ExpertТеория5 min

unsafe и cgo

Пакет unsafe: указательная арифметика, zero-copy конвертации, cgo и компиляторные директивы

Пакет unsafe -- это "аварийный выход" из системы типов Go. Он позволяет обходить type safety для низкоуровневых оптимизаций и взаимодействия с C-кодом. Название пакета -- предупреждение: код с unsafe может сломаться при обновлении компилятора.

unsafe.Pointer -- обход системы типов

unsafe.Pointer -- это Go-аналог void* в C. Он может быть конвертирован из/в любой тип указателя:

import "unsafe"

// Normal Go: cannot convert between unrelated pointer types
var x int64 = 42
// p := (*float64)(unsafe.Pointer(&x)) -- converts *int64 to *float64

// Reading int64 as float64 (reinterpret cast)
var n int64 = 0x4059000000000000 // IEEE 754 representation of 100.0
f := *(*float64)(unsafe.Pointer(&n))
fmt.Println(f) // 100

Ключевое свойство: unsafe.Pointer -- это единственный тип, из которого можно конвертировать в uintptr и обратно, и из/в любой *T.

unsafe.Sizeof, unsafe.Alignof, unsafe.Offsetof

type Example struct {
    a bool    // 1 byte
    b int64   // 8 bytes
    c bool    // 1 byte
    d int32   // 4 bytes
}

// Sizeof -- size of the type in bytes (including padding)
fmt.Println(unsafe.Sizeof(Example{})) // 24 (not 14! due to alignment padding)

// Alignof -- required alignment of the type
fmt.Println(unsafe.Alignof(Example{}.a)) // 1
fmt.Println(unsafe.Alignof(Example{}.b)) // 8
fmt.Println(unsafe.Alignof(Example{}.d)) // 4

// Offsetof -- offset of a field within a struct
fmt.Println(unsafe.Offsetof(Example{}.a)) // 0
fmt.Println(unsafe.Offsetof(Example{}.b)) // 8  (7 bytes padding after a!)
fmt.Println(unsafe.Offsetof(Example{}.c)) // 16
fmt.Println(unsafe.Offsetof(Example{}.d)) // 20

Оптимизация расположения полей

Порядок полей влияет на размер структуры из-за выравнивания:

// Bad layout: 24 bytes (with padding)
type Bad struct {
    a bool    // 1 + 7 padding
    b int64   // 8
    c bool    // 1 + 3 padding
    d int32   // 4
}

// Good layout: 16 bytes (fields sorted by alignment)
type Good struct {
    b int64   // 8
    d int32   // 4
    a bool    // 1
    c bool    // 1 + 2 padding
}

fmt.Println(unsafe.Sizeof(Bad{}))  // 24
fmt.Println(unsafe.Sizeof(Good{})) // 16 -- saved 33%!

Правило: Сортируйте поля от наибольшего к наименьшему размеру для минимизации padding.

Арифметика указателей

В Go нельзя делать арифметику с указателями напрямую, но можно через uintptr:

// Access array elements via pointer arithmetic
arr := [5]int{10, 20, 30, 40, 50}

// Get pointer to first element
base := unsafe.Pointer(&arr[0])

// Access third element (index 2)
elementSize := unsafe.Sizeof(arr[0])
third := (*int)(unsafe.Pointer(uintptr(base) + 2*elementSize))
fmt.Println(*third) // 30

// This is equivalent to arr[2], but UNSAFE

Критически важно: Конвертация unsafe.Pointer -> uintptr -> unsafe.Pointer должна происходить в одном выражении! Если разбить на отдельные строки, GC может переместить объект между конвертациями.

// WRONG: GC can move the object between these lines!
p := unsafe.Pointer(&x)
addr := uintptr(p)
// GC runs here, moves x to different address
// addr is now stale!
p2 := unsafe.Pointer(addr) // DANGLING POINTER!

// CORRECT: single expression
p2 := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + offset)

Конвертация между типами указателей

// Convert *T1 to *T2 (when you know the memory layout is compatible)
type Vec2 struct {
    X, Y float64
}

type Complex struct {
    Real, Imag float64
}

v := Vec2{X: 3, Y: 4}
c := (*Complex)(unsafe.Pointer(&v))
fmt.Printf("%.1f + %.1fi\n", c.Real, c.Imag) // 3.0 + 4.0i

String/[]byte zero-copy конвертация

Стандартная конвертация string(b) и []byte(s) копирует данные. С unsafe можно избежать копирования:

// Zero-copy string -> []byte (Go 1.17+ with unsafe.Slice)
func stringToBytes(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

// Zero-copy []byte -> string (Go 1.20+ with unsafe.String)
func bytesToString(b []byte) string {
    return unsafe.String(unsafe.SliceData(b), len(b))
}

Опасно: Модификация []byte, полученного из stringToBytes, -- это undefined behavior! Строки в Go иммутабельны, и компилятор может разделять память между строками.

// Safe usage: only read from the result
s := "hello"
b := stringToBytes(s)
fmt.Println(b[0]) // OK: reading
// b[0] = 'H'     // UNDEFINED BEHAVIOR: modifying string data!

Когда zero-copy оправдан

// Hot path processing large strings without modification
func containsPattern(s string, pattern []byte) bool {
    // Convert string to []byte without copying
    data := stringToBytes(s)
    return bytes.Contains(data, pattern) // Only reads data
}

cgo: взаимодействие с C

Основы cgo

package main

/*
#include <stdio.h>
#include <stdlib.h>
#include <math.h>

// Custom C function
int add(int a, int b) {
    return a + b;
}
*/
import "C"
import (
    "fmt"
    "unsafe"
)

func main() {
    // Call C function
    result := C.add(C.int(40), C.int(2))
    fmt.Println(int(result)) // 42

    // Use C math library
    sqrtResult := C.sqrt(C.double(144))
    fmt.Println(float64(sqrtResult)) // 12

    // C strings
    cStr := C.CString("Hello from Go!")
    defer C.free(unsafe.Pointer(cStr))
    C.puts(cStr)

    // C malloc
    p := C.malloc(C.size_t(100))
    defer C.free(p)
}

Передача данных между Go и C

/*
#include <string.h>

typedef struct {
    int id;
    char name[64];
} User;

void fill_user(User* u, int id, const char* name) {
    u->id = id;
    strncpy(u->name, name, 63);
    u->name[63] = '\0';
}
*/
import "C"

func main() {
    var cUser C.User
    name := C.CString("Alice")
    defer C.free(unsafe.Pointer(name))

    C.fill_user(&cUser, C.int(1), name)

    fmt.Printf("ID: %d, Name: %s\n",
        int(cUser.id),
        C.GoString(&cUser.name[0]),
    )
}

Стоимость cgo

Вызов C-функции через cgo значительно дороже вызова Go-функции:

// Go function call: ~1-2 ns
// cgo call: ~50-100 ns (due to stack switching, scheduler)

Причины:

  • Переключение стека (Go goroutine stack -> C stack)
  • Блокировка потока OS
  • Невозможность прервать C-код для GC

Компиляторные директивы

//go:linkname

Связывает локальное имя с символом из другого пакета (обход экспорта):

//go:linkname nanotime runtime.nanotime
func nanotime() int64

// Now you can call runtime's internal nanotime function
t := nanotime()

Предупреждение: //go:linkname ломает инкапсуляцию и может перестать работать при обновлении Go. Используйте только как крайнюю меру.

//go:noescape

Сообщает компилятору, что аргументы не "убегают" на heap:

//go:noescape
func fastHash(data []byte) uint64

Это помогает escape analysis оставить данные на стеке. Применяется только к функциям без тела (реализованным на ассемблере).

//go:nosplit

Запрещает проверку стека при вызове (no stack split check):

//go:nosplit
func criticalPath() {
    // Very fast but limited stack space
}

//go:noinline

Запрещает инлайнинг:

//go:noinline
func doWork() int {
    return 42
}

Полезно для точного бенчмаркинга и отладки.

Шесть легальных паттернов unsafe.Pointer

Спецификация Go определяет только 6 допустимых конвертаций с unsafe.Pointer:

1. *T1 -> unsafe.Pointer -> *T2

// Convert between pointer types with same memory layout
f := math.Float64bits(1.5)
p := *(*float64)(unsafe.Pointer(&f))

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

p := unsafe.Pointer(&x)
fmt.Printf("address: %x\n", uintptr(p))
// Do NOT convert uintptr back to unsafe.Pointer later!

3. unsafe.Pointer -> uintptr -> arithmetic -> unsafe.Pointer (одно выражение)

// Access struct field by offset
p := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.field))

4. syscall.Syscall arguments

// Pass to syscall (uintptr converted at call boundary)
syscall.Syscall(SYS_READ, uintptr(fd),
    uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))

5. reflect.Value.Pointer/UnsafeAddr -> unsafe.Pointer

p := unsafe.Pointer(reflect.ValueOf(&x).Pointer())

6. reflect.SliceHeader/StringHeader (deprecated, use unsafe.Slice/String instead)

// Old way (deprecated):
header := (*reflect.StringHeader)(unsafe.Pointer(&s))

// New way (Go 1.17+):
data := unsafe.StringData(s)
slice := unsafe.Slice(data, len(s))

Почему unsafe существует и когда оправдан

Оправданные случаи:

  1. Системное программирование -- syscalls, memory-mapped I/O
  2. Высокопроизводительная сериализация -- zero-copy парсинг
  3. Интероп с C через cgo
  4. Runtime и стандартная библиотека (сам Go использует unsafe повсюду)
  5. Атомарные операции на нестандартных типах

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

  1. Для "оптимизации", которая экономит наносекунды на некритичном пути
  2. Когда есть безопасная альтернатива
  3. В бизнес-логике
  4. Без бенчмарков, доказывающих необходимость
  5. Без тестов с -race

Правило: Если вы не можете объяснить, почему unsafe безопасен в вашем конкретном случае -- не используйте его. "It's called unsafe for a reason."

Проверь себя

Почему нельзя разбивать конвертацию unsafe.Pointer -> uintptr -> unsafe.Pointer на отдельные строки?

Как правильно расположить поля структуры для минимизации padding?

Почему вызов C-функции через cgo стоит ~50-100 ns против ~1-2 ns для Go?

В каких случаях zero-copy конвертация string <-> []byte безопасна?