
Приветствую!
Код тут
В этой части мы рассмотрим что такое guard variables и как под капотом работают синглтоны Майерса.
И так, стандарт начиная с С++11 гарантирует нам что локальные статические переменные инициализируются потокобезопасно. Но кто и как это обеспечивает? ABI.
Представьте себе вот такой код:
struct Singleton { int a = 0; explicit Singleton(int val) : a(val) { a++; } static Singleton *instance() { static Singleton x(13); return &x; } }; // ... pthread_t t1; pthread_create(&t1, nullptr, make_singleton, nullptr); pthread_t t2; pthread_create(&t2, nullptr, make_singleton, nullptr); pthread_join(t1, nullptr); pthread_join(t2, nullptr);
Потока два, а объект один и конструктор должен быть вызван только однажды. Itanium требует реализации следующих функций для обеспечения этого:
extern "C" int __cxa_guard_acquire ( __int64_t *guard_object );
Вызывается до инициализации объекта, т.е. в нашем случае до вызова конструктора возвращает 0 если объект уже создан и 1 если нет.
extern "C" void __cxa_guard_release(__int64_t *guard_object);
Устанавливает первый байт объекта guard_object в ненулевое значение. Эта функция вызывается после успешной инициализации объекта. После установки первого байта компилятор считает что объект успешно инициализирован. Наша блокировка, захваченная функцией __cxa_guard_acquire освобождается.
extern "C" void __cxa_guard_abort(__int64_t *guard_object);
Вызывается в случае если конструктор бросил исключение. Соответственно нужно освободить захваченный лок.
Для начала нужно рассмотреть что из себя представляет сам guard_object. Я сразу скажу что нет смысла говорить о порядке следования байтов потому что Itanium рассуждает в терминах индексов:
The size of the guard variable is 64 bits. The first byte (i.e. the byte at the address of the full variable) shall contain the value 0 prior to initialization of the associated variable, and 1 after initialization is complete. Usage of the other bytes of the guard variable is implementation-defined
Что означает следующее:
Нулевой байт должен быть равен 0 если охраняемый объект еще не инициализирован и 1 в противном случае. С остальными байтами мы можем делать все что захотим.
Itanium говорит что компилятор делает примерно следующее, цитата:
The following is pseudo-code showing how these functions can be used: if (obj_guard.first_byte == 0) { if ( __cxa_guard_acquire (&obj_guard) ) { try { ... initialize the object ...; } catch (...) { __cxa_guard_abort (&obj_guard); throw; } ... queue object destructor with __cxa_atexit() ...; __cxa_guard_release (&obj_guard); } }
Для нашего примера это превращается в следующий псевдо код:
if (guard.first_byte == 0) { if (__cxa_guard_acquire(&guard)) { // Только здесь вызывается конструктор. в успешном сценарии // и только один раз new (&x) Singleton(13); __cxa_guard_release(&guard); } }
Или графически:

Если все хорошо, то объект пытается создаться только однажды, исключения не поддерживаются с моем ABI, но тем не менее рассмотрим сценарий исключения поддерживаются и конструктор может бросить исключение. Тогда код становится немного сложнее:
if (__cxa_guard_acquire(&guard)) { try { new (&x) Singleton(13); __cxa_guard_release(&guard); } catch (...) { __cxa_guard_abort(&guard); throw; } }
Вот графика:

после этого следующий вызов нашей функции make_singleton попытается снова создать объект и далее по сценарию выше.
И так, получается что нулевой байт используется компилятором, значит мы можем хранить свою блокировку в любом байте кроме него.
Работать будем со следующим куском кода:
struct Singleton { int a = 0; explicit Singleton(int val) : a(val) { a++; printf("%s\n", __PRETTY_FUNCTION__); } ~Singleton() { printf("%s\n", __PRETTY_FUNCTION__); } static Singleton *instance() { printf("before static thread [%lu]\n", pthread_self()); static Singleton x(13); printf("after static, x.a = %d thread [%lu]\n", x.a, pthread_self()); return &x; } }; void *make_singleton(void *) { auto a = Singleton::instance(); printf("%s a = %d\n", __PRETTY_FUNCTION__, a->a); return nullptr; } // ... pthread_t t1; pthread_create(&t1, nullptr, make_singleton, nullptr); printf("pthread_create t1 = [%lu]\n", t1); pthread_t t2; pthread_create(&t2, nullptr, make_singleton, nullptr); printf("pthread_create t2 = [%lu]\n", t2); pthread_join(t1, nullptr); pthread_join(t2, nullptr); make_singleton(nullptr); // ...
Компилятор генерирует что то вроде
static _Guard __guard; static Singleton __object;
И далее инициализация объекта находится под охраной Guard. Я представил 8 байт guard в виде структуры Guard:
struct _Guard { uint8_t _M_init; // 1 если объект инициализирован и 0 иначе uint8_t _M_lock; // Это лок для синхронизации потоков uint8_t _M_unused[6]; // Функция захвата лока. Тут нет цикла потому что мы захватываем право на // инициализацию объекта. Это нужно сделать только однажды. Любым потоком. // не имеет значения никаким именно, так что если лок захвачен, значит // какой-то поток // уже инициализирует объект и нет смысла ждать. Мы опоздали bool _M_try_lock() { __STD_NAMESPACE::atomic_ref<uint8_t> __in_use(_M_lock); uint8_t __expected = __lock_free; return __in_use.compare_exchange_strong(__expected, __lock_busy, __STD_NAMESPACE::memory_order_acquire, __STD_NAMESPACE::memory_order_relaxed); } // проверяем выполнена ли инициализаия объекта. Если так, то нулевой байт равен 1 bool _M_is_done() { __STD_NAMESPACE::atomic_ref<uint8_t> __init(_M_init); return __init.load(__STD_NAMESPACE::memory_order_acquire) == __obj_initialized; } // освобождаем наш лок void _M_release() { __STD_NAMESPACE::atomic_ref<uint8_t> __in_use(_M_lock); __in_use.store(__lock_free, __STD_NAMESPACE::memory_order_release); } // сообщаем компилятору что объект успешно инициализирован void _M_set_initialized(uint8_t state) { __STD_NAMESPACE::atomic_ref<uint8_t> __init(_M_init); __init.store(state, __STD_NAMESPACE::memory_order_release); } };
Вот рабочий алгоритм:
int __cxa_guard_acquire(_Guard *__g) { printf("%s: object constructed %d guard state %d thread [%lu]\n", __func__, __g->_M_init, __g->_M_lock, pthread_self()); for (;;) { // Если объект инициализирован, возвращаем 0 if (__g->_M_is_done()) { return __cxa_guard_done; } // иначе пробуем захватить лок if (__g->_M_try_lock()) { // проверяем, возможно уже не актуально if (__g->_M_is_done()) { __g->_M_release(); return __cxa_guard_done; } // иначе возвращаем 1 сообщая компилятору что давай, вызывай конструктор return __cxa_guard_creating; } cpu_relax(); } } // Сообщаем что объект инициализирован и отпускаем лок void __cxa_guard_release(_Guard *__g) { printf("%s: object constructed %d guard state %d thread [%lu]\n", __func__, __g->_M_init, __g->_M_lock, pthread_self()); __g->_M_set_initialized(__obj_initialized); __g->_M_release(); } // конструктор бросил исключение, сообщаем что объект не был инициализирован // и освобождаем лок для следующих попыток void __cxa_guard_abort(_Guard *__g) { printf("%s: object constructed %d guard state %d thread [%lu]\n", __func__, __g->_M_init, __g->_M_lock, pthread_self()); __g->_M_set_initialized(__obj_not_initialized); __g->_M_release(); }
Само собой в гитхаб нет никаких printf. Я добавил логи только для того, что бы мы могли посмотреть что происходит. Запускаем код и видим следующее:
__cxa_guard_acquire: object constructed 0 guard state 0 thread [138529461097728] __cxa_guard_release: object constructed 0 guard state 1 thread [138529461097728] __cxa_guard_acquire: object constructed 0 guard state 0 thread [138529461097728] __cxa_guard_acquire: object constructed 0 guard state 1 thread [138529453635264] __cxa_guard_release: object constructed 0 guard state 1 thread [138529461097728] pthread_create t1 = [138529453635264] before static thread [138529453635264] __cxa_guard_acquire: object constructed 0 guard state 0 thread [138529453635264] Singleton::Singleton(int) __cxa_guard_release: object constructed 0 guard state 1 thread [138529453635264] before static thread [138529445242560] after static, x.a = 14 thread [138529445242560] void* make_singleton(void*) a = 14 pthread_create t2 = [138529445242560] after static, x.a = 14 thread [138529453635264] void* make_singleton(void*) a = 14 before static thread [138529461097728] after static, x.a = 14 thread [138529461097728] void* make_singleton(void*) a = 14 Singleton::~Singleton()
Поток 138529461097728 захватывает лок на право инициализации, 138529453635264 опаздывает. Получается
А что если сломать алгоритм? что же будет тогда?
int __cxa_guard_acquire(_Guard *__g) { printf("%s: object constructed %d guard state %d thread [%lu]\n", __func__, __g->_M_init, __g->_M_lock, pthread_self()); /*for (;;) { if (__g->_M_is_done()) { return __cxa_guard_done; } if (__g->_M_try_lock()) { if (__g->_M_is_done()) { __g->_M_release(); return __cxa_guard_done; } return __cxa_guard_creating; } cpu_relax(); }*/ return 0; }
Какой будет вывод в этом случае? Конструктор не будет вызван никогда! Компилятор считает что объект уже создан потому что __cxa_guard_acquire вернула 0.
__cxa_guard_acquire: object constructed 0 guard state 0 thread [132796181903040] after static, x.a = 0 thread [132796181903040] void* make_singleton(void*) a = 0 pthread_create t2 = [132796173510336] before static thread [132796173510336] __cxa_guard_acquire: object constructed 0 guard state 0 thread [132796173510336] after static, x.a = 0 thread [132796173510336] void* make_singleton(void*) a = 0 before static thread [132796188767488] __cxa_guard_acquire: object constructed 0 guard state 0 thread [132796188767488] after static, x.a = 0 thread [132796188767488] void* make_singleton(void*) a = 0
Что будет если мы сделаем наоборот?
int __cxa_guard_acquire(_Guard *__g) { printf("%s: object constructed %d guard state %d thread [%lu]\n", __func__, __g->_M_init, __g->_M_lock, pthread_self()); /*for (;;) { if (__g->_M_is_done()) { return __cxa_guard_done; } if (__g->_M_try_lock()) { if (__g->_M_is_done()) { __g->_M_release(); return __cxa_guard_done; } return __cxa_guard_creating; } cpu_relax(); }*/ return 1; }
Безусловно у нас появляется гонка данных
pthread_create t1 = [135922966329024] before static thread [135922966329024] __cxa_guard_acquire: object constructed 0 guard state 0 thread [135922966329024] Singleton::Singleton(int) __cxa_guard_release: object constructed 0 guard state 0 thread [135922966329024] after static, x.a = 14 thread [135922966329024] void* make_singleton(void*) a = 14 pthread_create t2 = [135922957936320] before static thread [135922957936320] after static, x.a = 14 thread [135922957936320] void* make_singleton(void*) a = 14 before static thread [135922974082304] after static, x.a = 14 thread [135922974082304] void* make_singleton(void*) a = 14 Singleton::~Singleton()
Получается неправильный сценарий:
Thread A входит в foo() Thread A вызывает __cxa_guard_acquire() Thread A получает 1 Thread A вызывает constructor Thread B входит в foo() Thread B вызывает __cxa_guard_acquire() Thread B тоже получает 1 Thread B тоже вызывает constructor Thread C входит в foo() Thread C тоже получает 1 Thread C тоже вызывает constructor
Такой код полностью ломает гарантию C++: локальный статический объект должен быть инициализирован ровно один раз. Правда этот код фактически не вызывает конструктор 2 раза как это видно из логов во всяком случае в GCC 16.1. Но что если мы измени код и добавим туда usleep(500000) что бы второй поток гарантированно мог туда пролезть?
struct Singleton { int a = 0; explicit Singleton(int val) : a(val) { int count = g_constructor_calls.fetch_add(1, std::memory_order_acq_rel) + 1; printf("%s constructor call #%d thread [%lu]\n", __PRETTY_FUNCTION__, count, pthread_self()); /* * Искусственно держим конструктор. * Пока первый поток строит объект, второй поток должен успеть * зайти в guard acquire для того же static Singleton x. */ usleep(500000); a++; } ~Singleton() { printf("%s\n", __PRETTY_FUNCTION__); } static Singleton *instance() { printf("before static thread [%lu]\n", pthread_self()); static Singleton x(13); printf("after static, x.a = %d thread [%lu]\n", x.a, pthread_self()); return &x; } };
Каждый поток успеет влезть и вызвать конструктор благодаря usleep(500000);. вот и вывод:
pthread_create t1 = [127135326926528] before static thread [127135326926528] __cxa_guard_acquire: object constructed 0 guard state 0 thread [127135326926528] // Вызов конструктора раз Singleton::Singleton(int) constructor call #1 thread [127135326926528] pthread_create t2 = [127135318533824] before static thread [127135318533824] __cxa_guard_acquire: object constructed 0 guard state 0 thread [127135318533824] // Вызов конструктора два Singleton::Singleton(int) constructor call #2 thread [127135318533824] __cxa_guard_release: object constructed 0 guard state 0 thread [127135326926528] after static, x.a = 14 thread [127135326926528] void* make_singleton(void*) a = 14 __cxa_guard_release: object constructed 1 guard state 0 thread [127135318533824] after static, x.a = 15 thread [127135318533824] void* make_singleton(void*) a = 15 before static thread [127135333938432] after static, x.a = 15 thread [127135333938432] void* make_singleton(void*) a = 15 Test failed: /home/dmitry/osdev/osdev-libcppabi/tests/habr_test.cpp:66: Constructor called more than once Singleton::~Singleton() Singleton::~Singleton() 1 tests, 0 passed, 1 failed
Все, привет. Синглтона Майерса стало 2.
Вывод этого кода при с правильным алгоритмом:
pthread_create threads[0] = [138113519187648] pthread_create threads[1] = [138113510794944] before static thread [138113510794944] Singleton::Singleton(int) constructor call #1 thread [138113510794944] before static thread [138113519187648] after static, x.a = 14 thread [138113519187648] void* make_singleton(void*) a = 14 thread [138113519187648] after static, x.a = 14 thread [138113510794944] void* make_singleton(void*) a = 14 thread [138113510794944] Singleton::~Singleton()
До новых встреч!
