Что предотвращает состояние гонки при проверке значения семафора?

Я изучаю многопоточность и пытаюсь понять концепцию семафоров и взаимных исключений. Большинство примеров, которые я нашел в Интернете, используют какую-то библиотеку (например, pthread) для реализации семафора или мьютекса, но меня больше интересует реализация простого семафора, который устанавливает критическую секцию — не более одного потока, обращающегося к определенный участок памяти.

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

Я предполагаю, что двоичный семафор содержит значение int для отслеживания блокировки:

Semaphore
---------
int lock = 1;

unsigned P(void){
    if(lock > 0){
        lock--;
        return 0; /* success */
    }
    return 1; /* fail */
}

void V(void){
    lock++;
}

Предположим, что два потока вызывают функцию P одновременно, они оба достигают проверки if(lock > 0) одновременно и оценивают условие как true — это создает состояние гонки, при котором обоим потокам предоставляется доступ к той же области памяти в то же время.

Так что же предотвращает возникновение этого состояния гонки в реальных реализациях семафоров?


person Vilhelm Gray    schedule 23.04.2013    source источник


Ответы (3)


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


С OSDev.org

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


Вот хороший статья об атомарности тоже (правда, в Delphi).

person bash.d    schedule 23.04.2013
comment
Таким образом, как только поток вызывает функцию P, он выполняет инструкции как атомарные операции, которые гарантируют, что ЦП не переключится на другой процесс в середине вызова. Предотвращает ли это также одновременный вызов функции P другим процессором и затирание памяти? - person Vilhelm Gray; 23.04.2013
comment
Во-первых верно, это гарантировано. Во-вторых, все процессы планируются планировщиком, и из-за виртуальной памяти нет другого процессора, который мог бы повредить эту память, по крайней мере, в нормальных условиях. - person bash.d; 23.04.2013
comment
@ bash.d Почти все реализации потоков позволяют нескольким процессорам одновременно обращаться к одному и тому же виртуальному адресному пространству. Никто не заботится об обеспечении атомарности через планировщик, это было бы слишком медленно для большинства практических приложений. - person Art; 23.04.2013
comment
То же самое виртуальное адресное пространство не относится к фактическому адресу в памяти, поэтому в данном случае это не имеет значения. Кроме того, кто гарантирует? - person bash.d; 23.04.2013
comment
Если операционная система не гарантирует атомарность с помощью планировщика, может ли материнская плата it предотвратить повреждение памяти вторым процессором? - person Vilhelm Gray; 23.04.2013
comment
Нет, материнская плата - это просто аппаратное обеспечение... Важно программное обеспечение. - person bash.d; 23.04.2013
comment
@VilhelmGray Короткий ответ: да, материнская плата помогает предотвратить гонки здесь. В общем, существует сложный протокол того, как процессорам разрешено взаимодействовать с памятью. Каждая строка кэш-памяти может быть записана только одним ЦП за раз, и существуют способы, которыми ЦП сигнализируют друг другу, чтобы гарантировать, что записи правильно видны другим ЦП и другим устройствам в машине. - person Art; 23.04.2013
comment
Вы говорите о микрооперациях, которые представляют собой работающую программу, а не чистое оборудование. - person bash.d; 23.04.2013
comment
@bash.d В опубликованной вами статье описывается, как if L = 1 then N:= N + K; в потоке 2 может получить K, равное 0, но как это возможно, если поток 1 является единственным поток устанавливает память и выполняется атомарно? Я думал, что переупорядочивание не может происходить в потоке, выполняющемся атомарно — и единственный поток, способный изменить значения, выполняется атомарно — поэтому не должно ли K быть равным 5, если L равно 1? - person Vilhelm Gray; 23.04.2013
comment
Нет, операции блокировки выполняются атомарно. Все заблокированные области не будут выполняться атомарно. И помните, потоки имеют общее адресное пространство и все привязаны к процессу. - person bash.d; 23.04.2013
comment
Да, это может случиться. Вы не можете предсказать какую-либо последовательность, хотя - person bash.d; 23.04.2013
comment
Итак, мой последний вопрос: как мне заставить серию операций выполняться атомарно в C? - person Vilhelm Gray; 23.04.2013
comment
Ну, atomically в этом смысле невозможен, но если вы хотите выполнять их только из одного потока в многопоточном процессе, используйте mutex или semaphore. Для этого они и существуют. Дополнительную информацию см. в Википедии. - person bash.d; 23.04.2013
comment
Подождите, но тогда мы возвращаемся к исходной проблеме: мою наивно реализованную функцию бинарного семафора можно вызывать двумя потоками одновременно, если только инструкции внутри вызова не могут выполняться атомарно — вопрос в том, как я могу гарантировать свою реализация P выполняется атомарно, как я ожидаю от известных реализаций, таких как pthread; это просто сводится к инструкциям для конкретного процессора, а не C? - person Vilhelm Gray; 23.04.2013
comment
давайте продолжим это обсуждение в чате - person Vilhelm Gray; 23.04.2013
comment
Нет, не совсем... В вашем коде у вас потенциальное состояние гонки, потому что ваш механизм блокировки не является атомарным. Поток A блокирует последовательность, к которой вы хотите получить доступ... Если поток B теперь перейдет к оператору lock, он заблокируется, потому что он не может ввести блокированный оператор. Когда поток A освобождает блокировку, поток B может проснуться и потребовать блокировку для себя. - person bash.d; 23.04.2013

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

Используя инструкцию сравнения и замены, ваш пример P может быть реализован как:

int oldlock;
retry:
oldlock = lock;
if (oldlock > 0) {
    if (compare_and_swap(&lock, oldlock, oldlock - 1))
        goto retry;
    return 0;
}
return 1;

Конечно, реальность намного сложнее, но метод сравнения и установки прост для понимания и объяснения, и у него есть приятное свойство, заключающееся в том, что он может реализовать (почти?) все другие примитивы блокировки.

Вот статья в Википедии.

person Art    schedule 23.04.2013

Разница между semaphore (или mutex) и "нормальным" variable не так уж велика. Те библиотеки, которые предлагают вам эту функциональность, просто убедитесь, что доступ к semaphore осуществляется только через атомарные операции. Есть несколько способов добиться этого:

  • Специальные инструкции по сборке, гарантирующие атомарный доступ, например: TSL или XCHG.

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

  • Использование особенностей языка, таких как ключевое слово synchronise в Java.


Пример использования инструкции TSL:

enter_region:        ; A "jump to" tag; function entry point.

  tsl reg, flag      ; Test and Set Lock; flag is the
                     ; shared variable; it is copied
                     ; into the register reg and flag
                     ; then atomically set to 1.

  cmp reg, #0        ; Was flag zero on entry_region?

  jnz enter_region   ; Jump to enter_region if
                     ; reg is non-zero; i.e.,
                     ; flag was non-zero on entry.

  ret                ; Exit; i.e., flag was zero on
                     ; entry. If we get here, tsl
                     ; will have set it non-zero; thus,
                     ; we have claimed the resource
                     ; associated with flag.

leave_region:
  move flag, #0      ; store 0 in flag
  ret                ; return to caller

Кстати, как вы уже правильно указали, mutex — это просто особый вид semaphore, допускающий только FALSE (представленный 0 в C) и TRUE (представленный 1 или любым другим значением != 0) для его внутренней переменной int . Таким образом, получается так называемый binary semaphore.

person winklerrr    schedule 30.07.2018