go:linkname и unsafe в Go: когда это оправдано
Директива go:linkname и пакет unsafe дают доступ к внутренностям рантайма Go, но ломаются между версиями и обходят проверки компилятора. Разбираем, где их применение оправдано, а где это мина в проде.

В Go 1.23 сборка библиотек, тянувших приватные символы рантайма через //go:linkname, начала падать: команда Go закрыла часть символов, помеченных как внутренние, и код, работавший годами, перестал линковаться. Это касается известных пакетов вроде некоторых версий uber-go/zap-адаптеров и старых сборок gopsutil. Если вы держите зависимость, которая лезет в runtime.nanotime или reflect.typelinks в обход публичного API, обновление минорной версии Go может превратиться в час отладки линковщика. Разберём, что делают unsafe и go:linkname, чем именно вы платите за их использование и в каких трёх-четырёх сценариях это оправдано.
Что это за инструменты и почему они опасны по-разному
Это два разных механизма, которые часто идут в паре, но решают разные задачи.
unsafe — пакет стандартной библиотеки. Он даёт unsafe.Pointer (указатель без типа, между которым и любым типизированным указателем можно кастовать), а также Sizeof, Offsetof, Alignof и, начиная с Go 1.17, unsafe.Add и unsafe.Slice. С Go 1.20 добавились unsafe.SliceData, unsafe.String и unsafe.StringData. Он обходит систему типов, но остаётся в рамках языка: компилятор его знает, garbage collector — тоже, если вы соблюдаете правила из документации пакета.
//go:linkname — директива компилятора, а не часть языка. Она связывает локальный символ с символом в другом пакете по строковому имени, включая приватные функции рантайма. Компилятор про эту связь ничего не проверяет: ошиблись в имени или сигнатуре — узнаете в лучшем случае при линковке, в худшем — в рантайме на повреждённой памяти.
Символ (symbol) — имя, под которым функция или переменная известна линковщику. Приватные символы рантайма Go (те, что с маленькой буквы, вроде runtime.nanotime) не входят в публичный контракт и меняются между версиями без предупреждения.
Как выглядит linkname на практике
Классический приём — быстрый монотонный таймер без аллокаций и без перехода в системный вызов через time.Now(). Для этого пробрасывают приватную функцию рантайма:
// Go 1.21+, файл fasttime.go
// Требуется импорт "unsafe" даже без прямого использования —
// без него компилятор не разрешит go:linkname.
package fasttime
import (
_ "unsafe" // нужен для активации go:linkname
)
// Связываем локальную объявленную-но-не-реализованную функцию
// с приватной nanotime из рантайма.
//go:linkname nanotime runtime.nanotime
func nanotime() int64
// Now возвращает монотонное время в наносекундах.
func Now() int64 {
return nanotime()
}
Здесь nanotime объявлена без тела — реализацию подставит линковщик. Это работает ровно до тех пор, пока символ runtime.nanotime существует с той же сигнатурой. В Go 1.23 команда Go начала требовать, чтобы такие «pull»-связи объявлялись через новую форму директивы, и закрыла символы, не входящие в список разрешённых. Проверяйте актуальный статус в runtime и в release notes вашей версии — не полагайтесь на то, что было в 1.20.
Чем вы платите
Оба механизма снимают гарантии, за которые вы обычно и выбираете Go: безопасность памяти, переносимость между версиями, читаемость для нового человека в команде.
- Отвязка от версии рантайма. Приватный символ может исчезнуть, сменить сигнатуру или семантику в любом минорном релизе. Публичного контракта нет — жаловаться некому.
- Молчаливое повреждение памяти. Ошибка в
unsafe.Pointer-арифметике не всегда падает сразу. Она может проявиться через сотни запросов как порча чужих данных или паника в неожиданном месте. - Слепая зона для GC. Если вы держите указатель через
uintptrмежду двумя выражениями, GC может переместить или собрать объект — правило про недопустимость такого разрыва прямо описано в документации пакетаunsafe. - Стоимость ревью. Каждый
unsafe-блок читается дольше и требует человека, который понимает раскладку структур. Это налог на всю команду, а не только на автора.
Правило команды Go простое: если у вас есть безопасный способ сделать то же самое с сопоставимой производительностью —unsafeиlinknameне нужны. Они оправданы там, где безопасного способа нет вовсе или его цена измеряется в разы, а не в процентах.
Когда это оправдано
1. Конверсия string↔[]byte без копирования — почти всегда есть штатный способ
Исторически трюк с unsafe.Pointer использовали, чтобы получить []byte из строки без аллокации. С Go 1.20 для этого есть штатные функции, которые не требуют ручной работы с заголовками слайсов:
// Go 1.20+
package zerocopy
import "unsafe"
// StringToBytes возвращает []byte, разделяющий память со строкой.
// ВАЖНО: результат менять нельзя — строки в Go неизменяемы,
// запись в такой слайс — неопределённое поведение.
func StringToBytes(s string) []byte {
if s == "" {
return nil
}
return unsafe.Slice(unsafe.StringData(s), len(s))
}
Это оправдано в горячем пути парсера или сериализатора, где вы точно читаете, а не пишете, и профилировщик показал аллокации именно здесь. В остальных случаях обычный []byte(s) надёжнее и его цена — одна копия, часто незаметная.
2. Раскладка структур для FFI и бинарных протоколов
unsafe.Offsetof и unsafe.Sizeof уместны, когда вы описываете структуру, которую читает C-код или сетевой протокол с фиксированной раскладкой. Здесь вы не обходите систему типов ради скорости, а фиксируете контракт с внешним миром. Это штатное применение, компилятор его понимает, между версиями Go оно стабильно.
3. linkname — только когда публичного API физически нет
Пробрасывать приватный символ рантайма оправдано в узком классе задач: высокочастотные таймеры, доступ к внутренним счётчикам планировщика для профилировщика, интеграция с рантаймом на уровне, который команда Go сознательно не открыла. Это территория библиотек уровня инфраструктуры, а не прикладного кода. Если вы пишете HTTP-сервис — вам это почти наверняка не нужно.
Сравнение подходов
| Подход | Что даёт | Чем платите | Стабильность между версиями Go |
|---|---|---|---|
Штатный API (unsafe.String, unsafe.Slice) | zero-copy без ручных заголовков | риск записи в неизменяемую память | стабильно с 1.20 |
unsafe.Offsetof/Sizeof | раскладка для FFI и бинпротоколов | ошибка в оффсете — порча памяти | стабильно |
Ручной каст unsafe.Pointer | полный контроль | легко нарушить правила GC | зависит от вашей аккуратности |
//go:linkname на приватный символ | доступ к внутренностям рантайма | отвязка от версии, ломается тихо | нестабильно, символы закрывают |
Когда что выбирать
- Нужна конверсия string/[]byte в горячем пути и профиль это подтвердил — штатные
unsafe.String/unsafe.Sliceиз Go 1.20+, без ручных заголовков. - Описываете структуру для C или бинарного протокола —
Offsetof/Sizeof, это законное применение. - Хотите приватную функцию рантайма для удобства — не берите. Ищите публичный аналог; для монотонного времени в большинстве случаев хватает
time.Now()и разницыSub, которая уже монотонна. - Пишете инфраструктурную библиотеку, где публичного API нет физически —
linknameоправдан, но изолируйте его в один файл с тестом на конкретную версию Go в CI.
Что ломается на практике
Три типичные проблемы, которые вы реально увидите.
- Разрыв uintptr между строками. Записали
p := uintptr(unsafe.Pointer(obj))в переменную, а на следующей строке кастуете обратно — между этими строками GC мог собратьobj. Правильно: вся арифметика в одном выражении. Это ошибка, которую не ловит компилятор, но ловитgo vetи детекторgo build -gcflags=-d=checkptr. - Ошибка линковки после обновления Go. Текст вида
missing function bodyили отказ линковщика связать символ означает, что приватный символ закрыли или переименовали. Лечится либо откатом версии Go, либо поиском обновлённой версии зависимости. - Запись в строку через zero-copy слайс. Получили
[]byteиз строки и куда-то передали, а там его изменили — получаете порчу строкового пула или панику. Такой слайс всегда read-only.
Инвалидация раскладки структур при смене архитектуры (32 против 64 бит) — отдельная тема, здесь её не разворачиваем; помните лишь, что Sizeof зависит от платформы.
Что делать дальше
Включите в проект защитные механизмы, а не полагайтесь на аккуратность. Запускайте сборку с -gcflags=all=-d=checkptr в CI — он ловит нарушения правил unsafe.Pointer в рантайме тестов. Добавьте go vet в пайплайн: он предупреждает о подозрительных преобразованиях uintptr. Каждую зависимость, которая использует go:linkname, отметьте у себя и проверяйте её при апгрейде минорной версии Go — именно они ломаются первыми. И перед тем как писать unsafe, снимите профиль (pprof) и убедитесь, что горячий путь действительно здесь, а не в трёх строках выше, которые чинятся без всякого unsafe.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: Package unsafe — Go documentation, Go Compiler Directives — cmd/compile documentation
Частые вопросы
У меня старая версия Go, а не 1.23 — эти ограничения меня касаются?
Что делать, если после обновления Go сборка падает с ошибкой линковки?
unsafe.String и unsafe.Slice — это тоже небезопасно?
Как отловить ошибки unsafe до прода?
Грозит ли использование unsafe проблемами при обновлении зависимостей?
Есть ли способ получить монотонное время без linkname?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.