Приветствую!В этой части мы рассмотрим что такое guard variables и как под капотом работают синглтоны Майерса.И так, стандарт начиная с С++11 гарантирует нам что локальные статические переменные инициализируются потокобезопасно. Но кто и как это обеспечивает? Это делает ABI.И все таки о чем же идет речь? Представьте себе что есть вот такая структура: Читать далее

Приветствую!
Код тут
Itanium application binary interface
В этой части мы рассмотрим что такое 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()До новых встреч!
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | OSDEV: Itanium ABI часть 1: Array Operator new Cookies | 0 | 5 | 04-07-2026 |
| 2 | osdev-libstdc: реализация std::atomic и spin_lock | 0 | 5 | 28-06-2026 |
| 3 | OSDEV: Разработка аллокатора на С++ часть 3. Финальный аллокатор со списками свободных блоков | 0 | 5 | 09-06-2026 |
| 4 | Алиасинг памяти в C++: прошлое, настоящее, будущее | 0 | 7 | 10-06-2026 |
| 5 | OSDEV: Разработка аллокатора на С++ часть 5. osdev-libstdc | 0 | 5 | 28-06-2026 |
| 6 | OSDEV: Разработка аллокатора на С++ часть 4. mem_malloc_aligned | 0 | 5 | 13-06-2026 |
| 7 | [Перевод] Подробно об ABI для работы с C++ | 0 | 7 | 22-06-2026 |
| 8 | С/С++ в современном машинном обучении: традиционные роли и возможности нового стандарта | 5 | 7 | 19-06-2026 |
| 9 | Шаблоны C++ как инструмент архитектуры: compile-time dispatch, type traits и type erasure | 5 | 7 | 28-06-2026 |
| 10 | OSDEV: vsnprintf полная реализация без поддержки чисел с плавающей точкой | 0 | 5 | 15-06-2026 |