std::atomic. Модель памяти C++ в примерах

    Для написания эффективных и корректных многопоточных приложений очень важно знать какие существуют механизмы синхронизации памяти между потоками исполнения, какие гарантии предоставляют элементы многопоточного программирования, такие как мьютекс, join потока и другие. Особенно это касается модели памяти C++, которая была создана сложной таковой, чтобы обеспечивать оптимальный многопоточный код под множество архитектур процессоров. Кстати, язык программирования Rust, будучи построенным на LLVM, использует модель памяти такую же, как в C++. Поэтому материал в этой статье будет полезен программистам на обоих языках. Но все примеры будут на языке C++. Я буду рассказывать про std::atomic, std::memory_order и на каких трех слонах стоят атомики.


    В стандарте C++11 появилась возможность писать многопоточные программы на C++, используя только стандартные средства языка. В то время многоядерные процессоры уже завоевали рынок. Особенность выполнения программы на многоядерном процессоре в том, что инструкции программы из разных потоков физически могут исполняться одновременно. Ранее многопоточность на одном ядре эмулировалась частым переключением контекста исполнения с одного потока на последующие. Для оптимизации работы с памятью у каждого ядра имеется его личный кэш памяти, над ним стоит общий кэш памяти процессора, далее оперативная память. Задача синхронизации памяти между ядрами - поддержка консистентного представления данных на каждом ядре (читай в каждом потоке). Очевидно, что если применить строгую упорядоченность изменений памяти, то операции на разных ядрах уже не будут выполнятся параллельно: остальные ядра будут ожидать, когда одно ядро выполнит инструкции изменения данных. Поэтому процессоры поддерживают работу с памятью с менее строгими гарантиями консистентности памяти. Более того, разработчику программы предоставляется выбор, какие гарантии по доступу к памяти из разных потоков требуются для достижения максимальной корректности и производительности многопоточной программы. Задача предоставить разные гарантии по памяти решалась по-разному для разных архитектур процессоров. Наиболее популярные архитектуры x86-64 и ARM имеют разные представления о том, как синхронизировать память.

    Язык C++ компилируется под множество архитектур, поэтому в вопросе синхронизации данных между потоками в С++11 была добавлена модель памяти, которая обобщает механизмы синхронизации различных архитектур, позволяя генерировать для каждого процессора оптимальный код с необходимой степенью синхронизации.

    Отсюда следует несколько важных выводов: модель синхронизации памяти C++ — это "искусственные" правила, которые учитывают особенности различных архитектур процессоров. В модели C++ некоторые конструкции, описанные стандартом как undefined behavior (UB), могут корректно работать на одной архитектуре, но приводить к ошибкам работы с памятью на других архитектурах.

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

    Код каждого потока компилируется и выполняется так, как будто он один в программе. Вся синхронизация данных между потоками возложена на плечи атомиков (std::atomic), т.к. именно они предоставляют возможность форсировать "передачу" изменений данных в другой поток. Далее я покажу, что мьютексы (std::mutex) и другие многопоточные примитивы либо реализованы на атомиках, либо предоставляют гарантии, семантически похожие на атомарные операции.  Поэтому ключом к написанию корректных многопоточных программ является понимание того, как конкретно работают атомики.

    Три слона

    На мой взгляд, основная проблема с атомиками в C++ состоит в том, что они несут сразу три функции. Так на каких же трех слонах держатся атомики?

    1. Атомики позволяют реализовать… атомарные операции.

    2. Атомики накладывают ограничения на порядок выполнения операций с памятью в одном потоке.

    3. Синхронизируют память в двух и более потоках выполнения.

    Атомарная операция — это операция, которую невозможно наблюдать в промежуточном состоянии, она либо выполнена либо нет. Атомарные операции могут состоять из нескольких операций. Если говорить про тип std::atomic, то он предоставляет ряд примитивных операций: load, store, fetch_add, compare_exchange_* и другие. Последние две операции — это read-modify-write операции, атомарность которых обеспечивается специальными инструкциями процессора.

    Рассмотрим простой пример read-modify-write операции, а именно прибавление к числу единицы. Пример 0, link:

    static int v1 = 0;
    static std::atomic<int> v2{ 0 };
    
    int add_v1() {
        return ++v1;
        /* Generated x86-64 assembly:
            mov     eax, DWORD PTR v1[rip]
            add     eax, 1
            mov     DWORD PTR v1[rip], eax
        */
    }
    
    int add_v2() {
        return v2.fetch_add(1);
        /* Generated x86-64 assembly:
            mov     eax, 1
            lock xadd       DWORD PTR _ZL2v2[rip], eax
        */
    }

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

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

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

    Случаи, когда синхронизация памяти не требуется:

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

    2. Если разные потоки используют эксклюзивно разные участки памяти

    Далее будет рассмотрены более сложные случаи, когда требуется чтение и запись одного участка памяти из разных потоков. Язык C++ предоставляет три способа синхронизации памяти. По мере возрастания строгости: relaxed, release/acquire и sequential consistency. Рассмотрим их.

    Неделимый, но расслабленный

    Самый простой для понимания флаг синхронизации памяти — relaxed. Он гарантирует только свойство атомарности операций, при этом не может участвовать в процессе синхронизации данных между потоками. Свойства:

    • модификация переменной "появится" в другом потоке не сразу

    • поток thread2 "увидит" значения одной и той же переменной в том же порядке, в котором происходили её  модификации в потоке thread1

    • порядок модификаций разных переменных в потоке thread1 не сохранится в потоке thread2

    Можно использовать relaxed модификатор в качестве счетчика. Пример 1, link:

    std::atomic<size_t> counter{ 0 };
     
    // process can be called from different threads
    void process(Request req) {
    	counter.fetch_add(1, std::memory_order_relaxed);
    	// ...
    }
    
    void print_metrics() {
    	std::cout << "Number of requests = " << counter.load(std::memory_order_relaxed) << "\n";
    	// ...
    }

    Использование в качестве флага остановки. Пример 2, link:

    std::atomic<bool> stopped{ false };
     
    void thread1() {
    	while (!stopped.load(std::memory_order_relaxed)) {
    		// ...
    	}
    }
     
    void stop_thread1() {
    	stopped.store(true, std::memory_order_relaxed);
    }

    В данном примере не важен порядок в котором thread1 увидит изменения из потока, вызывающего stop_thread1. Также не важно то, чтобы thread1 мгновенно (синхронно) увидел выставление флага stopped в true.

    Пример неверного использования relaxed в качестве флага готовности данных. Пример 3, link:

    std::string data;
    std::atomic<bool> ready{ false };
     
    void thread1() {
    	data = "very important bytes";
    	ready.store(true, std::memory_order_relaxed);
    }
     
    void thread2() {
    	while (!ready.load(std::memory_order_relaxed));
    	std::cout << "data is ready: " << data << "\n"; // potentially memory corruption is here
    }

    Тут нет гарантий, что поток thread2 увидит изменения data ранее, чем изменение флага ready, т.к. синхронизацию памяти флаг relaxed не обеспечивает.

    Полный порядок

    Флаг синхронизации памяти "единая последовательность" (sequential consistency, seq_cst) дает самые строгие. Его свойства:

    • порядок модификаций разных атомарных переменных в потоке thread1 сохранится в потоке thread2

    • все потоки будут видеть один и тот же порядок модификации всех атомарных переменных. Сами модификации могут происходить в разных потоках

    • все модификации памяти (не только модификации над атомиками) в потоке thread1, выполняющей store на атомарной переменной, будут видны после выполнения load этой же переменной в потоке thread2

    Таким образом можно представить seq_cst операции, как барьеры памяти, в которых состояние памяти синхронизируется между всеми потоками программы.

    Этот флаг синхронизации памяти в C++ используется по умолчанию, т.к. с ним меньше всего проблем с точки зрения корректности выполнения программы. Но seq_cst является дорогой операцией для процессоров, в которых вычислительные ядра слабо связаны между собой в плане механизмов обеспечения консистентности памяти. Например, для x86-64 seq_cst дешевле, чем для ARM архитектур.

    Продемонстрируем второе свойство. Пример 4, из книги [1], link:

    std::atomic<bool> x, y;
    std::atomic<int> z;
     
    void thread_write_x() {
    	x.store(true, std::memory_order_seq_cst);
    }
     
    void thread_write_y() {
    	y.store(true, std::memory_order_seq_cst);
    }
     
    void thread_read_x_then_y() {
    	while (!x.load(std::memory_order_seq_cst));
    	if (y.load(std::memory_order_seq_cst)) {
    		++z;
    	}
    }
     
     
    void thread_read_y_then_x() {
    	while (!y.load(std::memory_order_seq_cst));
    	if (x.load(std::memory_order_seq_cst)) {
    		++z;
    	}
    }

    После того, как все четыре потока отработают, значение переменной z будет равно 1 или 2, потому что потоки thread_read_x_then_y и thread_read_y_then_x "увидят" изменения x и y в одном и том же порядке. От запуска к запуску это могут быть: сначала x = true, потом y = true, или сначала y = true, потом x = true.

    Модификатор seq_cst всегда может быть использован вместо relaxed и acquire/release, еще и поэтому он является модификатором по умолчанию. Удобно использовать seq_cst для отладки проблем, связанных с гонкой данных в многопоточной программе: добиваемся корректной работы программы и далее заменяем seq_cst на менее строгие флаги синхронизации памяти. Примеры 1 и 2 также будут корректно работать, если заменить relaxed на seq_cst, а пример 3 начнет работать корректно после такой замены.

    Синхронизация пары. Acquire/Release

    Флаг синхронизации памяти acquire/release является более тонким способом синхронизировать данные между парой потоков. Два ключевых слова: memory_order_acquire и memory_order_release работают только в паре над одним атомарным объектом. Рассмотрим их свойства:

    • модификация атомарной переменной с release будет видна видна в другом потоке, выполняющем чтение этой же атомарной переменной с acquire

    • все модификации памяти в потоке thread1, выполняющей запись атомарной переменной с release, будут видны после выполнения чтения той же переменной с acquire в потоке thread2

    • процессор и компилятор не могут перенести операции записи в память раньше release операции в потоке thread1, и нельзя перемещать выше операции чтения из памяти позже acquire операции в потоке thread2

    Важно понимать, что нет полного порядка между операциями над разными атомиками, происходящих в разных потоках. Например, в примере 4 если все операции store заменить на memory_order_release, а операции load заменить на memory_order_acquire, то значение z после выполнения программы может быть равно 0, 1 или 2. Это связано с тем, что, независимо от того в каком порядке по времени выполнения выполнены store для x и y, потоки thread_read_x_then_y и thread_read_y_then_x могут увидеть эти изменения в разных порядках. Кстати, такими же изменениями для load и store можно исправить пример 3. Такое изменение будет корректным и производительными, т.к. тут нам не требуется единый порядок изменений между всеми потоками (как в случае с seq_cst ), а требуется синхронизировать память между двумя потоками.

    Используя release, мы даем инструкцию, что данные в этом потоке готовы для чтения из другого потока. Используя acquire, мы даем инструкцию "подгрузить" все данные, которые подготовил для нас первый поток. Но если мы делаем release и acquire на разных атомарных переменных, то получим UB вместо синхронизации памяти.

    Рассмотрим реализацию простейшего мьютекса, который ожидает в цикле сброса флага для того, чтобы получить lock. Такой мьютекс называют spinlock. Это не самый эффективный способ реализации мьютекса, но он обладает всеми нужными свойствами, на которые я хочу обратить внимание. Пример 5, link

    class mutex {
    public:
    	void lock() {
    		bool expected = false;
    		while(!_locked.compare_exchange_weak(expected, true, std::memory_order_acquire)) {
    			expected = false;
    		}
    	}
     
    	void unlock() {
    		_locked.store(false, std::memory_order_release);
    	}
     
    private:
    	std::atomic<bool> _locked;
    };

    Функция lock() непрерывно пробует сменить значение с false на true с модификатором синхронизации памяти acquire. Разница между compare_exchage_weak и strong незначительна, про нее можно почитать на cppreference. Функция unlock() выставляет значение в false с синхронизацией release. Обратите внимание, что мьютекс не только обеспечивает эксклюзивным доступ к блоку кода, который он защищает. Он также делает доступным те изменения памяти, которые были сделаны до вызова unlock() в коде, который будет работать после вызова lock(). Это важное свойство. Иногда может сложиться ошибочное мнение, что мьютекс в конкретном месте не нужен.

    Рассмотрим такой пример, называемый Double Checked Locking Anti-Pattern из [2]. Пример 6, link:

    struct Singleton {
    	// ...
    };
     
    static Singleton* singleton = nullptr;
    static std::mutex mtx;
    static bool initialized = false;
     
    void lazy_init() {
    	if (initialized) // early return to avoid touching mutex every call
    		return;
     
    	std::unique_lock l(mtx); // `mutex` locks here (acquire memory)
    	if (!initialized) {
    		singleton = new Singleton();
    		initialized = true;
    	}
    	// `mutex` unlocks here (release memory)
    }

    Идея проста: хотим единожды в рантайме инициализировать объект Singleton. Это нужно сделать потокобезопасно, поэтому имеем мьютекс и флаг инициализации. Т.к. создается объект единожды, а используется singleton указатель в read-only режиме всю оставшуюся жизнь программы, то кажется разумным добавить предварительную проверку if (initialized) return. Данный код будет корректно работать на архитектурах процессора с более строгими гарантиями консистентности памяти, например в x86-64. Но данный код неверный с точки зрения стандарта C++. Давайте рассмотрим такой сценарий использования:

    void thread1() {
    	lazy_init();
    	singleton->do_job();
    }
     
    void thread2() {
    	lazy_init();
    	singleton->do_job();
    }

    Рассмотрим следующую последовательность действий во времени:

    1. сначала отрабатывает thread1 -> выполняет инициализацию под мьютексом:

    • lock мьютекса (acquire)

    • singleton = ..

    • initialized = true

    • unlock мьютекса (release)

    2. далее в игру вступает thread2:

    • if(initalized) возвращает true (память, где содержится initialized могла быть неявно синхронизирована между ядрами процессора)

    • singleton->do_job() приводит к segmentation fault (указатель singleton не обязан был быть синхронизирован с потоком thread1)

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

    Семантика acquire/release классов стандартной библиотеки

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

    std::thread::(constructor) vs функция потока

    Вызов конструктора объекта std::thread (release) синхронизирован со стартом работы функции нового потока (acquire). Таким образом функция потока будет видеть все изменения памяти, которые произошли до вызова конструктора в исходном потоке.

    std::thread::join vs владеющий поток

    После успешного вызова join поток, в котором был вызван join, "увидит" все изменения памяти, которые были выполнены завершившимся потоком.

    std::mutex::lock vs std::mutex::unlock

    успешный lock синхронизирует память, которая была изменена до вызова предыдущего unlock.

    std::promise::set_value vs std::future::wait

    set_value синхронизирует память с успешным wait.

    И так далее. Полный список можно найти в книге [1].

    Что это все значит? Повторю эту важную мысль еще раз: это значит, на примере std::promise::set_value и std::future::wait, что тут мы не только получили данные, которые содержатся в примитиве синхронизации, но и нам доступны все изменения памяти, которые были в потоке до того, как он выполнил set_value. Это маленькое чудо нам кажется само собой разумеющееся с нашим бытовым, последовательным причинно-следственным, взглядом на мир. Но в мире многоядерного процессора, законы которого больше похожи на квантовую физику, которую никто до конца не понимает, нет единого последовательно порядка изменения памяти в разных ядрах процессора, если это не затребовано разработчиком явно, или неявно через многопоточные примитивы.

    Заключение

    Сложно представить современную C++ программу, которая была бы однопоточной. Опасно писать многопоточные программы, не имея представления о правилах синхронизации памяти. Я считаю, что нужно знать, как работают атомики в C++. Чтобы не совершать ошибок типа volatile bool, чтобы понимать, какие изменения в каких потоках будут видны после использования того или иного многопоточного примитива, чтобы использовать read-modify-write атомарные операции вместо мьютекса, там где это возможно. Данная статья помогла мне систематизировать материал, который я находил в разных источниках и освежить знания в памяти. Надеюсь, она поможет и вам!

    Источники

    [1] Anthony Williams. C++ Concurrency in Action. https://www.amazon.com/C-Concurrency-Action-Practical-Multithreading/dp/1933988770

    [2] Tony van Eerd. C++ Memory Model & Lock-Free Programming. https://www.youtube.com/watch?v=14ntPfyNaKE

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 37

      +3
      На самом деле, интереснее каноничный пример с синглтоном:
      static Singleton* singleton = nullptr;
      static std::mutex mtx;
       
      Singleton *instance() {
      	if (singleton ) // early return to avoid touching mutex every call
      		return singleton;
       
      	std::unique_lock l(mtx); // `mutex` locks here (acquire memory)
      	if (!singleton)
      		singleton = new Singleton();
              return singleton;
      	// `mutex` unlocks here (release memory)
      }


      Казалось бы, тут всё хорошо? Но нет, Александреску в своей статье пишет что всё плохо.
      singleton = new Singleton();

      может развернуться в что-то типа такого
      
      // выделили память и записали указатель,
      singleton = (Singleton*)malloc(sizeof(Singleton)); 
      // если второй тред прочитает указатель в этом месте, то всё упадет
      // а вот теперь создали объект (тут еще куча операций)
      new (singleton) Singleton();
      

      Мы же хотим что-то типа такого
      
      auto tmp = (Singleton*)malloc(sizeof(Singleton)); // выделили память
      new (tmp) Singleton(); // создали объект
      singleton = tmp; // записали указатель
      

      Но, понятное дело, гарантий что будет выбран вариант 2 нам никто не дает (скорее всего, не будет, потому что это медленнее). Если же руками написать этот код, то компилятор также может соптимизировать в вариант 1.
      К счастью, начиная с С++11 не должна больше болеть голова о синглтонах=)
        +6
        Самый простой для понимания флаг синхронизации памяти — relaxed.

        Флаг синхронизации памяти «единая последовательность» (sequential consistency, seq_cst) самый строгий и понятный


        Хорошо, когда самых понятных флагов два. Лучше бы, конечно, три.
          0
          Спасибо, что заметили. Поменяю слова. Вообще «понятный», «простой для понимания» — это слишком субъективно.
          0

          Ооо, спасибо за статью! Как раз сейчас дорешиваю https://leetcode.com/problemset/concurrency/ (можно сдавать решения и на С++).

            0
            Пожалуйста, Хорошего кодинга на leetcode!
            0
            Можно ли использовать std::atomic с разделяемой памятью? Насколько это безопасно?
            • UFO just landed and posted this here
                0
                Можете, пожалуйста, пояснить свою мысль? Мне казалось, PTHREAD_PROCESS_SHARED даёт возможность ОС правильно обрабатывать ситуацию, если заблокировать spinlock не получилось и пришлось воспользоваться futex() (который иначе будет вызван с FUTEX_PRIVATE_FLAG). А shared memory в архитектуре абстрактной машины ничем не отличается, используется ли в 1 или разных процессах.
                  0

                  Почему нет гарантий? std::atomic — это про память и инструкции процессора. И, насколько мне известно, стандарт не регламентирует в какой именно памяти должна быть расположена atomic переменная. Кмк тут все зависит от конкретной имплементации std::atomic.

                  • UFO just landed and posted this here
                –1
                Классная статья, подписался!
                  +4
                  Статья весьма хорошая и интересная, но пара неточностей несколько снижает, на мой взгляд, её полезность.
                  1. Выполнение нескольких потоков на одном процессоре в режиме разделения времени не дает гарантии того, что выполнение оператором C++ операции вида read-modify-write будет защищено от вмешательства со стороны другого потока. Потому что эта операция транслируется в несколько машинных команд. Выполнение потока может быть прервано после одной из команд середине операции, и управление после этого может быть передано другому потоку, который внесет конфликтующие изменения.
                  В однопроцессорной системе есть только гарантия того, что данные не будут изменены другим потоком во время выполнения одной машинной команды, а в многопроцессорной — нет и этой гарантии. Однако с точки зрения программы на языке высокого уровня это мало что меняет.
                  2. Мьютекс не реализуется через атомарные операции. Для его работы требуется обращение к ядру ОС. Потому что при возникновении состояния ожидания потока необходимо обратиться к планировщику в ядре ОС, чтобы он запустил на выполнение другой поток (варианты планировщиков пользовательского режима, в которых единицей исполнения является не поток режима ядра, а другие конструкции, аналогичные promise или Task в других языках, я не тут не рассмартриваю). Ядро ОС же может использовать для синхронизации доступа к общим для нескольких процессоров структурам данных другие механизмы (например, спин-блокировки или межпроцессорные прерывания).
                  Да, существуют «легкие» варианты реализации мьютекса (в Windows — критическая секция), в которых обращение к ядру откладывается, насколько это возможно, чтобы не вызывать ядро, когда блокировка с передачей управления другому потоку не происходит либо состояние блокировки очень быстро проходит. И для реализации такого поведения действительно используются атомарные операции. Но для осуществления передачи процессора для выполнения другого потока обращение к ядру неизбежно.
                  Полную семантику мьютексов — с перепланированием потоков — на атомарных операциях реализовать невозможно, максимум что возможно — это реализовать семантику спин-блокировки, которая захватывает процессор вместо того, чтобы передать его другому потоку для выполнения.

                  Впрочем, в большинстве практических применений эти неточности существенной роли не играют.
                  И ещё. Я считаю, что, говоря о разнице между compare_exchange_strong и compare_exchange_weak, следовало бы уделить ей больше внимания хотя бы в практическом аспекте: что для надежного использования compare_exchange_weak необходимо организовывать циклическую поверку при неудачном сравнении, а вариант compare_exchange_strong этого не требует, но зато он обходится дороже (потому как требует немедленной синхронизации кэшей процессоров, что в некоторых архитектурах является весьма дорогой операцией) — в статье по приведенной ссылке все это, естественно, есть, но далеко не в первых ее словах.
                    +1
                    Спасибо за ваши развернутые замечания!

                    По 1. Полностью с вами согласен. Я подправлю текст статьи.

                    По 2. Все таки не соглашусь с вами. Атомарные операции (инструкции процессора) — более фундаментальное понятие, чем мьютекс в операционной системе. Реализация мьютексов в OS реализованы на атомиках (по крайней мере в linux).

                    Вот кусок из mutex.c из исходных кодов линукс:
                    /*
                     * Optimistic trylock that only works in the uncontended case. Make sure to
                     * follow with a __mutex_trylock() before failing.
                     */
                    static __always_inline bool __mutex_trylock_fast(struct mutex *lock)
                    {
                    	unsigned long curr = (unsigned long)current;
                    	unsigned long zero = 0UL;
                    
                    	if (atomic_long_try_cmpxchg_acquire(&lock->owner, &zero, curr))
                    		return true;
                    
                    	return false;
                    }
                    
                    static __always_inline bool __mutex_unlock_fast(struct mutex *lock)
                    {
                    	unsigned long curr = (unsigned long)current;
                    
                    	if (atomic_long_cmpxchg_release(&lock->owner, curr, 0UL) == curr)
                    		return true;
                    
                    	return false;
                    }
                    


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

                    3. Про compare_exchange_weak vs strong. Я с вами согласен. Но этот вопрос достаточно внятно описан в документации этих методов. Мне не хотелось тратить время читателя на «простые вещи», описанные в документации. В том числе на такие, как std::atomic::is_lock_free и std::atomic_flag.
                  0

                  Поясните, пожалуйста. Когда в примере с мьютексом вы пишете: "Он так же делает доступным те изменения памяти, которые были сделаны до вызова unlock() в коде, который будет работать после вызова lock()" — дело касается всех модифицированных переменных, не только атомиков?


                  Или другим потокам будут гарантированно видны только все изменённые атомики? Или всё-таки для получения актуального значения атомика надо вызвать load или acquire-операцию? В чём заключается роль мьютекса по синхронизации изменений памяти?

                    0

                    В этом, собственно, и есть смысл мьютекса. Сериализовать доступ к данным, которые он защищает. Сам по себе как таковой он редко нужен. А блокировка/разблокировка в этом смысле как раз соответствуют захвату/отпусканию.
                    Все операции, которые ниже load/acquire так и останутся ниже; они не могут быть переупорядочены вверх и выполниться до захвата/лока. Те, которые выше — могут просочиться вниз и выполниться уже после. Все, которые выше save/release не могут просочиться ниже. Те, которые изначально были ниже — могут выполниться раньше. И вот это вот, что операции ниже/выше блока acquire-release могут дрейфовать в него — и есть разница между полным барьером со строгой последовательностью и "полубарьером". Но главный паттерн "захватили мьютекс, изменили переменную, отпустили мьютекс" при этом работает. Гарантия, что сперва захватится мьютекс, а потом изменится переменная. И отпускание состоится после изменений. А значит захват мьютекса в другом потоке, когда он состоялся, гарантирует, что изменения, если они были, ему видимы.


                    А вот вторая часть непонятна. Для получения значения (любого) нужно выполнить load-операцию. Чтобы при этом сохранилась ещё и последовательность — при записи значения должно быть release, при чтении — acquire. Именно так, парой. Если хотя бы одна операция (чтение или запись) relaxed, то последовательность уже не гарантируется. Один процессор записал единичку в relaxed режиме, другой пока об этом знать не знает и читает всё ещё нолик.

                    +1
                    К моему стыду я не понимаю как «ожидание» реализовано на процессоре (пусть 86, например), поэтому задам вопрос здесь, вроде тематика подходит. Для простоты — пусть у нас есть всего один поток, есть какой-то код, который захватил мьютекс, и есть второй, который его ждет. Что именно (на уровне байткода, наверное?) происходит в момент «ожидания»? Можно просто ссылку что «покурить», буду благодарен.

                    Ну и второй вопрос, который как по мне схож. Вот у меня есть ядро процессора, оно выполняет 100 операций в секунду. Но в данный момент у меня есть ему предложить только 50. Понятное дело что там есть какое-то квантование по времени. Но что делает ядро в те кванты, когда делать нечего? Молотит nop'ы? Думаю что сейчас врятли, энергоэффективность же. Считаем что частоту снижать уже некуда (я сейчас про всякие «base clock 1.2, boost clock 100500 GHz». Или оно все равно снижает ниже, просто юзер не в курсе?
                      0
                      Что именно (на уровне байткода, наверное?) происходит в момент «ожидания»?

                      Зависит от реализации мьютекса. В общем случае переключается на другой поток. Но вот при ожидании на critical section из Windows сперва делается спинлок («крутится»), а потом, по прошествии некоторого времени (определяется глобально для системы ключом реестра), опять таки переключается на другой поток.
                        0

                        futex в Linux, на котором основана в том числе реализация mutex, работает точно также — пытаемся решить максимум в userspace и только если все плохо делаем "тяжелые" системные вызовы

                        0
                        На второй вопрос. Если речь про ОС с планировщиком, то планировщик переключит выполнение на idle процесс/поток (реализован как вечный цикл) или выполнит остановку процессора (в будущем процессор будет разбужен прерыванием аппаратного таймера с возвратом в планировщик). Все зависит от того что дешевле
                          0
                          Немного оффтоп: есть какие-то рекомендации, когда надо полагаться на средства языка и ОС, а когда стоит вкорячить собственный планировщик в программу?
                            0
                            До 10 000 rps заморачиваться с уходом от ОС точно не стоит. До 100 000 rps можно начать задумываться, но тоже не обязательно. До больших скоростей я нечасто добирался.
                            Ну либо если какаято система реального времени
                        +1
                        Программист: *хочет синхронизировать данные между двумя потоками*.
                        Язык программирования: вот вам конструкция для синхронизации.
                        Программист: спасибо.
                        Язык программирования: только помните, что есть нюансы для разных архитектур
                        Язык программирования: и 5 флагов, меняющих алгоритм работы, не синхронизируя то одно, то другое.
                        Программист:
                        Язык программирования: а если сделать вот так (*с виду логичная и непротиворечивая конструкция*), то всё упадет в самый неожиданный момент!
                        Программист:
                        Язык программирования: в следующей версии стандарта будет ещё удобнее, добавим ещё десяток флагов и способов синхронизации (с нюансами, как вы любите), не благодарите!
                          +1

                          А что будет, если один атомик пытаются инкрементить сразу два потока на двух разных процессорах с помощью fetch_add ( 1, std::memory_order_relaxed )? Может ли случиться, что результат будет, как будто инкремент случился лишь единожды?
                          Вопрос именно про модель relaxed.
                          Один поток взял и увеличил переменную, как положено, атомарно. Никому специально ничего не сказал (потому что relaxed).
                          Второй взял её же из кэша и тоже увеличил. Тоже, как положено, атомарно. Но при этом изменений от первого потока ещё не увидел, потому что синхронизации/барьеров нет.

                            +1
                            А что будет, если один атомик пытаются инкрементить сразу два потока на двух разных процессорах с помощью fetch_add ( 1, std::memory_order_relaxed )? Может ли случиться, что результат будет, как будто инкремент случился лишь единожды?
                            Нет. Реализация гарантирует что все будет работать правильно.
                            Один поток взял и увеличил переменную, как положено, атомарно. Никому специально ничего не сказал (потому что relaxed).
                            Второй взял её же из кэша и тоже увеличил. Тоже, как положено, атомарно. Но при этом изменений от первого потока ещё не увидел, потому что синхронизации/барьеров нет.
                            Мemory order это больше не про использование кеша, это про видимый порядок операций чтения/записи из другого потока. Грубо, зачитывать/записывать ли все измененные данные до/после указанной операции. Сам адрес в любом случае будет обновлен в кеше перед операцией. Fetch_add(1, std::memory_order_relaxed), не является эквивалентом i++. По-этому и говорят что атомарные операции медленные, т.к. мы вынуждены тратить время на синхронизацию кеша.
                              0
                              Может ли случиться, что результат будет, как будто инкремент случился лишь единожды?

                              Нет, инкремент случится всегда дважды. Об этом пример 1, как раз.
                              godbolt.org/z/jx85P9

                              Код:
                              counter.fetch_add(1, std::memory_order_relaxed);
                              

                              Транслируется в команду на x86-64:
                              lock add        QWORD PTR counter[rip], 1
                              

                              На уровне ЦПУ это работает так: выполнить оператор add, захватив эксклюзивный доступ к кэш линии. Это соответсвует состоянию Exclusive в протоколе синхронизации кешей MESI ( en.wikipedia.org/wiki/MESI_protocol ). Состояние exclusive означает, что в линии кэша ядра лежит актуальное значение (соотвествующее main memory) и в других ядрах кэш линии инвалидируется (состояние invalid).

                              Когда два ядра захотят выполнить fetch_add(1, std::memory_order_relaxed), то им придется по-очереди захватить exclusive лок на кэш линию и каждое ядро (поток) прибавит свою единичку правильно.

                              Ядре (архитектура x86-64), выполняющем print_metrics(), кэш линия с counter будет в состоянии invalid, ядро запросит чтение из этой кэш линии, и она перейдет в состояние shared на всех ядрах. В этот момент в кэш линии уже будет актуальное значение, совпадающее с количеством fetch_add(1), которые успели выполнится в других потоках.
                                0

                                Ну вот этот момент и смущает. Если это особенность именно архитектуры (вот, решили разработчики компилятора использовать именно такую команду) — то это одно.
                                А если доступ всегда by design оказывается синхронизированным — то выходит, чистый атомик "всегда безопасен". Например, как переменная для счётчика ссылок. Можно не глядя инкрементить/декрементить, и только если счётчик обнулился — возможно, для верности имеет смысл поставить полный барьер, чтобы удаление объекта спекулятивно не выполнилось до самой проверки.
                                Если же такой алгоритм специфичен только для x86-64, а на каком-нибудь cortex или itanium всё иначе — тогда не так всё прозрачно.

                                  0
                                  Я описал как в железа на x86-x64. На других архитектурах механизмы другие, но будут соблюдены гарантии модели памяти C++. Компилятор не в праве компилировать корректный с точки зрения стандарта языка код в некорректный машинный код.

                                  Поэтому — да, так всегда by design языка.

                                  Кстати, насчет счетчика ссылок. Для инкремента можно использовать relaxed, а для декремента нужен acquire/release, т.к. декремент счетчика ссылок до 0 должен быть синхронизирован для однократного вызова деструктора объекта и деаллокации памяти.
                                    +1
                                    На самом деле, думать об атомиках в терминах кэшей достаточно бесполезно. Можно (условно) считать что кэши процессора всегда когерентны — если вы записали в ячейку памяти, то другие ядра это увидят. Проблема в том что помимо кешей есть и другие уровни абстракции (буфер записи в кеш) и регистры процессора, которые не обязаны быть когерентными. Гадать, попало ваше значение в кэш или ещё находится в регистре — занятие неблагодарное, не надо об этом думать=)

                                    Основная проблема не в том когда данные попадут в память (в кэш, в оперативку), а в видимом порядке действий — компилятор/процессор вольны переставлять (независимые по данным инструкции) как им покажется удобным. У меня на работе код изобилует примерами типа 3 (безо всяких атомиков) — люди ничего не слышали про reordering и думают — ну раз я записал в bool ready true, то значит могу читать данные из другого потока (но нет).

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

                                    О механике acquire/release так же удобно думать высокоуровнево — когда вам нужен ресурс, вы захватываете его через «acquire», когда закончили — отпускаете с «release».

                                    Я не устаю рекомендовать цикл статей kixmax про lock-free программирование, там есть ответы на ваши вопросы.
                                      0

                                      Считать можно, но зачем тогда relaxed, если всё всегда синхронизируется?
                                      Я его воспринимаю как раз как "просто атомик", без всяких гарантий. Т.е. ровно то, что операция чтения/модификации/записи выполнится атомарно. А то, что результат мгновенно окажется видимым в других потоках на других процессорах — не факт. Или то, что оно выполнится прямо сейчас, а не пару десятков инструкций спустя — тоже не факт. И если такие гарантии становятся нужны — там уже да, надо явно писать ack для load, rel для store, или (как в случае с изменяющими операциями над одной переменной) — комбинированный acquire_release, два-в-одном. Но эти задачи возникают, когда кроме атомиков подразумеваются ещё некие сущности, не-атомарные, доступ к которым хочется упорядочить с помощью атомиков. Если мне нужно передать данные через барьер — я сохраню данные, а потом флажок в атомик с release, в другом потоке прочитаю атомик с acquire и буду уверен, что данные валидны. Или сохраню указатель на данные прямо в атомик с release, а в другом потоке прочитаю его с consume и тоже буду уверен. Но это всё уже подразумевает зависимости и барьеры (или полубарьеры). А вот от relaxed ожидается шустрая работа без лишних синхронизаций и барьеров

                                        0
                                        relaxed нужен для того чтобы подсказать компилятору\процессору что вот эта переменная (потенциально) используется для синхронизации.
                                        без нее код типа
                                        bool done = false; //global static
                                        void doWork() {
                                        while (!done)
                                            foo();
                                        }


                                        может быть соптимизирован в
                                        bool done = false; //global static
                                        void doWork() {
                                        if (done)
                                            return;
                                        while (true)
                                            foo();
                                        }


                                        Обусловлено это тем что компилятор волен предполагать что переменная done не меняется в _этом_ потоке (например, если он сможет это доказать, заинлайнив foo), а значит оба варианта эквивалентны.
                                        На практике я такого поведения не встречал (компилятору сложно доказать утверждение выше) и любой sane компилятор так делать не будет, но насколько я знаю, никто ему это не запрещает делать (привет, то самое UB). Миф про volatile же не на ровном месте появился.
                                          0
                                          А разве в этом конкретном случае bool volatile done не решает проблему?
                                            0
                                            От примитивов синхронизации нужно несколько вещей
                                            1. отсутствие оптимизаций компилятора (как пример выше)
                                            2. отсутствие оптимизаций процессора (например, перестановка операций в рантайме)
                                            3. неделимость (атомарность) самой операции.
                                            4. что-то еще забыл?

                                            volatile помогает только с пунктом 1, но одного этого мало. В данном примере его, возможно, и достаточно (но стандарт С++ говорит, что нет).
                                            Пример с relaxed и пунктом 3 подробно разобран в статье на примере инкремента. Это второй юзкейз relaxed, я не стал про него потому что про это уже написали до меня весьма подробно.
                                            Атомики/мьютексы обладают всеми тремя пунктами требований, volatile — только одним из них.
                                              0
                                              Разве в этом случае нужны второй и третий пункты? Про ордеринг тут не идет речи, так как мы просто ждем переключение флага; в крайнем случае нужен будет барьер на потоке-переключателе. И атомарность тут ни к чему — тут ведь не инкремент, а запись ненулевого значения туда, где было нулевое, все будет в порядке даже на платформах, где запись bool неатомарна (хотя такие вряд ли существуют).
                                                0
                                                Я на это уже ответил — я не знаю причин, почему именно этот пример не работал бы с volatile. Но он и без volatile может работать (у меня весь проект на работе такой) — можно проверить disasm что там нет неожиданностей.
                                                Проблема x86 именно в том что там есть из коробки многие базовые вещи, предоставляемые атомиками — это и отсутствие reorder'а и когерентность кешей — ваш lock-free алгоритм может работать даже если написан неверно или вообще без атомиков.
                                0
                                Здравствуйте, у меня вопрос по примеру с мьютексом и синглтоном.
                                singleton->do_job() приводит к segmentation fault (указатель singleton не обязан был быть синхронизирован с потоком thread1)

                                Правильно ли я понимаю, что для того, чтобы указатель был синхронизирован с потоком thread1 необходимо вызвать
                                std::unique_lock l(mtx); // `mutex` locks here (acquire memory)

                                ?
                                Как я понял именно в этот момент произойдет синхронизация памяти между потоками и указатель в thread2 станет валидным.
                                  0
                                  Да, вы правильно поняли. На самом деле нужно убрать предварительную проверку:
                                  if (initialized) // early return to avoid touching mutex every call
                                  	return;
                                  

                                Only users with full accounts can post comments. Log in, please.