thread_local в области блока

Какая польза от переменной thread_local в области блока?

Если компилируемый образец помогает проиллюстрировать вопрос, вот он:

#include <thread>
#include <iostream>

namespace My {
    void f(int *const p) {++*p;}
}

int main()
{
    thread_local int n {42};
    std::thread t(My::f, &n);
    t.join();
    std::cout << n << "\n";
    return 0;
}

Выход: 43

В примере новый поток получает свой собственный n, но (насколько мне известно) ничего интересного с ним сделать не может, так зачем заморачиваться? Есть ли смысл использовать собственный n нового потока? А если это бесполезно, то какой в ​​этом смысл?

Естественно, я предполагаю, что есть точка. Я просто не знаю, в чем может быть смысл. Вот почему я спрашиваю.

Если собственный n нового потока требует (как я полагаю) специальной обработки со стороны ЦП во время выполнения, возможно, потому, что на уровне машинного кода нельзя получить доступ к собственному n обычным способом через предварительно рассчитанное смещение от базового указателя нового потока. стек, то разве мы не просто тратим впустую машинные циклы и электроэнергию? А ведь даже если бы особой обработки не требовалось, все равно никакого прироста! Не то, чтобы я мог видеть.

Итак, почему thread_local в области блока, пожалуйста?

Ссылки


person thb    schedule 15.03.2019    source источник


Ответы (4)


Я считаю, что thread_local полезен только в трех случаях:

  1. Если вам нужно, чтобы каждый поток имел уникальный ресурс, чтобы им не приходилось делиться, мьютексом и т. д. для использования указанного ресурса. И даже в этом случае это полезно только в том случае, если ресурс велик и/или дорог в создании или должен сохраняться при вызовах функций (т. е. локальной переменной внутри функции будет недостаточно).

  2. Ответвление (1) - вам может потребоваться специальная логика для запуска, когда вызывающий поток в конечном итоге завершается. Для этого можно использовать деструктор объекта thread_local, созданного в функции. Деструктор такого объекта thread_local вызывается один раз для каждого потока, вошедшего в блок кода с объявлением thread_local (в конце жизни потока).

  3. Вам может понадобиться выполнить некоторую другую логику для каждого уникального потока, который его вызывает, но только один раз. Например, вы можете написать функцию, которая регистрирует каждый уникальный поток, вызвавший функцию. Это может показаться странным, но я нашел применение этому в управлении ресурсами, собранными из мусора, в библиотеке, которую я разрабатываю. Это использование тесно связано с (1), но не используется после его построения. Фактически является сторожевым объектом на протяжении всего времени существования потока.

person Cruz Jean    schedule 15.03.2019
comment
Ваш ответ свидетельствует об опыте. Это освещает. Это ценится. Это дало мне перспективу, которой у меня не было, +1, но разве все три ваших случая не удовлетворяются объектом thread_local в области namespace? Если вы знаете о каком-либо использовании объектов thread_local в области block, мне было бы интересно прочитать об этом. - person thb; 16.03.2019
comment
@thb Конечно. Если вы поместите его в область пространства имен, все созданные потоки будут создавать его экземпляры, что может снизить производительность. Если вы поместите его в область блока, он будет создан ровно один раз каждым потоком при первом входе элемента управления в эту область. то есть вы не создаете ничего, что не используете. - person Cruz Jean; 16.03.2019
comment
Разве это не интересно? Вы случайно не знаете, где хранится такой предмет? То есть знаете ли вы, в каком сегменте компоновщика (если это правильный термин) находится его хранилище? Для меня не очевидно, что такой объект можно было хранить в стеке потока; потому что, если бы он хранился там, я не знаю, как исполняемый код когда-либо найдет этот объект. - person thb; 16.03.2019
comment
(Не стесняйтесь игнорировать последний комментарий. Очевидно, я могу сам поэкспериментировать со своим компилятором и изучить объектный файл, который он создает. Мне было любопытно, если у вас есть что-нибудь интересное, чтобы добавить.) - person thb; 16.03.2019
comment
@thb Обычно в куче выделяется thread_local объектов (поэтому компоновщик не участвует). Однако компилятор гарантирует, что для них вызываются деструкторы и очищаются ресурсы. - person Cruz Jean; 16.03.2019
comment
Потрясающие ответы, намного лучше, чем я заслуживаю. Я ищу переключатель +99 на своем экране, но не могу его найти. Я вижу, что у вас не так много времени, чтобы ответить на вопросы о Stack Overflow, поэтому я рад, что вы были готовы ответить на мой вопрос вчера и сегодня. - person thb; 16.03.2019

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

int main()
{
    static thread_local int n {42};
    std::thread t(My::f, &n);
    t.join();
    std::cout << n << "\n"; // prints 43
    return 0;
}

Переменные, объявленные с помощью thread_local внутри функции, не так уж сильно отличаются от глобально определенных thread_locals. В обоих случаях вы создаете объект, уникальный для каждого потока, время жизни которого привязано к времени жизни потока.

Разница только в том, что глобально определенные thread_locals будут инициализированы при запуске нового потока до того, как вы введете какие-либо функции, специфичные для потока. Напротив, локальная локальная переменная потока инициализируется в первый раз, когда управление проходит через ее объявление.

Примером использования может быть ускорение функции путем определения локального кеша, который повторно используется в течение всего времени существования потока:

void foo() {
  static thread_local MyCache cache;
  // ...
}

(Я использовал здесь static thread_local, чтобы явно указать, что кеш будет использоваться повторно, если функция выполняется несколько раз в одном и том же потоке, но это дело вкуса. Если вы отбросите static, это не будет иметь никакого значения.)


Комментарий к вашему примеру кода. Возможно, это было сделано намеренно, но на самом деле поток не обращается к thread_local n. Вместо этого он работает с копией указателя, созданной потоком, выполняющим main. Из-за этого оба потока ссылаются на одну и ту же память.

Другими словами, более подробный способ был бы таким:

int main()
{
    thread_local int n {42};
    int* n_ = &n;
    std::thread t(My::f, n_);
    t.join();
    std::cout << n << "\n"; // prints 43
    return 0;
}

Если вы измените код, чтобы поток обращался к n, он будет работать со своей собственной версией, а n, принадлежащий основному потоку, не будет изменен:

int main()
{
    thread_local int n {42};
    std::thread t([&] { My::f(&n); });
    t.join();
    std::cout << n << "\n"; // prints 42 (not 43)
    return 0;
}

Вот более сложный пример. Он вызывает функцию два раза, чтобы показать, что состояние сохраняется между вызовами. Также его вывод показывает, что потоки работают в своем собственном состоянии:

#include <iostream>
#include <thread>

void foo() {
  thread_local int n = 1;
  std::cout << "n=" << n << " (main)" << std::endl;
  n = 100;
  std::cout << "n=" << n << " (main)" << std::endl;
  int& n_ = n;
  std::thread t([&] {
          std::cout << "t executing...\n";
          std::cout << "n=" << n << " (thread 1)\n";
          std::cout << "n_=" << n_ << " (thread 1)\n";
          n += 1;
          std::cout << "n=" << n << " (thread 1)\n";
          std::cout << "n_=" << n_ << " (thread 1)\n";
          std::cout << "t executing...DONE" << std::endl;
        });
  t.join();
  std::cout << "n=" << n << " (main, after t.join())\n";
  n = 200;
  std::cout << "n=" << n << " (main)" << std::endl;

  std::thread t2([&] {
          std::cout << "t2 executing...\n";
          std::cout << "n=" << n << " (thread 2)\n";
          std::cout << "n_=" << n_ << " (thread 2)\n";
          n += 1;
          std::cout << "n=" << n << " (thread 2)\n";
          std::cout << "n_=" << n_ << " (thread 2)\n";
          std::cout << "t2 executing...DONE" << std::endl;
        });
  t2.join();
  std::cout << "n=" << n << " (main, after t2.join())" << std::endl;
}

int main() {
  foo();
  std::cout << "---\n";
  foo();
  return 0;
}

Выход:

n=1 (main)
n=100 (main)
t executing...
n=1 (thread 1)      # the thread used the "n = 1" init code
n_=100 (thread 1)   # the passed reference, not the thread_local
n=2 (thread 1)      # write to the thread_local
n_=100 (thread 1)   # did not change the passed reference
t executing...DONE
n=100 (main, after t.join())
n=200 (main)
t2 executing...
n=1 (thread 2)
n_=200 (thread 2)
n=2 (thread 2)
n_=200 (thread 2)
t2 executing...DONE
n=200 (main, after t2.join())
---
n=200 (main)        # second execution: old state is reused
n=100 (main)
t executing...
n=1 (thread 1)
n_=100 (thread 1)
n=2 (thread 1)
n_=100 (thread 1)
t executing...DONE
n=100 (main, after t.join())
n=200 (main)
t2 executing...
n=1 (thread 2)
n_=200 (thread 2)
n=2 (thread 2)
n_=200 (thread 2)
t2 executing...DONE
n=200 (main, after t2.join())
person Philipp Claßen    schedule 18.03.2019

static thread_local и thread_local в области блока эквивалентны; thread_local имеет продолжительность хранения потока, не статичную и не автоматическую; поэтому статические и автоматические спецификаторы, т. е. thread_local, то есть auto thread_local, и static thread_local не влияют на продолжительность хранения; семантически их использование бессмысленно, и они просто неявно означают продолжительность хранения потока из-за наличия thread_local; static даже не изменяет привязку в области блока (потому что это всегда не привязка), поэтому у него нет другого определения, кроме изменения продолжительности хранения. extern thread_local также возможно в области блока. static thread_local в области файла дает внутреннюю связь переменной thread_local, что означает, что будет одна копия на единицу перевода в TLS (каждая единица перевода будет разрешаться в свою собственную переменную в индексе TLS для .exe, потому что ассемблер вставит переменную в раздел rdata$t файла .o и пометить его в таблице символов как локальный символ из-за отсутствия директивы .global для символа). extern thread_local в области файла допустимо, как и в области блока, и использует копию thread_local, определенную в другой единице перевода. thread_local в области файла не является неявно статическим, поскольку может предоставить определение глобального символа для другой единицы перевода, чего нельзя сделать с помощью переменной области блока.

Компилятор сохранит все инициализированные переменные thread_local в .tdata (включая переменные блочной области) для формата ELF и неинициализированные в .tbss для формата ELF, или все в .tls для формата PE. Я предполагаю, что библиотека потоков при создании потока будет обращаться к сегменту .tls и выполнять вызовы Windows API (TlsAlloc и TlsSetValue), которые выделяют переменные для каждого .exe и .dll в куче и помещают указатели в массив TLS потока. TEB в сегменте GS и возвращает выделенный индекс, а также вызывает подпрограммы DLL_THREAD_ATTACH для динамических библиотек. Предположительно, указатель на значение в пространстве, определяемом _tls_start и _tls_end, передается в TlsSetValue в качестве указателя значения.

Разница между областью файла static/extern thread_local и областью блока (extern) thread_local такая же, как и общая разница между областью файла static/extern и областью блока static/extern, в том, что переменная области блока thread_local выйдет из области действия в конце функции, в которой она определена, хотя она может по-прежнему будет возвращен и доступен по адресу из-за продолжительности хранения потока.

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

MSVC

thread_local int a = 5;

int square(int num) {
thread_local int i = 5;
    return a * i;
}
_TLS    SEGMENT
int a DD        05H                           ; a
_TLS    ENDS
_TLS    SEGMENT
int `int square(int)'::`2'::i DD 05H                        ; `square'::`2'::i
_TLS    ENDS

num$ = 8
int square(int) PROC                                    ; square
        mov     DWORD PTR [rsp+8], ecx
        mov     eax, OFFSET FLAT:int a      ; a
        mov     eax, eax
        mov     ecx, DWORD PTR _tls_index
        mov     rdx, QWORD PTR gs:88
        mov     rcx, QWORD PTR [rdx+rcx*8]
        mov     edx, OFFSET FLAT:int `int square(int)'::`2'::i
        mov     edx, edx
        mov     r8d, DWORD PTR _tls_index
        mov     r9, QWORD PTR gs:88
        mov     r8, QWORD PTR [r9+r8*8]
        mov     eax, DWORD PTR [rcx+rax]
        imul    eax, DWORD PTR [r8+rdx]
        ret     0
int square(int) ENDP                                    ; square

Это загружает 64-битный указатель из gs:88 (gs:[0x58], который является линейным адресом локального массива хранения потока), затем загружает 64-битный указатель, используя TLS array pointer + _tls_index*8 (очевидно, это поиск индекса в массиве * размер указателя). Затем из этого указателя + смещения загружается Int a; в сегмент .tls. Поскольку обе переменные используют один и тот же _tls_index, предполагается, что существует индекс для каждого файла .exe, т. е. для каждого раздела .tls, действительно существует один индекс _tls_index для каждого каталога TLS в .rdata, и переменные упакованы вместе по адресу, на который указывает TLS-массив. static thread_local переменные в разных единицах перевода будут объединены в .tls, и все они будут упакованы вместе по одному индексу.

Я считаю, что mainCRTStartup, который компоновщик всегда включает в окончательный исполняемый файл и делает его точкой входа, если он компонуется как консольное приложение, ссылается на переменную _tls_used (поскольку каждому .exe нужен свой собственный индекс), и это было прагматично чтобы перейти во фрагмент T .rdata в любом объектном файле в libcmt.lib, который определяет его (и поскольку mainCRTStartup ссылается на него, компоновщик включит его в окончательный исполняемый файл). Если компоновщик найдет ссылку на переменную _tls_used, он обязательно включит ее и убедится, что каталог TLS заголовка PE указывает на нее.

#pragma section(".rdata$T", long, read)    //creates a read only section called `.rdata` if not created and a fragment T in the section
#define _CRTALLOC(x) __declspec(allocate(x))
#pragma data_seg()   //set the compilers current default data section to `.data`

_CRTALLOC(".rdata$T")  //place in the section .rdata, fragment T
const IMAGE_TLS_DIRECTORY _tls_used =
{
 (ULONG)(ULONG_PTR) &_tls_start, // start of tls data in the tls section
 (ULONG)(ULONG_PTR) &_tls_end,   // end of tls data
 (ULONG)(ULONG_PTR) &_tls_index, // address of tls_index
 (ULONG)(ULONG_PTR) (&__xl_a+1), // pointer to callbacks
 (ULONG) 0,                      // size of tls zero fill
 (ULONG) 0                       // characteristics
};

http://www.nynaeve.net/?p=183

_tls_used — это переменная структуры типа IMAGE_TLS_DIRECTORY с вышеуказанным инициализированным содержимым, и она фактически определена в tlssup.c. До этого он определяет _tls_index, _tls_start и _tls_end, помещая _tls_start в начало раздела .tls и _tls_end в конец раздела .tls, помещая его в раздел фрагментZZZ таким образом, чтобы он в алфавитном порядке заканчивался в конце раздела:

#pragma data_seg(".tls") //set the compilers current default data section to `.tls`

#if defined (_M_IA64) || defined (_M_AMD64)
_CRTALLOC(".tls")   //place the following in the section named `.tls`
#endif
char _tls_start = 0;   //if not defined, place in the current default data section, which is also `.tls`

#pragma data_seg(".tls$ZZZ")

#if defined (_M_IA64) || defined (_M_AMD64)
_CRTALLOC(".tls$ZZZ")
#endif
char _tls_end = 0;

Их адреса затем используются в качестве маркеров в каталоге _tls_used TLS. Адрес будет разрешен компоновщиком только тогда, когда раздел .tls завершен и имеет фиксированное относительное местоположение lea.

GCC (TLS находится непосредственно перед базой FS; необработанные данные, а не указатели)

 mov    edx,DWORD PTR fs:0xfffffffffffffff8 //access thread_local int1 inside function
 mov    eax,DWORD PTR fs:0xfffffffffffffffc //access thread_local int2 inside function

Если сделать одну, обе или ни одну из переменных локальными, получится идентичный код.

Когда выполнение потока завершится, библиотека потоков в Windows освободит память с помощью вызовов TlsFree() (она также должна освободить память в куче, на которую указывает указатель, возвращенный TlsGetValue()).

person Lewis Kelsey    schedule 26.03.2020

Отложив в сторону замечательные примеры, уже приведенные Крузом Жаном (не думаю, что я мог бы добавить к ним), учтите также следующее: нет причин запрещать это. Я не думаю, что вы сомневаетесь в полезности thread_local или задаетесь вопросом, почему он вообще должен быть в языке. Переменная области блока thread_local имеет четко определенное значение просто в результате того, как классы хранения и области работают в C++. Тот факт, что невозможно придумать что-то «интересное» для каждой возможной комбинации языковых возможностей, не означает, что все комбинации языковых возможностей, которые не имеют хотя бы одного известного «интересного» приложения, должны быть явно запрещены. По этой логике мы также должны пойти дальше и запретить классам без приватных членов иметь друзей и тому подобное. По крайней мере, мне кажется, что С++, в частности, следует философии «если нет конкретной технической причины, по которой функция X не может работать в ситуации Y, то нет причин запрещать ее», что я считаю вполне здоровым подходом. Запрет без уважительной причины означает добавление сложности без уважительной причины. И я думаю, что все согласятся с тем, что в C++ уже достаточно сложностей. Это также предотвращает счастливые случайности, например, когда спустя много лет внезапно обнаруживается, что определенная языковая функция имеет ранее немыслимые приложения. Наиболее ярким примером такого случая, вероятно, будут шаблоны, которые (по крайней мере, насколько мне известно) изначально не были задуманы с целью метапрограммирования; только потом выяснилось, что их можно было использовать и для этого…

person Michael Kenzel    schedule 15.03.2019
comment
Это интересный момент, +1. Я не думал об этом совсем таким образом. Действительно, зачем запрещать? Тут уже достаточно сложностей, как вы говорите. - person thb; 16.03.2019