Синхронизация потоков 101 - PullRequest
18 голосов
/ 31 марта 2010

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

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

На работе я пытался отладить какой-то многопоточный код, и я столкнулся с этим:

EnterCriticalSection(&m_Crit4);
m_bSomeVariable = true;
LeaveCriticalSection(&m_Crit4);

Теперь m_bSomeVariable - это Win32 BOOL (не энергозависимый), который, насколько я знаю, определен как int, а при чтении и записи в x86 эти значения являются одной инструкцией, и так как переключение контекста происходит на границы команд, то нет необходимости синхронизировать эту операцию с критическим разделом.

Я провел еще несколько онлайн-исследований, чтобы выяснить, не нуждается ли эта операция в синхронизации, и я предложил два сценария, которые она сделала:

  1. ЦП реализует не по порядку исполнение или второй поток работает на другом ядре, а обновленное значение не записывается в ОЗУ, чтобы другое ядро ​​могло видеть; и
  2. int не выровнен по 4 байта.

Я считаю, что номер 1 можно решить с помощью ключевого слова "volatile". В VS2005 и более поздних версиях компилятор C ++ окружает доступ к этой переменной с помощью барьеров памяти, гарантируя, что переменная всегда полностью записывается / читается в основную системную память перед ее использованием.

Номер 2 Я не могу проверить, я не знаю, почему выравнивание байтов будет иметь значение. Я не знаю набор инструкций x86, но нужно ли mov дать 4-байтовый выровненный адрес? Если нет, вам нужно использовать комбинацию инструкций? Это привело бы к проблеме.

Итак ...

ВОПРОС 1: Использует ли ключевое слово «volatile» (простота, используя барьеры памяти и подсказку компилятору не оптимизировать этот код), освобождает программиста от необходимости синхронизировать 4-байтовую / 8- байт в переменной x86 / x64 между операциями чтения / записи?

ВОПРОС 2: Существует ли явное требование, чтобы переменная была выровнена по 4 или 8 байтов?

Я еще немного покопался в нашем коде и переменных, определенных в классе:

class CExample
{

private:

    CRITICAL_SECTION m_Crit1; // Protects variable a
    CRITICAL_SECTION m_Crit2; // Protects variable b
    CRITICAL_SECTION m_Crit3; // Protects variable c
    CRITICAL_SECTION m_Crit4; // Protects variable d

    // ...

};

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

Я думаю, единственное, что может изменить переменные вне критической секции, - это если процесс разделяет страницу памяти с другим процессом (можете ли вы это сделать?), И другой процесс начинает изменять значения. Здесь также могут помочь мьютексы: именованные мьютексы являются общими для процессов или только для процессов с одинаковыми именами?

ВОПРОС 3: Является ли мой анализ критических секций правильным, и следует ли переписать этот код для использования мьютексов? Я посмотрел на другие объекты синхронизации (семафоры и спин-блокировки), они лучше подходят здесь?

ВОПРОС 4: Где наиболее подходят критические секции / мьютексы / семафоры / спин-блокировки? То есть к какой проблеме синхронизации они должны быть применены. Существуют ли значительные потери производительности при выборе одного над другим?

И пока мы на нем, я читал, что спин-блокировки не следует использовать в одноядерной многопоточной среде, только в многоядерной многопоточной среде. Итак, ВОПРОС 5: Это неправильно, или если нет, то почему это правильно?

Заранее спасибо за любые ответы:)

Ответы [ 6 ]

13 голосов
/ 31 марта 2010

1) Нет летучих, просто говорит, перезагружать значение из памяти каждый раз, когда он все еще может быть наполовину обновлен.

Edit: 2) Windows предоставляет некоторые элементарные функции. Посмотрите «Блокированные» функции .

Комментарии заставили меня немного больше читать. Если вы прочитали Руководство по системному программированию Intel , вы увидите, что выровнены чтения и записи ARE atomic.

8.1.1 Гарантированные атомные операции Процессор Intel486 (и более новые процессоры с тех пор) гарантирует, что следующее Основные операции с памятью всегда будут выполняться атомарно:
• Чтение или запись байта
• Чтение или запись слова, выровненного по 16-битной границе
• Чтение или запись двойного слова, выровненного по 32-битной границе
Процессор Pentium (и более новые процессоры с тех пор) гарантирует, что следующее дополнительные операции с памятью всегда будут выполняться атомарно:
• Чтение или запись четырех слов, выровненных по 64-битной границе
• 16-битный доступ к некэшированным областям памяти, которые вписываются в 32-битную шину данных
Процессоры семейства P6 (и более новые процессоры с тех пор) гарантируют, что следующее дополнительная операция с памятью всегда будет выполняться атомарно:
• Нераспределенные 16-, 32- и 64-битные обращения к кешируемой памяти, которые помещаются в кеш линия
Доступ к кешируемой памяти, разделенной по ширине шины, строкам кеша и Intel Core 2 Duo не гарантирует, что границы страниц будут атомарными Atom, Intel Core Duo, Pentium M, Pentium 4, Intel Xeon, семейство P6, Pentium и Процессоры Intel486. Intel Core 2 Duo, Intel Atom, Intel Core Duo, Pentium M, Процессоры семейства Pentium 4, Intel Xeon и P6 обеспечивают сигналы управления шиной, которые разрешить подсистемам внешней памяти сделать разделенный доступ атомарным; тем не мение, доступ к данным без выравнивания серьезно повлияет на производительность процессора и необходимо избегать. Инструкция x87 или инструкция SSE, обеспечивающая доступ к данным, превышающим четырехзначное слово может быть реализовано с использованием нескольких обращений к памяти. Если такая инструкция хранит в память, некоторые из доступов могут завершиться (запись в память), а другой приводит к сбою операции по архитектурным причинам (например, из-за записи в таблице страниц) это помечено как «нет»). В этом случае последствия завершенного доступа может быть виден программному обеспечению, даже если общая инструкция вызвала ошибку. Если TLB аннулирование было отложено (см. раздел 4.10.3.4), могут возникнуть такие сбои страниц даже если все обращения к одной и той же странице.

В общем, да, если вы выполняете 8-битное чтение / запись с любого адреса, 16-битное чтение / запись с 16-битного выровненного адреса и т. Д. И т. Д., Вы получаете атомарные операции. Также интересно отметить, что вы можете выполнять выравнивание чтения / записи памяти в пределах кэша на современной машине. Правила кажутся довольно сложными, поэтому я бы не стал на них полагаться. Приветствую комментаторов, это хороший учебный опыт для меня:)

3) Критическая секция попытается несколько раз закрутить блокировку для своей блокировки, а затем заблокирует мьютекс. Spin Locking может высосать мощность процессора, ничего не делая, а мьютекс может занять некоторое время, чтобы сделать свое дело. CriticalSections - хороший выбор, если вы не можете использовать заблокированные функции.

4) Существуют штрафы за выбор одного за другим. Это довольно большая просьба пройти через все преимущества здесь. Справка MSDN содержит много полезной информации по каждому из них. Я sugegst, читая их.

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

8 голосов
/ 31 марта 2010

1: Volatile само по себе практически бесполезно для многопоточности. Это гарантирует, что чтение / запись будет выполняться вместо сохранения значения в регистре, и гарантирует, что чтение / запись не будет переупорядочено относительно других volatile операций чтения / записи . Но он все еще может быть переупорядочен по отношению к энергонезависимым, что составляет в основном 99,9% вашего кода. Microsoft пересмотрела volatile, чтобы также обернуть все обращения в барьеры памяти, но это не гарантируется в общем случае. Он просто молча сломается на любом компиляторе, который определяет volatile как стандарт. (Код будет скомпилирован и запущен, он просто больше не будет потокобезопасным)

Кроме того, чтение / запись в целочисленные объекты являются атомарными в x86, если объект хорошо выровнен. (У вас нет гарантии на , когда запись произойдет, хотя. Компилятор и ЦП могут переупорядочить ее, поэтому она атомарна, но не поточно-ориентирована)

2: Да, объект должен быть выровнен, чтобы чтение / запись были атомарными.

3: Не совсем. Только один поток может одновременно выполнять код внутри заданной критической секции . Другие потоки все еще могут выполнять другой код. Таким образом, вы можете иметь четыре переменные, каждая из которых защищена отдельным критическим разделом. Если бы они все использовали один и тот же критический раздел, я бы не смог манипулировать объектом 1, пока вы манипулируете объектом 2, что неэффективно и ограничивает параллелизм больше, чем необходимо. Если они защищены различными критическими секциями, мы просто не сможем одновременно манипулировать одним и тем же объектом .

.

4: Спинлоки являются редко хорошей идеей. Они полезны, если вы ожидаете, что потоку придется ждать очень короткое время, прежде чем он сможет получить блокировку, и вам абсолютно необходимы минимальные задержки. Это позволяет избежать переключения контекста ОС, что является относительно медленной операцией. Вместо этого поток просто сидит в цикле, постоянно опрашивая переменную. Поэтому более высокая загрузка ЦП (ядро не высвобождается для запуска другого потока в ожидании спин-блокировки), но поток сможет продолжить , как только , как только будет снята блокировка.

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

Что касается не использования спин-блокировки в одноядерной среде, помните, что спин-блокировка на самом деле не дает. Поток Ожидание спин-блокировки фактически не приостанавливается, что позволяет ОС планировать запуск потока B. Но так как A ожидает этого спин-блокировки, какой-то другой поток должен будет снять эту блокировку. Если у вас есть только одно ядро, то этот другой поток сможет работать только при выключенном A. В случае нормальной операционной системы это рано или поздно произойдет как часть обычной смены контекста. Но так как мы знаем, что A не сможет получить блокировку, пока у B не будет времени на выполнение и отпустит блокировку, было бы лучше, если бы A сразу же уступил, был помещен в дождитесь очереди ОС и перезапустите, когда B снял блокировку. И это то, что делают все другие типы блокировки. Спин-блокировка все еще будет работать в одноядерной среде (при условии ОС с вытесняющей многозадачностью), она будет очень и очень неэффективной.

7 голосов
/ 31 марта 2010

Q1: использование ключевого слова volatile

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

Точно. Если вы не создаете переносимый код, Visual Studio реализует его именно так. Если вы хотите быть портативным, ваши варианты в настоящее время "ограничены". До C ++ 0x не существует переносимого способа задания атомарных операций с гарантированным порядком чтения / записи, и вам необходимо реализовывать решения для каждой платформы. Тем не менее, Boost уже сделал грязную работу за вас, и вы можете использовать его атомарные примитивы .

Q2: переменная должна быть выровнена на 4 байта / 8 байтов?

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

В3: Должен ли этот код быть переписан для использования мьютексов?

Критический раздел - легкий мьютекс. Если вам не нужно синхронизировать процессы, используйте критические секции.

В4: Где лучше всего подходят критические секции / мьютексы / семафоры / спин-блокировки?

Критические разделы может даже делать вращение ждет вас .

Q5: Спинлоки не должны использоваться в одноядерных

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

5 голосов
/ 31 марта 2010

Не используйте летучие. Это практически не имеет ничего общего с безопасностью потоков. См. здесь для более низкого уровня.

Назначение для BOOL не требует каких-либо примитивов синхронизации. Он будет работать без особых усилий с вашей стороны.

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

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

3 голосов
/ 31 марта 2010

Volatile не подразумевает барьеры памяти.

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

Поскольку не существует никаких барьеров памяти, компилятор может переупорядочивать инструкции по желанию. Единственная гарантия состоит в том, что порядок чтения / записи различных энергозависимых переменных будет таким же, как в коде:

void test() 
{
    volatile int a;
    volatile int b;
    int c;

    c = 1;
    a = 5;
    b = 3;
}

С помощью приведенного выше кода (при условии, что c не оптимизирован) обновление до c может происходить до или после обновлений a и b, обеспечивая 3 возможных результата. Обновления a и b гарантированно будут выполняться по порядку. c может быть легко оптимизирован любым компилятором. Обладая достаточным количеством информации, компилятор может даже оптимизировать a и b (если можно доказать, что никакие другие потоки не читают переменные и что они не связаны с аппаратным массивом (поэтому в этом случае они могут фактически быть удаленным.) Обратите внимание, что стандарт требует не определенного поведения, а воспринимаемого состояния с правилом as-if.

2 голосов
/ 31 марта 2010

Вопросы 3: CRITICAL_SECTION и мьютексы работают, в основном, одинаково. Мьютекс Win32 - это объект ядра, поэтому его можно разделить между процессами и ожидать с помощью WaitForMultipleObjects, чего нельзя сделать с помощью CRITICAL_SECTION. С другой стороны, CRITICAL_SECTION легче и поэтому быстрее. Но логика кода не должна зависеть от того, что вы используете.

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

...