Насколько эффективна блокировка разблокированных мьютексов? Какова стоимость мьютекса?

На языке низкого уровня (C, C++ или что-то еще): у меня есть выбор между наличием нескольких мьютексов (например, что дает мне pthread или того, что предоставляет нативная системная библиотека) или одного для объекта.

Насколько эффективно блокировать мьютекс? То есть сколько инструкций ассемблера существует и сколько времени они занимают (в случае, если мьютекс разблокирован)?

Сколько стоит мьютекс? Является ли проблемой наличие действительно большого количества мьютексов? Или я могу просто добавить столько переменных мьютекса в мой код, сколько у меня есть переменных int, и это не имеет большого значения?

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

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


Сообщение в блоге WebKits (2016) о блокировке очень связано с этим вопросом и объясняет различия между спин-блокировкой, адаптивной блокировкой, фьютексом и т.д.

Ответ 1

У меня есть выбор между тем, у кого есть куча мьютексов или одна для объекта.

Если у вас много потоков, и доступ к объекту происходит часто, то несколько блокировок будут увеличиваться parallelism. За счет ремонтопригодности, поскольку больше блокировки означает большую отладку блокировки.

Насколько эффективно блокировать мьютексы? То есть сколько инструкций ассемблера существует и сколько времени они берут (в случае разблокировки мьютекса)?

Точные инструкции ассемблера являются наименьшими издержками мьютекс - совместимость с памятью/кешем - основные издержки. И реже происходит конкретный замок - лучше.

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

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

Блокировка разблокированных мьютексов действительно дешевая. Разблокировать мьютекс без конкуренции тоже дешево.

Сколько стоит мьютекс? Это проблема, которая действительно содержит много мьютексов? Или я могу просто перебросить в мой код как можно больше переменных mutex, поскольку у меня есть переменные int, и это не имеет большого значения?

Вы можете перебросить как можно больше переменных mutex в свой код. Вы ограничены только объемом памяти, которую может предоставить ваше приложение.

Резюме. Блокировки пользовательского пространства (и, в частности, мьютексы) дешевы и не подвержены никакому системному ограничению. Но слишком многие из них описывают кошмар для отладки. Простая таблица:

  • Меньше замков означает больше утверждений (медленные системные вызовы, стойки CPU) и меньше parallelism
  • Меньше блокировок означает меньше проблем, отлаживающих проблемы многопоточности.
  • Больше блокировок означает меньше утверждений и выше parallelism
  • Больше блокировок означает больше шансов на то, чтобы бежать в непобедимые тупики.

Необходимо найти и поддерживать сбалансированную схему блокировки для приложения, обычно балансируя # 2 и # 3.


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

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

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

Ответ 2

Я хотел знать то же самое, поэтому я измерил это. На моем компьютере (AMD FX (tm) -8150, восьмиъядерный процессор с частотой 3,612361 ГГц), Блокировка и разблокировка разблокированного мьютекса, который находится в собственной строке кэша и уже кэширован, занимает 47 часов (13 нс).

Из-за синхронизации между двумя ядрами (я использовал CPU # 0 и # 1), Я могу вызывать пару блокировки/разблокировки только один раз каждые 102 нс в двух потоках, таким образом, один раз каждые 51 нс, из чего можно сделать вывод, что для восстановления потребуется примерно 38 нс после того, как поток выполнит разблокировку, прежде чем следующий поток сможет снова его заблокировать.

Программа, которую я использовал для исследования этого, может быть найдена здесь: https://github.com/CarloWood/ai-statefultask-testsuite/blob/b69b112e2e91d35b56a39f41809d3e3de2f9e4b8/src/mutex_test.cxx

Обратите внимание, что в нем есть несколько жестко закодированных значений, специфичных для моего блока (xrange, yrange и rdtsc overhead), поэтому вам, вероятно, придется поэкспериментировать с ним, прежде чем он будет работать для вас.

График, который он производит в этом состоянии:

enter image description here

Это показывает результаты теста производительности в следующем коде:

uint64_t do_Ndec(int thread, int loop_count)
{
  uint64_t start;
  uint64_t end;
  int __d0;

  asm volatile ("rdtsc\n\tshl $32, %%rdx\n\tor %%rdx, %0" : "=a" (start) : : "%rdx");
  mutex.lock();
  mutex.unlock();
  asm volatile ("rdtsc\n\tshl $32, %%rdx\n\tor %%rdx, %0" : "=a" (end) : : "%rdx");
  asm volatile ("\n1:\n\tdecl %%ecx\n\tjnz 1b" : "=c" (__d0) : "c" (loop_count - thread) : "cc");
  return end - start;
}

Два вызова rdtsc измеряют количество часов, необходимое для блокировки и разблокировки mutex (с накладными расходами в 39 часов для вызовов rdtsc на моем ящике). Третий ассм - это петля задержки. Размер цикла задержки на 1 счет меньше для потока 1, чем для потока 0, поэтому поток 1 немного быстрее.

Вышеуказанная функция вызывается в узком цикле размером 100 000. Несмотря на то, что функция немного быстрее для потока 1, оба цикла синхронизируются из-за вызова мьютекса. Это видно на графике из того факта, что число тактов, измеренных для пары блокировка/разблокировка, немного больше для потока 1, чтобы учесть более короткую задержку в цикле под ним.

На приведенном выше графике нижняя правая точка представляет собой измерение с задержкой loop_count, равной 150, и затем, следуя точкам внизу, влево, loop_count уменьшается на единицу для каждого измерения. Когда она становится 77, функция вызывается каждые 102 нс в обоих потоках. Если впоследствии loop_count уменьшается еще больше, то больше невозможно синхронизировать потоки, и мьютекс начинает фактически блокироваться большую часть времени, что приводит к увеличению количества часов, которое требуется для блокировки/разблокировки. Также из-за этого увеличивается среднее время вызова функции; таким образом, точки заговора теперь идут вверх и вправо снова.

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

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

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

ОБНОВЛЕНИЕ: я получил гораздо больше знаний по этому вопросу сейчас и начинаю сомневаться в заключении, которое я представил здесь. Прежде всего, CPU 0 и 1 оказываются гиперпоточными; Несмотря на то, что AMD утверждает, что имеет 8 реальных ядер, безусловно, есть что-то очень сомнительное, потому что задержки между двумя другими ядрами намного больше (то есть 0 и 1 образуют пару, как и 2 и 3, 4 и 5, и 6 и 7). Во-вторых, std::mutex реализован таким образом, что он немного вращает блокировки перед тем, как фактически выполнять системные вызовы, когда не удается немедленно получить блокировку для мьютекса (что, без сомнения, будет чрезвычайно медленным). Итак, что я измерил здесь, так это абсолютную наиболее идеальную ситуацию, и на практике блокировка и разблокировка могут занять значительно больше времени на блокировку/разблокировку.

В итоге мьютекс реализован с использованием атомики. Чтобы синхронизировать атомы между ядрами, должна быть заблокирована внутренняя шина, которая замораживает соответствующую строку кэша на несколько сотен тактов. В случае, если блокировка не может быть получена, системный вызов должен быть выполнен, чтобы перевести поток в спящий режим; это, очевидно, очень медленно. Обычно это на самом деле не проблема, потому что этот поток должен спать anyway--, но это может быть проблемой с большим конфликтом, когда поток не может получить блокировку в течение времени, когда он обычно вращается, и так делает системный вызов, но CAN возьмите замок вскоре после этого. Например, если несколько потоков блокируют и разблокируют мьютекс в узком цикле, и каждый из них удерживает блокировку в течение 1 микросекунды или около того, то они могут сильно замедлиться из-за того, что их постоянно усыпляют и снова просыпают.

Ответ 3

Это зависит от того, что вы на самом деле называете "мьютексом", режимом ОС и т.д.

В минимум это стоимость операции с блокировкой памяти. Это относительно тяжелая операция (по сравнению с другими примитивными командами ассемблера).

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

Например, на процессоре Intel Core Duo, Windows XP. Операция с блокировкой: занимает около 40 циклов процессора. Вызов режима ядра (то есть системный вызов) - около 2000 циклов ЦП.

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

Ответ 4

Стоимость будет варьироваться в зависимости от реализации, но вы должны иметь в виду две вещи:

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

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

В обоих случаях инструкции относительно эффективны.

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

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

Ответ 5

Я абсолютно новичок в pthreads и mutex, но я могу подтвердить из экспериментов, что цена блокировки/разблокировки мьютекса почти равна нулю, когда нет конкуренции, но когда есть конфликты, цена блокировки чрезвычайно высока. Я запустил простой код с пулом потоков, в котором задача состояла в том, чтобы просто вычислить сумму в глобальной переменной, защищенной блокировкой мьютекса:

y = exp(-j*0.0001);
pthread_mutex_lock(&lock);
x += y ;
pthread_mutex_unlock(&lock);

Программа одним потоком суммирует 10 000 000 значений практически мгновенно (менее одной секунды); с двумя потоками (на MacBook с 4 ядрами), та же самая программа занимает 39 секунд.