Threadsafe lazy initialization: static vs std:: call_once vs double checked lock

Для потокобезопасной ленивой инициализации следует предпочесть статическую переменную внутри функции, std:: call_once или явную двойную проверку блокировки? Существуют ли какие-либо значимые различия?

В этом вопросе можно увидеть все три.

Double-Checked Lock Singleton в С++ 11

В Google появляются две версии двойной проверки блокировки в С++ 11.

Энтони Уильямс показывает и двойную блокировку с явным упорядочением памяти и std:: call_once. Он не упоминает статические, но эта статья могла быть написана до того, как были доступны компиляторы С++ 11.

Jeff Preshing, в обширной writeup, описывает несколько вариантов двойной блокировки. Он упоминает использование статической переменной в качестве опции, и он даже показывает, что компиляторы будут генерировать код для двойной проверки блокировки для инициализации статической переменной. Мне это не ясно, если он сделает вывод, что один путь лучше другого.

Мне кажется, что обе статьи должны быть педагогическими и что нет оснований для этого. Компилятор сделает это за вас, если вы используете статическую переменную или std:: call_once.

Ответ 1

GCC использует специфические для платформы трюки, чтобы избежать атомных операций целиком по быстрому пути, используя тот факт, что он может выполнять анализ static лучше, чем call_once или двойную проверку.

Поскольку двойная проверка использует атомику как способ избежать расы, она должна каждый раз платить цену за покупку. Это не высокая цена, но это цена.

Это нужно заплатить, потому что атомы должны оставаться атомарными во всех случаях, даже в сложных операциях, таких как обмен обмена. Это очень сложно оптимизировать. Вообще говоря, компилятор должен оставить его, на всякий случай, если вы используете переменную больше, чем просто двойную блокировку. У него нет простого способа доказать, что вы никогда не используете одну из более сложных операций на вашем атоме.

С другой стороны, static является узкоспециализированным и частью языка. Он был разработан с самого начала, чтобы его было легко инициализировать. Соответственно, компилятор может использовать ярлыки, недоступные для более общей версии. Компилятор действительно испускает следующий код для статического:

простая функция:

void foo() {
    static X x;
}

переписывается внутри GCC:

void foo() {
    static X x;
    static guard x_is_initialized;
    if ( __cxa_guard_acquire(x_is_initialized) ) {
        X::X();
        x_is_initialized = true;
        __cxa_guard_release(x_is_initialized);
    }
}

Что очень похоже на блокировку с двойной проверкой. Однако компилятор немного обманывает здесь. Он знает, что пользователь никогда не может писать напрямую с помощью cxa_guard. Он знает, что он используется только в особых случаях, когда компилятор решает использовать его. Таким образом, с этой дополнительной информацией он может сэкономить некоторое время. Спецификации защиты CXA, как распределенные, как они есть, имеют общее правило : __cxa_guard_acquire никогда не изменят первый байт охранника, а __cxa_guard__release будет установлен это к ненулевому.

Это означает, что каждый охранник должен быть монотонным, и он точно определяет, какие операции будут делать это. Соответственно, он может воспользоваться преимуществами существующих защитных чехлов в хост-платформе. Например, на x86 защита LL/SS, гарантированная сильно синхронизированными CPU, оказывается достаточной для создания этого шаблона получения/выпуска, поэтому он может выполнять чтение raw этого первого байта, когда он делает его двойную блокировку, а не считывание. Это возможно только потому, что GCC не использует атомарный API С++ для двойной блокировки - он использует платформенный подход.

GCC не может оптимизировать атом в общем случае. На архитектурах, которые сконструированы так, чтобы быть менее синхронизированными (например, рассчитанными на 1024 ядра), GCC не может полагаться на архетект, чтобы сделать LL/SS для него. Таким образом, GCC вынужден фактически испускать атом. Однако на обычных платформах, таких как x86 и x64, это может быть быстрее.

call_once может иметь эффективность статики GCC, поскольку она аналогичным образом ограничивает количество операций, которые можно выполнить с помощью once_flag, до доли функций, которые могут быть применены к атому. Компромисс заключается в том, что статика гораздо удобнее использовать, когда они применимы, но call_once работает во многих случаях, когда статики недостаточны (например, once_flag, принадлежащих динамически сгенерированному объекту).

Существует небольшая разница в производительности между static и call_once на этих более высоких платформах. Многие из этих платформ, не предлагая LL/SS, будут, по крайней мере, предлагать чтение без слежения целого числа. Эти платформы могут использовать это и указатель на конкретный поток, чтобы подсчитать количество потоков для предотвращения атомистики. Этого достаточно для статического или call_once, но зависит от того, что счетчик не перевернулся. Если у вас нет безупречного 64-битного целого числа, call_once должен беспокоиться о опрокидывании. Реализация может или не стоит беспокоиться об этом. Если он игнорирует эту проблему, она может быть такой же быстрой, как статика. Если он обращает внимание на этот вопрос, он должен быть таким же медленным, как атомистика. Static знает во время компиляции, сколько статических переменных/блоков существует, поэтому он может доказать, что во время компиляции нет опрокидывания (или, по крайней мере, уверенно!)