Pull to refresh

Comments 45

Дело в том, что внутри ядра мьютекс реализован с помощью спинлоков, а вот спинлоки реализованы сами по себе, автономно. Они — действительно базовый примитив. Ниже — только сам процессор.

Никто не мешает реализовать мьютекс что внутри ядра, что вне его при помощи атомарной compare-and-exchange операции, как это сделано с CS в Win. И ниже будет только процессор.
Ну так эти CAS операции внутри мьютексов являются частью спинлоков. Иначе как?
Я не понимаю вашего возражения.
Простой compare-and-exchange — самостоятельная операция, реализованная в CPU и спинлок ею пользуется. Как и мьютекс. Это не делает верным высказывание "мьютекс реализован с помощью спинлоков, а вот спинлоки реализованы сами по себе".
Мьютекс — это и есть lock. Spinlock — подвид lock'а, со специфичной реализацией. Поэтому, опуская детали реализации, "мьютекс реализован с помощью спинлоков, а вот спинлоки реализованы сами по себе" = "lock реализован при помощи lock'а, а вот lock — автономно, сам по себе".

Самый обычный EnterCriticalSection/LeaveCriticalSection — это тоже lock (он же mutex), который реализован на compare-and-exchange операции.
Поставлю вопрос иначе. Как на счет мьютекса без спинлока?
Никаких проблем, см. выше. На секундочку — не ведется речь об эффективности и т.п. Это чисто теоритический вопрос.
Вы немогли бы схематично изобразить код такого мьютекса?
Схематично:
1) Атомарная проверка и обмен
2) Удалось захватить? Выход
3) Не удалось — п.1

Да, это неэффективно — профессор простаивает, но это другой вопрос. Но реализуется на любом кольце. Только не говорите, пожалуйста, что переход от 3 к 1 это спин, потому что спинлок выделяется особо из-за такого цикла, прокрученного в userspace некоторое кол-во раз перед отдачей своего кванта времени и перехода ко сну в противоположность системе, где при первой неудаче происходит переход ко сну. Здесь же цикл не ограничен ничем, и он может быть на любом уровне, даже если нет разделения на kernel и userspace.
Это Вы написали спинлок. :)

А вот реализация мьютекса. Реальная. https://github.com/dzavalishin/phantomuserland/blob/master/phantom/threads/mutex.c

Попробуйте переписать без спинлока. Обратите внимание на thread_block — ему передаётся запертый спинлок, который отпирается "на той стороне" переключения контекста. И не зря. Иначе может случиться ситуация, когда mutex_unlock попытался разбудить нас до того, как нить реально покинула процессор, и будет беда. Поверьте — будет. Я пробовал.
Хорошо, я допускаю, что неверно использовал термин spinlock, а вы правы по этой части, приношу мои извинения. Но так или иначе, если пользоваться этой терминологией, то спинлок в чистом виде — это тоже реальная реализация мьютекса, пусть и неэффективная.

Но это все равно не меняет того, что спинлок — только реализация синхронизационного примитива "мьютекс", а не примитив сам по себе.
Часто под mutex'ом понимают примитив, имеющим дополнительную семантику, связанную с переключением контекста при невозможности его захвата. Спинлок же сам никогда не паркует тред (если говорить про userspace) и не допускает вытеснения в принципе (если говорить про kernelspace/firmware). Из-за этой семантики классические мьютексы и семафоры не используются, если необходимо избежать переключения контекста.

Если не вносить такую семантику в понятие mutex'а, то он становится просто более общим термином в соответствии с оригинальным значением. Так что спор выглядит терминологическим.
Часто под mutex'ом понимают примитив, имеющим дополнительную семантику, связанную с переключением контекста при невозможности его захвата

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

Но да, это терминологический вопрос. Чаще всего в литературе встречается (по моему опыту) указание, как синхронизационных примитивов, мьютекса и семафора. Иногда монитора, но это отдельный разговор.
Кажется, описана критическая секция в целом.
Верно ли это? По мне, если говорить не академическим языком, мьютекс — черный ящик, который может быть в каждый момент времени захвачен только одним потоком. Спинлок ли это в чистом виде, комбинация спинлока и засыпания или попытка захвата и засыпание — детали реализации.

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

Монитор несколько более сложный концепт, который может быть описан в терминах семафоров/мьютексов и условный переменных. Или самостоятельно, но в терминах блокировки и условного пробуждения потоков.

Сразу отвечу miolini, что CS — вещь дуальная к мьютексам и др., она защищается примитивом синхронизации, как необходимое условие корректной работы программы.
У того же Таненбаума спинлок, семафор и мьютекс (который рассматривается как частный случай семаформа с максимальным значением 1) разделены

В той редакции, что есть у меня на руках, спинлок (спин-блокировка) указывается в разделе методов, а не примитивов. Наравне с запретом прерываний, например. К примитивам отнесены мьютекс, семафор и монитор. Это как раз то, о чем я и говорю — мьютекс — это абстрактный примитив, спин — способ реализации.
ОК, чтобы быть совсем точным укажу, что у меня перед глазами 3 изд. в переводе издательства "Питер", 2013.

Раздел 2.3, где описаны спинлоки, называется "Взаимодействие процессов". Конкретнее спинлоки описаны в подразделе 2.3.3 "Взаимное исключение с активным ожиданием". В следующем подразделе 2.3.4 фигурирует фраза:
Теперь рассмотрим некоторые примитивы взаимодействия процессов, которые блокируют работу, пока им не разрешается входить в критическую область, вместо напрасной траты времени центрального процессора.
Далее описаны примитивы sleep/wakeup. Ещё дальше, в 2.3.5-2.3.6 описаны семафоры и мьютексы. А после — мониторы. При чтении у меня складывается впечатление, что все описанные вещи — примитивы синхронизации, но в разных системах и на разных уровнях примитивами являются те или иные объекты, над которыми уже достраивается остальное.

Почему вы называете мьютекс примитивом, если он прекрасно определяется через семафор?

Как в userspace linux примитивом является futex, в реализации pthreads NPTL (glibc) примитивами обзывают мьютексы, join и прочие радости, хотя они реализованы через futex:
In NPTL, thread synchronization primitives (mutexes, thread joining, and so on) are implemented using the Linux futex(2) system call.

В java примитивами синхронизации будут мониторы (syncronized/wait/notify) и CAS-операции.

Как можно увидеть, есть некоторая естественная путаница, т. к. на разных уровнях абстракции примитивами IPC и синхронизации являются различные конструкции. Общее у них то, что на соответствующем уровне все примитивы для следующего уровня описываются и/или реализованы через примитивы текущего уровня. В общем, примитивы — базис описание операций синхронизации/IPC, но он не обязан быть единственным.
Раздел 2.3, где описаны спинлоки, называется «Взаимодействие процессов»… При чтении у меня складывается впечатление, что все описанные вещи — примитивы синхронизации, но в разных системах и на разных уровнях примитивами являются те или иные объекты, над которыми уже достраивается остальное

Это и так и не так. Мы сталкиваемся здесь с трудностями перевода. Дело в том, что в оригинале, в англоязычном издании, данный раздел называется Interprocess Communication. И в предисловии вы можете увидеть рассуждение о том, что communication, собственно, состоит из разных частей/проблем: как передать само сообщение физически от процесса к процессу (т.е. всякие send/receive message, доступ к address space другого процесса и т.п.), как обеспечить синхронизацию и еще один пункт.
Поэтому весь раздел в целом говорит о communication, а синхронизация, которую мы обсуждаем — только один из его аспектов. Как я назвал выше — реализация, деталь реализации.

Почему вы называете мьютекс примитивом, если он прекрасно определяется через семафор?

Да, логически можно определить мьютекс как частный случай семафора. Но изначально они введены в разное время для разных задач (producer/consumer для семафора, с множественными записью/чтением и просто разделяемый ресурс для мьютекса), к тому же мьютекс используется много чаще. Скажем так, это настолько часто используемая и важная вариация семафора, что выделяется как отдельный вид.
Кстати, в свете разговора выше о спинлоке и мьютексе: забавно, что у того же Танненбаума по этому поводу сказано:

They are(мьютексы)
easy and efficient to implement, which makes them especially useful in thread
packages that are implemented entirely in user space.
Because mutexes are so simple, they can easily be implemented in user space
proVided that a TSL or XCHG instruction is available. The code for mutex lock and
mutex_unlock for use with a user-level threads package are shown in Fig. 2-29.
The solution with XCHG is essentially the same.

mutex_lock:
TSL REGISTER,MUTEX
CMP REGISTER,#O
JZE ok
CALL thread_yield
JMP mutex_lock
ok: RET

mulex_unlock:
MOVE MUTEX,#O
RET
Figure 2·29. Implementation of mutex_lock and mutex_unlock

Вот тебе и "мьютекс или спинлок" и "каноничная реализация мьютекса".
Вот тебе и «мьютекс или спинлок» и «каноничная реализация мьютекса».
Это чистая реализация мьютекса, вызов thread_yield кагбэ намекаэ. А то, что она более эффективна, чем спинлок, так это в контексте идеального userspace'а.
Какбэ yield просто отдает квант, это спинлок спинлоком.
Собственно это обычно и отличает спинлок и мьютекс. Спинлок использует busy wait и не отдаёт свой квант (кроме вытеснения другой задачей/прерыванием), мьютекс же блокирует поток при неудачной попытке захвата. В реализации выше нет spin'а.
Это оптимизированный спинлок. :) В user mode спор не имеет смысла. Категорическая (не в плане производительности, а семантическая) разница возникает только в ядре.
В user space основная разница, естественно в производительности (в разрезе latency). Как примеры из java-мира — hotspot, где обычный лок использует различные стратегии (biased на базе CAS операций, thin на базе spinlock'а и inflated на базе системного мьютекса). Или jetty, где экономят на переключении контекстов и повторном прогреве кэша после вытеснения dispatch-потока.
Спинлок это цикл с атомарными попытками захватить ресурс. Userspace здесь не имеет значения.

"In software engineering, a spinlock is a lock which causes a thread trying to acquire it to simply wait in a loop ("spin") while repeatedly checking if the lock is available."
https://en.wikipedia.org/wiki/Spinlock
И вот (практичемски канонический) pthread mutex:

int pthread_mutex_lock(pthread_mutex_t *mutex) {
  if (mutex->kind == PTHREAD_MUTEX_NORMAL){
    if (atomic_exchange(&mutex->lock, 1) != 0) {
      while (atomic_exchange(&mutex->lock, -1) != 0) {
        if (waitone(mutex->event, INFINITE) != 0) return EINVAL;
      }
    }
  } else {
    pthread_t self = pthread_self();

    if (atomic_exchange(&mutex->lock, 1) == 0) {
      mutex->recursion = 1;
      mutex->owner = self;
    } else {
      if (pthread_equal(mutex->owner, self)) {
        if (mutex->kind == PTHREAD_MUTEX_RECURSIVE) {
          mutex->recursion++;
        } else {
          return EDEADLK;
        }
      } else {
        while (atomic_exchange(&mutex->lock, -1) != 0) {
          if (waitone(mutex->event, INFINITE) != 0) return EINVAL;
          mutex->recursion = 1;
          mutex->owner = self;
        }
      }
    }
  }

  return 0;
}
Чистый спинлок — тоже мьютекс, если исключить waitone(mutex->event, INFINITE) и использовать процессорное время полностью.
С утра очень удобно яичницу жарить будет. ;-)
А то. Но вопрос был о теоритическом делении.
Userspace здесь не имеет значения.

Да, я ошибся по этой части, мои извинения. Почему-то в голове отложилось, что спинлок это конкретно вид мьютекса со спином именно в userspace, а не любой мьютекс с проверкой в цикле.
Он будет со спинлоком. Просто код спинлока придётся написать руками. Впрочем, есть вариант — можно всю работу попробовать организовать lockless, но, боюсь, будет очень тяжко. И ещё: по некоторым причинам, переключение контекста несёт через себя запертый спинлок, который обязательно отпирается только после того, как переключение завершено. Это вообще неясно как сделать без спинлока.

На юзерлевеле этого всего не видно, а в ядре снег тает, и суть вещей проявляется. :)

Если найду время — напишу заметку про контекст свитч и синхронизацию внутри машинерии мьютексов и переключения тредов.
Спор в изрядной степени терминологический. Атомарные операции процессора сами по себе не являются достаточными и удобными — спинлок поверх них реализован не зря.

Но, в принципе, и сам мьютекс можно не делать, как и тред свитч — вписывать каждый раз в код конкретной функуции реализацию mutex_lock и thread switch...

Ну, то есть, логика дробления на уровни абстракции ( atomic CPU instruction, spinlock, mutex) необязательна и спорна. Но — сложилась. У спинлока есть некая семантика, отличная от atomic CPU instructions, и она востребована.

Но, конечно, я не настаиваю — можно сделать вид, что спинлока нет и писать вместо него его код инлайн. :)
Первая инструкция — load linked — просто читает значение из памяти. Но при этом в процессоре взводится триггер, который «следит» за считанной ячейкой — не было ли в неё записи.

Вторая — store conditional — сохраняет значение в память. Но! Если со времени исполнения load linked в эту ячейку кто-то уже записал новое значение, store conditional вернёт признак неуспеха (и, конечно, ничего в ячейку памяти записывать не будет).

А можно поподробнее?
Допустим, несколько ядер (#0,1,2) обратились к одной ячейке. Одно (#1) успело что-то записать, другое (#2) — нет. После этого к ней обратились ядра #3 и #4. Ядро #0, а потом #3 попытались что-то записать. Будет ли хотя бы одна попытка записи успешной? Когда сеанс общения с ячейкой считается законченным, и после нового LL можно будет перезаписать ячейку?
Ядро #0, а потом #3 попытались что-то записать. Будет ли хотя бы одна попытка записи успешной?

попытка ядра #0 точно не будет успешной, потому что уже была успешная запись от #1 после LL с #0, успешность попытки #3 зависит от многих обстоятельств. Например, в каких-то MIPS-ах SC может пофейлиться, например, если LL и SC лежат в памяти слишком далеко, или если на процессоре выполнившем LL были какие-то другие обращения к памяти перед выполнением SC, ну или боле простой случай, если, например, произошло какое-то исключение на этом процессоре.

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

да, можно инцировать новую RMW последовательность выполнив LL.
например, если LL и SC лежат в памяти слишком далеко

В смысле исполняемые инструкции? Ячейка одна и та же.

Рассмотрим два сценария.
`

0, #1, #2 выполнили LL
#1 выполнило SC (удачно)
#2 выполнило SC (неудачно)
#3, #4 выполнили LL (с той же ячейкой)
#0 выполнило SC (???)
#3 выполнило SC (???)



0, #1, #2 выполнили LL
#1 выполнило SC (удачно)
#2 выполнило SC (неудачно)
#3, #4 выполнили LL (с той же ячейкой)
#3 выполнило SC (???)
#0 выполнило SC (???)

`
Сценарии различаются двумя последними строчками.
Считается, что инициирована новая последовательность? Может ли #0 выполнить SC? Может ли #3 не выполнить SC?
В смысле исполняемые инструкции?

очевидно да.

В первом сценарии, #0 SC точно не удачно, а #3 SC зависит (как я уже писал). Во втором сценарии, опять же #0 безусловно неудачно, #3 опять же зависит. Конекретные причины, которые приводят к провалу SC нужно смотреть в документации процессора.

Считается, что инициирована новая последовательность?

каждый процессор выполнивший LL инициарует RMW последовательность, завершается последовательность выполнением SC, удачным или нет.
Все очень просто. С точки зрения процессора LL — это обычная команда load, а SC — обычная команда store. Они могут быть кэшированные (то есть лезут в кэш проца) и некэшированные (когда в обход кэша лезут прямо на внешнюю шину и дальше в память — DDR или что там у вас).

1. Кэшированные:
Кэшированные LL/SC требуют наличия аппаратной когерентности кэшей (именно поэтому внизу написали, что в TMS320C66xx они пропали). Предположим, что кэш пуст. Когда несколько ядер выполнят LL на ячейку памяти с каким-то одинаковым адресом, то это вызовет промах кэша во всех из них, и кэш-линия, содержащая этот адрес, будет прочитана в кэши всех ядер и во всех из них будет отмечена как «Shared» (гуглите MOESI — «Shared» это и есть «S» в этой аббревиатуре). Каждое ядро заодно запомнит внутри себя этот адрес и будет проверять по нему все последующие записи в память. Теперь, если все эти ядра выполнят SC по этому адресу абсолютно одновременно, то контроллер кэша каждого из ядер запросит перевод строки в состояние «Modified» (поскольку с точки зрения ядра это обычная запись, а запись в «Shared»-строку запрещена). Протокол MOESI регламентирует, что только одно из них получит разрешение перевести строку в «Modified», запросы от остальных будут забуферизированы и на это время они будут обязаны перевести свои строки в состояние «Invalid». Все ядра, в которых строка кэша стала «Invalid», поймут, что кто-то другой записал по этому адресу. Как только первое ядро закончит запись, начнет выполняться забуферизированный запрос от другого ядра: строка, отмеченная как «Modified», станет «Invalid», и наоборот — одна из тех, что были «Invalid», станет «Modified», и т.д. То есть контроллер кэшей будет гонять строку, содержащую наш адрес, туда-сюда между ядрами, и в каждый конкретный момент времени только у одного ядра будет разрешение на запись, но это уже к делу не отностится.
Интересный побочный эффект: поскольку протоколы когерентности кэшей работают со строками, то доступ к любому слову в этой строке будет воспринят как доступ конкретно к нашей ячейке.
2. Некэшированные:
Некэшированные LL/SC вызывают особый тип трансфера на внешней шине: «Exclusive» (в протоколе AXI, например, такой есть). И слэйв на шине (например, контроллер памяти) должен эту функциональность реализовать сам (то есть следить, какой мастер записал по какому адресу и записал ли туда другой мастер что-либо). Если он ее не реализует как положено, то LL/SC защищать ничего не будут. Зачем вообще это надо? Ну, предположим, у вас в системе два четырехядерных проца, и внутри своих четырех ядер они когерентность кэшей обеспечивают, а между собой — нет. Придется вам использовать некэшируемые LL/SC.
Это же не исторический труд… Всего не упомянешь. Хотя, признаться, я хотел про него написать, но вылетело из головы в процессе.
Это интересно. Разработчики архитектуры MIPS написали мне сегодня, что в современных MIPS-ах есть возможность при реализации спинлока использовать пару LL+PAUSE, чтобы заснуть до тех пор, пока объект, прочитанный через LL не изменится.

На минуточку, это позволяет реализовать spinless spinlock! :)
Получается примерно как на arm cortex-a/r с использованием wfe/sev?
Спасибо.

А у меня такой наивный вопрос: если спинлок — это такая нужная и важная вещь, то почему intel не реализовала его аппаратно? Т.е. процессора через собственную шину (через которую кеши инвалидируют) договариваются о блокировке, как только блокировка снята, всем ожидающим приходит уведомление. Очевидный плюс — спинлок вместо 100% CPU делает что-то типа "sleep'а".

Т.е. почему нет операции SPNLK $ADDR или как-то так?
В mips и arm есть. Может быть, и Интел уже сделал...
Любопытно, что в версиях процессоров TI C64xx команды синхронизации LL/SC были, а в TI C66xx исчезли. Насколько я понял, их роль выполняют DMA, shared memory, ещё что-то… но от атомарных команд решили отказаться.
Более того, они отказались от когерентности кэша.

См. Multicore Programming Guide

The TCI66xx and C66xx devices do not support automated cache coherency because of the power consumption involved and the latency overhead introduced. Realtime applications targeted for these devices require predictability and determinism, which comes from data coherency being coordinated at select times by the application software. As developers manage this coherency, they develop designs that run faster and at lower power because they control when and if local data must be replicated into different memories.
Sign up to leave a comment.

Articles