Я оставил остальную часть реализации для простоты, потому что она здесь не актуальна.
Рассмотрим классическую реализацию Двойная проверка блокировки описал в Современный дизайн C ++.
Singleton& Singleton::Instance()
{
if(!pInstance_)
{
Guard myGuard(lock_);
if (!pInstance_)
{
pInstance_ = new Singleton;
}
}
return *pInstance_;
}
Здесь автор настаивает на том, что мы избегаем условия гонки. Но я прочитал статью, которую, к сожалению, не очень хорошо помню, в которой был описан следующий поток.
В этой статье автор заявил, что хитрость заключается в том, что на линии pInstance_ = new Singleton;
память может быть выделена, назначена pInstance, что конструктор будет вызываться в этой памяти.
Ссылаясь на стандартные или другие надежные источники, может ли кто-нибудь подтвердить или опровергнуть вероятность или правильность этого потока? Спасибо!
Проблема, которую вы описываете, может возникнуть только в том случае, если по причинам, которые я не могу себе представить, концептуалы синглтона используют явную (и неработающую) двухэтапную конструкцию:
...
Guard myGuard(lock_);
if (!pInstance_)
{
auto alloc = std::allocator<Singleton>();
pInstance_ = alloc.allocate(); // SHAME here: race condition
// eventually other stuff
alloc.construct(_pInstance); // anything could have happened since allocation
}
....
Даже если по какой-либо причине требуется такая двухступенчатая конструкция, _pInstance
член никогда не должен содержать ничего, что nullptr
или полностью построенный экземпляр:
auto alloc = std::allocator<Singleton>();
Singleton *tmp = alloc.allocate(); // no problem here
// eventually other stuff
alloc.construct(tmp); // nor here
_pInstance = tmp; // a fully constructed instance
Но остерегаться: исправление гарантировано только на моно процессоре. Ситуация может быть намного хуже в многоядерных системах, где действительно требуется атомарная семантика C ++ 11.
Проблема в том, что при отсутствии гарантий в противном случае хранилище указателя в 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
очень просто: он просто читает из глобального 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 относительно сильная модель памяти, и все магазины уже освободить семантику. Я не буду описывать полную семантику, но в контексте двух магазинов, как указано выше, это означает, что магазины появляются в порядке программы на другие процессоры: так что любой процессор, который видит вторую запись выше (для pInstance_
) обязательно увидите предварительную запись (до 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 и более поздних версиях у нас наконец есть формальная модель памяти, встроенная в язык, и что нам нужно, для двойной проверки блокировки релиз магазин, как последний магазин 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
барьер возник между двумя магазинами.
Итак, суть в том, что:
std::atomic
с memory_order_acquire
загружает и memory_order_release
магазины.Вышеуказанное охватывает только половину проблемы: хранить из pInstance_
, Другая половина, которая может работать неправильно, — это нагрузка, и нагрузка на самом деле наиболее важна для производительности, поскольку она представляет собой обычный быстрый путь, который выполняется после инициализации синглтона. Что делать, если pInstance_->x
был загружен раньше pInstance
сам был загружен и проверен на ноль? В этом случае вы все равно можете прочитать неинициализированное значение!
Это может показаться маловероятным, так как pInstance_
должен быть загружен до это защищено, верно? То есть, по-видимому, существует фундаментальная зависимость между операциями, которая предотвращает переупорядочение, в отличие от случая магазина. Ну, как выясняется, и аппаратное поведение, и программная трансформация могут все еще сбить вас с толку, а детали еще сложнее, чем в магазине. Если вы используете memory_order_acquire
хотя, ты будешь в порядке. Если вам нужна последняя производительность, особенно на PowerPC, вам нужно разобраться в деталях memory_order_consume
, Сказка на другой день.
1 В частности, это означает, что компилятор должен иметь возможность видеть код для конструктора Singleton()
так что он может определить, что он не читает из pInstance_
,
2 Конечно, на это очень опасно полагаться, так как вам придется проверять сборку после каждой компиляции, если что-то изменилось!
Это раньше был не указано до C ++ 11, потому что не было стандартной модели памяти, обсуждающей несколько потоков.
IIRC указатель мог быть установлен на выделенный адрес до завершения конструктора, пока эта нить никогда не сможет заметить разницу (это может произойти только для тривиального / не бросающего конструктора).
Начиная с C ++ 11, секвенировали-перед тем правила запрещают это изменение порядка, особенно
8) Побочный эффект (модификация левого аргумента) встроенного оператора присваивания … определяется после вычисления значения … как левого, так и правого аргументов, …
Поскольку правильным аргументом является выражение new, должно быть выполнено распределение & Конструкция до левой стороны может быть изменена.