Перепроверьте проблемы блокировки, c ++ - PullRequest
0 голосов
/ 01 июня 2018

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

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

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

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

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

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

Ответы [ 3 ]

0 голосов
/ 01 июня 2018

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

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

Начиная с C ++ 11,Правила sequenced-before запрещают переупорядочение, в частности

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

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

0 голосов
/ 10 июня 2018

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

0 голосов
/ 01 июня 2018

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

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

Даже если по какой-либо причине такой шаг 2Требовалось создание, элемент _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.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...