Страницы

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

понедельник, 1 октября 2018 г.

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

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


Ответ

Volatile и мьютекс не имеют ничего общего. Volatile лишь означает, что доступ к переменной не будет оптимизироваться компилятором. В мультиядерной (обращаю внимание, не просто мультипоточной!) системе это имеет значение: демонстрация сбоев программы при отсутствии барьеров памяти
Volatile в этом примере могла бы обеспечить правильный порядок инструкций чтения/записи, так что ответ скорее - положительный, т.е. да, на многоядерных системах, когда доступ к переменной может быть неожиданным из другого потока, ее следует помечать volatile, это избавит Вас от множества проблем.
Только надо не забывать, что существует 2 типа барьеров памяти: барьеры процессора и барьеры компиляции. Volatile позволяет избегать только барьеров второго типа, поэтому может более наглядным примером будет Memory ordering, barriers, acquire and release semantics (Compiler re-ordering)
Комментарии:
однако однозначного мнения о его использовании в многопоточке не нахожу
Ну, скажем так: если Вы точно не можете сказать, какой порядок отношений будет между потоками касательно определенной переменной, то ее следует пометить volatile, чтобы не заморачиваться с барьерами компиляции _ReadBarrier/_WriteBarrier/_ReadWriteBarrier. Но если Вы уже определились с этим порядком, то правильно-расставленные барьеры будут гораздо эффективнее volatile, т.к. позволят все же оптимизировать доступ к этой переменной.
Я лично понимаю это так: если программа готова, то можно заняться заменой volatile на барьеры, это может добавить скорости ее работы. Нет - не стоит заморачиваться, используйте 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 provides greater flexibility for programmers. TSX enables optimistic execution of transactional 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 должна запретить кодогенератору перемещать присваивание.
Я знаю только об одной прагме, которая потенциально могла бы такое делать: #pragma optimize, но по ней нет данных о том, что она будет именно блокировать перемещения. Возможно, конечно, но скорее всего это будет вкупе с отключением общей оптимизации, что еще хуже, чем volatile
Если Вы о такой прагме, то это очень странный совет, особенно на фоне того, что уже было сказано о volatile
Во-вторых, сам mutex генерирует полный memory barrier
Вот мне почему-то кажется, что внутренняя реализация подобного рода объектов дело исключительно платформозависимое. И не будет в большинстве случаев так категорична. В POSIX ведь не даром их реализация не закреплена. Поэтому Ваши доводы по поводу наличия каких-либо барьеров в недрах "абстрактного" мьютекса скорее всего неверны
p.s.: у меня уже давно закончились комментарии, а новый ответ я начать не могу, так что, пожалуйста, обращайте внимание на мои комментарии, которые я оставляю при редактировании ответа.

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

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