Приветствую!

Часть 1

Первые атомики

Новая реализация на гитхаб

Код тут

В этой части мы рассмотрим что такое 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()

До новых встреч!