company_banner

ARM и программирование без блокировок

Автор оригинала: Брюс Доусон
  • Перевод


Выпуск ARM-процессора Apple M1 вдохновил меня на то, чтобы написать в Твиттер про опасности программирования без блокировок (lock-free). Этот твит вызвал бурную дискуссию. Обсуждение прошло довольно неплохо, учитывая то, что попытки втиснуть в рамки Твиттера обсуждениие такой сложной темы, как модели памяти центрального процессора, — в принципе бессмысленны. Но у меня осталось желание немного раскрыть тему.

Этот пост задуман не только как обычная вводная статья про опасности программирования без блокировок (о которых я в последний раз писал около 15 лет назад), но и как объяснение, почему слабая модель памяти ARM ломает некоторый код, и почему этот код, вероятно, не работал изначально. Я также хочу объяснить, почему стандарт C++11 значительно улучшил ситуацию в программировании без блокировок (несмотря на возражения против противоположной точки зрения).

Необходимая оговорка: если в вашем коде блокировки встречаются каждый раз, когда несколько потоков используют одни и те же данные, вам не о чем беспокоиться. Правильно реализованные блокировки позволяют избежать проблемы data races — конкуренции из-за обращения к одним и тем же данным (в отличие от некудышного замка из этого видео).

Основные проблемы программирования без блокировок лучше всего объяснять на примере паттерна производитель/потребитель, в котором не используются блокировки и поток-производитель выглядит следующим образом (псевдокод C ++ без деления на функции):

// Поток-производитель

Data_t g_data1, g_data2, g_data3;
bool g_flag
g_data1 = calc1();
g_data2 = calc2();
g_data3 = calc3();
g_flag = true; // Сигнализируем, что данные можно использовать.

А вот поток-потребитель, который извлекает и использует данные:

// Поток-потребитель
if (g_flag) {
  DoSomething(g_data1, g_data1, g_data2, g_data3);

Здесь опущено множество деталей: Когда сбрасывается g_flag? Как потоки избегают простоя (spinning)? Но этого примера достаточно для наших целей. Вопрос в том, что не так с этим кодом, особенно с потоком-производителем?

Основная проблема в том, что этот код предполагает, что данные будут записываться в три переменные g_data до флага, но этот код не может этого гарантировать. Если компилятор переставит местами инструкции записи, то флаг g_flag может получить значение true до того, как будут записаны все данные, и поток-потребитель увидит неверные значения.

Оптимизирующие компиляторы могут очень интенсивно менять местами инструкции. Это один из способов заставить сгенерированный код работать быстрее. Компиляторы делают это, чтобы использовать меньше регистров, лучше использовать конвеер процессора, или просто из-за какой-то случайной эвристики, которую добавили, чтобы немного ускорить загрузку Office XP. Нет смысла слишком много думать о том, почему компиляторы могут что-то переставлять, важно понимать, что они это умеют и практикуют.

Компиляторам разрешено переставлять местами инструкции записи, потому что есть правило as-if, которое гласит, что компилятор выполнил свою работу, если программа, которую он генерирует, ведет себя так, «как если бы» её не оптимизировали. Поскольку абстрактная машина C/C ++ долгое время допускала только однопоточное выполнение — без внешних наблюдателей — вся эта перестановка инструкций записи была правильной и разумной и использовалась десятилетиями.

Весь вопрос в том, что нужно сделать, чтобы компилятор не ломал наш красивый код? Давайте на минуточку представим себя программистом 2005 примерно года, который пытается сделать так, чтобы этот код сработал. Вот несколько не очень хороших идей:

  1. Объявить g_flag как volatile. Это не позволит компилятору пропустить чтение/запись g_flag, но, к удивлению многих, не избавит от основной проблемы — перестановки. Компиляторам запрещено переставлять инструкции чтения/записи volatile-переменных относительно друг друга, но разрешено переставлять их относительно обычных инструкций чтения/записи. То, что мы добавили volatile, никак не решает нашу проблему перестановок (/volatile:ms на VC ++ решает, но это нестандартное расширение языка, из-за которого код будет выполняться медленнее).
  2. Если недостаточно объявить g_flag как volatile, тогда давайте попробуем объявить все четыре переменные как volatile! Тогда компилятор не сможет изменить порядок записи и наш код будет работать… на некоторых компьютерах.

Оказывается, не только компиляторы любят переставлять инструкции чтения и записи. Процессоры тоже любят это делать. Не нужно путать это с out-of-order исполнением, всегда незаметным для вашего кода, к тому же на самом деле есть in-order процессоры, которые меняют порядок чтения/записи (Xbox 360), и есть out-of-order процессоры, которые в большинстве случаев не меняют местами чтение и запись (x86/x64).

Таким образом, если вы объявите все четыре переменные как volatile, вы получите код, который будет правильно исполняться только на x86/x64. И этот код потенциально неэффективен, потому что при оптимизации нельзя будет удалить никакие операции чтения/записи этих переменных, а это может привести к лишней работе (например, когда g_data1 дважды передается в DoSomething).

Если вас устраивает неэффективный непортируемый код, можете остановиться на этом, хотя я думаю, что мы можем сделать лучше. Но давайте при этом останемся в рамках вариантов, которые у нас были в 2005 году. Тогда нам придётся использовать… барьеры памяти.

Чтобы предотвратить перестановку в x86/x64, нужно использовать компиляторный барьер памяти. Вот как это делается:

g_data1 = calc1();
g_data2 = calc2();
g_data3 = calc3();
_ReadWriteBarrier(); // Только для VC++ и уже deprecated, но для 2005 это окей.
g_flag = true; // Сигнализируем, что данные можно использовать.

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

Проблема в том, что этот код не будет работать на процессорах со слабой моделью памяти. «Слабая модель памяти» означает, что процессоры могут переставлять инструкции чтения и записи (для большей эффективности или простоты реализации). К таким процессорам относятся, например, процессоры с архитектурами: ARM, PowerPC, MIPS и практически все используемые сейчас процессоры, кроме x86/x64. Эту проблему решает тот же барьер памяти, но на этот раз это должна быть инструкция процессора, которая сообщает ему, что менять порядок не нужно. Что-то вроде этого:

g_data1 = calc1();
g_data2 = calc2();
g_data3 = calc3();
MemoryBarrier(); // Дорогостоящий полный барьер памяти (full memory barrier). Только для Windows.

g_flag = true; // Сигнализируем, что данные можно использовать.

Конкретная реализация MemoryBarrier зависит от процессора. На самом деле, как сказано в комментарии в коде, MemoryBarrier здесь не идеальный выбор, потому что нам просто нужен барьер между записями в память (write-write) вместо гораздо более дорогого полного барьера памяти (full memory barrier), который заставляет операции чтения ждать окончательного завершения операции записи. Но сейчас этого достаточно для наших целей.

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

#ifdef X86_OR_X64
#define GenericBarrier _ReadWriteBarrier
#else
#define GenericBarrier MemoryBarrier
#endif
g_data1 = calc1();
g_data2 = calc2();
g_data3 = calc3();
GenericBarrier(); // И почему мне пришлось самому это писать?
g_flag = true; // Сигнализируем, что данные можно использовать.

Если у вас есть код 2005 приблизительно года, и он без этих барьеров памяти, то ваш код не работает и никогда не работал как надо, на всех процессорах, потому что компиляторам всегда разрешалось переставлять инструкции записи. С барьерами памяти, реализованными для разных компиляторов и платформ, ваш код будет красивым и портируемым.

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

Код, который я привёл выше, может содержать ошибки (как реализованы эти барьеры?), к тому же он избыточен и неэффективен. К счастью, когда появился C++11, у нас появились варианты получше. На самом деле до C ++ 11 в языке не было модели памяти, было лишь встроено неявное предположение, что весь код является однопоточным, и если вы меняете общие данные не под блокировкой, то пусть бог простит вашу грешную душу. В C ++ 11 появилась модель памяти, которая признаёт существование потоков. Стало очевидным, что приведенный выше код без барьеров не работал, но одновременно у нас появились возможности, чтобы его исправить, например:

// Поток-производитель

Data_t g_data1, g_data2, g_data3;
std::atomic<bool> g_flag // Обратите внимание!
g_data1 = calc1();
g_data2 = calc2();
g_data3 = calc3();
g_flag = true; // Сигнализируем, что данные можно использовать.

Небольшое изменение, которое легко не заметить. Я только изменил тип данных g_flag с bool на std :: atomic . Для компилятора это означает — не игнорировать инструкции чтения и записи этой переменной (ну, в основном), не менять порядок чтения и записи между чтением и записью в эту переменную и, при необходимости, добавлять соответствующие процессорные барьеры памяти. Мы даже можем немного оптимизировать этот код:

// Поток-производитель

Data_t g_data1, g_data2, g_data3;
std::atomic<bool> g_flag
g_data1 = calc1();
g_data2 = calc2();
g_data3 = calc3();
g_flag.store(true, std::memory_order_release);

С помощью memory_order_release мы сообщаем компилятору, что именно мы делаем, чтобы он мог использовать соответствующий (менее затратный) тип инструкции барьера памяти или, наоборот, вообще не использовать барьер памяти (в случае x86/x64). Наш код теперь относительно чист и наиболее эффективен.

Теперь написать поток-потребитель очень просто. Фактически, с новым описанием g_flag исходная версия потока-потребителя теперь верна! Но мы можем ещё немного улучшить её:

// Поток-потребитель

if (g_flag.load(std::memory_order_acquire)) {
  DoSomething(g_data1, g_data1, g_data2, g_data3);

Флаг std :: memory_order_acquire сообщает компилятору, что нам не нужен полный барьер памяти. Барьер чтения-захвата (read-acquire) гарантирует, что до g_flag нет чтения общих данных, не блокируя при этом другие перестановки.

Здесь я предоставлю читателю возможность потренироваться, и самому закончить пример так, чтобы потоки могли избежать работы вхолостую (Busy-Wait) и других проблем.

Если вы хотите научиться использовать этот подход, советую вам начать с внимательного изучения статей: Jeff Preshing’s introduction to lock-free programming или This is Why They Call It a Weakly-Ordered CPU. После этого подумайте, не лучше ли вам уйти в монастырь (мужской или женский). Lock-free programming — это самое опасное оружие в арсенале C++ (а это о многом говорит) и применять его стоит лишь в редких случаях.

Примечание: Чтобы написать x86-эмулятор на ARM, придётся помучаться с этим подходом, потому что никогда не знаешь, в какой момент перестановка инструкций станет проблемой. Из-за этого приходится вставлять много барьеров памяти. Или можно следовать стратегии Apple, то есть добавить в CPU режим, который обеспечивает порядок доступов к памяти как в x86|x64 и включать его при эмуляции.
Яндекс
Как мы делаем Яндекс

Комментарии 59

    –1
    Иронично, что язык, который пытается всячески откреститься от своего C-прошлого и предоставляет все более и более высокоуровневые языковые конструкции, предлагает думать на уровне команд процессора (причем весьма продвинутых), чтобы писать корректные программы.

      +3

      От мыслей о барьерах и слабой модели памяти вас не защитит и C# с Java :-)

        +11
        «В компьютерных науках есть только две сложные проблемы – инвалидация кэша, придумывание названий и выход за границы» — Фил Карлтон
          +5
          но, позвольте! у вас тут три пробле… oh shit!!!
          +2
          C++ это язык, который пытается всячески преодолеть ограничения своего C-прошлого, не принося в жертву эффективность и скорость, предоставляя широкий набор возможностей и инструментов. Но такой подход опасен тем, что его пользователю предоставляется полная свобода прострелить себе ноги. А lock-free подход — это самое опасное оружие из этого арсенала, которое при неумелом использовании может не прострелить, а целиком отстрелить обе ноги сразу %)
          Если для вас это проблема — не используйте lock-free. А многопоточное программирование само по себе очень сложная тема.
            0
            Многопоточно программировать очень легко на python, спасибо gil :)
          0
          Что то меня наводит на мысль, что одно атомарное поле, как замок, над пачкой не атомарных, тоже хорошо работает «случайно».
            +1
            Работа с g_data1, g_data2, g_data3 действительно происходит неатомарно. Но работа с этими данными заканчивается строго до того (happens before), как происходит атомарное изменение флага. Поэтому пофиг, как именно происходит взаимодействие с g_data*, параллельных обращений к ним нет. Это верно для ограниченного примера, где среди читателей есть только пример с
            if (g_flag) f(g_data1, g_data2, g_data3);

            Если бы было чтение или запись в эти переменные до проверки g_flag, то действительно была бы гонка, а с т.з. стандарта языка — UB.
              0
              Это не соотвествует действительности. Так было бы если использовался std::atomic_thread_fence. В данном случае атомарный доступ к флагу никак не влияет на видимость полей g_data1, g_data2, g_data3. Более того, даже компилятор может вызовы местами переставить.
                0
                по-моему, все же влияет, и segoon прав:

                «A store operation with this memory order performs the release operation: no reads or writes in the current thread can be reordered after this store. All writes in the current thread are visible in other threads that acquire the same atomic variable»

                (взято отсюда: en.cppreference.com/w/cpp/atomic/memory_order)
                  0
                  Ключевое в этой фразе
                  the same atomic variable
                  .
                  Т.е. упорядочиваются все операции чтения/записи в конкретную атомик переменную.
                  На уровне x86 CPU — это означает синхронизацию(очистка store-buffer) конкретной кеш линии с переменной, а не вообще всей памяти.
                    0
                    нет. При release-семантике все операции записи (а не только в атомики), сделанные до записи в атомик A с release-барьером становятся видимыми другим тредам, но только при условии, что они выполняют чтение этой переменной (A) с acquire-барьером. Если они читают другой атомик B с acquire-барьером, то тогда не гарантируется, что они увидят все операции записи, в том числе в A, в нужном порядке, и вообще их увидят, что логично, так как B может в другой кэш-линии лежать.
              0
              работает «случайно».

              #include <atomic>
              void test(int *ptr1, int*ptr2, std::atomic<bool> f)
              {
                *ptr1 = 1;
                *ptr2 = 2;
                f.store(true, std::memory_order_release);
              }
              
              test(int*, int*, std::atomic<bool>):                // @test(int*, int*, std::atomic<bool>)
                      mov     w8, #1
                      mov     w9, #2
                      str     w8, [x0]
                      str     w9, [x1]
                      stlrb   w8, [x2]
                      ret
              

              STLRB — Store-release register byte

              A store-release will be observed by each observer after that observer observes any loads or stores that appear in program order before the store-release, but says nothing about loads and stores appearing after the store-release.

              Т.е. запись во флаг (STLRB) выполняется только после того как другие ядра увидят все чтения и записи выполненные до инструкции stlrb в порядке следования программы.
                0
                К сожалению, я не могу аргументированно спорить, т.к. не знаю модели памяти С++ и гарантий которые она дает.
                Но в примере выше, по факту это барьер памяти, который требует выдавить из буферов записи все перед тем как запишет сам.
                На этой платформе это так, а как будет на остальных?
                Пойду как почитаю про модель памяти в с++)
              –2
              От блокировок избавится очень просто: достаточно объявить данные только для чтения, и только для записи. То-есть поток данных будет всегда однонаправленным. Нужно двухстороннее общение — вот вам два потока.
              К тому-же в ARM на уровне команд реализована атомарная передача данных, для случаев когда объект на запись доступен всем.
                0

                Нет такого.

                  0
                  Есть.
                  volatile — всегда сохранять в память
                  const volatile — всегда читать из памяти
                  Эти комбинации модификаторов сами по себе являются барьерами памяти. То-есть они будут безусловно выполнены, даже если есть валидная копия в регистре. Но это не спасает от оптимизатора — чтение/запись могут быть буквально где у годно. И тогда приходят на помощь операторы условия и цикла — способ выполнить участок кода в строго определённом месте.

                  Атомарная передача для ARM __STREXW(), и так далее.
                    0
                    const volatile, кстати — неопределённое поведение. const-значения не могут менять свои значения, а значит компилятор при оптимизации может пропустить повторное чтение, даже если это volatile. Т.е. первое чтение будет по правилам volatile, а последующие могут быть опущены. И даже видел такой эффект на каком-то компиляторе.
                    Но на практике практически всегда работает, конечно.
                    И то, о чём вы говорите ни разу не помогает в обсуждаемом вопросе — на ARM чтение вашего const volatile точно так же может быть исполнено ПОСЛЕ того, как начнётся работа этим значением — это аппаратная особенность. Т.е. всё равно нужны платформо-зависимые барьеры чтения/записи, а значит, ничего не меняется — хоть оно двунаправленное, хоть однонапрвленное.
                      0
                      По стандарту это не так. Сonst всего лишь говорит что семантика переменной такова что её нельзя изменить в данном контексте. Компилятор не может рассчитывать, и почти никогда не рассчитывает, что сonst переменная не может быть изменена извне. На практике это является большим препятствием для возможных оптимизаций. Простейший пример:
                      int var1 = ...
                      ...
                      const int& var2 = var1;
                      

                      Var2 просто может появляться в контексте в котором в неё нельзя писать, и всё. Тоже самое относиться и к классам и их методам.
                      Volatile — не разрешает кеширование переменной. Т.е. её чтение всегда должно производится из памяти. Это легко можно проверить посмотрев выход ассемблера.
                      Const volatile — заставляет всегда читать значение переменной из памяти и запрещает туда писать, но значение может бы изменено извне.
                      Модификатор volatile также не имеет никакого отношения к многопоточности и не может заставить компилятор вставлять барьеры и не управляет когерентностью кешей.
                        0
                        Нет, не так.
                        По станандарту изменение const-значения приводит к неопределнному поведению вне зависимости от volatile. Вот, например цитата из C++11 раздел 1.9 пункт 4 (повторяется в стандарте несколько раз):
                        Certain other operations are described in this International Standard as undefined (for example, the effect of attempting to modify a const object).
                        Аналогичное правило есть во всех остальных версиях стандарта.

                        В результате компилятор имеет право кэшировать volatile const значения. Да, он обязан прочитать их в месте использовани и соблюсти семантику volatile, но только один раз.
                        Сюрприз-сюрприз.
                          0
                          А вот тут люди не согласны isocpp.org/wiki/faq/const-correctness#aliasing-and-const и приводят примерно тот же пример, то выше. Да и ваша цитата не говорит ничего про данную ситуацию. Модификация константного объекта и модификация неконстантного объекта, на который где-то там существует константная ссылка, это совершенно разные вещи. Запретить последнее это надо сильно умнее компилятор иметь по типу раста. Поэтому в примере выше var1 можно спокойно менять и var2 будет у всех под ногами меняться, а вот var1 через var2 поменять не сможем. Ну разве что через const_cast, ибо ссылаемся на неконстантный объект. Опять же, совершенно легально.
                            0
                            Я лишь хотел сказать, что вы не можете рассчитывать на то, что const будет оптимизирован (кеширован). Пример который вы привели не противоречит этому. Стандарт говорит о том что будет если вы «хакерскими» методами (грубо reinterpret_cast) попытаетесь поменять значение которое объявлено как const. Представьте что "..." это граница между разными файлами или модулями. Компилятор спокойно в праве не догадаться меняется переменная из вне или нет, а это будет важно в случае если это указатели, ведь переная не обязательно имеет POD тип. Это может быть массив, структура или класс, поэтому всегда гарантированно сделать оптимизацию он не может: ему возможно понадобиться знать адрес, инициализация окажется очень сложной и т.д.:
                            const int var = 5; // Думаю всегда заоптимизируется

                            const int var1 = 5; // Скорее всего не будет оптимизирован
                            const int& var2 = &var1;

                            const MyClass var1(..., ..., ...); // Зависит от класса, но рассчитывать на это я бы не стал
                            

                              0
                              то, что на мутабельную переменную есть const ссылка, не делает её иммутабельной
                                0
                                Я как раз об этом и говорю. const в С++ это, по большому счету, бесполезная конструкция. Она призвана только какие-то инварианты программиста соблюдать. На генерацию кода, я думаю, она практически не влияет кроме самых самых очевидных случаев. С++ не имеет понятия и поддержки иммутабельности, он не умеет трекать какие ссылки когда были созданы, чтобы понять, что константный указатель на самом деле ссылается на неконстантную переменную. Мало того, что const сам по себе бесполезен по-определению, так еще дано полно инструментов для его легального обхода, что полностью связывает руки компилятору.
                                  0
                                  По сути я с вами согласен, но вот утверждение что const бесполезно, это вы слишком… Сonst бывает бесполезен с точки зрения оптимизации, особенно с ссылками и указателями с точки зрения алиасинга. Но также const может задавать константность методов где наоборот можно добиться гарантированной оптимизации, типа:
                                  const Huge& MyClass::GetHuge() const
                                  {
                                      return m_huge;
                                  }
                                  
                                  Huge MyClass::GetHuge()
                                  {
                                      return m_huge;
                                  }
                                  Я всегда пишу код соблюдающий const-корректность и это сильно помогает, но все же, думаю, лучше рассматривать const не как оптимизацию, а как выражение семантики неизменяемости с точки зрения внешнего кода. Т.е. мы гарантированно не можем поменять состояние объекта снаружи, но это не значит что он не может поменяться откуда-то еще.
                                  Но человек же утверждает что const отменяет volatile и вот это уже очень странно, т.к. такое поведение полностью нарушает логику кода, на мой взгляд.
                                0
                                Ваш пример про преобразование типов. Если const убрать (через привидение типов или через указатель), то несомненно всё будет иначе работать, но с точки зрения компилятора это будет другое значение с другими атрибутами и другим поведением, что очевидно.
                                  0
                                  Нет, мой пример про
                                  int var1 = 1;
                                  const int& var2 = var1;
                                  var1 = 2; //var2 тоже станет равен 2 и это легально
                                  


                                  А про const_cast это лишь к тому, что в языке столько инструментов для того, чтобы сломать const, что у компилятора нет практически никаких шансов чего-то там закэшировать. Уж точно он не может кэшировать значение, на которое ссылается константный указатель. Это значение может поменяться в любой момент. Вот сам указатель пусть кэширует сколько влезет.
                                    0
                                    Нет, там такого. Там указатели, а вы тут пример ссылки приводите. Ваш пример ссылками некорректен и однозначно приводит к неопределённому поведению поведению. У вас в этом примере вообще aliasing жестокого нарушен и все современные компиляторы на этом споткнутся.
                                      0
                                      Там указатели, а вы тут пример ссылки приводите

                                      Указатель/ссылка здесь абсолютно эквивалентны. Заменяем & на * и пример остается ровно таким же.

                                      Ваш пример ссылками некорректен

                                      Я вам скинул ссылку на isocpp.org (где как раз про алиазинг речь), где черным по белому говорят, что это корректный код. Давайте тогда цитату из стандарт, если считаете это UB. Ваш цитата выше тут не подходит.
                                        0
                                        Указатель и ссылка с точки зрения компилятора и стандарта ни разу не эквивалентны.
                                0
                                Стандарт имеет в виду «хакерское» изменение переменной, типа:
                                const int var = 5;
                                ...
                                *const_cast<int&>(&var) = 6;

                                Volatile же как раз и нужен для того, что бы сказать компилятору что бы он не кешировал переменную. У вас же получается что const отменяет volatile. Вы можете привести ссылку на стандарт где бы было об этом прямо написано?
                                  0
                                  В стандарте нет исключения для const volatile. Там просто написано, что изменение const — неопределённое поведение.
                                  volatile влияет на const, не так как вы думаете. В почему-то много додумываете за стандарт. Для volatile будет соблюдён порядок выполнения относительно других volatile. Само чтение будет исполнено. Но результат может быть запомнен! Где тут «отмена» const? Без «volatile» компилятор вообще может переставить чтение в другое место или опустить его если значение не используется, так что ничего не отменяется. Но кэшировать значение компилятор может.
                              0
                              const volatile, кстати — неопределённое поведение

                              Это не свойства переменной, это модификатор памяти где она находится. Значение переменной, и конкретные байты в физической памяти, где лежит эта переменная — могут иметь разные модификаторы.
                              Есть очень нехорошая шутка «register volatile», за которую могут оторвать руки и ноги. А если не дойдёт с четвёртого раза, то и голову.
                                0
                                Register де-факто уже давным-давно не имеет никакого влияния на компилятор, а начиная с С++17 и де-юре.
                        +1

                        Кстати, раз тут вспомнили М1 — то там есть возможность нативно отключить для потока слабую модель памяти и использовать TSO ;-)

                          0

                          А эта возможность где-то задокументирована или доступна в пользовательском коде? Или вы просто из существования rosetta 2 делаете вывод?

                            0
                              0

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

                          +1
                          TL;DR: на x86 переданный в атомарные операции memory_order по сути не имеет значения, а на ARM имеет. Благо там по умолчанию std::memory_order_seq_cst, поэтому если вы не меняли этот аргумент, у вас всё продолжит работать. Если меняли, значит наверно подумали зачем?
                            0
                            Почему не имеет? Это так же указание оптимизатору, что ему позволено с кодом делать.
                            0

                            Вопрос, разве g_dataN не должны быть тоже атомарными (например для простых типов), что бы memory_order при g_flag.store() имел на них влияние?

                              0

                              Смотрите, тут есть два вопроса.


                              Один — это доступ к разделяемым данным. Атомарные операции над атомарными типами позволяют нескольким процессорам наблюдать определённые состояние атомарных величин. Естественно, если процессоры совместно, одновременно выполняют неатомарные операции с разделяемыми данными — они наблюдают их в неопределённом состоянии. Поэтому подразумевается, что атомарный g_flag используется для синхронизации доступа к неатомарным g_data. То есть, только один процессор в один момент времени — на протяжении «критической секции» — должен работать с g_data. В этом случае этот процессор будет уверенно наблюдать определённое поведение g_data: если туда записать 123, там так и останется 123 — ведь никто другой больше не трогает g_data.


                              Другой вопрос — это каким образом атомарные операции с g_flag гарантируют, что при входе в критическую секцию процессор увидит изменения, которые выполнялись с «не связанными» данными в g_data. Ведь как вы правильно заметили — это совершенно разные регионы памяти и не очевидно, почему атомарная операция по адресу &g_flag должна вдруг влиять на значения по адресу &g_data. Вопрос можно поставить и по-другому: будет ли g_flag влиять на g_some_other_data? Откуда процессор знает, что именно g_data связана с g_flag, а не что-то другое?


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


                              Наиболее примивная реализация «барьеров памяти»: атомарные операции влияют на всю память сразу. После атомарной операции с g_flag (или любым другим флагом) процессор увидит обновления в g_data (или любом другом месте памяти). Когда один процессор трогает атомарную переменную, то все другие процессоры обязаны сбросить все свои кеши и читать значения из памяти.


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

                                0

                                Один процессор, находясь в критической секции записал в неатомарную переменную i=42. А когда это i реально окажется в ОЗУ?
                                В это время второй процессор в нетерпении перетаптывается на входе в критическую секцию и как только она освобождается — мигом читает значение этой переменной… из СВОЕГО кеша.
                                Или первый процессор, во время записи переменной, даже не скинув ещё данные в ОЗУ — сообщает остальным, что если у кого в кеше есть данные с этого адреса, то они протухли, ждите, сейчас скину обновление?

                                  +1

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


                                  Для ARM не скажу, к сожалению, я в их деталях не силён. Например, x86 процессоры Intel используют протокол MESIF. В нём процессоры обмениваются информацией о том, что происходит с их кеш-линиями, и могут отправлять их друг другу в обход памяти. (Для того, чтобы i = 42 добралось до RAM, надо сначала пройти через L2 и L3 кеш.)


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


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


                                  Кроме того, запись из регистров в кеш-линию тоже происходит не напрямую. Записи обычно попадают в ещё одну очередь, а попадают в кеш этого же процессора уже оттуда. Роль «барьера записи» как раз в том, чтобы перелить все обновления в кеш (и разослать уведомления другим процессорам, где что протухло).


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

                                    0
                                    Что-то я первый раз слышу о влиянии барьеров памяти на кэш.

                                    В моём представлении — барьер памяти это… таки «барьер», через который процессор не может перемещать определённые (или все — в зависимости от типа барьера) команды в процессе переупорядочивания операций, чем балуются процессоры для повышения эффективности работы.

                                    А можно поподробнее, как барьеры памяти влияют на кэш?
                                      0

                                      Да, правильно: барьер не позволяет процессору перемещать операции через него. Ключевая идея здесь в том, что «повторное чтение значения из кеша» — это фактически операция «чтения из памяти», перемещённая далеко в прошлое, в тот момент, когда значение попало в кеш (и его считали из памяти таким, каким оно тогда было). Барьер чтения не даёт выполнить процессору такое «как бы перемещение», запрещая читать данные из кеша (если он инвалидирован; если же значение занесено в кеш уже после барьера — пожалуйста). Аналогично, барьер записи не позволяет процессору отложить запись в кеш куда-то в будущее, на потом, вынуждая записать данные сейчас.


                                      Сейчас мне пора идти спать. Если всё ещё не понятно, я завтра постараюсь нарисовать схему, как это примерно работает.

                                        0
                                        Да, без схемы — трудно уловить идею.
                                        +1
                                        Это от архитектуры зависит. В некоторых архитектурах с кэшированием в команде барьера может передавать адрес, что вызывает сброс/инвалидацию кэшей по этому адресу.
                                        Для ARM барьеры на кэш не влияют — они работают только внутри процессорного ядра. Когерентность кэша описывается отдельно и кэши ядер могут быть когерентными или некогерентными. Вплоть до того, что это это может быть отельной управляемой опцией шины/кэша для каждого из ядер — Snoop Control Logic (SCU).
                                        Если кэши некогерентны, то нужно ещё кроме барьеров чтения/записи выдавать инструкции сброса/инвалидации кэша по нужным адресам — DCCSW, например.
                                        Эффективная многоядерность на ARM этот ещё адок.
                                          0
                                          Если барьеры на кеши других ядер не влияют, тогда в чем их смысл на ARM, что они делают? Ведь ядро которое производило запись и так видит все свои локальные изменения.
                                            +1
                                            1. В процессорах с суперскаляроной архитектурой несколько ассемлерных комманд выполняются однорменно. Барьер гарнтирует результат и/или порядок выполнения таких команд.
                                            2. В процессоре кроме кэш памяти есть ещё очереди чтения/записи. Барьеры гарантируют, что эти очереди будут сброшены/очищены. Команды барьеров, например есть на архитектура вообще без кэша. Cortex-M3, например, в лице всяких STM32. Кэшей там нет, а команды барьеров есть и нужны. Без этих команд запись в порт будет выполена не во время исполня соответствующей инструкции, а когда-нибудь потом.
                                            3. Управление кэшированием может осуществляться в MMU (зависит от архитектуры и реализации). Т.е. для некоторых регионов памяти кэширование может выключенено или частично выключено (всякие аппаратыне регистры, DMA и т.п.), но барьеры всё равно нужны в силу особенностей, описанных в пункте 2.
                                        0

                                        Получается запись (или чтение) атомарных переменных это невероятно дорого, так как по сути сбрасывается кэш на всех процессорах? А использование shared_ptr влияет на все потоки, так как там тоже атомарные переменные.
                                        Спасибо за ответ! я пару лет назад пытался сымитировать такое спонтанное чтение, но не смог, я так понял что причина в была в x86 которая не даёт такой проблемы.

                                          0
                                          А использование shared_ptr влияет на все потоки, так как там тоже атомарные переменные.

                                          Атомарный там только счётчик ссылок, поэтому синхронизация происходит только при копировании/разрушении shared_ptr. Доступ к объекту по указателю не является атомарным (и дорогим), поэтому в многопоточной среде требует внешних средств синхронизации.

                                            0

                                            Если копирование одного умного указателя в нескольких потоках требует mutex, зачем тогда в нем атомарный счётчик?

                                              0

                                              Я имел в виду синхронизацию кэшей при изменении атомарного счётчика. Мьютексов там нет.

                                            0

                                            На самом деле любая запись инвалидирует кеш у других процессоров. Но не сразу, а с задержкой. А вот запись + барьер инвалидирует кеш сразу же.


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


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


                                            Особенность x86 в том, что его LOCK-префикс — это полный барьер: и на запись после операции, и на чтение перед. Поэтому там не только дорого читать с этим префиксом (что логично: вы только что возможно сбросили себе кеш), но и писать тоже — после записи у вас теперь возможно сброшенный кеш. Был бы барьер только на запись — пишущие процессоры могли бы дальше считать, что то, что они только что записали — это актуальное значение.


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


                                            Дороги не атомарные операции сами по себе. Дороги барьеры.

                                              +1
                                              Не так.
                                              На том же ARM барьеры не влияют кэш. Барьеры работают внутри ядра, а кэш — снаружи. Так что, после барьера записи на ARM остальные ядра могут ничего и не увидеть.
                                              0
                                              не весь кэш и не на всех процессорах. На пишущем происходит сброс кэша по нужным адресам, а на читающих — инвалидация. Для этого есть специальные команды. Ну, или можно зафигачить когерентный кэш, но производительность упадёт.
                                      0

                                      del

                                        0
                                        Если компилятор переставит местами инструкции записи, то флаг g_flag может получить значение true до того, как будут записаны все данные


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

                                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                        Самое читаемое