Я использую ожидание для синхронизации доступа к критическим регионам, например:
while (p1_flag != T_ID);
/* begin: critical section */
for (int i=0; i<N; i++) {
...
}
/* end: critical section */
p1_flag++;
p1_flag - глобальная изменчивая переменная, которая обновляется другим параллельным потоком. На самом деле, у меня есть два критических раздела внутри цикла, и у меня есть два потока (оба выполняются в одном цикле), которые коммутируют выполнение этих критических областей. Например, критические области называются A и B.
Thread 1 Thread 2
A
B A
A B
B A
A B
B A
B
Параллельный код выполняется быстрее, чем серийный, но не так сильно, как я ожидал. Профилирование параллельной программы с помощью VTune Amplifier я заметил, что в директивах синхронизации тратится большое количество времени, то есть while(...)
и обновление флага. Я не уверен, почему я вижу такие большие накладные расходы в этих "инструкциях", так как область A точно такая же, как в регионе B. Моя лучшая догадка заключается в том, что это связано с латентностью когерентности кеша: я использую Intel i7 Ivy Bridge Machine и эта микроархитектура решает когерентность кеша на L3. VTune также сообщает, что команда while (...)
потребляет всю пропускную способность интерфейса, но почему?
Чтобы сделать вопрос понятным: почему while(...)
и инструкции флага обновления занимают так много времени выполнения? Почему команда while(...)
насыщает пропускную способность интерфейса?
Накладные расходы, которые вы платите, вполне могут быть связаны с передачей переменной синхронизации между ядрами.
Согласованность кеша диктует, что при изменении строки кэша (p1_flag++) вам необходимо иметь право собственности на нее. Это означает, что это приведет к аннулированию любой копии, существующей в других ядрах, ожидая, что она вернет любые изменения, сделанные этим другим ядром, на уровень общего кэша. Затем он предоставит строку запрашивающему ядру в состоянии M
и выполнит модификацию.
Тем не менее, другое ядро к тому времени будет постоянно читать эту строку, прочитав, что будет следить за первым ядром и спросить, есть ли у него копия этой строки. Поскольку первое ядро имеет копию M
этой строки, оно будет записано обратно в общий кэш, и ядро потеряет право собственности.
Теперь это зависит от фактической реализации в HW, но если строка была зачерпнута до того, как было сделано изменение, первое ядро должно было попытаться снова получить право собственности на нее. В некоторых случаях я предполагаю, что это может привести к нескольким итерациям попыток.
Если вы настроены на ожидание занятости, вы должны хотя бы использовать паузу внутри нее: _mm_pause
intrisic или просто __asm("pause")
. Это послужило бы для того, чтобы дать другому потоку шанс получить блокировку и освободить вас от ожидания, а также снизить нагрузку на процессор в оживленном ожидании (процессор вне порядка будет заполнять все конвейеры параллельными экземплярами этого оживленного ожидания, потребляя много энергии - пауза будет сериализовать его, чтобы только одна итерация могла запускаться в любой момент времени - гораздо меньше потребления и с тем же эффектом).
pause
, однако в будущем я хотел бы перенести эту программу на архитектуру ARM - знаете ли вы, есть ли аналогичная инструкция для ARM? Другое дело: основываясь на экспериментах, я предполагаю, что конвейер полон инструкций загрузки, но почему процессор не ждет завершения одной загрузки, прежде чем выдать другую?
cpuid
или другие сериализационные модули. Я не уверен насчет ARM ISA, но там тоже должны быть инструкции по сериализации. Возможно, попробуйте ISB
( infocenter.arm.com/help/topic/com.arm.doc.dai0179b/… ). Имейте в виду, что я говорю о сериализации на уровне команд, а не об ограждении памяти (необходимо заблокировать эти ветви).
Занят-ожидание почти никогда не является хорошей идеей в многопоточных приложениях.
Когда вы заняты - ожидание, алгоритмы планирования потоков не будут знать, что ваш цикл ждет в другом потоке, поэтому они должны выделять время, как будто ваш поток выполняет полезную работу. И это занимает процессорное время, чтобы проверять эту переменную больше и больше, и снова и снова, и снова и снова... пока она не будет окончательно "разблокирована" другим потоком. Тем временем ваш другой поток будет вытеснен вашей оживленной нитью снова и снова, без всякой цели.
Это еще хуже, если планировщик основан на приоритете, а поток ожидания ожидания занят более высоким приоритетом. В этой ситуации поток с более низким приоритетом НИКОГДА не будет вытеснять поток с более высоким приоритетом, таким образом, у вас есть ситуация взаимоблокировки.
Вы должны ВСЕГДА использовать семафоры или объекты мьютекса или обмен сообщениями для синхронизации потоков. Я никогда не видел ситуации, когда занятое ожидание было правильным решением.
Когда вы используете семафор или мьютекс, планировщик никогда не должен планировать этот поток до тех пор, пока не будет выпущен семафор или мьютекс. Таким образом, ваша нить никогда не будет уходить от потоков, которые действительно работают.