Как устроена работа thread_local переменных: разбираемся и добавляем поддержку в учебную ОС

от автора

Эта статья написана по мотивам моей курсовой работы, основной смысл которой описан здесь. В процессе работы над ней мне понадобилось добавить в учебной ОС, над которой я работал, поддержку thread_local переменных, о чём я и хочу здесь рассказать в надежде что кому-то это окажется полезно.

Здесь рассмотрен совсем простой случай: поддержки динамической загрузки других бинарников не будет, а способ реализации рассмотрен только один.

Код расположен в двух репозиториях.

Что такое thread_local переменные?

Язык C++ позволяет объявлять глобальные переменные thread_local. Переменная, объявленная таким образом, будет разной в разных потоках программы: разные потоки могут её использовать, не используя никакой синхронизации, и каждого из них будет быть своя копия этой переменной.

Как они работают?

Давайте напишем какой-нибудь код, который что-то делает с thread_local переменной и посмотрим, в какой ассемблер он компилируется. Также посмотрим, во что превращается такой же код, но с обычной переменной, а не thread_local:

extern "C" { // чтобы имя функции в ассемблерном коде было понятным thread_local int value = 5; void fn() {     value++; } }

превращается в

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 файле, где находятся соответствующие данные.

Соответственно, когда исполняемый файл запускается, необходимо:

  1. Прочитать заголовок сегмента .data

  2. Выделить участок памяти по указанному в заголовку адресу и указанному в заголовке размеру

  3. Скопировать в этот участок памяти данные из .data сегмента ELF файла

  4. Сделать шаги 1 и 2 для сегмента .bss, но вместо шага 3 занулить всю выделенную память.

  5. По-хорошему, в случае с 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.

Заключение

Вроде вот и всё. Вопросы?


ссылка на оригинал статьи https://habr.com/ru/post/702814/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *