Если вы когда-нибудь углублялись в ассемблер, компиляторы или ядро Linux, вероятно, встречали необычную секцию в ELF-файлах — .tls. Это не код и не обычные данные. Это место, где живут локальные переменные для потоков. Разберёмся, как это устроено, без академической вода и с реальными примерами.
- Зачем вообще нужны поточные локальные переменные
- Что такое секция .tls в ELF
- Два способа адресания: статический TLS и динамический
- Вариант 1: статический TLS (general dynamic / local exec)
- Когда работает
- Вариант 2: динамический TLS (general dynamic / local dynamic)
- Что влияет на выбор модели
- Детали реализации секции .tls в разных ABI
- Как посмотреть, что лежит в .tls
- Типичные ошибки при работе с TLS
- Когда что использовать
- Как лучше сделать
- Итог
Зачем вообще нужны поточные локальные переменные
Представьте: у вас есть функция strerror, которая возвращает указатель на строку ошибки. Если сделать буфер глобальным, два потока сразу начнут его перезаписывать друг друга. Если хранить на стеке — он умрёт при выходе из функции. Нужна переменная, которая:
- видна только внутри функции (как локальная);
- при этом живёт между вызовами;
- и самое главное — у каждого потока своя копия.
Именно для этого существуют TLS (Thread-Local Storage) и секция .tls в исполняемых файлах.
Что такое секция .tls в ELF
В линковке и загрузке ELF-файлов секция .tdata содержит инициализированные данные для TLS, а .tbss — неинициализированные (зануляются при старте потока). В ходе компиляции и линковки компилятор и линкер объединяют эти данные, и в финальном исполняемом файле вы часто видите одну секцию .tls, которая объединяет оба блока: и .tdata, и .tbss. Компоновщик собирает их вместе — так загрузчик и рантайм получают единую картину того, что нужно копировать для каждого потока.
Когда загрузчик разбирает заголовки ELF, он видит .tls .tdata — начальные значения переменных, и .tbss — размер обнуляемой части.
Что происходит при запуске программы:
- Сначала-наперво загрузчик берёт
.tdataи копирует её содержимое в TLS-блок главного потока. - Затем добавляется кусок из нулей размером с
.tbss. - У каждого нового потока рантайм (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 делает следующее:
- Берёт идентификатор модуля (обычно это индекс в списке загруженных модулей TLS).
- Находит TLS-блок текущего потока для этого модуля (при первом обращении блок выделяется).
- Добавляет смещение
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. Если нет, перепишите код так, чтобы переменная жила в модуле, который может использовать эту модель.
Как лучше сделать
Практические рекомендации:
- Начинайте с
static __threadдля простых случаев. Это даёт и безопасность, и производительность. - Если переменная нужна в нескольких модулях — выносите её в отдельный объектный файл с явным
tls_modelили используйтеpthread_key_t. - Проверяйте модель через
objdumpилиreadelf. Если видите неожиданные вызовы__tls_get_addrтам, где ожидали быстрый доступ — пересмотрите дизайн переменной. - Не кладите в
__threadобъекты с нетривиальными деструкторами в динамически загружаемых библиотеках — порядок уничтожения может быть неочевидным. - Для больших данных предпочитайте динамический TLS через
pthread_key_create— это даст контроль над временем жизни и сэкономит память в потоках, которым переменная не нужна.
Итог
Секция .tls в ELF — это не магия, а просто начальный образ данных, который копируется в TLS-блок каждого потока. Поточные локальные переменные реализуются через комбинацию этой секции, блока управления потоком и адресания через fs/gs или через вызовы __tls_get_addr. Выбор между статическим и динамическим TLS влияет на производительность и гибкость. Если вы пишете многопоточный код и используете __thread, стоит один раз проверить, какую модель выбрал компилятор, и убедиться, что она соответствует вашим ожиданиям. Это сэкономит вам время на поиск неочевидных багов и преждевременных оптимизаций.
