Что такое секция .tls и как работают поточные локальные переменные

Если вы когда-нибудь углублялись в ассемблер, компиляторы или ядро Linux, вероятно, встречали необычную секцию в ELF-файлах — .tls. Это не код и не обычные данные. Это место, где живут локальные переменные для потоков. Разберёмся, как это устроено, без академической вода и с реальными примерами.

Зачем вообще нужны поточные локальные переменные

Представьте: у вас есть функция strerror, которая возвращает указатель на строку ошибки. Если сделать буфер глобальным, два потока сразу начнут его перезаписывать друг друга. Если хранить на стеке — он умрёт при выходе из функции. Нужна переменная, которая:

  • видна только внутри функции (как локальная);
  • при этом живёт между вызовами;
  • и самое главное — у каждого потока своя копия.

Именно для этого существуют TLS (Thread-Local Storage) и секция .tls в исполняемых файлах.

Что такое секция .tls в ELF

В линковке и загрузке ELF-файлов секция .tdata содержит инициализированные данные для TLS, а .tbss — неинициализированные (зануляются при старте потока). В ходе компиляции и линковки компилятор и линкер объединяют эти данные, и в финальном исполняемом файле вы часто видите одну секцию .tls, которая объединяет оба блока: и .tdata, и .tbss. Компоновщик собирает их вместе — так загрузчик и рантайм получают единую картину того, что нужно копировать для каждого потока.

Когда загрузчик разбирает заголовки ELF, он видит .tls .tdata — начальные значения переменных, и .tbss — размер обнуляемой части.

Что происходит при запуске программы:

  1. Сначала-наперво загрузчик берёт .tdata и копирует её содержимое в TLS-блок главного потока.
  2. Затем добавляется кусок из нулей размером с .tbss.
  3. У каждого нового потока рантайм (pthread) выделяет такой же блок и заполняет его — копирует .tdata, добивает нулями.

После этого каждый поток может обращаться к своим TLS-переменным по одному и тому же смещению внутри своего блока.

Два способа адресания: статический TLS и динамический

В TLS есть два базовых варианта: статический TLS и динамический TLS. Секция .tls активно используется в обоих, по-разному в каждом.

Рассмотрим оба варианта.

Вариант 1: статический TLS (general dynamic / local exec)

Это самый простой и быстрый путь. Компилятор при компиляции уже знает относительные смещения каждой переменной внутри будущего TLS-блока. В итоге обращение к переменной выглядит как: «возьми TLS-блок текущего потока и прибавь к нему смещение».

В x86-64 TLS-блок текущего потока доступен через сегментный регистр fs (или gs — зависит от платформы, в Linux x86-64 обычно fs). Для local exec-модели код примерно такой:

mov rax, fs:[0]          ; указатель на TLS-блок текущего потока
mov ecx, rax + offset     ; смещение конкретной переменной

Всё, переменная получена. Никаких вызовов библиотечных функций. Минимальные накладные расходы.

Когда работает

Ограничение статического TLS — он работает только тогда, когда TLS-блок расположен непосредственно после блоком управления потоком (TCB) и смещения известны на этапе линковки. Это так для:

  • исполняемых файлов (не PIE, или PIE с -ftls-model=local-exec);
  • переменных в основной программе и статически линкованных библиотеках.

Вариант 2: динамический TLS (general dynamic / local dynamic)

Когда переменная живёт в .so (разделяемой библиотеке) или используется в PIE-исполняемом файле с global dynamic, линкер не знает заранее её смещение в TLS-блоке. Приходится вызывать функции рантайма, такие как __tls_get_addr.

Для каждой TLS-переменной компилятор создаёт структуру tls_index:

struct tls_index {
    // модуль, в котором определена переменная
    unsigned long int ti_module;
    // смещение внутри TLS-блока модуля
    unsigned long int ti_offset;
};

Обращение к переменной в итоге выглядит как:

struct tls_index index;
__tls_get_addr(&index)  // возвращает адрес конкретной переменной

Функция __tls_get_addr делает следующее:

  1. Берёт идентификатор модуля (обычно это индекс в списке загруженных модулей TLS).
  2. Находит TLS-блок текущего потока для этого модуля (при первом обращении блок выделяется).
  3. Добавляет смещение ti_offset и возвращает адрес.

Естественно, это вызов функции с поиском в таблице модулей TLS и, возможно, выделением блока при первом обращении. Это дороже, чем local-exec, но гибче и поддерживает динамическую загрузку библиотек.

Что влияет на выбор модели

Компилятор выбирает модель TLS в зависимости от контекста. Управлять можно через атрибуты переменных и ключи компилятора. Частая ситуация:

  • статическая линковка или исполняемый файл без адресно-независимого кода → компилятор может использовать local-exec;
  • вы поставили __thread int x; в заголовке общей библиотеки → скорее всего будет global-dynamic;
  • явное указание через __attribute__((tls_model("local-exec"))) заставит компилятор даже в PIE использовать быстрый путь.

Если вы заметили, что простая переменная static __thread неожиданно стала генерировать вызовы __tls_get_addr вместо одного mov через fs — это сигнал, что модель выбрана динамическая.

Детали реализации секции .tls в разных ABI

Чтобы реально понять, откуда берутся описанные выше цифры и структуры, нужно заглянуть в ABI для конкретной архитектуры. В x86-64 System V ABI раздел TLS описывает расположение блока: TCB располагается по отрицательным смещениям относительно регистра fs, а TLS-блок модулей — по положительным. Именно поэтому статический TLS в local-exec может просто прибавить смещение к fs:[0]. Архитектуры могут использовать gs, TLS-регистр или другой механизм. Важно то, что секция .tls в ELF хранит именно начальный образ, а преобразования адресов происходят по правилам конкретной архитектуры.

Как посмотреть, что лежит в .tls

Если вы хотите посмотреть, что именно попадает в .tls в вашей программе, используйте стандартные инструменты:

readelf -S a.out | grep -E '\.tdata|\.tbss|\.tls'
objdump -s -j .tls a.out

В выводе readelf вы увидите секции .tdata и .tbss (или объединённую .tls), их размер и смещение. В objdump -s — сами начальные значения переменных.

Также полезно посмотреть дизассемблированный код обращения к переменной:

objdump -d a.out | grep -A 10 'tls_get_addr'

Если видите вызовы __tls_get_addr — модель динамическая. Если только обращения через fs: — статическая.

Типичные ошибки при работе с TLS

Использование __thread в статической библиотеке, загружаемой через dlopen. В старых версиях glibc это приводило к ошибкам, потому что модуль не мог зарегистрировать свой TLS-блок. В современных glibc это работает, но с оговорками: если библиотека загружается динамически, все её TLS-переменные должны быть скомпилированы с global-dynamic или local-dynamic, и при первом обращении из потока будет вызван __tls_get_addr. Если вы ожидаете минимальные накладные расходы, это не ваш случай.

Хранение больших структур в __thread. Каждый поток получит полную копию. Если у вас 100 потоков и структура на 4 КБ — это 400 КБ дополнительной памяти, причём при старте каждого потока будет копироваться .tdata и обнуляться .tbss. Иногда это оправдано, но часто лучше использовать динамический TLS через pthread_key_create и выделять данные только при необходимости.

Доступ к TLS-переменной из деструктора потока после освобождения TLS-блока. Порядок вызова деструкторов в pthread может привести к тому, что TLS-блок уже уничтожен, а вы пытаетесь обратиться к переменной. Это приводит к use-after-free. Будьте осторожны с __thread переменными в разделяемых библиотеках, загружаемых через dlopen.

Когда что использовать

Выбор между статическим и динамическим TLS — это всегда компромисс между скоростью и гибкостью. Вот простые сценарии:

  • Переменная в основном исполняемом файле, не в разделяемой библиотеке — используйте static __thread. Компилятор сам выберет local-exec, и обращение будет максимально быстрым.
  • Переменная в заголовке, который подключают разные библиотеки — лучше явно указать __attribute__((tls_model("global-dynamic"))) или использовать pthread_key_t, если нужна сложная логика освобождения.
  • Большие данные, нужные не каждому потоку — используйте pthread_key_create с деструктором. Это динамический TLS, но с явным управлением временем жизни.
  • Критичная производительность — проверьте через objdump, что компилятор использует local-exec. Если нет, перепишите код так, чтобы переменная жила в модуле, который может использовать эту модель.

Как лучше сделать

Практические рекомендации:

  1. Начинайте с static __thread для простых случаев. Это даёт и безопасность, и производительность.
  2. Если переменная нужна в нескольких модулях — выносите её в отдельный объектный файл с явным tls_model или используйте pthread_key_t.
  3. Проверяйте модель через objdump или readelf. Если видите неожиданные вызовы __tls_get_addr там, где ожидали быстрый доступ — пересмотрите дизайн переменной.
  4. Не кладите в __thread объекты с нетривиальными деструкторами в динамически загружаемых библиотеках — порядок уничтожения может быть неочевидным.
  5. Для больших данных предпочитайте динамический TLS через pthread_key_create — это даст контроль над временем жизни и сэкономит память в потоках, которым переменная не нужна.

Итог

Секция .tls в ELF — это не магия, а просто начальный образ данных, который копируется в TLS-блок каждого потока. Поточные локальные переменные реализуются через комбинацию этой секции, блока управления потоком и адресания через fs/gs или через вызовы __tls_get_addr. Выбор между статическим и динамическим TLS влияет на производительность и гибкость. Если вы пишете многопоточный код и используете __thread, стоит один раз проверить, какую модель выбрал компилятор, и убедиться, что она соответствует вашим ожиданиям. Это сэкономит вам время на поиск неочевидных багов и преждевременных оптимизаций.

Оцените статью
PEFile — Безопасность и технологии простым языком