Поточная безопасная ленивая инициализация: статический vs std::call_once vs двойная проверка блокировки

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

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

Дважды проверил Lock Singleton в C++11

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

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

Джефф Прешинг в обширной рецензии описывает несколько вариантов блокировки с двойной проверкой. Он упоминает об использовании статической переменной в качестве опции и даже показывает, что компиляторы будут генерировать код для двойной проверки блокировки для инициализации статической переменной. Мне не ясно, если он заключит, что один путь лучше, чем другой.

Я понимаю, что обе статьи должны быть педагогическими, и что нет причин делать это. Компилятор сделает это за вас, если вы используете статическую переменную или 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, гарантированная сильно синхронизированными процессорами, оказывается достаточной для выполнения этого шаблона получения / освобождения, поэтому он может выполнить необработанное чтение этого первого байта, когда выполняет двойную блокировку, а не приобретать-читать. Это возможно только потому, что GCC не использует атомарный API C++ для двойной блокировки - он использует подход, специфичный для платформы.

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

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

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

Другие вопросы по тегам