34. Внутреннее устройство string

В прошлой статье (33.1. sync.Map под капотом. Read и dirty словари) мы погрузились в мир lock-free программирования и конкурентных словарей.
Большинство ключей в мапах, логов и JSON-ответов бэкенда — это текст.

Строки (string) в Go кажутся самым привычным и безобидным типом данных. Мы склеиваем их плюсиками, передаем в функции как аргументы и парсим регулярными выражениями. Но с точки зрения рантайма, процессорного конвейера и аллокатора памяти, строка — это мина замедленного действия.

Непонимание того, как устроены строки, приводит к скрытым OOM (Out Of Memory), повреждению многобайтовых символов (кракозябры), невидимым аллокациям в куче и тяжелым просадкам производительности при банальной конкатенации. Пришло время разобрать анатомию текста в Go с позиций системного программирования.


1. Анатомия строки: 16 байт

В классическом языке C строка — это просто непрерывный массив байтов, оканчивающийся нулевым байтом (\0). Чтобы узнать длину такой строки, процессору нужно пройти её от начала до конца через strlen(), тратя $O(N)$ тактов и рискуя выйти за границы памяти при отсутствии терминирующего нуля (источник сотен тысяч уязвимостей переполнения буфера).

Создатели Go (Кен Томпсон и Роб Пайк) пошли по пути так называемых «длинных строк» (Pascal-style strings), но оформили их в виде компактного двухсловного дескриптора.

Как мы помним из 29. Внутреннее устройство slice, слайс занимает 24 байта (Pointer, Length, Capacity).
Строка — это «младший брат» слайса. На 64-битной архитектуре заголовок строки (исторически reflect.StringHeader, а во внутренностях рантайма — структура stringStruct) всегда занимает ровно 16 байт (два машинных слова по 8 байт).

Go
// Внутреннее представление строки в рантайме Go (src/runtime/string.go)
type stringStruct struct {
    str unsafe.Pointer // Указатель на первый байт массива (8 байт)
    len int            // Длина в БАЙТАХ (8 байт)
}

Чего здесь не хватает по сравнению со слайсом? Вместимости (cap / Capacity).
Строке не нужна вместимость, потому что строки в Go абсолютно иммутабельны (неизменяемы). Их нельзя расширить (append) на месте, им нельзя изменить внутренние элементы.

flowchart LR
    subgraph HEADER ["Заголовок строки s (16 байт на стеке)"]
        direction TB
        PTR["str: 0x1040a10"]
        LEN["len: 5"]
    end

    subgraph MEMORY ["Память (Куча или Read-Only Data)"]
        direction LR
        B1["H"] -.- B2["e"] -.- B3["l"] -.- B4["l"] -.- B5["o"]
    end

    PTR -.->|"Указывает на неизменяемый массив"| B1

    style MEMORY fill:transparent,stroke:#00aaff,stroke-width:2px
  

Зачем нужна иммутабельность?

Многие разработчики, пришедшие из C или PHP, поначалу удивляются: «Почему я не могу сделать str[0] = 'a'?».

Иммутабельность дает три фундаментальных преимущества:

  1. Thread-safety (Потокобезопасность): Миллион горутин могут одновременно читать одну и ту же строку без единого мьютекса или атомика. Данные никогда не изменятся под капотом, и гонка данных (Data Race) физически невозможна.
  2. Идеальный ключ для map: Хэш строки вычисляется на основе её байтов. Если бы строку можно было изменить после вставки в мапу, её хэш бы изменился, она оказалась бы «не в том бакете», и мы бы потеряли элемент навсегда.
  3. Совместное использование памяти (Sharing) и нулевая цена копирования: Передача строки в функцию как параметра копирует всего 16 байт заголовка (два регистра CPU RAX и RBX в Go ABIInternal), независимо от того, весит ли сама строка 5 байт или 5 гигабайт!
  4. Сегмент .rodata: Строковые литералы, жестко прописанные в исходном коде (например, "hello"), компилятор помещает в сегмент констант бинарника (.rodata). Эти страницы памяти защищены аппаратным флагом процессора (Read-Only). Они загружаются ядром ОС в память один раз и вообще не расходуют кучу. Попытка записать байт по такому адресу через unsafe приведет к немедленному аппаратному прерыванию CPU SIGSEGV (Segmentation Fault).

2. Ловушка UTF-8: Байты против Рун

Это один из самых частых вопросов на собеседованиях любого уровня.

В Go строка — это просто неизменяемый срез произвольных байт. Рантайму абсолютно всё равно, что там лежит: текст, бинарный payload, сжатый gzip или кусок картинки. Никакой валидации при создании строки рантайм не выполняет.

Но по умолчанию исходный код Go и вся стандартная библиотека предполагают, что текст закодирован в UTF-8. Это неудивительно: саму кодировку UTF-8 Кен Томпсон и Роб Пайк разработали в 1992 году на салфетке в кафетерии для операционной системы Plan 9 в стенах Bell Labs!

В кодировке UTF-8:

  • Символы стандартной таблицы ASCII (английские буквы, цифры, знаки препинания) кодируются ровно 1 байтом (старший бит равен 0).
  • Символы кириллицы, греческого, арабского алфавитов занимают 2 байта.
  • Символы иероглифических письменностей (китайский, японский) занимают 3 байта.
  • Редкие символы, исторические алфавиты и эмодзи (например, 🚀 или 🤖) занимают 4 байта.
Go
func main() {
    s := "Привет"
    fmt.Println(len(s)) // Выведет 6? НЕТ! Выведет 12!
}

Функция len(s) возвращает количество байт, а не количество символов. Слово «Привет» (6 букв) в UTF-8 занимает 12 байт, потому что каждая кириллическая буква кодируется парой байтов.

TEXT
Слово:    П       р       и       в       е       т
Байты: [d0 9f] [d1 80] [d0 b8] [d0 b2] [d0 b5] [d1 82]
Индекс: 0   1   2   3   4   5   6   7   8   9   10  11

Как получить количество символов?

Символ (Unicode Code Point) в Go называется rune (руна, которая является алиасом для целочисленного типа int32).

Плохой способ (лишняя аллокация в куче):

Go
runes := []rune("Привет") // Конвертация выделяет новый массив int32 в куче!
fmt.Println(len(runes))   // 6. Верно, но мы сожгли CPU и память на аллокацию 24 байт.

Mechanical Sympathy (быстрый способ без аллокаций):

Go
import "unicode/utf8"

count := utf8.RuneCountInString("Привет") // 6. Работает за O(N) без аллокаций!

Функция utf8.RuneCountInString просто сканирует байты строки по маске UTF-8 в регистрах процессора, подсчитывая стартовые байты символов без выделения памяти в куче.


3. Взятие подстроки (Slicing) и Утечки памяти

Взятие подстроки s2 := s1[0:5] работает так же дешево, как со слайсами. Новая память не аллоцируется. Рантайм просто создает новый 16-байтный заголовок на стеке, где поле str указывает на исходный байтовый массив, а поле len равно новому размеру.

И в этом механизме кроется классическая ловушка утечки памяти (Memory Leak):

Go
func extractID(logLine string) string {
    // logLine весит 10 Мегабайт (например, гигантская JSON-строка)
    // Мы возвращаем строку из 5 байт:
    return logLine[10:15] 
}

Сборщик мусора (GC) работает с блоками памяти целиком. Он видит, что возвращенная строка extractID всё ещё содержит указатель str, ссылающийся внутрь того самого 10-мегабайтного блока памяти.

В результате сборщик мусора не удалит 10-мегабайтный лог из памяти! Ваш крошечный 5-байтный возвращенный string держит в заложниках весь огромный массив кучи. Если вы обработаете 1 000 таких логов и сохраните ID в мапу, ваше приложение съест 10 гигабайт памяти вместо 5 килобайт.

Решение (Go 1.18+):

Go
import "strings"

func extractID(logLine string) string {
    return strings.Clone(logLine[10:15]) 
}

Функция strings.Clone принудительно аллоцирует в куче ровно 5 байт и побайтово копирует туда данные, полностью отвязываясь от оригинального гигантского массива. Старый 10-мегабайтный буфер спокойно утилизируется сборщиком мусора.


4. Конкатенация (Склеивание)

Поскольку строки иммутабельны, базовая операция a + b всегда выделяет новый блок памяти размером len(a) + len(b) и побайтово копирует туда обе строки.

В Go конкатенация через + оптимизирована компилятором. Если вы пишете в одну строчку выражение:

Go
msg := a + b + c + d

Компилятор не создает промежуточные временные строки для a+b и (a+b)+c. Он заменяет эту цепочку на один вызов внутренней функции рантайма runtime.concatstrings. Она заранее суммирует длины всех слагаемых, выполняет ровно одну аллокацию нужного суммарного размера и копирует туда все 4 переменные.

Но если вы делаете конкатенацию в цикле — это алгоритмическая и аппаратная катастрофа:

Go
var result string
for _, v := range items {
    result += v // O(N^2) аллокаций и копирований памяти!
}

На каждой итерации процессор заново аллоцирует кусок памяти, копирует всё, что уже накопилось в result, добавляет v, а предыдущий кусок выбрасывает в мусор. Для $N$ элементов это дает квадратичную сложность $O(N^2)$ и гигантскую нагрузку на GC.

Спаситель: strings.Builder

Для эффективной сборки текста в цикле был создан strings.Builder.
Под капотом это структура с изменяемым срезом байтов buf []byte.

Go
var builder strings.Builder
builder.Grow(100) // Mechanical Sympathy: заранее выделяем память (Pre-allocation)
for _, v := range items {
    builder.WriteString(v) // Просто append к []byte, без мусора!
}
fmt.Println(builder.String())

Метод Grow позволяет заранее выделить буфер нужного размера, избегая промежуточных реаллокаций внутреннего слайса (как мы изучали в 30. Рост slice. append, realloc и copy).

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph BAD ["Конкатенация result += v в цикле"]
        A1["Итерация 1: аллокация len1"]:::process --> A2["Итерация 2: аллокация len1+len2, копия"]
        A2 --> A3["Итерация 3: аллокация len1+len2+len3, копия"]
        A3 --> A4["... O(N^2) копирований байт в куче"]
    end

    subgraph GOOD ["strings.Builder с pre-allocation"]
        B1["builder.Grow(totalLen): 1 аллокация"]:::process --> B2["builder.WriteString(): линейный append в []byte"]
        B2 --> B3["builder.String(): O(1) преобразование без копирования!"]
    end
  

5. Оптимизация компилятора: Zero-Copy поиск в Map

Есть еще один блестящий трюк рантайма Go, о котором полезно знать каждому инженеру.

Представьте, что вы читаете данные из сокета в слайс байт buf := []byte("user_id_123") и хотите проверить, есть ли такой ключ в мапе m := map[string]User{}.
Обычный код:

Go
user, ok := m[string(buf)]

Казалось бы: каст string(buf) обязан выделить память в куче и скопировать туда байты из buf! Ведь мапе нужна иммутабельная строка.

Однако компилятор Go содержит специальную оптимизацию: если конвертация string(buf) происходит прямо внутри индексного выражения мапы (m[string(buf)]), компилятор не выделяет память в куче. Он временно конструирует виртуальный 16-байтный заголовок строки прямо на регистрах, берет хэш от слайса и ищет в бакетах. Аллокация равна 0 байт!

Но если вы напишете:

Go
key := string(buf) // Тут произойдет честная аллокация!
user, ok := m[key]

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


Итог

  1. Строка — это не массив символов. Это 16-байтный дескриптор (Указатель str + Длина в байтах len), указывающий на неизменяемую память.
  2. Строки иммутабельны. Это обеспечивает потокобезопасность, стабильность хэшей для мапы и дешевое создание подстрок за $O(1)$.
  3. Вызов len() возвращает байты, а не символы. Для работы с человеческими символами (рунами) используйте пакет unicode/utf8.
  4. Срез от гигантской строки удерживает её в куче. Используйте strings.Clone() для предотвращения утечек памяти.
  5. Для конкатенации в цикле используйте strings.Builder, который работает со слайсом байт и превращает его в строку за $0$ аллокаций через магию unsafe.String и unsafe.SliceData.

Мы часто упоминали, что строка — это отличный ключ для мапы, а пустой интерфейс (interface{} или any) позволяет класть в мапу что угодно. Но как именно работают интерфейсы? Почему interface{} в памяти весит 16 байт, а не размер самой структуры? И что такое «Interface Boxing», о котором мы говорили в статьях про аллокации и Escape Analysis?

Пришло время открыть черный ящик системы типов Go. В следующей статье мы разберем: 35. Интерфейсы под капотом. eface и iface