Проблема в том, что при отсутствии гарантий в противном случае сохранение указателя в pInstance_
может быть замечено некоторым другим потоком до завершения построения объекта.В этом случае другой поток не войдет в мьютекс и сразу же вернет pInstance_
, а когда вызывающий использует его, он может увидеть неинициализированные значения.
Это очевидное переупорядочение между хранилищами, ассоциированнымис конструкцией на Singleton
и сохранением на pInstance_
может быть вызвано компилятором или оборудованием.Ниже я кратко рассмотрю оба случая.
Переупорядочение компилятора
Отсутствуют какие-либо конкретные гарантии, связанные с одновременным чтением (например, предоставляемые объектами C ++ 11 std::atomic
)компилятору нужно только сохранить семантику кода, видимую текущим потоком .Это означает, например, что он может компилировать код «не по порядку» так, как он выглядит в исходном коде, при условии, что он не имеет видимых побочных эффектов (как определено стандартом) в текущем потоке.
В частности, для компилятора было бы весьма необычно переупорядочить хранилища, выполненные в конструкторе, для Singleton
, с хранилищем в pInstance_
, при условии, что он может видеть, что эффект тот же 1 .
Давайте рассмотрим конкретную версию вашего примера:
struct Lock {};
struct Guard {
Guard(Lock& l);
};
int value;
struct Singleton {
int x;
Singleton() : x{value} {}
static Lock lock_;
static Singleton* pInstance_;
static Singleton& Instance();
};
Singleton& Singleton::Instance()
{
if(!pInstance_)
{
Guard myGuard(lock_);
if (!pInstance_)
{
pInstance_ = new Singleton;
}
}
return *pInstance_;
}
Здесь конструктор для Singleton
очень прост: он просто читает изglobal value
и присваивает его x
, единственному члену Singleton
.
Используя godbolt, мы можем точно проверить, как gcc и clang компилируют .Версия gcc, аннотированная, показана ниже:
Singleton::Instance():
mov rax, QWORD PTR Singleton::pInstance_[rip]
test rax, rax
jz .L9 ; if pInstance != NULL, go to L9
ret
.L9:
sub rsp, 24
mov esi, OFFSET FLAT:_ZN9Singleton5lock_E
lea rdi, [rsp+15]
call Guard::Guard(Lock&) ; acquire the mutex
mov rax, QWORD PTR Singleton::pInstance_[rip]
test rax, rax
jz .L10 ; second check for null, if still null goto L10
.L1:
add rsp, 24
ret
.L10:
mov edi, 4
call operator new(unsigned long) ; allocate memory (pointer in rax)
mov edx, DWORD value[rip] ; load value global
mov QWORD pInstance_[rip], rax ; store pInstance pointer!!
mov DWORD [rax], edx ; store value into pInstance_->x
jmp .L1
Последние несколько строк являются критическими, в частности два хранилища:
mov QWORD pInstance_[rip], rax ; store pInstance pointer!!
mov DWORD [rax], edx ; store value into pInstance_->x
По сути, строка pInstance_ = new Singleton;
была преобразованав:
Singleton* stemp = operator new(sizeof(Singleton)); // (1) allocate uninitalized memory for a Singleton object on the heap
int vtemp = value; // (2) read global variable value
pInstance_ = stemp; // (3) write the pointer, still uninitalized, into the global pInstance (oops!)
pInstance_->x = vtemp; // (4) initialize the Singleton by writing x
Упс!Любой второй поток, прибывающий, когда (3) произошел, но (4) нет, увидит ненулевое pInstance_
, но затем прочитает неинициализированное (мусорное) значение для pInstance->x
.
Такдаже без какого-либо странного переупорядочения оборудования этот шаблон небезопасен без дополнительной работы.
Переупорядочение оборудования
Допустим, вы организовали так, чтобы переупорядочение магазинов выше непроисходит на вашем компиляторе 2 , возможно, если поставить барьер компилятора , такой как asm volatile ("" ::: "memory")
.С этим небольшим изменением , gcc теперь компилирует это, чтобы иметь два критических хранилища в «желаемом» порядке:
mov DWORD PTR [rax], edx
mov QWORD PTR Singleton::pInstance_[rip], rax
Так что у нас все хорошо, верно?
Хорошо на x86, мы.Бывает, что у x86 относительно сильная модель памяти, и во всех магазинах уже есть семантика выпуска .Я не буду описывать полную семантику, но в контексте двух хранилищ, как указано выше, это означает, что хранилища отображаются в программном порядке для других процессоров: так что любой процессор, который видит вторую запись выше (до * 1069)*) обязательно увидит предыдущую запись (pInstance_->x
).
Мы можем проиллюстрировать это, используя функцию C ++ 11 std::atomic
, чтобы явно запросить хранилище релизов для pInstance_
(это такжепозволяет нам избавиться от барьера компилятора):
static std::atomic<Singleton*> pInstance_;
...
if (!pInstance_)
{
pInstance_.store(new Singleton, std::memory_order_release);
}
Мы получаем разумную сборку без каких-либо аппаратных барьеров памяти или чего-либо еще (сейчас есть избыточная нагрузка, но этопропущенная оптимизация с помощью gcc и следствие того, как мы написали функцию).
Итак, мы закончили, верно?
Нет - на большинстве других платформ нет сильного хранилища.хранить порядок, который делает x86.
Давайте посмотрим на сборку ARM64 вокруг создания нового объекта:
bl operator new(unsigned long)
mov x1, x0 ; x1 holds Singleton* temp
adrp x0, .LANCHOR0
ldr w0, [x0, #:lo12:.LANCHOR0] ; load value
str w0, [x1] ; temp->x = value
mov x0, x1
str x1, [x19, #pInstance_] ; pInstance_ = temp
Таким образом, мы имеем str
до pInstance_
в качестве последнегоМагазин, идущий после магазина temp->x = value
, как мы хотим.Однако модель памяти ARM64 не гарантирует , что эти хранилища появляются в программном порядке при наблюдении другим процессором.Таким образом, даже если мы приручили компилятор, аппаратное обеспечение может сбить нас с толку.Вам понадобится барьер, чтобы решить это.
До C ++ 11 не было портативного решения этой проблемы.Для конкретного ISA вы можете использовать встроенную сборку, чтобы создать правильный барьер.Ваш компилятор может иметь встроенную функцию, такую как __sync_synchronize
, предлагаемую gcc
, или ваша ОС может даже иметь что-то .
В C ++ 11 и более поздних версиях, однако, мы наконец имеемФормальная модель памяти, встроенная в язык, и то, что нам нужно для блокировки двойной проверки, - это хранилище release , в качестве окончательного хранилища pInstance_
.Мы видели это уже для x86, где мы проверили, что барьер компилятора не был создан, используя std::atomic
с memory_order_release
, код публикации объекта становится :
bl operator new(unsigned long)
adrp x1, .LANCHOR0
ldr w1, [x1, #:lo12:.LANCHOR0]
str w1, [x0]
stlr x0, [x20]
Основное отличие состоит в том, чтопоследний магазин теперь stlr
- релиз магазина .Вы также можете проверить сторону PowerPC, где между двумя магазинами появился барьер lwsync
.
Итак, суть в том, что:
- Блокировка с двойной проверкой безопасна в последовательной последовательной системе .
- Практические системы почтивсегда отклоняться от последовательной согласованности, либо из-за аппаратного обеспечения, компилятора, либо из-за того и другого.
- Чтобы решить эту проблему, вам нужно указать компилятору, что вы хотите, и он будет избегать переупорядочения и выдавать необходимые барьерные инструкции,если таковые имеются, чтобы предотвратить возникновение проблем с оборудованием.
- До C ++ 11 "способ, которым вы говорите компилятору", чтобы это делать, зависел от платформы / компилятора / ОС, но в C ++ вы можете простоиспользуйте
std::atomic
с memory_order_acquire
загрузками и memory_order_release
хранилищами.
Загрузка
Вышеприведенная только покрытая половина проблемы: store ofpInstance_
.Другая половина, которая может работать неправильно, - это нагрузка, и нагрузка на самом деле наиболее важна для производительности, поскольку она представляет собой обычный быстрый путь, который берется после инициализации синглтона.Что, если pInstance_->x
был загружен до того, как сам pInstance
был загружен и проверен на ноль?В этом случае вы все равно можете прочитать неинициализированное значение!
Это может показаться маловероятным, поскольку pInstance_
необходимо загрузить до того, как будет защищено, верно?То есть, по-видимому, существует фундаментальная зависимость между операциями, которая предотвращает переупорядочение, в отличие от случая магазина.Ну, как выясняется, и аппаратное поведение, и программная трансформация могут все еще сбить вас с толку, а детали еще сложнее, чем в магазине.Если вы используете memory_order_acquire
, все будет в порядке.Если вам нужна последняя производительность, особенно на PowerPC, вам нужно изучить детали memory_order_consume
.Рассказ о другом дне.
1 В частности, это означает, что компилятор должен видеть код для конструктора Singleton()
, чтобы он мог определить, чтоон не читает из pInstance_
.
2 Конечно, на это очень опасно полагаться, так как вам придется проверять сборку после каждой компиляции, если что-то изменилось!