Последняя статья про классические примитивы синхронизации.

(Наверное, потом напишу ещё одну про совсем уже нетипичную задачу, но это потом.)

Сегодня мы немножко заглянем в процессор. Чуть-чуть.

По сути, мы будем говорить про единственный примитив, который принципиально отличается от остальных: спинлок. Spinlock.

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

На уровне процесса всё так и есть — различия между спинлоком и мьютексом — чисто технические, вопрос реализации и производительности.

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

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

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

Итак, иерархия реализации такова: mutex/cond/sema сделаны на базе спинлоков, спинлоки — на базе атомарных операций, предоставляемых процессором. Мы в них немного заглянем сегодня.

Как устроен спинлок?

Принцип невероятно прост. Спинлок — это просто переменная, которая содержит ноль или единицу (бывают варианты).

Если ноль — спинлок свободен, и его можно захватить. Если не ноль — спинлок заперт, и нить, которая желает его захватить, будет ждать, крутясь (spin — вращение) в небольшом цикле непрерывной проверки освобождения спинлока. Вот, собственно, реализация:

    while( !  _spin_try_lock( &(sl->lock)  ) )
        while( sl->lock )
            ;


Операция _spin_try_lock — атомарная, реализована на ассемблере соответствующего процессора. Пытаемся запереть спинлок, если не удалось — крутимся в цикле, пока он заперт, потом снова пытаемся.

Вот и всё, в целом.

Теперь детали. Операция _spin_try_lock тоже очень проста:

#define	_spin_try_lock(p)	(!({  register int _r__; 	    __asm__ volatile("movl $1, %0; \n			  xchgl %0, %1" 			: "=&r" (_r__), "=m" (*(p)) ); 	    _r__; }))


Дело в том, что инструкция xchgl процессора Интел атомарно обменивает местами регистр и переменную в памяти. Атомарно с учётом возможной работы других процессоров, то есть в среде SMP.

Что происходит? Мы меняем местами значение sl->lock и регистр, в котором лежит единица. Если спинлок не был заперт (sl->lock был равен нулю), он станет равен единице и _spin_try_lock вернёт ноль — мы успешно заперли спинлок. Если sl->lock был равен единице, то в итоге sl->lock после обмена опять будет равен единице, но и результатом _spin_try_lock будет считанное предыдущее значение sl->lock — единица, что означает неуспех захвата спинлока.

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

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

Интересно, что есть совершенно иная схема работы с атомарными объектами. Процессоры mips не имеют инструкции вида «атомарно что-то сделать с ячейкой памяти». Вместо этого у MIPS есть интересная связанная пара инструкций:

LL - load linked
SC - store conditional


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

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

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


В отличие от Интеловского xchg такая пара позволяет делать не только спинлоки, но и реализовывать более сложные алгоритмы. Вернёмся к разговору о семафорах:

    rpos = atomic_add( &read_pos, 1 );
    ret = buf[rpos];
    read_pos %= sizeof(buf);


Все проблемы с этим кодом на процессоре MIPS можно решить довольно просто:

do 
{
    rpos = load_linked( &read_pos )
    ret = buf[rpos++];
    rpos %= sizeof(buf);
} while( !store_conditional( &read_pos, rpos ) );


К сожалению, такой код будет непереносим. Хотя и довольно эффективен. Впрочем, для реально критических участков можно выполнить и процессоро-зависимую оптимизацию.

Что ещё важно знать про спинлоки в среде ядра ОС (или при программировании для микроконтроллеров, где, как правило, нет режима пользователя): при захвате спинлока прерывания должны быть запрещены.

Дело в том, что спинлок может защитить нас от другого процессора, но не от нашего же — если внутри запертого спинлока случится прерывание и в прерывании процессор попробует захватить тот же спинлок, наступит клинч: до возврата из прерывания отпереть спинлок некому, а прерывание не закончится, пока спинлок не отперт.

Поэтому типовая реализация спинлоков или запрещает прерывания сама, или, по крайней мере, проверяет, не забыл ли их запретить вызывающий код.

Возвращаясь к вопросу о мьютексах и спинлоках: внутри спинлока много чего нельзя. Нельзя спать — нас ждут, нельзя переключать контекст (отдавать процессор) — это чревато дедлоком аналогично ситуации с прерываниями. Нельзя вызывать функции, которые имеют шанс выполнить вышеперечисленное. Например, в ядре или в коде микроконтроллера может быть невозможно вызвать malloc — как правило, он сам синхронизирован и может при нехватке памяти ожидать её освобождения. Ну и, например, могут быть под запретом функции записи в лог — особенно если они пытаются отправить данные на сервер логирования через syslog over UDP.

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

На этом сочтём обзор традиционных примитивов синхронизации завершённым. Через недельку я постараюсь вернуться к теме и окончательно взорвать вам мозг статьей о проблемах реализации примитивов синхронизации в среде персистентной виртуальной памяти. :)

Это серия статей «Обзор примитивов синхронизации»:

Комментарии (38)


  1. MacIn
    06.03.2016 18:06
    +1

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

    Никто не мешает реализовать мьютекс что внутри ядра, что вне его при помощи атомарной compare-and-exchange операции, как это сделано с CS в Win. И ниже будет только процессор.


    1. miolini
      06.03.2016 19:00

      Ну так эти CAS операции внутри мьютексов являются частью спинлоков. Иначе как?


      1. MacIn
        06.03.2016 19:11

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

        Самый обычный EnterCriticalSection/LeaveCriticalSection — это тоже lock (он же mutex), который реализован на compare-and-exchange операции.


        1. miolini
          06.03.2016 19:12

          Поставлю вопрос иначе. Как на счет мьютекса без спинлока?


          1. MacIn
            06.03.2016 19:14

            Никаких проблем, см. выше. На секундочку — не ведется речь об эффективности и т.п. Это чисто теоритический вопрос.


            1. miolini
              06.03.2016 19:15

              Вы немогли бы схематично изобразить код такого мьютекса?


              1. miolini
                06.03.2016 19:19

                Без спинлока.


              1. MacIn
                06.03.2016 19:38
                -3

                Схематично:
                1) Атомарная проверка и обмен
                2) Удалось захватить? Выход
                3) Не удалось — п.1

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


                1. dzavalishin
                  06.03.2016 19:44
                  +6

                  Это Вы написали спинлок. :)

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

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


                  1. MacIn
                    06.03.2016 19:49
                    -1

                    Хорошо, я допускаю, что неверно использовал термин spinlock, а вы правы по этой части, приношу мои извинения. Но так или иначе, если пользоваться этой терминологией, то спинлок в чистом виде — это тоже реальная реализация мьютекса, пусть и неэффективная.

                    Но это все равно не меняет того, что спинлок — только реализация синхронизационного примитива "мьютекс", а не примитив сам по себе.


                    1. grossws
                      06.03.2016 20:09
                      +2

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

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


                      1. MacIn
                        06.03.2016 20:14

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

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

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


                        1. miolini
                          06.03.2016 20:15

                          Кажется, описана критическая секция в целом.


                          1. MacIn
                            06.03.2016 20:16

                            Это тоже не примитив.


                        1. grossws
                          07.03.2016 02:46

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

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

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

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


                          1. MacIn
                            07.03.2016 03:14

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

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


                            1. grossws
                              07.03.2016 05:03

                              ОК, чтобы быть совсем точным укажу, что у меня перед глазами 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, но он не обязан быть единственным.


                1. miolini
                  06.03.2016 19:44
                  +1

                  Спинлок это цикл с атомарными попытками захватить ресурс. 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


                  1. miolini
                    06.03.2016 19:48

                    И вот (практичемски канонический) 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;
                    }


                    1. MacIn
                      06.03.2016 19:53

                      Чистый спинлок — тоже мьютекс, если исключить waitone(mutex->event, INFINITE) и использовать процессорное время полностью.


                      1. miolini
                        06.03.2016 19:54
                        +2

                        С утра очень удобно яичницу жарить будет. ;-)


                        1. MacIn
                          06.03.2016 19:55

                          А то. Но вопрос был о теоритическом делении.


                  1. MacIn
                    06.03.2016 19:51
                    +1

                    Userspace здесь не имеет значения.

                    Да, я ошибся по этой части, мои извинения. Почему-то в голове отложилось, что спинлок это конкретно вид мьютекса со спином именно в userspace, а не любой мьютекс с проверкой в цикле.


            1. dzavalishin
              06.03.2016 19:39
              +2

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

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

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


        1. dzavalishin
          06.03.2016 19:35
          +2

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

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

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

          Но, конечно, я не настаиваю — можно сделать вид, что спинлока нет и писать вместо него его код инлайн. :)


  1. Mrrl
    06.03.2016 20:52

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

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

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


    1. kmu1990
      06.03.2016 21:23

      Ядро #0, а потом #3 попытались что-то записать. Будет ли хотя бы одна попытка записи успешной?

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

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

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


      1. Mrrl
        06.03.2016 23:04

        например, если 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?


        1. kmu1990
          06.03.2016 23:14

          В смысле исполняемые инструкции?

          очевидно да.

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

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

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


  1. alecv
    07.03.2016 20:48

    А где же ссылка на великого Дейкстру и операции P (Probeer) и V (Verhoog) (голл.) ?


    1. dzavalishin
      08.03.2016 02:35

      Это же не исторический труд… Всего не упомянешь. Хотя, признаться, я хотел про него написать, но вылетело из головы в процессе.


  1. dzavalishin
    08.03.2016 02:38

    Это интересно. Разработчики архитектуры MIPS написали мне сегодня, что в современных MIPS-ах есть возможность при реализации спинлока использовать пару LL+PAUSE, чтобы заснуть до тех пор, пока объект, прочитанный через LL не изменится.

    На минуточку, это позволяет реализовать spinless spinlock! :)


    1. grossws
      08.03.2016 02:54

      Получается примерно как на arm cortex-a/r с использованием wfe/sev?


      1. dzavalishin
        08.03.2016 11:35

        Да, на вид — очень похоже.


  1. amarao
    08.03.2016 11:54

    Спасибо.

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

    Т.е. почему нет операции SPNLK $ADDR или как-то так?


    1. dzavalishin
      08.03.2016 12:11
      +1

      В mips и arm есть. Может быть, и Интел уже сделал...


  1. Mrrl
    09.03.2016 12:08

    Любопытно, что в версиях процессоров TI C64xx команды синхронизации LL/SC были, а в TI C66xx исчезли. Насколько я понял, их роль выполняют DMA, shared memory, ещё что-то… но от атомарных команд решили отказаться.


    1. encyclopedist
      12.03.2016 14:25

      Более того, они отказались от когерентности кэша.

      См. 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.