Подтвердить что ты не робот

Как компилятор вроде GCC реализует семантику получения/выпуска для std:: mutex

Я понимаю, что std:: mutex lock и unlock имеют семантику получения/выпуска, которая предотвращает перемещение инструкций между ними.

Таким образом, для получения/выпуска следует отключить инструкции компилятора и процессора.

Мой вопрос в том, что я смотрю на базу кода GCC5.1 и не вижу ничего особенного в std:: mutex:: lock/unlock, чтобы предотвратить коды переупорядочения компилятора.

Я нахожу потенциальный ответ в does-pthread-mutex-lock-have-happens-before-semantics, который указывает mail, который говорит, что вызов внешней функции действует как забор памяти компилятора.

Всегда ли это так? А где стандарт?

4b9b3361

Ответ 1

Все эти вопросы связаны с правилами переупорядочения компилятора. Одним из основополагающих правил для переупорядочения является то, что компилятор должен доказать, что переупорядочение не изменяет результат программы. В случае std::mutex точное значение этой фразы указано в блоке около 10 страниц legaleese, но общее интуитивное чувство "не изменяет результат программы". Если у вас была гарантия о том, какая операция была первой, в соответствии со спецификацией, компилятор не может переупорядочить таким образом, который нарушает эту гарантию.

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

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

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

EDIT. Для полноты я хотел бы указать, что эти правила были введены в С++ 11. Правила переупорядочения С++ 98 и С++ 03 запрещали только изменения, влияющие на результат текущего потока. Такая гарантия недостаточно сильна для разработки многопоточных примитивов, таких как мьютексы.

Чтобы справиться с этим, многопоточные API, такие как pthreads, разработали свои собственные правила. из Раздел спецификации Pthreads 4.11:

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

Затем он перечисляет несколько десятков функций, которые синхронизируют память, включая pthread_mutex_lock и pthread_mutex_unlock.

Компилятор, который хочет поддерживать библиотеку pthreads, должен реализовать что-то, что поддерживает эту синхронизацию с чередованием потоков, хотя спецификация С++ ничего не говорила об этом. К счастью, любой компилятор, в котором вы хотите выполнять многопоточность, был разработан с учетом того, что такие гарантии являются основополагающими для всей многопоточности, поэтому каждый компилятор, поддерживающий многопоточность, имеет это!

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

Все это, конечно, специфично для компилятора. Стандартных ответов на этот вопрос не было, пока С++ 11 не придумал новую модель памяти.

Ответ 2

Потоки - довольно сложная, низкоуровневая функция. Исторически сложилось так, что стандартная функциональность потока C не существовала, и вместо этого она выполнялась по-разному на разных ОС. Сегодня в основном есть стандарт потоков POSIX, который был реализован в Linux и BSD, а теперь с расширением OS X, и есть потоки Windows, начиная с Win32 и дальше. Потенциально, помимо них могут быть и другие системы.

GCC напрямую не содержит реализацию потоков POSIX, а может быть клиентом libpthread в системе linux. Когда вы создаете GCC из исходного кода, вам нужно отдельно настраивать и создавать дополнительные вспомогательные библиотеки, поддерживая такие вещи, как большие числа и потоки. Именно в этом пункте вы выбираете, как будет выполняться потоковая обработка. Если вы сделаете это стандартным способом в linux, у вас будет реализация std::thread в терминах pthreads.

В Windows, начиная с соответствия MSVC С++ 11, разработчики MSVC реализовали std::thread в терминах интерфейса собственных потоков Windows.

Это задание ОС, чтобы гарантировать, что блокировки concurrency, предоставленные их API, действительно работают - std::thread означает кросс-платформенный интерфейс для такого примитива.

Ситуация может быть более сложной для более экзотических платформ/кросс-компиляции и т.д. Например, в проекте MinGW (gcc для Windows) - исторически у вас есть возможность построить MinGW gcc, используя либо порт pthreads для окон, или используя собственную модель потоковой обработки на основе win32. Если вы не настроите это при сборке, вы можете получить компилятор С++ 11, который не поддерживает std::thread или std::mutex. См. Этот вопрос для получения более подробной информации. Ошибка MinGW: 'thread не является членом' std

Теперь, чтобы ответить на ваш вопрос более непосредственно. Когда задействован мьютекс, на самом низком уровне это предполагает некоторый вызов в libpthreads или какой-то win32 API.

pthread_lock_mutex();
do_some_stuff();
pthread_unlock_mutex();

(pthread_lock_mutex и pthread_unlock_mutex соответствуют реализациям lock и unlock of std::mutex на вашей платформе, а в идиоматическом коде С++ 11 они, в свою очередь, вызываются в ctor и dtor of std::unique_lock, если вы используете это.)

Как правило, оптимизатор не может переупорядочить их, если не уверен, что pthread_lock_mutex() не имеет побочных эффектов, которые могут изменять наблюдаемое поведение do_some_stuff().

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

Если есть ресурс

int resource;

который находится в конфликте между различными потоками, это означает, что существует некоторое тело функции

void compete_for_resource();

и указатель на это в некоторой предыдущей точке передается в pthread_create... в вашей программе, чтобы инициировать другой поток. (Это, по-видимому, будет в реализации ctor of std::thread.) В этот момент компилятор может видеть, что любой вызов в libpthread может потенциально вызвать compete_for_resource и коснуться любой памяти, к которой прикасается эта функция. (С точки зрения компилятора libpthread есть черный ящик - это немного .dll/.so и он не может делать предположений о том, что именно он делает.)

В частности, вызов pthread_lock_mutex(); потенциально имеет побочные эффекты для resource, поэтому его нельзя переупорядочить с помощью do_some_stuff().

Если вы на самом деле не создаете какие-либо другие потоки, то, насколько мне известно, do_some_stuff(); можно переупорядочить вне блокировки мьютекса. Поскольку libpthread не имеет доступа к resource, это просто личная переменная в вашем источнике и не распространяется на внешнюю библиотеку даже косвенно, и компилятор может это увидеть.

Ответ 3

ПРИМЕЧАНИЕ. Я не специалист в этой области, и мои знания об этом в состоянии спагетти. Поэтому возьмите ответ с солью.

ПРИМЕЧАНИЕ 2. Это может быть не тот ответ, который ожидает OP. Но вот мои 2 цента в любом случае, если это помогает:

Мой вопрос в том, что я смотрю на базу кода GCC5.1 и не вижу ничего особенного в std:: mutex:: lock/unlock для предотвращения компиляции переупорядочивающие коды.

g++ с использованием библиотеки pthread. std:: mutex - это всего лишь тонкая оболочка вокруг pthread_mutex. Таким образом, вам действительно нужно пойти и посмотреть на реализацию мьютекса pthread.
Если вы углубитесь в реализацию pthread (здесь вы можете найти здесь), вы увидите, что он использует атомарные инструкции вместе с вызовами futex,

Здесь нужно запомнить две второстепенные вещи:
1. В атомных инструкциях используются барьеры.
2. Любой вызов функции эквивалентен полному барьеру. Не помните, откуда я его читал.
3. mutex вызовы могут помещать поток в режим сна и вызывать контекстный переключатель.

Теперь, что касается переупорядочения, одна из вещей, которая должна быть гарантирована, заключается в том, что никакая инструкция после lock и до unlock не должна быть переупорядочена до lock или после unlock. Это, я считаю, не полный барьер, а скорее просто приобретать и выпускать барьер соответственно. Но это опять зависит от платформы, x86 обеспечивает последовательную согласованность по умолчанию, тогда как ARM обеспечивает более слабую гарантию порядка.

Я настоятельно рекомендую эту серию блога: http://preshing.com/archives/ Это объясняет много вещей более низкого уровня в легко понятном языке. Угадайте, я должен прочитать это еще раз:)

UPDATE:: Невозможно прокомментировать ответ @Cort Ammons из-за длины

@Kane Я не уверен в этом, но люди вообще пишут барьеры для уровня процессора, который также заботится о барьерах уровня компилятора. То же самое не относится к встроенным барьерам компилятора.

Теперь, поскольку определения функций pthread_*lock* отсутствуют в блоке перевода, где вы его используете (это сомнительно), вызов блокировки - разблокировка должна обеспечить вам полный барьер памяти. Реализация pthread для платформы использует атомарные инструкции для блокировки доступа любого другого потока к ячейкам памяти после блокировки или до разблокировки. Теперь, поскольку только один поток выполняет критическую часть кода, обеспечивается, что любое переупорядочение внутри этого не изменит ожидаемое поведение, как указано в предыдущем комментарии.

Атомы довольно сложно понять и получить право, так что то, что я написал выше, из моего понимания. Был бы очень рад узнать, неправильно ли здесь мое понимание.