Устранение конфликта блокировок: размер критических секций

Managing Lock Contention- Large and Small Critical Sections [Eng., PDF 147KB]

Аннотация

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

Введение

Критические секции обеспечивают целостность данных при одновременном доступе нескольких потоков к одним и тем же ресурсам. Они также делают выполнение кода внутри себя последовательным. Потоки должны работать внутри критических секций как можно меньше времени, чтобы другие потоки меньше времени теряли на ожидание установки своей блокировки – состояние, известное как «конфликт блокировок» (lock contention). Другими словами, лучше, чтобы критические секции были маленькими. С другой стороны, использование большого количества критических секций малого размера увеличивает системные издержки, связанные с установкой и снятием большого числа блокировок. В данной статье рассматриваются разные варианты, использующие критические секции большего или меньшего размера.

В примере 1 функция Thread содержит две критические секции. Подразумевается, что эти критические секции защищают разные данные и что функции DoFunc1 и DoFunc2 работают независимо, а также, что количество времени, уходящее на работу функций update, пренебрежимо мало.

Пример 1:

 

Begin Thread Function ()
  Initialize ()

  BEGIN CRITICAL SECTION 1
    UpdateSharedData1 ()
  END CRITICAL SECTION 1

  DoFunc1 ()

  BEGIN CRITICAL SECTION 2
    UpdateSharedData2 ()
  END CRITICAL SECTION 2

  DoFunc2 ()
End Thread Function ()

 

Критические секции разделены вызовом функции DoFunc1. Если потоки проводят мало времени в теле DoFunc1, то издержки на работу двух критических секций могут быть неоправданными. В данном случае лучше объединить две маленькие критические секции в одну большую, как показано в примере 2.

Пример 2:

 

Begin Thread Function ()
  Initialize ()

  BEGIN CRITICAL SECTION 1
    UpdateSharedData1 ()
    DoFunc1 ()
    UpdateSharedData2 ()
  END CRITICAL SECTION 1

  DoFunc2 ()
End Thread Function ()

 

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

Рассмотрим вариант первого случая, при котором потоки тратят много времени на работу функции UpdateSharedData2. Использование одной критической секции для синхронизации доступа к функциям UpdateSharedData1 и UpdateSharedData2, как в примере 2, является не самым удачным решением, поскольку возрастает вероятность возникновения конфликта блокировок. При работе поток, получивший доступ в критическую секцию, проводит там значительное время, а остальные потоки остаются заблокированными. Когда поток снимает блокировку, ее сразу устанавливает другой поток, в то время как остальные, ожидающие своей очереди, вынуждены простаивать. Следовательно, в данном случае лучшим решением будет пример 1.

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

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

Пример 3:

 

Begin Thread Function ()
  Initialize ()

  BEGIN CRITICAL SECTION 1
    UpdateSharedData1 ()
  END CRITICAL SECTION 1

    DoFunc1 ()

  BEGIN CRITICAL SECTION 2
    UpdateSharedData2 ()
  END CRITICAL SECTION 2

  BEGIN CRITICAL SECTION 3
    UpdateSharedData3 ()
  END CRITICAL SECTION 3

  DoFunc2 ()
End Thread Function ()

 

Совет

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

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

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

В примере 4 показан вариант кода, который можно использовать с потоковым API Win32. В данном примере на объекте CRITICAL_SECTION установлена опция ожидания в цикле. Поток, который не получил возможность войти в критическую секцию, запускает цикл ожидания, не освобождая процессор. Если CRITICAL_SECTION освобождается до окончания цикла ожидания, то смены контекста не происходит. Параметр spinCount определяет, сколько оборотов совершит цикл перед установкой блокировки. В однопроцессорных системах этот параметр игнорируется. В данном примере счетчик цикла равен 1000 на каждый поток приложения, максимальным значением является 8000.

Пример 4:

 

int gNumThreads;
CRITICAL_SECTION gCs;

int main ()
{
  int spinCount = 0;
  ...
  spinCount = gNumThreads * 1000;
  if (spinCount > 8000) spinCount = 8000;
  InitializeCriticalSectionAndSpinCount (&gCs, spinCount);
  ...
}

DWORD WINAPI ThreadFunc (void *data)
{
  ...
  EnterCriticalSection (&gCs);
  ... 
  LeaveCriticalSection (&gCs);
}

 

Рекомендации

Параметр счетчика, использованный в примере 4, нужно определять иначе на процессорах с технологией Intel® Hyper-Threading (Intel® HT Technology), где циклы ожидания плохо сказываются на общей производительности. В отличие от настоящих симметричных многопроцессорных систем (SMP), имеющих несколько физических процессоров, технология Intel HT создаёт на одном ядре процессора два логических ядра. То есть поток, ожидающий в цикле, и другой поток, выполняющий полезную работу, оба претендуют на ресурсы одного ядра. Таким образом, поток, ожидающий в цикле в системе с Intel HT, оказывает большее негативное воздействие на производительность системы, чем в SMP системе. Величина счетчика в примере 4 должна быть уменьшена или не использоваться вовсе.

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