- Linux thread local storage
- Как устроена работа thread_local переменных: разбираемся и добавляем поддержку в учебную ОС
- Что такое thread_local переменные?
- Как они работают?
- Как что-то записать в сегментные регистры
- Выделяем память для TLS и инициализируем её
- Заключение
- 6.49. Thread-Local Storage
- 6.49.1. ISO/IEC 9899:1999 Edits for Thread-Local Storage
- 6.49.2. ISO/IEC 14882:1998 Edits for Thread-Local Storage
Linux thread local storage
Thread-local storage ( TLS ) is a mechanism by which variables are allocated such that there is one instance of the variable per extant thread. The runtime model GCC uses to implement this originates in the IA-64 processor-specific ABI, but has since been migrated to other processors as well. It requires significant support from the linker ( ld ), dynamic linker ( ld.so ), and system libraries ( libc.so and libpthread.so ), so it is not available everywhere.
At the user level, the extension is visible with a new storage class keyword: __thread . For example:
__thread int i; extern __thread struct state s; static __thread char *p;
The __thread specifier may be used alone, with the extern or static specifiers, but with no other storage class specifier. When used with extern or static , __thread must appear immediately after the other storage class specifier.
The __thread specifier may be applied to any global, file-scoped static, function-scoped static, or static data member of a class. It may not be applied to block-scoped automatic or non-static data member.
When the address-of operator is applied to a thread-local variable, it is evaluated at run time and returns the address of the current thread’s instance of that variable. An address so obtained may be used by any thread. When a thread terminates, any pointers to thread-local variables in that thread become invalid.
No static initialization may refer to the address of a thread-local variable.
In C++, if an initializer is present for a thread-local variable, it must be a constant-expression , as defined in 5.19.2 of the ANSI/ISO C++ standard.
See ELF Handling For Thread-Local Storage for a detailed explanation of the four thread-local storage addressing models, and how the runtime is expected to function.
Как устроена работа thread_local переменных: разбираемся и добавляем поддержку в учебную ОС
Эта статья написана по мотивам моей курсовой работы, основной смысл которой описан здесь. В процессе работы над ней мне понадобилось добавить в учебной ОС, над которой я работал, поддержку thread_local переменных, о чём я и хочу здесь рассказать в надежде что кому-то это окажется полезно.
Здесь рассмотрен совсем простой случай: поддержки динамической загрузки других бинарников не будет, а способ реализации рассмотрен только один.
Что такое thread_local переменные?
Язык C++ позволяет объявлять глобальные переменные thread_local. Переменная, объявленная таким образом, будет разной в разных потоках программы: разные потоки могут её использовать, не используя никакой синхронизации, и каждого из них будет быть своя копия этой переменной.
Как они работают?
Давайте напишем какой-нибудь код, который что-то делает с thread_local переменной и посмотрим, в какой ассемблер он компилируется. Также посмотрим, во что превращается такой же код, но с обычной переменной, а не thread_local:
fn: addl $1, %fs:value@tpoff ret value: .long 5
Если убрать thread_local, то будет:
fn: # @fn addl $1, value(%rip) retq value: .long 5 # 0x5
Итак, что мы видим? Переменная объявляется в ассемблерном коде (забегая вперёд, скажу, что в бинарнике (ELF-файле) эта переменная будет обявляться в сегменте .data (или .tdata)). Если thread_local нет, то обращение к переменной происходит просто по её адресу в памяти. Если же переменная thread_local, то происходит обращение по какому-то адресу %fs:value . Эта запись означает обращение по адресу ( %fs + value ). %fs — это segment register. В данном случае достаточно знать, что это регистр, который используется только для доступа к thread local storage — данным конкретного потока.
Следовательно, чтобы поток имел доступ к своему thread local storage (TLS), при его запуске в регистр %fs необходимо записать указатель на конец участка памяти, выделенного под TLS. Осталось разобраться, как в этот регистр что-то записать, как понять, какой должен быть размер TLS и как его инициализировать.
Как что-то записать в сегментные регистры
Всего есть 6 сегментных регистров: %cs , %ds , %ss , %es , %gs и собственно интересующий нас %fs . Поскольку изначально эти регистры задумывались для настройки виртуальной памяти операционной системой, по умолчанию в эти регистры писать может только операционная система (в реальности в настоящее время виртуальная память настраивается с помощью таблиц страниц, и первые 4 перечисленные регистра не используются). Поскольку делать системный вызов просто для того, чтобы что-то записать в регистр довольно неэффективно, то в современных процессорах есть специальные инструкции для этого: readfsbase , writefsbase , readgsbase и writegsbase . Однако использовать их просто так невозможно: дело в том, что некоторые ядра ОС полагаются на то, что пользовательские программы не могут изменять регистры %fs и %gs, поэтому для того, чтобы процессор позволил выполнить такую инструкцию, ядро ОС должно это явно разрешить, выставив флаг в регистре %cr4 .
Итак, есть два варианта, как можно писать в регистр %fs для настройки TLS:
- Добавить системные вызовы для чтений и записи регистров %fs и %gs . Ядро ОС может писать в них просто инструкцией mov . Способ не эффективен по времени, так как системный вызов делать долго, зато будет работать на всех процессорах.
- Разрешить инструкции readfsbase , writefsbase , readgsbase и writegsbase и их использовать. Эти инструкции есть только на некоторых процессорах, но меня это не волнует, так как данная учебная ОС запускается через qemu без аппаратной виртуализации, и можно выбрать процессор с поддержкой этих инструкций.
Я выбрал второй вариант, поэтому сначала надо, чтобы ядро ОС при запуске разрешило нужные инструкции. Для этого заменяем
mov $0x6b0, %eax mov %eax, %cr4
mov $0x106b0, %eax mov %eax, %cr4
Выделяем память для TLS и инициализируем её
Теперь нам надо как-то понять, какой размер должен быть у TLS и что там должно быть записано.
Обычные глобальные переменные размещаются в ELF-файле в сегментах .data и .bss. В .data попадают инициализированные глобальные переменные, а в .bss попадают неинициализированные, то есть те, в которых нулевые значения. В заголовках сегментов .data и .bss указывается, какой у них размер и по какому адресу в виртуальном адресном пространстве они должны быть загружены. Заголовок .data также содержит указатель на место в ELF файле, где находятся соответствующие данные.
Соответственно, когда исполняемый файл запускается, необходимо:
- Прочитать заголовок сегмента .data
- Выделить участок памяти по указанному в заголовку адресу и указанному в заголовке размеру
- Скопировать в этот участок памяти данные из .data сегмента ELF файла
- Сделать шаги 1 и 2 для сегмента .bss, но вместо шага 3 занулить всю выделенную память.
- По-хорошему, в случае с C++ до вызова функции main ещё и должен произойти вызов конструкторов глобальных объектов.
С thread local storage всё устроено довольно похоже. Инициализированные и неинициализированные переменные в ELF-файле находятся в сегментах .tdata и .tbss, соответственно. При запуске потока необходимо выделить память для TLS размера сегментов .tdata и .tbss, инициализировать эту память и записать адрес её конца в регистр %fs . В случае с С++ ещё надо позвать конструкторы thread_local объектов, а при завершении потока надо позвать деструкторы.
Посмотрим на код. Объявляем функции для записи и чтения регистров %fs , %gs .
asm( ".global rdfsbase\n" "rdfsbase:\n" " rdfsbase %rax\n" " ret\n" ); asm( ".global rdgsbase\n" "rdgsbase:\n" " rdgsbase %rax\n" " ret\n" ); asm( ".global wrfsbase\n" "wrfsbase:\n" " wrfsbase %rdi\n" " ret\n" ); asm( ".global wrgsbase\n" "wrgsbase:\n" " wrgsbase %rdi\n" " ret\n" );
Собственно подготовка thread local storage:
// в tbss и tdata записаны заголовки сегментов .tdata, .tbss из ELF // файла. Перед вызовом этой функции должна быть вызвана функция чтения ELF файла void prepare_thread_local() < thread_local_size = tbss.sh_size + tdata.sh_size; if (thread_local_size == 0) < return ; // ничего делать не нужно >uint64_t alignment = tdata.sh_addralign; if (tbss.sh_addralign > alignment) < alignment = tbss.sh_addralign; >// c alignment мог немного напутать, но на моих тестах всё работает thread_local_size = alignup(thread_local_size, alignment); // выделяем память auto addr = (char *)malloc(thread_local_size + sizeof(long) + alignment); // здесь может в зависимости от реализации malloc можеть быть нужна // проверка на выровненность адреса // записываем конец памяти в %fs wrfsbase((uint64_t)(addr + thread_local_size)); // по соглашению с компилятором, в конце TLS должен быть записан указатель // на него самого для того, чтобы прочитать значение %fs *(long *)(addr + thread_local_size) = (addr + thread_local_size); // now we need to initialize memory int fd = open(ELF_NAME, 0); // инициализируем иницаилизированные переменные данными из .tdata pread(fd, addr, tdata.sh_size, tdata.sh_offset); close(fd); // обнуляем данные из .tbss memset(addr + tdata.sh_size, 0, tbss.sh_size); >
В конце работы потока надо освободить выделенное хранилище:
void clean_up_thread_local() < if (thread_local_size == 0) < return; >free((void *)(rdfsbase() - thread_local_size)); >
Первую функцию надо запустить в начале работы потока, вторую — в конце:
class ThreadLocalHolder < public: ThreadLocalHolder(thread_entry_arg* arg) < prepare_thread_local(); // готовим TLS. Чтение ELF файла к этому моменту // уже произошло, оно происходит при запуске процесса ptr = arg; >~ThreadLocalHolder() < run_thread_atexit(); // эта функция запускает все задачи, которые // должны выполниться в конце работы потока, то есть деструкторы clean_up_thread_local(); delete ptr; syscall(Syscall::SYS_THREAD_LEAVE); >private: thread_entry_arg* ptr; >; // эта функция вызывается ядром при запуске нового потока. void thread_func(thread_entry_arg* arg) < ThreadLocalHolder holder(arg); // вызывается конструктор try < arg->f(arg->ptr); // вызываем функцию, переданную при создании потока с аргументом > catch (. ) < printf("thread leaving due to exception of type %s\n", __cxa_last_exception_name()); >// вызывается деструктор >
Посмотрим, как происходит вызов конструкторов и деструкторов thread local объектов. Для этого посмотрим на этот код и во что он компилируется.
Если мы заведём thread local переменную, имеющую тип, требующий вызова конструктора и деструктора, то компилятор сгенерирует функцию __tls_init, которая, если вызвана в потоке в первый раз, вызывает конструктор этого объекта и вызывает функцию __cxa_thread_atexit, передавая ей деструктор и объект. __cxa_thread_atexit добавит вызов деструктора в список дел, которые должны быть сделаны перед окончанием работы потока. Функция __tls_init будет вызываться при первом обращении к объекту, требующему инициализации. Чтобы всё коректно работало, нам надо реализовать функцию __cxa_thread_atexit.
Заключение
6.49. Thread-Local Storage
6.49.1. ISO/IEC 9899:1999 Edits for Thread-Local Storage
Within either execution environment, a thread is a flow of control within a program. It is implementation defined whether or not there may be more than one thread associated with a program. It is implementation defined how threads beyond the first are created, the name and type of the function called at thread startup, and how threads may be terminated. However, objects with thread storage duration shall be initialized before thread startup.
An object whose identifier is declared with the storage-class specifier __thread has thread storage duration. Its lifetime is the entire execution of the thread, and its stored value is initialized only once, prior to thread startup.
With the exception of __thread, at most one storage-class specifier may be given […]. The __thread specifier may be used alone, or immediately following extern or static.
The declaration of an identifier for a variable that has block scope that specifies __thread shall also specify either extern or static. The __thread specifier shall be used only with variables.
6.49.2. ISO/IEC 14882:1998 Edits for Thread-Local Storage
The following are a set of changes to ISO/IEC 14882:1998 (aka C++98) that document the exact semantics of the language extension.
- [intro.execution] New text after paragraph 4
A thread is a flow of control within the abstract machine. It is implementation defined whether or not there may be more than one thread.
It is unspecified whether additional action must be taken to ensure when and whether side effects are visible to other threads.
The thread that begins execution at the main function is called the main thread. It is implementation defined how functions beginning threads other than the main thread are designated or typed. A function so designated, as well as the main function, is called a thread startup function. It is implementation defined what happens if a thread startup function returns. It is implementation defined what happens to other threads when any thread calls exit.
The storage for an object of thread storage duration shall be statically initialized before the first statement of the thread startup function. An object of thread storage duration shall not require dynamic initialization.
The type of an object with thread storage duration shall not have a non-trivial destructor, nor shall it be an array type whose elements (directly or indirectly) have non-trivial destructors.
Thread, static, and automatic storage durations are associated with objects introduced by declarations […].
The keyword __thread applied to a non-local object gives the object thread storage duration. A local variable or class data member declared both static and __thread gives the variable or member thread storage duration.
With the exception of __thread, at most one storage-class-specifier shall appear in a given decl-specifier-seq. The __thread specifier may be used alone, or immediately following the extern or static specifiers. […]