Что не так с этим исправлением для двойной проверки блокировки? - PullRequest
5 голосов
/ 03 июня 2009

Итак, я видел много статей, в которых утверждается, что в C ++ блокировка с двойной проверкой, обычно используемая для предотвращения попытки нескольких потоков инициализировать лениво созданный синглтон, не работает. Обычный код блокировки с двойной проверкой выглядит следующим образом:

class singleton {
private:
    singleton(); // private constructor so users must call instance()
    static boost::mutex _init_mutex;

public:
    static singleton & instance()
    {
        static singleton* instance;

        if(!instance)
        {
            boost::mutex::scoped_lock lock(_init_mutex);

            if(!instance)           
                instance = new singleton;
        }

        return *instance;
    }
};

Проблема, очевидно, заключается в экземпляре назначения строки - компилятор может свободно выделить объект и затем назначить ему указатель, ИЛИ установить указатель на то, где он будет размещен, а затем выделить его. В последнем случае нарушается идиома - один поток может выделить память и назначить указатель, но не запускать конструктор синглтона до его перевода в спящий режим - тогда второй поток увидит, что экземпляр не равен нулю, и попытается вернуть его хотя он еще не построен.

Я увидел предложение использовать локальный логический поток и проверить это вместо instance. Примерно так:

class singleton {
private:
    singleton(); // private constructor so users must call instance()
    static boost::mutex _init_mutex;
    static boost::thread_specific_ptr<int> _sync_check;

public:
    static singleton & instance()
    {
        static singleton* instance;

        if(!_sync_check.get())
        {
            boost::mutex::scoped_lock lock(_init_mutex);

            if(!instance)           
                instance = new singleton;

            // Any non-null value would work, we're really just using it as a
            // thread specific bool.
            _sync_check = reinterpret_cast<int*>(1);
        }

        return *instance;
    }
};

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

class singleton {
private:
    singleton(); // private constructor so users must call instance()
    static boost::mutex _init_mutex;

public:
    static singleton & instance()
    {
        static bool sync_check = false;
        static singleton* instance;

        if(!sync_check)
        {
            boost::mutex::scoped_lock lock(_init_mutex);

            if(!instance)           
                instance = new singleton;

            sync_check = true;
        }

        return *instance;
    }
};

Почему бы это не сработало? Даже если sync_check должен был быть прочитан одним потоком, когда он назначается в другом, значение мусора все равно будет отличным от нуля и, следовательно, верным. В этой статье доктора Добба утверждается, что вам нужно блокировать, потому что вы никогда не выиграете битву с компилятором за инструкции по переупорядочению. Что заставляет меня думать, что это не должно работать по какой-то причине, но я не могу понять, почему. Если требования к точкам последовательности столь же потеряны, как заставляет меня верить статья доктора Добба, я не понимаю, почему любой код после блокировки не может быть переупорядочен до блокировки. Что сделало бы многопоточность C ++ прерывистым периодом.

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

Так это будет работать или нет? Почему?

Ответы [ 3 ]

5 голосов
/ 16 июня 2009

Ваше исправление ничего не исправляет, так как запись в sync_check и экземпляр может выполняться не по порядку на процессоре. В качестве примера представьте, что первые два обращения к экземпляру происходят примерно в одно и то же время на двух разных процессорах. Первый поток получит блокировку, инициализирует указатель и установит sync_check в true, в этом порядке, но процессор может изменить порядок записи в память. Тогда на другом процессоре второй поток может проверить sync_check, увидеть, что это правда, но экземпляр еще не может быть записан в память. Подробнее см. Особенности программирования без блокировки для Xbox 360 и Microsoft Windows .

Тогда должно работать упомянутое вами решение для sync_check для конкретного потока (при условии, что вы инициализируете указатель на 0).

1 голос
/ 03 июня 2009

Здесь есть отличное прочтение (хотя оно ориентировано на .net / c #): http://msdn.microsoft.com/en-us/magazine/cc163715.aspx

Что сводится к тому, что вы должны быть в состоянии сказать процессору, что он не может переупорядочить ваши операции чтения / записи для доступа к этой переменной (начиная с оригинального Pentium, процессор может переупорядочивать определенные инструкции, если считает, что логика на это не повлияет), и что он должен гарантировать, что кэш совместим (не забывайте об этом - мы разработчики притворяемся, что вся память - это всего лишь один плоский ресурс, но в действительности каждое ядро ​​ЦП имеет кеш, некоторые unhared (L1), некоторые могут иногда использоваться совместно (L2)) - ваша инициализация может записывать в основную RAM, но другое ядро ​​может иметь неинициализированное значение в кеше. Если у вас нет семантики параллелизма, процессор может не знать, что его кэш грязный.

Я не знаю сторону C ++, но в .net вы бы указали переменную как volatile для защиты доступа к ней (или вы бы использовали методы барьера чтения / записи памяти в System.Threading).

Кроме того, я читал, что в .net 2.0 блокировка с двойной проверкой гарантированно будет работать без «изменчивых» переменных (для любых читателей .net) - это не поможет вам с вашим кодом на C ++ .

Если вы хотите быть в безопасности, вам нужно будет сделать эквивалент в c ++, помечая переменную как volatile в c #.

0 голосов
/ 03 июня 2009

"Последний случай нарушает идиому - два потока могут в конечном итоге создать синглтон."

Но если я правильно понимаю код, то в первом примере вы проверяете, существует ли экземпляр (может быть выполнен несколькими потоками одновременно), если один поток не получает его для блокировки, и он создает экземпляр - только один поток может выполнить создание в это время. Все остальные темы заблокированы и будут ждать.

Как только экземпляр создан и мьютекс разблокирован, следующий ожидающий поток заблокирует мьютекс, но не будет пытаться создать новый экземпляр, потому что проверка не удастся.

При следующей проверке переменной экземпляра она будет установлена, поэтому никакие потоки не будут пытаться создать новый экземпляр.

Я не уверен в случае, когда один поток назначает новый указатель экземпляра экземпляру, в то время как другой поток проверяет ту же самую переменную - но я полагаю, что в этом случае он будет обработан правильно.

Я что-то здесь упускаю?

Хорошо, не уверен насчет переупорядочения операций, но в этом случае это изменит логику, поэтому я не ожидаю, что это произойдет, - но я не эксперт в этой теме.

...