Олег Марков
Сегмент constants в Go - устройство constants-segment и практическое использование
Введение
Сегмент constants (часто называемый constants-segment или сегмент констант) — это часть скомпилированного бинарника, в которую компилятор Go складывает значения констант и других неизменяемых данных. Вы его не видите напрямую в коде, но он напрямую влияет на:
- структуру исполняемого файла;
- распределение памяти процесса;
- производительность при работе с константами и литералами;
- размер бинарника.
Смотрите, я покажу вам, как на практике константы, строковые и числовые литералы, а также некоторые статически инициализированные данные оказываются в отдельном сегменте, как рантайм Go к ним обращается и как это отражается на вашем коде.
Мы будем разбирать тему на уровне, который полезен:
- тем, кто оптимизирует производительность и память;
- тем, кто пишет библиотеки низкого уровня;
- тем, кто изучает внутреннее устройство Go и его рантайма.
Что такое constants-segment в Go
Логическое и физическое понятие сегмента констант
Когда говорят о сегменте constants в контексте Go, обычно имеют в виду:
- логический уровень — область в памяти процесса, где лежат константы и неизменяемые данные (аналог
.rodata,.textи других секций ELF / PE / Mach-O); - уровень Go-рантайма — набор указателей и границ (
rodata,noptrdata,funcdataи т. д.), с которыми работает сборщик мусора (GC) и другие части рантайма; - уровень компилятора — результат работы
cmd/compile, который группирует константы и литералы в общую область, чтобы:- не дублировать одинаковые значения;
- упростить адресацию;
- гарантировать неизменяемость данных.
Физически в бинарнике это обычно несколько секций:
.rodata— основная область неизменяемых данных;- дополнительные секции, которые рантайм использует для картирования (mapping) и маркировки указателей / неуказателей.
Какие данные попадают в constants-segment
Давайте перечислим основные типы данных, которые оказываются в области констант:
Числовые константы, которые:
- либо используются в выражениях и оптимизируются до немедленных значений (immediates) в инструкциях;
- либо превращаются в значения в
.rodata, если:- нужны как адресуемые данные (например, часть таблицы);
- используются в слайсах/массивах/структурах, которые имеют статическую инициализацию.
Строковые литералы:
- всегда живут в области только для чтения;
- разные вхождения одинаковой строки обычно интернируются (поделятся одним и тем же хранилищем).
Константные массивы / структуры, инициализируемые литералами:
- если вы объявляете глобальный массив и инициализируете его известными на этапе компиляции значениями, то сами значения обычно попадают в сегмент констант.
Метаданные компилятора и рантайма:
- имена типов;
- имена методов и полей;
- имена пакетов;
- информация для отражения (reflection);
- информация для паник, трассировок стека и т. д.
Часть этих данных логически относится к константной области, хотя в бинарнике может быть разбита по нескольким секциям.
Почему constants-segment важен для разработчика
На первый взгляд, это внутренняя деталь реализации. Но она влияет на:
- размер бинарника — большие таблицы констант, большие строковые литералы, подготовленные карты (maps) и массивы;
- потребление памяти — все, что лежит в сегменте констант, попадает в адресное пространство процесса и частично в рабочий набор памяти;
- работу GC — константные области либо не сканируются, либо сканируются по особым правилам, что влияет на время пауз и общее время работы сборщика;
- возможности оптимизации — некоторые операции компилятор может заменить доступом к константе, а некоторые — нет.
Теперь давайте посмотрим, как ваш код превращается в данные в сегменте констант.
Как Go компилирует константы в сегмент constants
Пример: числовые и строковые константы
Возьмем простой пример. Здесь я размещаю пример, чтобы вам было проще понять:
package main
const Pi = 3.1415926535
const (
MaxUsers = 1000
ServerName = "prod-server-1"
)
var greeting = "Hello, constants segment"
func main() {
// Используем константы и строку,
// чтобы компилятор не удалил их как неиспользуемые
_ = Pi + MaxUsers
println(ServerName, greeting)
}
Разберем, что происходит при компиляции:
Pi,MaxUsersиServerName— компиляторные константы:- при подстановке в выражения они часто не требуют выделения памяти вообще;
- значение может быть встроено напрямую в инструкции процессора.
ServerNameпри этом еще и строковый литерал:- его содержимое
"prod-server-1"попадает в сегмент констант (обычно.rodata); - структура
stringв Go — это пара: указатель на данные и длина; - сама структура, если используется как глобальная переменная, может попасть в
.dataилиrodata, а байты строки — вrodata.
- его содержимое
greeting— это глобальная переменная, инициализируемая строковым литералом:- байты строки
"Hello, constants segment"— в константной области; - сама переменная
greetingкакstring— в области глобальных данных (обычно.data), но указывает на неизменяемые байты вrodata.
- байты строки
Немедленные значения против данных в constants-segment
Важно понимать разницу:
немедленные значения (immediate) — константа вшита в инструкцию:
- например,
MOVQ $42, AX; - память в сегменте констант не нужна для хранения
42.
- например,
адресуемые константы — когда нужна ссылка (указатель) на значение:
- элемент таблицы;
- часть массива или структуры;
- литерал, который передается как адресуемый объект;
- в таком случае значение размещается в сегменте констант, а код работает с его адресом.
Давайте разберемся на примере:
package main
// Таблица коэффициентов - известна на этапе компиляции
var coeffs = [...]float64{
0.1, 0.2, 0.3, 0.4,
}
// Используем для вычислений
func SumCoeffs() float64 {
var sum float64
for _, c := range coeffs {
sum += c
}
return sum
}
Что здесь попадает в сегмент констант:
- массив
coeffs— это глобальные данные; - но его содержимое — это набор значений
float64, вычисленных на этапе компиляции; - компилятор формирует статический блок данных в константной области (или в
.dataс флагом только чтение, в зависимости от платформы и опций сборки); - рантайм получает фиксированный массив констант, который не меняется в ходе работы.
Интернирование строковых констант
Go-компилятор, как правило, не дублирует одинаковые строковые литералы. Если вы много раз пишете одну и ту же строку, она будет храниться в сегменте констант один раз, а разные места кода будут указывать на один и тот же набор байт.
Пример:
package main
func A() string {
// Строковый литерал "OK"
return "OK"
}
func B() string {
// Тот же самый строковый литерал
return "OK"
}
Как видите, этот код возвращает одинаковые строки. В большинстве случаев:
- байтовое содержимое
"OK"будет одно; - структуры
string, которые создаются на стеке при возврате, будут ссылаться на одни и те же байты в сегменте констант.
Это косвенно уменьшает размер бинарника и экономит память.
Взаимодействие рантайма Go с сегментом constants
Карта памяти и роль .rodata
Рантайм Go делит память программы на несколько крупных областей:
- код (
text); - неизменяемые данные (
rodata,funcdataи т. д.); - глобальные и статические данные (
data,bss,noptrdata,noptrbss); - кучи (heap);
- стеки горутин.
Сегмент constants — это в первую очередь часть rodata и связанных секций. Для рантайма важно знать:
- где начинаются и где заканчиваются эти области;
- какие из них содержат указатели, а какие — нет (например,
noptrdata).
Сборщик мусора должен понимать, какие участки памяти нужно сканировать в поисках указателей на кучу, а какие можно пропустить. Многие данные в constants-segment не содержат указателей (например, массив чисел), и это ускоряет GC.
Маркировка областей без указателей
Go-компилятор и линкер делят статические данные на:
- обычные данные (могут содержать указатели);
- данные без указателей (
noptrdataиnoptrbss).
Данные, помеченные как noptr, могут находиться в области, которую GC вообще не сканирует:
- это экономит время;
- снижает накладные расходы на сборку мусора.
Если ваш большой массив констант не содержит ссылок на кучу (например, массив float64), он может быть размещен именно в такой области.
Константы, к которым обращается GC
Некоторые константные структуры все-таки могут содержать указатели:
- например, глобальные таблицы с указателями на функции;
- структуры для рефлексии (содержат указатели на типы и методы);
- таблицы интерфейсов.
Эти данные обычно размещаются в областях, которые помечены как потенциально содержащие указатели, и GC будет их сканировать.
Теперь давайте посмотрим, как это может проявиться в вашем коде.
Статические инициализации и constants-segment
Статически инициализируемые переменные
В Go есть важная особенность: если переменная может быть полностью инициализирована на этапе компиляции, компилятор постарается разместить ее в статических данных, а не инициализировать в рантайме.
Пример:
package main
// Полностью статически инициализируемый массив
var lookup = [5]int{10, 20, 30, 40, 50}
// Нельзя полностью вычислить на этапе компиляции
var runtimeInit = []int{len(lookup), 2, 3}
func main() {
_ = lookup
_ = runtimeInit
}
Что происходит:
lookup:- известен полностью на этапе компиляции;
- компилятор просто кладет эти 5 чисел в статический блок данных (частично в константную область или в обычные данные, в зависимости от контекста).
runtimeInit:- первый элемент —
len(lookup), а это уже выражение, зависящее от другого объекта; - для слайса нужна динамическая структура: указатель, длина, емкость;
- итог — инициализация происходит при старте программы через сгенерированный код, а не чисто через constants-segment.
- первый элемент —
Большие таблицы и влияние на бинарник
Если вы создаете большие таблицы констант, например:
package main
// Воображаемая таблица синусов на 360 градусов
var sinTable = [360]float64{
0.0, 0.0174524, 0.0348995, /* ... и так далее ... */ 0.0,
}
func main() {
_ = sinTable
}
Такая таблица:
- увеличит размер бинарника;
- попадет в область статических данных, большая часть которых неизменяема;
- будет загружена в адресное пространство процесса.
Иногда выгоднее:
- генерировать таблицу при старте программы (если это не критично по времени);
- либо хранить ее в меньшем формате (например,
float32) и пересчитывать доfloat64по мере необходимости.
Константы, строки и управление памятью
Строковые литералы и их жизненный цикл
Строковые литералы в Go:
- живут столько же, сколько приложение;
- не перемещаются, не освобождаются и не модифицируются;
- доступны по стабильным адресам.
Покажу вам, как это реализовано на практике:
package main
func GetMessage() string {
// Строка живет в сегменте констант
return "precomputed-message"
}
func main() {
msg1 := GetMessage()
msg2 := GetMessage()
// Эти две строки указывают на одинаковые байты в rodata
println(msg1 == msg2) // true
}
Внутри:
- структура
string(data *byte,len int) может копироваться; - указатель
dataвсегда ведет в константную область; - GC знает, что эта память не на куче, и не пытается ее перемещать или освобождать.
Константные слайсы и массивы
Иногда хочется объявить "константный слайс". В Go const можно применить только к простым типам (числа, строки, булевы значения), поэтому обычно используют глобальные var:
package main
// Спрайты, таблица значений или константные параметры
var DefaultPorts = []int{80, 443, 8080}
func main() {
_ = DefaultPorts
}
Что здесь:
- слайс
DefaultPorts— изменяемый объект в памяти (указатель, длина, емкость); - сами элементы
[80, 443, 8080]— значения, которые известны во время компиляции.
Возможные варианты размещения:
- значения
[80, 443, 8080]могут быть зашиты в статический блок данных (ту же константную область); - структура слайса может быть инициализирована при старте через сгенерированный код.
Если вы хотите гарантировать невозможность изменения, используйте:
- экспортируемую функцию, возвращающую копию или новый слайс;
- или возвращайте массив через указатель / обертку.
Константы и аллокации на куче
Важно понимать: сами по себе константы в сегменте constants не вызывают аллокации на куче. Но операции над ними могут:
package main
func MakeSlice() []int {
// Литерал массива инициализируется в статике
a := [3]int{1, 2, 3}
// Создается слайс, который ссылается на a
return a[:]
}
Здесь:
- массив
aлежит на стеке (обычно, он маленький); - слайс, возвращаемый из функции, указывает на память стека;
- но стека после выхода из функции не будет, поэтому компилятор может:
- промотировать
aна кучу; - или оптимизировать этот код.
- промотировать
Сегмент констант здесь не участвует. Но если вы используете глобальные массивы/таблицы:
var table = [...]int{1, 2, 3, 4, 5}
func SliceFromTable() []int {
// Слайс указывает на глобальный массив, лежащий в статике
return table[:3]
}
Теперь:
tableлежит в статических данных, которые заданы на этапе компиляции (их значения попадают в сегмент констант или аналогичную область);- слайс не приводит к аллокации на куче, он просто указывает на эту статическую память.
Это важный паттерн, когда вы хотите избежать лишних аллокаций.
Влияние constants-segment на производительность и размер
Когда константы помогают производительности
Константы и сегмент constants помогают:
Избежать аллокаций:
- строки-литералы и глобальные массивы не требуют аллокаций в куче;
- доступ к ним выполняется через фиксированные адреса.
Упростить работу GC:
- области без указателей не сканируются;
- неизменяемые данные не нуждаются в дополнительном учете.
Ускорить доступ к данным:
- если данные локализованы и к ним много обращений, это хорошо ложится в кэш процессора.
Когда константы ухудшают ситуацию
Есть и обратная сторона:
Раздувание бинарника:
- большие таблицы, длинные строки, заранее сгенерированные структуры — всё это увеличивает размер файла;
- это влияет на:
- время загрузки;
- потребление диска;
- передачу бинарников по сети.
Память процесса:
- даже если вы не используете все константы сразу, они занимают адресное пространство;
- часть данных может подгружаться по требованию (demand paging), но крупные области
rodataвсё равно влияют на общий footprint.
Кэш процессора:
- если вы часто бегаете по огромной таблице, вы можете вытеснять из кэша другие полезные данные;
- это не столько проблема сегмента констант, сколько проблема общего доступа к памяти.
Практические советы по использованию констант
Давайте подведем промежуточные рекомендации:
Используйте строковые литералы вместо создания новых строк:
- не делайте
string([]byte("some")), если вам просто нужна строка; - строковый литерал — это один объект в сегменте констант.
- не делайте
Глобальные константные таблицы — это нормально, но разумно:
- таблица на 1–10 тысяч элементов обычно не проблема;
- гигантские таблицы на мегабайты стоит оценивать критичнее.
Если таблица редко используется, подумайте о ленивой инициализации:
- можно хранить сжатые данные и распаковывать по требованию;
- или генерировать таблицу в рантайме.
Не бойтесь числа констант, бойтесь их объема:
- десятки тысяч мелких целочисленных констант не опасны, если они не материализуются в виде таблиц;
- важно, сколько байт реально попадает в
.rodata.
Как увидеть и исследовать constants-segment на практике
Использование go tool compile и go tool objdump
Вы можете заглянуть внутрь бинарника и посмотреть, как компилятор разложил ваши константы.
Пример для Linux / macOS:
# Скомпилируем программу с сохранением промежуточных файлов
go build -gcflags="-S" -o app main.go
Флаг -S выводит ассемблерный листинг, где вы увидите:
- обращения к строкам через символы вроде
go.string.*; - константы инициализации глобальных массивов.
Для анализа готового бинарника удобно использовать go tool objdump:
go tool objdump -s "main\." ./app
Комментарии в листинге покажут, какие символы относятся к константам и какие обращения к ним происходят.
Использование внешних инструментов (objdump, nm)
Можно также использовать системные инструменты:
# Для ELF (Linux)
objdump -h app # показывает секции (включая .rodata)
objdump -s -j .rodata app # дамп сегмента констант
nm -S app # список символов и их размеры
Обратите внимание, как этот фрагмент вывода поможет понять:
- какие глобальные объекты вносят наибольший вклад;
- какие таблицы и строки занимают много места.
Связка с выводом go tool compile -m
Команда:
go build -gcflags="-m" main.go
покажет вам:
- что было аллоцировано на куче;
- какие переменные промотированы;
- что осталось в статике.
Хотя она не показывает напрямую constants-segment, вы поймете, какие данные оказались на куче, а какие — нет. Всё, что не на куче и не на стеке, а при этом глобально доступно и константно — почти всегда в сегменте статических/константных данных.
Типичные паттерны использования сегмента констант
Паттерн: таблица переходов или lookup table
Давайте посмотрим, что происходит в следующем примере:
package lexer
// Таблица классов символов для лексера
var charClass = [256]byte{
/* 0..31 */ 0, 0, 0, 0, /* ... заполнение ... */ 0,
// ...
}
// Функция, использующая таблицу
func Class(ch byte) byte {
// Обращаемся к глобальному массиву
return charClass[ch]
}
Особенности:
- массив
charClassизвестен при компиляции; - все значения — простые байты;
- таблица будет размечена в статических данных, часто часть содержимого — в сегменте констант;
- обращение к
charClass— это просто индексирование по известному адресу, без аллокаций.
Паттерн: таблица хэшей или CRC
package crc32
// Пример из стандартной библиотеки (упрощенно)
var table = [256]uint32{
0x00000000, 0x77073096, /* ... много значений ... */, 0x2D02EF8D,
}
func update(crc uint32, p []byte) uint32 {
for _, b := range p {
crc = table[byte(crc)^b] ^ (crc >> 8)
}
return crc
}
Здесь:
- огромная таблица значений CRC зашита в бинарник;
- это классический пример использования сегмента констант для ускорения вычислений;
- цена — увеличенный размер
.rodata.
Паттерн: embed и константы
Начиная с Go 1.16, можно встраивать файлы с помощью директивы //go:embed. Такие данные тоже попадают в бинарник и во многом ведут себя как большие константные массивы байт.
Пример:
package main
import _ "embed"
//go:embed config/default.yaml
var defaultConfig []byte
func main() {
// Массив байт лежит в бинарнике и в сегменте данных/констант
println(len(defaultConfig))
}
Важный момент:
- большие файлы, встроенные через
embed, могут серьезно раздуть размер бинарника; - эти данные по сути хранятся в аналогичной константной области, хоть и представлены как слайс байт.
Заключение
Сегмент constants (constants-segment) в Go — это ключевая часть механизма работы с неизменяемыми данными:
- туда попадают строковые литералы, значения константных таблиц, метаданные типов, имена и другая информация, известная на этапе компиляции;
- рантайм использует эту область как источник стабильных неизменяемых данных, не перемещаемых и не освобождаемых;
- сборщик мусора учитывает, какие части константной области могут содержать указатели, а какие — нет, и оптимизирует сканирование памяти.
Для вас, как разработчика, это означает:
- вы можете смело использовать строковые и числовые константы, не опасаясь лишних аллокаций;
- большие таблицы и встроенные данные нужно оценивать с точки зрения размера бинарника и потребления памяти;
- понимание того, какие данные попадают в сегмент констант, помогает анализировать производительность и поведение GC.
Если вам нужно еще глубже, можно изучить исходники рантайма Go (директория src/runtime), посмотреть, как описаны границы rodata и других областей, и сопоставить это с результатами go tool objdump и objdump системного уровня.
Частозадаваемые технические вопросы по теме и ответы
Как понять, какие именно глобальные объекты попали в константный сегмент, а какие в обычный data
На уровне Go-инструментов вы видите только косвенно. Практический путь:
- Соберите бинарник с
go build -ldflags="-s -w" -gcflags="-c=2". - Используйте
nm -S appилиgo tool nm appдля списка символов с размерами. - Сопоставьте имена переменных с секциями через
objdump -h appиobjdump -t app. - Символы в секции
.rodataи родственных — это ваш константный сегмент.
Можно ли заставить компилятор не класть большие таблицы в сегмент констант
Напрямую — нет, но можно:
- Инициализировать таблицу в
initруками, а не литералом:- тогда данные окажутся на куче или в
bss+ код инициализации.
- тогда данные окажутся на куче или в
- Хранить данные в сжатом виде (строка/байтовый слайс) и распаковывать по требованию.
- Вынести данные во внешний файл и читать в рантайме.
Это осознанный компромисс между временем старта, размером бинарника и потреблением памяти.
Как повлиять на то, распознает ли компилятор область как noptr (без указателей)
На уровне Go-кода это делается косвенно:
- Используйте типы без указателей (например,
int,float64,structбез полей-указателей). - Избегайте в таких структурах полей типа
interface{},string,[]T— они содержат указатели. - Компилятор сам разнесет такие объекты по
noptrdata/noptrbssи обычным секциям.
Ручного указания "эта переменная точно без указателей" в Go нет, все определяется по типу.
Влияет ли использование const вместо var на размещение данных в constants-segment
Косвенно:
constчасто вообще не требует памяти — значения встраиваются в инструкции;var, инициализируемая константным литералом, создает реальный объект в памяти (глобальный или локальный), который может ссылаться на сегмент констант (строки, массивы).
Если цель — минимизировать статические данные, используйте const, когда возможно.
Можно ли считать, что все данные в constants-segment никогда не сканируются GC
Не совсем:
- часть областей в
rodataдействительно может не сканироваться (если гарантированно без указателей); - данные с указателями (метаданные типов, таблицы интерфейсов) описываются специальными структурами в рантайме, и GC знает, где ожидать указатели.
В любом случае сами константные области не освобождаются и не перемещаются, GC только учитывает хранящиеся там ссылки на объекты в куче.