Страницы

Поиск по вопросам

понедельник, 25 ноября 2019 г.

Volatile в многопоточной программе


Всем доброго дня! 
Хотелось бы узнать мнения, стоит ли использовать volatile в многопоточных программа
на C/C++? Бывают ли на практике ситуации, когда его использование может быть более предпочтительным использованию мьютексов? Если у кого-то есть подобный положительный опыт его использования, поделитесь примерами, пожалуйста.    


Ответы

Ответ 1



Volatile и мьютекс не имеют ничего общего. Volatile лишь означает, что доступ к переменно не будет оптимизироваться компилятором. В мультиядерной (обращаю внимание, не просто мультипоточной!) системе это имеет значение: демонстрация сбоев программы при отсутствии барьеров памяти. Volatile в этом примере могла бы обеспечить правильный порядок инструкций чтения/записи так что ответ скорее - положительный, т.е. да, на многоядерных системах, когда доступ к переменной может быть неожиданным из другого потока, ее следует помечать volatile, это избавит Вас от множества проблем. Только надо не забывать, что существует 2 типа барьеров памяти: барьеры процессор и барьеры компиляции. Volatile позволяет избегать только барьеров второго типа, поэтому может более наглядным примером будет Memory ordering, barriers, acquire and release semantics (Compiler re-ordering) Комментарии: однако однозначного мнения о его использовании в многопоточке не нахожу Ну, скажем так: если Вы точно не можете сказать, какой порядок отношений будет межд потоками касательно определенной переменной, то ее следует пометить volatile, чтоб не заморачиваться с барьерами компиляции _ReadBarrier/_WriteBarrier/_ReadWriteBarrier. Но если Вы уже определились с этим порядком, то правильно-расставленные барьеры будут гораздо эффективнее volatile, т.к. позволят все же оптимизировать доступ к этой переменной. Я лично понимаю это так: если программа готова, то можно заняться заменой volatil на барьеры, это может добавить скорости ее работы. Нет - не стоит заморачиваться, используйте volatile, чтобы программа просто работала правильно. я так помнимаю, volatile в первом примере имеет смысл именно в "самодельном мьютексе"? В первом примере, volatile касается sharedValue, модификация которой может "выпрыгнуть из критического участка кода из-за переупорядочивания к ней доступа каким-нибудь компилятором Но не используется в примере потому, что acquire/release семантика в новом стандарте уже учитывает барьерную синхронизацию на уровне компилятора (см. реализацию atomic с используемой в ней _ReadWriteBarrier). Т.е., если бы вместо flag здесь стоял какой-нибудь самодельный синхронизатор, пуст даже реализованный на том же на CAS'е, но не использующий acquire/release, проблемы были бы сразу на 2-х фронтах. А вот вместо мьютекса (или семафора) для синхронизации на практике использоват переменные не стоит (как бы создатели компилятора их не называли). И дело тут в логической сложности подобных "алгоритмов", а не в свойствах переменных, переупорядочении команд, кэш-когерентности и т.п. @avp, конечно, логика тут не на последнем месте. Но на практике, Вы даже такую "самодельну синхронизацию" завернете в функцию, очевидно, ради элементарного удобства. Логически для самого программиста этим все и решится. Проблема "самопальной" синхронизации шире: чаще всего она инлайнится. Всем поняте смысл? А надежность API для синхронизации доступа к переменным обуславливается в основно еще и тем, что большинство (если не всех) компиляторов строят свои гипотезы по оптимизаци хранения значений между вызовами функций, т.е. считается, что если между операторам доступа к переменной стоит вызов функции, то через этот вызов значение переменных не будет переноситься в регистрах. Это свойство автоматического барьера памяти само по себе может решать проблемы доступа. А inline все это дело приводит в неопределенность. Поэтому "самопальную" синхронизацию надо на 100 раз перепроверять, и лучше - не вручную, а какими-то инструментами, типа Relacy Race Detector, Viva и т.п. На мой взгляд стоит пользоваться критическими секциями. Они шустры, однозначн и понятны. @mikelsv, критические секции, как бы ни были понятны, шустры только за счет spinlock'ов которыми пользуется процентов 10-20 из всех, кто что-то знает о критических секция (быть может, моя оценка оптимистична). Такие блокировки уже сегодня далеко не шустры А у Intel'а в планах еще Haswell и Skylake. По Haswell'у известно, что она будет расширена TSX, которая предусматривает неблокирующую работу потоков в т.н. "регионах транзакции", в которых потоки хардварно взаимоконтролируют друг друга на предмет одновременного доступа к ресурсу "на ходу", т.е. без простоя на блокировках. Для примера такой конкуренции, можете посмотреть на Хэшкоде мой вариант lock-free-стека Здесь есть 2 транзакции, перед CAS'ами, т.е. в случае неуспешного CAS'а, каждая из транзакций откатывается в начало тела цикла и повторяется до успешного исхода. Restricted Transactional Memory (RTM) is a new instruction set interface that provide greater flexibility for programmers. TSX enables optimistic execution of transactiona code regions. The hardware monitors multiple threads for conflicting memory accesses and aborts and rolls back transactions that cannot be successfully completed. Mechanisms are provided for software to detect and handle failed transactions. Такая техническая возможность процессоров скорее всего потребует смены парадигм синхронизации потоков, в которой банальная блокировка ресурса просто не выживет, т.к. будет бить по производительности параллельных систем, к которым мы постепенно, но очень уверенно движемся. а в ситуации, когда значение переменной было установлено до расщепления на потоки а затем только считывается (тоесть далее в течение всего времени работы программы эта переменная значение не меняет), одного volatile достаточно ведь? @margosh, по большому счету, volatile в этом случае не требуется. Есть небольша вероятность, что значение может быть записано после запуска потоков. Ее можно ликвидировать если после записи в эту переменную поставить барьер _WriteBarrier. Или _ReadWriteBarrier для перестраховки, перед самим запуском потоков, особенно, если с ней что-то делается в основном потоке. Так у компилятора будет больше возможностей к оптимизации. Если перекладывать барьеры на семантику acquire/release, то барьер можно заменит установкой значения Вашей переменной с помощью _InterlockedExchange_rel, а в потока читать ее через _InterlockedCompareExchange_acq( &value, x, x ), где value - это непосредственн переменная, которая не меняется в потоках, а x - самая маловероятная величина, которой может быть равна value, например, для неотрицательных value выбирают x = -1, чтобы не портить кэши. Не особо удобно, да? :) Тем не менее, считается, что это будет эффективнее volatile и барьера, т.к. мы явно сказали компилятору, где будет барьерная запись и чтение переменной. И как быть с чем-то вроде pipe, которые из разных потоков пишутся? Доступ к ним синхронизировать не нужно, но нужно ли их делать volatile? А можно поконкретнее, что из себя представляют Ваши "pipe"? В WinAPI это обычны файловый дескриптор, его значение не меняется при записи/чтении. Соответственно, volatile ему не нужен. @mega, учтите в своих ответах, что у @margosh программа в FreeBSD. Да, @avp, я тоже заметил комментарий. Сам я программирую для Windows, но сейчас реч идет в основном о компиляции, и о том, какими средствами ее контролировать. Поэтом не вижу проблем, даже когда опираюсь на виндовые интринсики _Interlockedxxx, т.к. их аналоги есть везде. Ну а их версии с acq/rel - судите сами, если уж они даже в стандарте прописались :) Второй не может начать читать, пока ему не прийдет информация из pipe, но есл она прийдет раньше, чем был записан флаг (изменение порядка операций в одном потоке) - алгоритм работы нарушится. @margosh, если запись в пайп будет производиться после установки признака, то обязательн будет такая ситуация, что второй в это время будет подчищать мусор и как раз наткнетс на этот самый признак, но до того, как первый пошлет что-либо в пайп. Если для Вас это штатная ситуация, то все хорошо, но если первый в это время будет пользоваться чем-то, что подчищает второй?!?! Думаю, ничего из этого хорошего не выйдет, тут нужна блокировка! free-поток удаляет из списка только одну структуру, и ждет что-то от pipe снова тоесть от 2 потоков прийдет 2 признака и удалится поочередно 2 структуры. Порядок удаления значения не имеет, так как это последние действия при завершении потоков : установить флаг, бросить признак в pipe. Если у Вас что-то вроде такого: pThreadStruct->CanFree = 1; SendToPipe( 1 ); то порядок действительно не имеет значения, но если вот такое: pThreadStruct->CanFree = 1; SendToPipe( pThreadStruct->... ); то здесь обязательно будет ошибка, т.к. CanFree = 1 можете понимать как delete pThreadStruct а удаление только одной структуры в ответ на сигнал из пайпа совсем не означает, что удалена будет именно та структура, чей поток послал в пайп признак. По поводу оптимизации и перестановок в коде : тоесть, присвоение может поменятьс местами с захватом блокировки, так как функция блокировки с точки зрения компилятора с защищаемой переменной никак не свзяна? Да, совершенно верно! Хорошо, но как на это повлияет использование этой переменной с volatile? volatile "скажет" компилятору, что к этой переменной нельзя применять "перестановки" т.е., если вы поместили запись в эту переменную в пределах критического участка, значит она из него и "не выпрыгнет" ни при каких условиях. Другое дело, что она и не в критическом участке никуда прыгнуть не сможет, а учитыва специфику процессоров, конвейеры которых обычно оптимизированы на параллельную обработк сразу нескольких команд но в определенном порядке, то компилятор не сможет такую запись или чтение volatile оптимизировать для таких процессоров (ради этого и производятся перестановки, чтобы уменьшить совокупное число тактов на работу алгоритма, в этом и заключается оптимизация по скорости). @margosh: мне кажется, в этом обсуждении ошибка. В компилятор обязана быть встроен магия (то есть конечно подходящая #pragma), которая запрещает перенос операций за границы mutex_lock/unlock, иначе эти конструкции просто не смогут правильно работать. Про автоматические барьеры между вызовами функций я уже писал. Это и есть Ваша "магия" И естественно, без лишних напоминаний об этом препроцессору, через #pragma. :) Про какое обсуждение @VladD, Вы сейчас говорите, не понял? @mega: про обсуждение выше в комментариях: о том, что присвоение переменной может при оптимизации вылететь за пределы области защищённой mutex'ом. Если защита в инлайне и без погружения в ядро - очень легко может вылететь. Я прост не знаю, на чем основаны мьютексы у @margosh и может ли их разворачивать компилятор. Если может, то вполне логичные результаты: при безуспешных попытках отладки программы под пока недопиленным FreeBSD-Valgrind очень удивилась, когда увидела среди кучи сообщений о блокировках с развернутым стеком вызовов, захват блокировки уже после присвоения @mega: тут есть две вещи. Во-первых, #pragma должна запретить кодогенератору перемещать присваивание. Я знаю только об одной прагме, которая потенциально могла бы такое делать: #pragm optimize, но по ней нет данных о том, что она будет именно блокировать перемещения. Возможно, конечно, но скорее всего это будет вкупе с отключением общей оптимизации, что еще хуже, чем volatile. Если Вы о такой прагме, то это очень странный совет, особенно на фоне того, что уже было сказано о volatile. Во-вторых, сам mutex генерирует полный memory barrier. Вот мне почему-то кажется, что внутренняя реализация подобного рода объектов дел исключительно платформозависимое. И не будет в большинстве случаев так категорична. В POSIX ведь не даром их реализация не закреплена. Поэтому Ваши доводы по поводу наличия каких-либо барьеров в недрах "абстрактного" мьютекса скорее всего неверны. p.s.: у меня уже давно закончились комментарии, а новый ответ я начать не могу, та что, пожалуйста, обращайте внимание на мои комментарии, которые я оставляю при редактировании ответа.

Ответ 2



Короткий ответ: согласно стандарту, volatile не имеет никакого отношения к многопоточности. Гонки в многопоточных программах Одна из первых глав стандарта - это Multi-threaded executions and data races [intro.multithread]. Там описано что такое потоки, и каким требованиям должна отвечать программа, чтобы быть корректной. Гонка (data race) происходит когда программа содержит два действия, обращающихся к одному месту памяти в разных потоках, хотя бы одно из действий модифицирует эту память, хотя бы одно из действий не атомарное, действия не упорядочены с помощью мьютекса. (см. happens before) Если при выполнении программы происходит гонка, то поведение программы не определено. volatile Вкратце, стандарт описывает volatile следующим образом (The cv-qualifiers [dcl.type.cv]): [Примечание: volatile - это подсказка (hint) компилятору, говорящая что надо избегат агрессивных оптимизаций, т.к. значение объекта может быть изменено чем-то, о чем компилятор не знает. Подробнее - в главе Program execution] При этом в главе Program execution [intro.execution] написано: обращения к volatile объектам выполняются строго согласно правилам абстрактной машин (т.е. например для volatile int& x; оптимизатор не может удалить код x = x; или заменить x - x на 0, если он не может доказать что такая оптимизация сохранит поведение программы); доступ к volatile объекту является побочным эффектом (side effect). Побочные эффект в одном полном выражении выполняются раньше побочных эффектов с следующем полном выражении (см. ниже пример с g++). Согласно стандарту, volatile объекты не являются атомарными, и без использовани мьютексов возникает гонка. В сноске написано "значение может быть изменено чем-то". Что это может быть? Например регистры или память физического устройства могут отображаться на адрес оперативной памяти. Тогда чтение или запись по таким адресам памяти будет приводить к получению или передаче данных в это физическое устройство. Либо программный код может перехватывать обращения к участку памяти и обрабатывать значения которые читаются или пишутся из/в этот участок памяти. В любом случае, это "что-то" работает в текущем потоке, а не в параллельном. volatile на x86 платформах На x86 платформах, чтение/запись одного слова памяти - атомарно. Означает ли это, что мы можем использовать там volatile? Нет, атомарность чтения/записи на x86 это особенность самого железа. Что с volatile, что без него - эффект будет одинаков. Однако компилятор может менять порядок операций, если это позволяет стандарт. Рассмотрим следующий код: volatile bool ready = false; int x = 10; void calc_and_go(int a) { x = x / a; ready = true; } Он вычисляет новое значение x, а затем сигнализирует что операция завершена. Есл мы скомпилируем этот код компилятором g++ с включенным оптимизатором (-O2), то мы получим следующее: mov eax, DWORD PTR x[rip] ; tmp = x mov BYTE PTR ready[rip], 1 ; ready = true cdq idiv edi ; value = tmp / a mov DWORD PTR x[rip], eax ; x = value; ret Компилятор поместил запись ready = true между чтением x и вычислением x/a, т.к. в таком порядке инструкции будут выполняться параллельно. Хотя стандарт и говорит, что запись x должна выполняться перед ready = true (sequence before), но здесь нет никаких объектов синхронизации, так что ничто не позволяет увидет этот порядок из другого потока. А раз никто не видит этот порядок - компилятор может его поменять, т.к. стандарт требует только соблюдения видимого поведения программы. Если же попробовать сделать std::thread t(calc_and_go, 5); while (!ready) /* wait*/ ; std::cout << x; то возникнет гонка для ready, а значит поведение будет не определено, и может напечататься исходное значение x равное 10. Компилятор MS Visual C++ В MSVC, если код скомпилирован с ключем /volatile:ms (который включен по-умолчани для не-ARM платформ), запись volatile переменных имеет release семантику, а чтение - acquire семантику. Это означает следующее: компилятор разместит запись volatile переменной после выполнения всех побочных эффектов (в т.ч. записи других переменных), которые предшествуют ей в коде программы; компилятор разместит чтение volatile переменной перед всеми побочными эффектам, которые следуют за ней в коде программы.

Ответ 3



@mega, у меня тоже комментарии закончились. Новое в компиляторе (и уже стандартизированное) - это, конечно, хорошо. Но, мой опыт подсказывает, что лет 5 (а лучше 10) в практических программах лучш им не пользоваться. Иначе сопровождение (с переносом) заканчивается "спагетти" из ifdef-ов и невменяемыми Makefile-ами.

Ответ 4



сори, комменты кончились. @avp, пока что я действительно компилирую все без оптимизации, ошибок при выполнени я пока не замечала, просто хочу до конца уяснить, как работает volatile, в первую очеред в С. Нашла вот эту статейку, но незнаю, можно ли все это с уверенностью отнести и volatile в С. Еще могу добавить, что при безуспешных попытках отладки программы под пока недопиленным FreeBSD-Valgrind, очень удивилась, когда увидела среди кучи сообщений о блокировках с развернутым стеком вызовов, захват блокировки уже после присвоения, что и породило мой интерес к volatile. @mega, в pipe посылается просто char-символ, а не структура, так что таких пробле не будет. Хорошее пояснение, спасибо! Все это в одинаковой мере относится как к С++ так и к С? Огромное вам обоим спасибо за ваши комментарии и терпение ;) Если у кого-либо еще есть, что веского добавить - welcome!

Комментариев нет:

Отправить комментарий