многопоточность — перепроверьте проблемы блокировки, переполнение стека

Я оставил остальную часть реализации для простоты, потому что она здесь не актуальна.
Рассмотрим классическую реализацию Двойная проверка блокировки описал в Современный дизайн C ++.

Singleton& Singleton::Instance()
{
if(!pInstance_)
{
Guard myGuard(lock_);
if (!pInstance_)
{
pInstance_ = new Singleton;
}
}
return *pInstance_;
}

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

  1. Поток 1 входит в первый оператор if
  2. Поток 1 входит в конец мьютекса и попадает во второй, если тело.
  3. Поток 1 вызывает оператор new и назначает память для pInstance, а затем вызывает конструктор в этой памяти;
  4. Предположим, что поток 1 назначил память pInstance, но не создал объект, и поток 2 входит в функцию.
  5. Поток 2 видит, что pInstance не является нулевым (но еще не инициализирован конструктором) и возвращает pInstance.

В этой статье автор заявил, что хитрость заключается в том, что на линии pInstance_ = new Singleton; память может быть выделена, назначена pInstance, что конструктор будет вызываться в этой памяти.

Ссылаясь на стандартные или другие надежные источники, может ли кто-нибудь подтвердить или опровергнуть вероятность или правильность этого потока? Спасибо!

3

Решение

Проблема, которую вы описываете, может возникнуть только в том случае, если по причинам, которые я не могу себе представить, концептуалы синглтона используют явную (и неработающую) двухэтапную конструкцию:

     ...
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.

4

Другие решения

Проблема в том, что при отсутствии гарантий в противном случае хранилище указателя в 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 барьер возник между двумя магазинами.

Итак, суть в том, что:

  • Двойная проверка блокировки является безопасно в последовательно согласованной системе.
  • Реальные системы почти всегда отклоняются от последовательной согласованности, как из-за аппаратного обеспечения, так и из-за компилятора.
  • Чтобы решить эту проблему, вам нужно сообщить компилятору, что вы хотите, и он будет избегать переупорядочения и выдавать необходимые барьерные инструкции, если таковые имеются, чтобы предотвратить проблемы с оборудованием.
  • До C ++ 11 «способ, которым вы говорите компилятору» делать это, зависел от платформы / компилятора / ОС, но в C ++ вы можете просто использовать std::atomic с memory_order_acquire загружает и memory_order_release магазины.

Загрузка

Вышеуказанное охватывает только половину проблемы: хранить из pInstance_, Другая половина, которая может работать неправильно, — это нагрузка, и нагрузка на самом деле наиболее важна для производительности, поскольку она представляет собой обычный быстрый путь, который выполняется после инициализации синглтона. Что делать, если pInstance_->x был загружен раньше pInstance сам был загружен и проверен на ноль? В этом случае вы все равно можете прочитать неинициализированное значение!

Это может показаться маловероятным, так как pInstance_ должен быть загружен до это защищено, верно? То есть, по-видимому, существует фундаментальная зависимость между операциями, которая предотвращает переупорядочение, в отличие от случая магазина. Ну, как выясняется, и аппаратное поведение, и программная трансформация могут все еще сбить вас с толку, а детали еще сложнее, чем в магазине. Если вы используете memory_order_acquire хотя, ты будешь в порядке. Если вам нужна последняя производительность, особенно на PowerPC, вам нужно разобраться в деталях memory_order_consume, Сказка на другой день.


1 В частности, это означает, что компилятор должен иметь возможность видеть код для конструктора Singleton() так что он может определить, что он не читает из pInstance_,

2 Конечно, на это очень опасно полагаться, так как вам придется проверять сборку после каждой компиляции, если что-то изменилось!

3

Это раньше был не указано до C ++ 11, потому что не было стандартной модели памяти, обсуждающей несколько потоков.

IIRC указатель мог быть установлен на выделенный адрес до завершения конструктора, пока эта нить никогда не сможет заметить разницу (это может произойти только для тривиального / не бросающего конструктора).

Начиная с C ++ 11, секвенировали-перед тем правила запрещают это изменение порядка, особенно

8) Побочный эффект (модификация левого аргумента) встроенного оператора присваивания … определяется после вычисления значения … как левого, так и правого аргументов, …

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

1
По вопросам рекламы [email protected]