Pull to refresh

Comments 64

Вот тут ошибка:
    if (pt == 0)
    {
        StaticLock lock;
        pt = &singleUnsafe<T>();
    }
    return *pt;

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

Про реализацию потокобезопасной инициализации статических переменных в gcc можно еще прочитать вот тут:
habrahabr.ru/post/149683/
Там как раз и применяется double-checked locking pattern с поправкой на то, что он используется только на архитектурах, где он будет работать. Советую вместо просмотра ассемблерного кода залезть в gcc/gcc/cp/dec.c и посмотреть на реализацию функции expand_static_init.

На некоторых архитектурах не спасет volatile, так как запись и чтения в память будут переупорядочены самим процессором. На них надо всегжа использовать мьютекс. Про то, какие архитектуры что переупорядочивают можно почитать тут:
en.wikipedia.org/wiki/Memory_ordering
singleUnsafe единовременно будет вызван только один раз и во второй просто вернет тот же указатель, никакой ошибки нет же, вроде?
Действительно, в этом я не прав. Получается, что существует два флага, проинициализирована ли переменная: один пользовательский, а один созданный компилятором.
Верно. Именно так и написано в статье:
В каком-то смысле здесь происходит тоже 2 проверки, только первая проверка вне блокировки использует указатель, а вторая — внутреннюю переменную, которая сгенерирована компилятором.
С memory ordering прикол в том, что в данном подходе не важно, когда произошла запись в память правильного адреса. Если он по каким-то причинам отложился, то мы войдем вовнутрь условия и возьмем лок, что по-любому приведет к правильному считыванию указателя. Главное — это атомарность записи адреса в нужную ячейку, что практически всегда выполняется.
А если отложится запись самого объекта? Сначала из кэша процессора в память запишется значение флага, мол все в порядке переменная инициализирована, а только потом в памяти появится инициализированный объект.
А другой поток увидит флаг и воспользуется еще не инициализированным объектом.
Чтобы ему увидеть флаг, надо сначала взять лок. Соответственно, задача лока — правильно сбросить кеши, в противном случае этот лок будет приводить к гонкам.
Нет, вы же сначала проверяете флаг, а только потом, если он false, захватываете лок. И проверяете флаг еще раз, под локом. Но если флаг запишется в память раньше, чем объект, то попытка захватить лок вообще не будет предпринята.
Если я правильно понял, под флагом имелся ввиду указатель на объект. Можно обратить внимание на эту строчку:

pt = &singleUnsafe<T>();

Здесь возвращается указатель на объект. Задача компилятора гарантировать, что результат функции singleUnsafe будет содержать валидный, т.е. проинициализированный объект. Иначе в случае однопоточного приложения будут те же грабли: если нам возвращается недоконструированный объект, то в следующий момент использования возможен креш. Затем мы этот указатель на валидный объект и присваиваем.
Да, под флагом имелся с виду указатель на объект. Он валидный, но только для текущего потока. Сам объект силит в кэше процессора. Текущий поток смотрит на память через кэш и поэтому компилятор выполнил все требования: для потока изменения видны в корректной последовательности.
Но другой поток, исполняющийся на другом ядре, может видеть изменения в другом порядке. И он может увидеть изменение в указателе реньше, чем увидит созданный объект. И это допускается стандартом, он не регламентирует в каком порядке видны изменения потокам, за исключением atomic переменных, если не ошибаюсь.
Да, действительно, такое может произойти. Я поправил реализацию. Теперь запись указателя происходит вне блокировок, поэтому результат создания объекта будет закомичен в памяти до того, как произойдет проверка указателя. Спасибо за наводку.
поэтому результат создания объекта будет закомичен в памяти до того, как произойдет проверка указателя.


Чем поможет вынос присвоения за unlock? Для этого необходим memory-barrier каковым не обязан являться unlock (говоря про pthread).
А почему для потоков/вывода не использовали stl? std::cout, std::thread, std::mutex и иже с ними?
Можно и это использовать. На мой взгляд, это не принципиально, подход остается ровно таким же.
> В настоящий момент сложно себе представить программное обеспечение, работающее в одном потоке.

Откройте для себя node.js/twisted/gevent/tornado.
Уж тогда asio или libev и иже с ними.
Возможно, стоило как-то по-другому сформулировать. Просто я в своей профессиональной деятельности работал только с многопоточными приложениями. Стоило привести полную цитату:
В настоящий момент сложно себе представить программное обеспечение, работающее в одном потоке. Конечно, существует ряд простых задач, для которых один поток более, чем достаточен. Однако так бывает далеко не всегда и большинство задач средней или высокой сложности так или иначе используют многопоточность.

Читая множество статей про инициализацию синглтона в многопточных приложениях я все никак не пойму: ну неужели так обязательно использовать для синглтона Lazy-инициализацию, да еще и из доп. потоков? Неужели сложно его создать в основном потоке до запуска доп. потоков которые могут его использовать? Либо опишите подробную практическую причину, почему он обязательно должен отложенно инициализироваться из многопоточного участка программы
Не всегда у разработчика есть доступ к этому самому «основному потоку», например, в контейнерах приложений. В этом случае инициализация дорогого ресурса (e. g. ConnectionFactory) будет производится из многопоточного окружения.
Статические синглтоны не нужны, они убивают архитектуру и тестирование.
Не согласен, синглтоны полезны (как и любой другой паттерн) в определенных случаях и я не вижу смысла беспредельно усложнять код, если в конкретном случае можно обойтись синглтоном
Добавление static scope неотвратимо усложняет ментальную модели работающего приложения. Хотя, с другой стороны, если нужно чтобы по-быстрому и просто работало…
Я там ответил, что это раскрыто в следующей статье. Конкретно: в объект An можно заливать любую реализацию, будь то синглтон, созданный фабрикой или что-то еще. Это задача использующего, а не используемого, обеспечить правильный экземпляр. Этот подход является продолжением DIP — Dependency inversion principle.
Рассмотрим это поподробнее. Данную строчку можно переписать следующим образом (я опущу обработку исключений для краткости)

Это все равно что сказать, что «a = b + c + d» можно записать в виде «a = b; a += c; a += d». Да, можно, но ткните меня носом в место стандарта, где сказано, что компилятор может так делать. Сначала будет вычислена правая часть выражения, и только потом произведено присваивание. Инициализация объекта входит в этот самый процесс вычисления правой части.
Собственно, это придумал не я. Можно ознакомиться со статьей: Double-Checked Locking Optimization

Example:

The Singleton object is not created yet and the instance is NULL.

Thread 1: is creating the Singleton object (the Singleton is not created in the virtual memory yet) due to optimizing actions taken by the compiler.

m_instance = new SingletonObject( )

However, the pointer to the Singleton is already created and is not NULL.

Thread 2: this thread gets focus, and will not fall through the first conditional check since the pointer is valid. As already mentioned before, the Singleton object is not created in the memory yet and the instance is returned.

Thread 2: will crash using this pointer, since it’s pointing to a memory which is not allocated yet.
Ну, в общем, эту гениальную догадку автора статьи можно смело убирать, поскольку она противоречит, в том числе, и стандарту.
А можно ссылочку на соответствующий параграф в стандарте? Для более конструктивной беседы.
Пардон, для C++11 это будет 5 Expressions и 5.17 Assignment and compound assignment operators
А что стандарт говорит в данном случае о многопоточном выполнении? Видят ли все потоки все изменения в одном и том же порядке? В частности присвоения, выполненные в конструкторе и просвоение указателя на синглтон.
Я для галочки чтоли дал ссылку на пункт «Expressions»? В pt не будет записано значение до тех пор, пока не будет вычислена правая часть выражения «pt = new T», то есть пока не завершится вычисление «new T».
Это понятно, естественно сначала будет вычислено значение «new T», в котором будет выполнен конструктор и инициализирован объект, а затем будет выполнено присваивание pt.
Но при этом стандарт не гарантирует, что другой поток увидит присваивания, сделанные в конструкторе, раньше, чем присваивание адреса, возвращенного new. Это гарантируется только для потока, в рамках которого выполнялись эти операции.

А не гарантируется это потому, что есть архитектуры, на которых такую гарантию предоставить нельзя.
Тогда чем такая ситуация отличается от финального варианта со статическим синглтоном под мьютексом?
1.10.5: мьютекс добавляет барьер в памяти и гарантирует, что изменения сделанные под ним станут видны всем, когда мьютекс будет освобожден.

В старом стандарте это вообще не регламентировалось и, теоретически, компилятор имел право, скажем, вытащить код из критической секции. Но компиляторы, естественно, такую фигню не делали.
Я рассматриваю вариант, когда под мьютексом стоит pt = new T — полная эквивалентность за исключением выделения памяти.
Указанный источник не прав в том, что ссылается на оптимизации, проводимые компилятором. Компиятор таких оптимизаций сделать не может. Но их может сделать процессор и, на некоторых архитектурах, даже так делает.

Стандарт определяет поведение, наблюдаемое программой. Компилятор может переставлять местами операции и делать иные оптимизации, если наблюдаемое поведение не меняется. Самый шикарный баг, про который я слышал — это перестановка местами освобождения мьютекса и присвоения флага в Double-Checked Locking.
О каких оптимизациях идет речь? О том, что mov [mem],value, выполенный на одном ядре, может быть «не виден» на другом?
Именно. Некоторое время он будет не виден, причем порядок, в котором изменения видны ядрам может быть любым.
Ни в коем случае! Завершение записи значения value в память по адресу mem означает, что чтение этого адреса на любом из ядер вернет одинаковый результат — value. Но если в программе есть код
a = b; c = d;
то возможно, что в момент присваивания c = d, в a еще не лежит значение b, потому что возможна параллельная запись в непересекающиеся регионы памяти. Однако чтение a в любой момент после a = b даст b.
Некая «недетерминированность» между ядрами как раз и отражает это: переход к «c = d» на одном ядре не означает, что оно завершило «a = b», из-за чего новое значение a может быть «не видно» остальным ядрам. Для этого и придумали memory barriers (которые, кстати, одинаково отсутствуют в обоих вариантах.
Ни в коем случае! Завершение записи значения value в память по адресу mem означает, что чтение этого адреса на любом из ядер вернет одинаковый результат — value.

А откуда такая информация?
Из интеловскиз мануалов.
Но при чем здесь интеловские мануалы? Мы же говорим не про x86 и не про x86_64, правда? Это же даже не самые распространенные процессоры.
В общем случае, как говорит стандарт языка, implementation defined outside scope of this document. Я, честно говоря, потерял нить дискуссии.
Ты меня пытаешься убедить, что «static T t; T *pt = &t;» лучше, чем «T* pt = new T» в многопоточной среде?
Нет. Я пытаюсь убедить, что вообще Double-Checked Locking не та вешь, которой можно пользоваться. Где-то была замечательная pdf'ка от гугла, если не ошибаюсь, как написать корректный Double-Checked Locking. Она заканчивалась тем, что после довабления всех необходимых барьеров памяти получался тот же самый результат, как если бы с самого начала был просто захвачен lock.
Отнють, первичную проверку разумно делать вне мьютекса, а под мьютексом перед второй проверкой уже и mfence можно выполнить.
Если вы пишете непереносимое ПО под конкретную архитектуру, то можно. Но потом придет dell с сервером на arm и вам понадобится mfence ДО проверки флага, иначе будет гонка. А это все равно, что сразу использовать лок.

Сделать проверку до захвата мьютекса может компилятор (и gcc это делает), но не вы, так как вы не знаете, на какой платформе будет работать ваше ПО.
Порядок, да, может быть любым. Но, если рассматривать одну конкретную переменную, то dimoclus прав: сразу после выполнения mov [mem],value в [mem] увидим значение value с любого ядра. Это гарантирует когерентность кэша. Протоколы разные, но почти все архитектуры их имеют (за исключением парочки экзотических). Перед тем как прочитать [mem] гарантируется, что протокол поддержки когерентности отработал.
Вот хорошая ссылка по Memory Barriers.
Я знаю про когерентность кэша. Но, скажем, на ARM она отключаема. Можете выбирать между эффективным исполнениме программ за счет переупорядочиавния интсрукций и удобством написания многопоточных приложений.

Есть еще и другие подводные камни, например:
Сразу после выполнения mov [mem],value в [mem] увидим значение value с любого ядра. Это гарантирует когерентность кэша.

Когренетность кэша этого не гарантирует. Гарантировано, что процессор вилит свои изменения и все процессоры видят изменения в каком-то порядке. Вы все еще можете считать старое значение. Можете прочитать про это тут:
bartoszmilewski.com/2008/11/05/who-ordered-memory-fences-on-an-x86/
В том числе, если вы вдруг не выровняли переменную и она попала в несколько кэшлайнов, то её модификации не атомарны: процессор может увидеть значение, которого в ней никогда не было.

Кстати, я вспомнил про замечательный документ, где описывается, почему DCLP не работает:
www.nwcpp.org/Downloads/2004/DCLP_notes.pdf

В итоге имеем огромное количество тонкостей и условий, необходимых для корректной работы алгоритма. Пытаемся обмануть компилятор, чтобы он не проводил легитимных оптимизаций. Оно того не стоит. Не используйте DCLP.
Тут дело в другом. Нет там никакого динамического выделения памяти
pt = operator new(sizeof(T));
. Память выделяется на этапе компиляции в серкии .bss или .data. В данном случае в .bss т.к. объект не мог быть создан на этапе компиляции. Есть только вызов конструктора объекта по этому адресу. Причем скорее всего не через placement new, а напрямую.
Нет там никакого динамического выделения памяти

RTFM
Простите, это вы к чему? Подскажите, с каких пор статические переменные живут в куче?
>void *pt = operator new(sizeof(T));
И где здесь статическая переменная?
В предложенной реализации синглтона используются статические переменные:
T& singleUnsafe() { static T t; return t; }
А если посмотреть еще внимательнее, то пункт относится к коду, где выделение динамическое есть
        if (pt == 0)  // вторая проверка, под мьютексом
            pt = new T;
Извиняюсь за глупый вопрос, но разве нельзя использовать критические секции?
Они там и используются, точнее одна. Но неявно. Смотрите последний листинг.
Интересно, как поведет себя «static Mutex mutex» при одновременном доступе из разных потоков… ;)
Насколько я понимаю, в данном случае его инициализацию произведёт CRT ещё до перехода к main(), т.е. инициализация заведомо однопоточная.
Как знать… Сильно зависит от приложения. Никто не мешает создать класс, который в конструкторе запускает несколько потоков, и создать его статический экземпляр до вызова main. Мне встречались примеры.
На мой взгляд это — пример плохого дизайна приложения. В принципе, все действия можно запихать в конструктор, только зачем? Задача конструктора — инициализация, а не выполнение каких-либо действий вообще говоря. Встречаются исключения, типа scoped операций по типу lock/unlock, или rollback в случае исключений, но в основном конструктор — он для конструирования, как бы банально это ни звучало.
Sign up to leave a comment.

Articles