Черная магия unsafe в Go: практические примеры и ошибки использования. Часть 1
- пятница, 13 марта 2026 г. в 00:00:18
В стандартной библиотеке Go есть пакет с отпугивающим названием «unsafe». Но он может быть реально полезен! Сегодня поговорим о том, как использовать его надежно и эффективно.
Привет, Хабр! Я — Владимир Балун, основатель balun.courses и it-interview.io, до этого руководил небольшой инфраструктурной командой в Яндексе. Я достаточно много писал на C++, но последнее время активно пишу на Go.

Эта статья будет для удобства разделена на две части. Из них вы узнаете, как можно создавать срезы без дорогостоящей инициализации, научитесь избавляться от Bound Checks и конвертировать строки в срезы и обратно без лишних копирований и аллокаций памяти.
Черную магию мы оставим на десерт, чтобы с ее использованием посмотреть, как можно проехаться по памяти для анализа сложных структур данных, модифицировать иммутабельные строки в Go и получать доступ к приватным полям структур.
Документация Go гласит, что это пакет, позволяющий обходить системы безопасности типов в этом языке программирования. Более того, этот пакет не защищен Go 1 compatibility guidelines — в следующих версиях Go что-то может измениться, и если вы используете unsafe, потенциально что-то может сломаться (об этом подробнее будет чуть позже).
Первая причина, по которой пакет unsafe может оказаться полезным, — это оптимизации.

Мы оптимизируем код нечасто, но бывают ситуации, когда это необходимо. С использованием пакета unsafe мы можем это делать.
Вторая причина использования пакета — когда нам как программистам на Go приходится взаимодействовать с C, используя CGO.
Третья причина: есть люди, которым интересно, как что-то устроено внутри. Они выясняют, как что-то работает под капотом. Пакет unsafe им тоже может с этим помочь.
В основе unsafe лежит сущность pointer.

Наверное, мы все сталкивались с unsafe.Pointer. Суть заключается в том, что любой указатель в языке программирования Go мы можем привести в unsafe.Pointer и обратно.
Более того, любой unsafe.Pointer мы можем привести в uintptr и обратно.uintptr — это отдельный тип данных, которого нет в пакете unsafe, но он нам сегодня пригодится.

Uintptr — это отдельный беззнаковый тип данных, который хранит у себя адрес участка в памяти. Здесь многие могут задать вопрос: в чем разница c указателями? Указатель ведь тоже хранит адрес участка в памяти, зачем тогда нужен uintptr? Но uintptr семантически не является указателем (об этом будет немного позже).
Пакет unsafe состоит из большого количества различных функций и списка различных типов данных.

Сейчас мы с каждым из них бегло познакомимся. Начнем с простой, скучной и понятной функции.

Sizeof возвращает размер некоторого объекта. Но в некоторых ситуациях она работает вопреки привычным ожиданиям. Если мы этой функции передаем какой-то reference type, например, срез, она вернет размер дескриптора. То есть size + capacity + pointer, но она точно не будет возвращать размер массива, который стоит за этим указателем.

Функция Offsetof возвращает смещение поля структуры относительно начала объекта этой структуры.
Третья функция более интересная и с различными нюансами.

Когда Alignof передаешь объект структуры или любой другой объект примитивного типа данных, она вернет выравнивание для этого типа данных.
Если говорить о выравнивании, нам понадобится такая табличка. Она чаще всего будет правдива, но в зависимости от компилятора и архитектуры данные в ней могут отличаться.

Эта табличка говорит о том, что однобайтовые типы данных выравниваются на 1 байт, двухбайтовые — на 2, четырехбайтовые — на 4.
Массивы выравниваются в зависимости от типа элемента. Если в массиве находятся двухбайтовые элементы, они выравниваются на 2 байта. Если в массиве находятся однобайтовые элементы, они выравниваются на 1 байт.
Структуры выравниваются в зависимости от требуемого выравнивания. Если в структуре максимальное поле типа int32, значит, структура будет выровнена на 4 байта. Если максимальное поле у вашей структуры двухбайтовое — на 2 байта.
В типе other types написано size of a native word — это uint, int, int64, uint64. На 64-битных системах они будут выравниваться по 8 байт, но на 32-битных системах какой смысл выравнивать их по 8 байт? Мы просто будем зря расходовать лишнюю память, поэтому на 32-битных системах они будут выравниваться на 8 байта.
Я говорил, что эти функции скучные — что с ними можно придумать интересного? Я как C++ разработчик в прошлом заметил одну особенность: функции Sizeof(), Offsetof() и Alignof() возвращают константы, известные во время компиляции.
А если они возвращают константы, известные во время компиляции, то можно делать различные проверки во время компиляции. Например, мы хотим проверить соответствие размера структуры в 32 байта. Зачем такое может быть нужно? Предположим, надо проверить, что какая-то структура помещается в кэш-линию (64 байт или 128 байт). Я пишу код, который сделает проверку на этапе компиляции:

Здесь unsafe.Sizeof возвращает константу, известную во время компиляции. Дальше считаю разность с требуемой константой, и результат преобразуется в тип uintptr беззнакового типа данных, поэтому компилятор не даст во время компиляции переполнить этот тип данных. Если размер структуры не будет равен 32 байтам, код просто не скомпилируется. Подобные проверки можно делать не в рантайме, а во время компиляции.
Аналогичное можно делать и с функциями Alignof() и Offsetof() — они точно также возвращают константы, известные во время компиляции.
В пакете unsafe есть отдельные типы данных ArbitraryType и IntegerType — они в большей степени используются для целей документации.

Осталось еще много различных функций, которые мы не рассмотрели. Мы постепенно со всеми познакомимся, а пока перейдем к нюансам и тонкостям пакета unsafe.
Считаю, без знания этих нюансов опасно писать код с использованием unsafe. Ваш код может корректно работать год-два, а на третий появятся проблемы, которые трудно отлаживать.
В целом, пакет unsafe часто используется для арифметики указателей.

Go позиционируется как простой и безопасный язык программирования, и было бы ошибочно давать в руки программисту способ проезжаться по памяти. Поэтому в Go нет арифметики указателей, но с использованием пакета unsafe мы можем это осуществить знакомым нам способом — unsafe.Pointer приводим к uintptr, его смещаем на нужное количество байт, и дальше приводим обратно в unsafe.Pointer. Когда я пишу именно такой код, это работает корректно и правильно. Но я покажу еще код, когда это будет работать абсолютно неправильно в некоторых ситуациях.
Для чего можно использовать арифметику указателей? Например, обратиться к n-му полю структуры или к n-му полю массива.

Немного позже я расскажу, зачем это делать, когда существует стандартный простой способ для осуществления этих действий.
Вторая важная вещь: когда мы делаем арифметику указателей в рамках одного выражения (преобразуем unsafe.Pointer в uintptr и обратно в unsafe.Pointer в рамках одного выражения), то есть условно в рамках одной линии кода, это работает корректно. Но я могу написать свой код так:

Казалось бы, этот вариант лучше, он более читаемый. Я декомпозировал выражение на несколько выражений. Но есть нюансы. Если в рамках одного выражения я сохранил uintptr, а другое выражение использует этот uintptr для какого-то смещения и обратного преобразования в unsafe.Pointer, могут возникать проблемы.
Uintptr — это всего лишь беззнаковый целочисленный тип данных, который хранит адрес памяти, как указатель.

Разница заключается в том, что семантически uintptr не является указателем. А если он семантически не является указателем, то Garbage Collector не знает ничего об этом указателе.
Пишем примерно такой код:

Выделяем два объекта. Дальше один из объектов приравниваем unsafe.Pointer, второй uintptr. Зовем сборщика мусора, пусть он очистит память. Если мы запустим этот код go run main.go, скорее всего, все отработает корректно в 99% случаев, но ох уж это 1% случаев….
На практике вы можете написать программу, которая несколько лет будет корректна, а потом в какой-то момент начнет стрелять от разных причин. Это undefined behaviour, знакомое многим C++ разработчикам.
А если мы запустим код с использованием флага gcflags=-d=checkptr, то нам скажут, что я указываю на невалидную алокацию. Почему я указываю на нее? Потому что сборщик мусора освободил этот участок памяти. Он ничего не знает про uintptr и о том, что кто-то uintptr’ом будет ссылаться на какую-то область памяти, и потом из этого uintptr восстанавливать значение. Так код писать нельзя, чревато ошибками.
Происходит примерно следующее.

Вы ссылаетесь на участок памяти, который был освобожден. Он уже может быть переиспользован — там другие данные, а вы их берете и перетираете. Конечно, это страшная бага, которую очень сложно отлаживать и диагностировать.
Это не единственная проблема, с которой вы можете столкнуться, когда будете писать арифметику указателей.
Помимо сборщика мусора, в Go есть еще одна особенность. Мы знаем, что у горутин есть свой стек, и знаем, что он динамически может расширяться условно как срезы.

Если мы используем uintptr, в таком сценарии моделируем рост стека, аллоцируем большуюа алокацию на стеке, и перед ростом стека запоминаем uintptr на какой-то массив. Дальше делаем так, чтобы стек вырос и смотрим на uintptr. Адрес массива изменился, но он переехал в другую область памяти, потому что стек вырос. При этом адрес, который хранится в uintptr, не изменился. Это еще одна страшная проблема, которую лучше не допускать, иначе ваш код может работать год, два, три корректно, а в какой-то момент начнет стрелять и вести себя абсолютно непонятно.
Получается такая концепция.

Вы ссылаетесь на участок памяти, который вам не принадлежит, и он уже может быть переиспользован. Кстати, здесь gcflags=-d=checkptr, к сожалению, не поможет. Тут уже нужно быть внимательным.
Более того, мне кажется, что с версии Go 1.17 не зря в пакет unsafe добавили функцию Add.

Эта функция простая. Мы передаем unsafe.Pointer и некоторые целочисленные значения, и она уже внутри себя инкапсулирует сложность работы с uintptr. Мне кажется, из-за этого упростили пакет unsafe, чтобы программисты, которые не знают об этих нюансах, не допускали страшные баги, которые потом нужно отлаживать.
В текущих реалиях правильнее будет использовать функцию Add для арифметики указателей, а не делать конвертацию в uintptr и обратно явно.
Uintptr периодически нужно использовать для работы с syscakk-ами. Нам редко приходится вызывать syscalls из Go, но бывают исключения. Мы используем пакет syscall, функцию syscall. Эта функция принимает у себя параметры, которые выражены uintptr типом.
Если мы пишем наш код так, это будет работать не совсем корректно:

Казалось бы, код более дружелюбный и читабельный, потому что мы декомпозировали одно выражение на несколько. Но этот вариант чреват проблемами, о которых говорилось ранее. Еще раз повторюсь: если вы используете uintptr, обратное преобразование в uintptr нужно осуществлять в рамках одного выражения.
Почему syscall работает именно так? Я нашел сигнатуру этой функции в рантайме и увидел там аннотацию go:uintptrkeepalive.

Простыми словами, это означает, что то, на что вы указываете с использованием uintptr, будет в памяти не повреждено, как будто ссылаетесь на это указателем. Ничего с ним не произойдет страшного во время вызова syscall.
Теперь посмотрим оптимизацию кода, который можно делать с использованием пакета unsafe.
Начнем с простого и понятного кейса. Возможно, кто-то из вас уже делал этот популярный сценарий.
Мы знаем, что строка — это иммутабельный тип данных, срез — мутабельный.

Если мы преобразуем строку в срез байтов либо обратно, у нас происходит аллокация и копирование данных. В большинстве случаев мы просто пишем код, который делает лишнюю аллокацию и копирование. Но в исключительных ситуациях, например, когда у нас большие данные, мы хотим избежать этого копирования и аллокации.
Здесь на помощь приходит небезопасный unsafe. Посмотрим на то, как структуры данных выражены в рантайме строки и среза.

Два первых поля у них одинаковы — первым идет указатель, затем длина. Пакет unsafe помогает обходить систему безопасности типов. Условно, один тип данных мы можем привести в другой тип данных, где у этих структур данных одинаковое начало. Мы можем написать именно такой код, когда с использованием unsafe преобразуем из типа данных срез байтов в тип данных строки.

Получается, что строка и срез указывают на один и тот же участок памяти, они шарят его между собой. Это может привести к некоторым проблемам в будущем.
Ранее я говорил о том, что пакет unsafe не защищен Go 1 compatibility guidelines. Это значит, что если пишите ваш код именно так, и в последующих версиях Go реализация среза меняется (например, первым пойдет не pointer, а capacity), и ваш код будет выглядеть именно так, он будет стрелять и вести себя абсолютно непредсказуемо. Поэтому если вы закладываетесь на внутреннее устройство тех или иных структур данных в рантайме, вы будете обязаны смотреть за изменениями в следующих версиях Go и в случае необходимости вносить в свои проекты правки при обновлении версии Go.

Если мы напишем такой бенчмарк, когда мы делаем конвертацию обычным способом и с использованием unsafe (с копированием, с аллокацией и без копирования, без аллокации), то такой бенчмарк для Hello World будет быстрее в 12 раз.

Но если будет больше строка, то будет больше разрыв в этом бенчмарке. Разница зависит от размера входных данных.
В обратную сторону можно конвертировать с таким же успехом из массива в срез байтов, но есть нюансы.
В версии Go 1.20 появились функции String и StringData.

Вопрос — зачем в пакете unsafe появляются новые функции? Я считаю, они появились для того, чтобы разработчики не закладывались на то, как в рантайме устроены строка и срез. В предыдущем способе мы именно это и делали, когда делали конвертацию из среза в строку и обратно. Если использовать эти функции, код выглядит так:

Первый способ — ToStringDepricated, я его так назвал - так все еще можно делать, но я бы не рекомендовал. Второй способ, условно новый — обычная функция ToString, которая использует функцию unsafe.SliceData. Она получает адрес начала массива, который стоит за срезом, получает его длину и использует функцию String, которая конструирует строку.
Напишем бенчмарк и сравним три способа:
Обычный с аллокацией и с копированием.
Новый с использованием функций unsafe.String.
Просто с преобразованием типов.

Оказалось, что старый способ, когда я просто привожу типы данных, все-таки работает быстрее всех остальных.

Потому что все, что там нужно сделать — это преобразовать тип данных. А в новом способе, помимо вызова функций, есть еще дополнительные накладные расходы на различные оверхеды. Там много пограничных случаев, которые обрабатываются в рамках реализации функции.

Думаю, что сейчас не стоит заморачиваться насчет таких мелких оптимизаций, чтобы использовать старый способ преобразования типов, уже нелогично, когда есть понятные интерфейсы, в том числе, в небезопасном пакете.
Кажется, что там оптимизировать — создали срез и работаем с ним. Бывают такие случаи, когда нужно создать большой срез на несколько гигабайт и не инициализировать его, а заполнять итеративно в процессе работы. Мы знаем о том, что если даже длину этого среза указать в ноль, а capacity в несколько гигабайт, все элементы будут инициализироваться.. А если так, зачем инициализация такого большого участка памяти? В некоторых ситуациях мы хотим от нее уйти.
Чтобы обойти этот кейс, мы можем использовать strings.Builder и сказать ему, чтобы он вырос на нужное мне количество байт.

Но в strings.Builder используется срез под капотом — он вырастает на нужное количество байт, он же будет проинициализирован? Но нет, в strings.Builder используется реализация, где просто функция из рантайма выделяет участок памяти без инициализации.
Дальше мы говорим strings.Builder, чтобы он вернул нам строку. Кстати, strings.Builder делает это с использованием пакета unsafe и знакомым нам способом StringData без лишнего копирования. Получаем адрес сначала массива, который стоит за строкой, и дальше конструируем срез с использованием функции unsafe.Slice, где передаем адрес начала массива и длину строки.
Здесь справа — бенчмарк, который создает достаточно большой срез обычным способом с инициализацией и способом без инициализации. Получается в три раза быстрее.

Важно понимать, что чем меньше срез, тем меньше разница; чем больше срез — тем больше разница. То есть разница в бенчмарке будет зависеть от количества элементов, которые мы тестируем.
Разберем, зачем и когда оптимизировать преобразование типов.
Периодически мы создаем отдельные типы данных, то есть используем type definition. Представим, что мы создали срез своих собственных int’ов и хотим это преобразовать в срез обычных int’ов. Стандартным способом нам бы пришлось копировать и проходиться по этому срезу. Но с использованием пакета unsafe мы можем обходить систему безопасности типов. Поэтому просто делаем приведение типа к обычному срезу int’ов.

Если мы запустим такой бенчмарк, важно отметить, на небольшом срезе, то разрыв будет очень большим — получается быстрее почти в 700 раз.

Разница в бенчмарке при этом зависит от размера этого среза.
Что такое Bound Check? Go в первую очередь позиционируется как безопасный язык программирования, где мы не можем себе позволить выходить за пределы срезов, массивов, строк. Поэтому в Go что при обращении к какому-то индексу вставляется условие: если я выхожу за какие-то рамки среза, массива или строки, вылетает паника. Это концепция безопасности.
Более того, в Go с версии 1.7 появилась оптимизация Bound Check Elimination.

Компилятор достаточно умен, и если он понимает, что вы точно не выйдете за пределы строки массива или среза, он уберет эти проверки, уберет if с паникой, если вы вдруг вылетаете за ограничения.
Ок, а что нам с этой оптимизации? Первым покажу сценарий, когда компилятор умен и он догадывается, что здесь не нужно вставлять никакие Bound Check.

В первом способе идем range по срезу, во втором способе идем обычным циклом от i до len элементов. В данном сценарии компилятор нигде не будет вставлять проверки на то, что вышли за границу, так как нигде мы не выйдем за границы. Кстати, с использованием этого флага можно смотреть, где компилятор вставляет Bound Checks в случае необходимости.
Немного усложним задачу компилятору — иногда все-таки приходится писать сложный код.

В первом сценарии выносим индекс за пределы циклы, добавляем условия, и сразу компилятор втыкает Bound Check — проверку, чтобы мы не выйдем за границу.
Во втором сценарии идем относительно минимальной длины двух срезов. Мы здесь не выйдем за пределы, но компилятор не догадывается — он снова вставляет Bound Check.
В третьем сценарии массив на 256 элементов. Мы идем до 128 элемента, помноженного на 2. Точно мы не выйдем за границы массива, но компилятор снова вставляет Bound Check.
В некоторых ситуациях компилятор не догадывается — ему можно подсказывать.

Когда мы понимаем эти особенности, возьмем какую-то строку, обращаемся с использованием констант к нулевому, первому, второму, третьему индексу. Если мы сделаем по возрастанию, то компилятор везде поставит Bound Checks. Он проверит, что вы постепенно не выйдете за границы этой строки.
Если это сделать в обратном порядке, то компилятор воткнет Bound Check только один раз. Если вы не вышли за пределы строки, обращаясь к третьему элементу, значит, точно не выйдете при обращении ко второму, первому и нулевому элементу строки.

Это работает только с константами. С переменными — нет, так как знаковая переменная может быть отрицательной, вы можете выйти за левую границу, например, среза.
Но здесь можно сделать другой трюк — получить срез от i до i нужного элемента, и потом пройти константами по нему. Поэтому Bound Check в данной ситуации выполнится ровно один раз при получении нового среза.
В runtime go, можно найти такой код. Даже в продакшене где-то видел что-то подобное, раньше я понимал зачем писать такой код, но теперь понимаю

Например, есть «горячий» цикл, вы обсчитываете сложный compute и не хотите, чтобы на каждой итерации проверялся выход за границу.
В первом сценарии на каждой итерации проверка проводится. Во втором сценарии обращаемся перед циклом к самому последнему элементу. Компилятор добавляет проверку на то, что не вышли за границу, и дальше бежим по циклу уже без Bound Checks.
Конечно, мы не всегда так пишем — это имеет смысл делать только тогда, когда в действительности у нас «горячий» цикл, мы много чего обсчитываем и важна эффективность.

Я много рассказал о Bound Checks, о Bounds Check Elimination. Вы можете сказать, зачем это все знать? Если вы вдруг хотите избавиться полностью от всего этого и не знать про это ничего, то пакет unsafe может прийти на помощь. С unsafе можно от этого абстрагироваться, с использованием арифметики указателей прыгать к нужному вам элементу массива, среза или строки — тогда точно не будет никаких Bound Checks. Да, код получается в разы сложнее, но работать будет немного быстрее.
Повторюсь: это имеет смысл только тогда, когда в действительности у вас есть «горячий» цикл.
Итак, я много рассказал о различных оптимизациях. В следующей части этого материала поговорим о «черной магии» — не зря статья называется именно так.
Переходите в мой ТГ-канал и на YouTube — там тоже много всего интересного!
А чтобы получить еще больше знаний, которые можно применить на практике уже сегодня, приходите на конференцию развития GolangConf 20 апреля! Принять участие можно как очно, так и в онлайн-формате. Если сейчас пройти квиз, вы можете получить доступ к топ-видеозаписям GolangConf 2025.