Сравнивал разные методы сделать call-once (а точнее инициализацию синглтона) - далее небольшие заметки.
Итак, нам нужно создать какой-то объект, пусть даже что-то нетривиальное (для определённости можно представлять что мы хотим прочитать данные из файла/эмбеддет ресурсов и разложить эти данные в хеш-мапу для будущих чтений). Рассмотрим следующие варианты:
статичный глобальный объект, вычисление вызывается в конструкторе
статичный глобальный объект + std::call_once
статичный локальный объект в функции, вычисление вызывается в конструкторе
Чтобы в годболте видеть что и где вызывается - определяем тип имеющий только объявления методов (это заставит компилятор ставить явный call)
struct TSomeExternal { TSomeExternal(int); ~TSomeExternal(); int DoCalc(); char Data[60]; // просто чтобы было проще увидеть };
Особенности первого варианта:
вычисление "до main"
неконтролируемый порядок инициализации глобальных ответов => нельзя иметь зависимости между такими объектами
вычисление только в рамках 1 потока
уникальный плюс: нет проверок "на горячем цикле" - при обращении к объекту вызывающая функция может считать что данные уже готовы (но именно этот плюс стоит нам второго пункта)
В годболте можно посмотреть на func1 - видно что она скомпилилась просто в забор поинтера, и call
TSomeExternal GlobalValue(7); int func1() { int x = GlobalValue.DoCalc(); return x + 14; }
Также видно что были порождены - указание что нужно разместить объект
GlobalValue: .zero 60
А также _GLOBAL__sub_I_example.cpp - там видно, что вызывается конструктор, и регистрируется указатель на деструктор в пост-колбеках (__cxa_atexit)
Второй вариант (более правдоподобно было бы внести call_once внутрь глобального объекта, но для простоты сравнения сделал как ниже)
static std::once_flag flag; static std::unique_ptr<TSomeExternal> ptr; void init() {ptr = std::make_unique<TSomeExternal>(7);} int func2() { std::call_once(flag, init); int x = ptr->DoCalc(); return x + 14; }
настоящая инициализация случается только в момент первого использования (ленивая инициализация). Это может быть как плюс (позволяет не тратиться на инициализацию если данные не будут использоваться), так и минус (первые запросы/итерации будут медленными)
разные инициализаторы данных вполне могут работать одновременно из разных потоков
имхо - дорогой основной цикл (очень много инструкций до
TSomeExternal::DoCalc) - это всё подготовка вызоваcall pthread_once, который будет выполнен каждый раз
Третий вариант годболт
int func3() { static TSomeExternal prepared = {7}; int x = prepared.DoCalc(); return x + 14; }
в рантайме есть проверка - но это проверка и так нужного нам указателя на null
push rbx movzx eax, byte ptr [rip + guard variable for func3()::prepared] test al, al je .LBB0_1
если указатель не нулевой, то переход не будет выполнен и мы сразу переходим к
lea rdi, [rip + func3()::prepared] call TSomeExternal::DoCalc()@PLT
в случае если указатель не готов, то подготовка случается под блокировкой (
__cxa_guard_acquire)конкретно в годболте не видно, но создаются слоты для
guard variable for func3()::preparedиfunc3()::prepared(в этом смысле разницы со вторым вариантом мало)
Итого: ленивое вычисление, также возможна многопоточная инициализация, а также дешёвый success-path
Я бы назвал относительным неудобством третьего варианта, что указатель оказывается скрыт внутри функции, и надо сделать ещё доп действие чтобы использовать объект из нескольких функций. Пример такой обёртки Singleton.
