Тема |
Описание |
Доп. |
|---|---|---|
Модель синхронизации потоков - Атомарные операции Атомарные операции представлены модулем стандартной библиотеки Rust std::sync::atomic (и дополнительно crate atomic). |
📌 Новый раздел
|
|
Для синхронизации доступа к ресурсу в многопоточной среде, который должен быть инициализирован только один раз |
||
std::sync::Once используется для выполнения какой-либо операции только один раз в течение жизни программы. Это особенно полезно для инициализации ресурсов, таких как глобальные или статические переменные.
|
||
OnceLock: Предоставляет удобный способ для хранения значения, инициализированного только один раз, и позволяет безопасно получать это значение в многопоточной среде.
|
||
std::sync::atomic Атомные типыAtomicBool, AtomicIsize, AtomicUsize |
Модуль std::sync::atomic содержит атомарные типы для без блокировочного конкурентного программирования. Атомные типы AtomicBool, AtomicIsize и AtomicUsize представляют операции, которые при правильном использовании синхронизируют обновления между потоками. Атомарные переменные безопасно разделять между потоками (они реализуют Sync), но сами по себе они не предоставляют механизма для совместного использования и следуют потоковой модели Rust. Атомарная операция — операция, которая либо выполняется целиком, либо не выполняется вовсе; операция, которая не может быть частично выполнена и частично не выполнена. Несколько потоков может одновременно читать и записывать атомарное значение, не опасаясь гонок данных. Несмотря на то, что Mutex обеспечивает превосходную безопасность потоков для совместно используемых изменяемых данных, существуют сценарии, в которых требуется одновременный доступ без блокировок. Совместное использование в потоках благодаря Arc |
|
Atomic type - это аналог Cell для Copy типов (i32, bool, etc) в многопоточной среде. Обеспечивают безопасный доступ к данным в многопоточном контексте через атомарные операции, которые являются атомарными (неделимыми). Это означает, что операции над такими типами выполняются целиком без прерывания и видны всем потокам как единое целое. Например, операции вроде fetch_add или compare_and_swap на атомарных типах обеспечивают согласованность данных без необходимости использования дополнительных механизмов синхронизации, таких как мьютексы. |
||
Атомарные типы часто не содержат непосредственно информацию, которую нужно передавать между потоками, потому что они ограничены по размеру. Обычно атомарные типы используются как инструменты для координации доступа к более крупным данным или структурам данных. |
||
Однако атомарные операции позволяют различным потокам безопасно читать и изменять одну и ту же переменную. Поскольку такая операция неделима, она происходит либо полностью до, либо полностью после другой операции, избегая неопределенного поведения. Атомарные операции являются основным строительным блоком всего, что связано с несколькими потоками. Все остальные примитивы параллелизма, такие как Mutex и Condvar, реализуются с использованием атомарных операций. Атомарные операции допускают модификацию через общую ссылку (например, &AtomicU8) |
||
Логика проверки того, что определенное переупорядочение или другая оптимизация компилятора не повлияет на поведение вашей программы, не учитывает другие потоки. Мы должны явно сообщать компилятору и процессору, что они могут и не могут делать с нашими атомарными операциями, поскольку их обычная логика игнорирует взаимодействия между потоками и может допускать оптимизации, которые действительно изменяют результат вашей программы. Порядок памяти (std::sync::atomic::Ordering) используется для контроля того, как операции с атомарными переменными синхронизируются между потоками. Понимание различных уровней порядка памяти важно для правильного управления конкурентными и многопоточными программами Почему важен порядок операций с памятью: В многопоточных программах, компилятор и процессор могут менять порядок выполнения команд для оптимизации, что нарушает согласованность данных между потоками. Управление порядком операций необходимо для синхронизации. SeqCst не так хорош в качестве значения по умолчанию, как вы думаете, и в большинстве случаев его следует избегать. Relaxed подходит, когда вы хотите применить атомарные операции к отдельным переменным, но не заботитесь о синхронизации чтения и записи для нескольких переменных. Например, для увеличения простых счетчиков или сбора статистики. Release/Acquire используется, когда вы хотите синхронизировать чтение и запись в несколько переменных в нескольких потоках. Сохранение значения в заданной переменной с помощью Release гарантирует, что все записи, которые произошли с этим моментом или до него, будут видны после загрузки той же переменной с помощью Acquire. Release/Acquire полезны для построения всех видов примитивов синхронизации, включая блокировки, каналы и сигналы. Думайте об Release освобождении некоторых изменений (или блокировки) для других потоков, в то время как Acquire получает эти изменения (или блокировку). Категории порядка операций: Relaxed: Используется, когда не требуется синхронизация между потоками, например, для обновления отдельных переменных. Release (для записи) /Acquire(для чтения): Используется для синхронизации потоков. Release гарантирует, что все записи до этого момента будут видимы после Acquire. Sequential Consistency (SeqCst): Строгий, но редко применяемый порядок, из-за высокого влияния на производительность. AcqRel (эквивалент «Приобретать для загрузки и выпуска для хранения»; полезно, когда оба участвуют в одной операции, такой как compare-and-swap) Пример использования Release/Acquire: Для передачи данных между потоками используется сигнализация с помощью атомарных переменных. Например, поток может записать данные с Release, и другой поток сможет их считать, после того как загрузит сигнал с Acquire. Пример синхронизации через блокировки: Простые примеры реализации блокировок с использованием атомарных переменных, где Acquire используется для получения блокировки, а Release — для её освобождения, гарантируя, что все изменения завершены. Relaxed - Это означает, что два потока могут видеть, что операции с разными переменными выполняются в разном порядке. Например, если один поток сначала записывает в одну переменную, а затем очень быстро после этого во вторую переменную, другой поток может увидеть, что это происходит в противоположном порядке. Каждый метод принимает значение Ordering, которое представляет собой силу барьера памяти для этой операции. Эти порядки такие же, как и атомарные порядки C++20 При доступе к / модификации атомного типа следует указывать порядок памяти, представляющий силу барьера памяти. Rust обеспечивает 5 примитивов порядка доступа к памяти (упорядочения памяти): нечто похожее на уровень изоляции транзакций в базе данных. |
||
Пример сигнализации/Signaling example |
Давайте рассмотрим пример Мар, где используется метод AtomicBoolto для оповещения о готовности некоторых данных: Основной поток ожидает DATA готовности и спит в течение 100 миллисекунд между каждой проверкой. Важно то, что строка READY.store(true, Release); гарантирует, что все записи, которые произошли с этим моментом или до него, будут видны после Acquire на этой же переменной . Обратите внимание, как DATA написано до момента Release, и это только с использованием Relaxed порядка. Когда основной поток наконец замечает, READY.load(Acquire) что true, мы выходим из while цикла и наконец считываем значение. Даже если DATA.load(Relaxed) использует Relaxed, он гарантированно увидит значение. Запись произошла до Release момента на READY переменной, и это load происходит после соответствующего Acquire на READY.
|
|
Пример блокировки/Locking example |
Следующий пример Мары для упорядочивания Release/Acquire — это очень элементарная блокировка, которая использует AtomicBool для защиты доступа к a String. Вот слегка измененная версия этого примера: Две критические линии:
Первая строка атомарно считывается LOCKED с использованием Acquire порядка, и если значение равно false, она устанавливает значение с true использованием Relaxed порядка. Вторая строка LOCKED возвращает нас к false использованию Release. Как мы уже видели, Release означает, что все записи, которые произошли с этим моментом или до него, будут видны после Acquire этой же переменной. Объединение этих двух строк гарантирует, что unsafe запись в DATA и Release запись в READY будут завершены до того, как другой поток заметит LOCKED использование false порядка Acquire.
|
|
std::sync::atomic::Ordering::Relaxed (самый слабый) |
Этот порядок обеспечивает только атомарность операций (т.е., операция выполняется как единое целое, без прерываний). Он не гарантирует никакой дополнительной синхронизации между потоками. Подходит для случаев, когда вам не нужно заботиться о том, как значения соотносятся между потоками, кроме обеспечения атомарности самой операции. Пример: Подсчёт количества событий в многопоточном приложении, где важен только корректный счёт, но не порядок этих событий.
|
|
std::sync::atomic::Ordering::Acquire (для чтения, только к операциям load) |
Гарантирует, что все операции чтения и записи, которые следуют за этой операцией в коде, видят обновлённое состояние памяти. Используется для синхронизации чтения с другими потоками. Применяется, когда вам нужно убедиться, что все предыдущие операции (в рамках одной логической последовательности) завершены до того, как будет выполнено чтение. Пример: Чтение флага, который показывает, что поток завершил какую-то задачу. |
|
std::sync::atomic::Release (для записи, только к операциям store) |
Гарантирует, что все операции записи, которые были сделаны до этой записи в коде, завершены до того, как будет выполнена запись. Используется для синхронизации записи с другими потоками. Применяется, когда вы хотите убедиться, что все предыдущие операции (в рамках одной логической последовательности) завершены перед записью нового значения. Пример: Установка флага, указывающего, что поток завершил задачу. |
|
std::sync::atomic::AcqRel (эквивалент «Acquire для загрузки и Release для хранения») (для чтения и записи) |
Обеспечивает как порядок чтения, так и порядок записи, то есть, гарантирует, что все предыдущие операции завершены до чтения, и все последующие операции завершены после записи. Полезен в случаях, когда требуется полная синхронизация между операциями чтения и записи. Пример: Операции, где вы хотите гарантировать, что предыдущие записи и чтения корректно синхронизированы.
|
|
std::sync::atomic::SeqCst (самый сильный) |
Гарантирует полный последовательный порядок для всех операций. Это означает, что все потоки видят операции в том же порядке, в каком они были выполнены. Это самый строгий порядок памяти. Применяется, когда требуется гарантировать, что все операции в программе видны в том же порядке, независимо от того, какие потоки их выполняли. Пример: Сложные синхронизационные примеры, где необходима полная последовательность операций.
|
|
Переполнение типа compare_exchange_weak static Самая продвинутая и гибкая атомарная операция — это операция сравнения и обмена compare_exchange. Эта операция проверяет, равно ли атомарное значение заданному значению, и только если это так, она заменяет его новым значением, все атомарно как одна операция. Она вернет предыдущее значение и сообщит нам, заменила ли она его или нет. |
Единственная проблема здесь — поведение переноса при переполнении. Чтобы остановить увеличение NEXT_ID сверх определенного предела и предотвратить переполнение, мы можем использовать Compare_exchange для реализации атомарного сложения с верхней границей. Используя эту идею, давайте создадим версию allocate_new_id, которая всегда корректно обрабатывает переполнение, даже в практически невозможных ситуациях:
Теперь мы проверяем и впадаем в панику перед изменением, гарантируя, что он никогда не увеличится больше |
|
Применение в static переменных |
|
|
std::sync::atomic::fence синхронизации между операциями на уровне атомарных операций |
fence используется для создания синхронизации между операциями на уровне атомарных операций. Это позволяет разработчикам контролировать порядок выполнения операций в многопоточной среде и обеспечивает дополнительные гарантии для предотвращения нежелательных оптимизаций компилятора и процессора.
|
|
std::sync::atomic::fence синхронизации между операциями на уровне атомарных операций |
В этом примере 10 потоков выполняют некоторые вычисления и сохраняют свои результаты в (неатомарной) общей переменной. Каждый поток устанавливает атомарное логическое значение, чтобы указать, что данные готовы к чтению основным потоком, используя обычное хранилище выпуска. Основной поток ждет полсекунды, проверяет все 10 логических значений, чтобы увидеть, какие потоки выполнены, и выводит готовые результаты. Вместо использования 10 операций acquire-load для чтения булевых значений, основной поток использует ослабленные операции и один забор acquire. Он выполняет забор перед чтением данных, но только если есть данные для чтения. Хотя в этом конкретном примере может быть совершенно излишним прилагать какие-либо усилия для такой оптимизации, этот шаблон для экономии накладных расходов на дополнительные операции получения данных может быть важен при построении высокоэффективных параллельных структур данных.
|
|
Для чего используют Атомные типы В этом примере мы используем счетчик AtomicUsize, который поддерживает атомарные операции без блокировки. Каждый поток вызывает fetch_add безопасное увеличение счетчика, и мы используем этот load метод для чтения конечного значения. |
то же самое, но в scope:
|
|
Одно из простых применений атомарных типов – прерывание потока |
Ниже приведен код рабочего потока:
Если в главном потоке мы решим прервать рабочий поток, то сохраним значение true в AtomicBool, а затем дождемся завершения потока:
Конечно, задачу можно решить и по-другому. Тип AtomicBool можно было бы заменить мьютексом Mutex |
|
spinlock |
Спин-блокировка — это механизм синхронизации, который позволяет потокам ожидать освобождения ресурса, активно "крутясь" (то есть, постоянно проверяя состояние ресурса), вместо того чтобы переходить в спящий режим. Это полезно, когда время ожидания ресурса предполагается коротким, поскольку спин-блокировки могут быть более эффективными, чем более сложные механизмы, такие как мьютексы. Преимущества: Спин-блокировка может быть более эффективной, чем использование мьютексов или других сложных механизмов синхронизации, если время ожидания невелико. Это связано с тем, что потоки не переходят в спящий режим, а продолжают активно проверять состояние ресурса. Недостатки: Спин-блокировки могут быть неэффективными, если время ожидания ресурса значительно, поскольку активное ожидание потребляет процессорное время, которое могло бы быть использовано для выполнения другой работы. Это также может привести к увеличению энергопотребления и снижению общей производительности.
|
|
|
||
|
||
|
Для гораздо большего типа данных, который не помещается в одну атомарную переменную AtomicU64, нам нужно AtomicPtr
Нужно использовать Acquire как для упорядочивания загрузочной памяти, так и для упорядочивания памяти ошибок Compare_exchange, чтобы иметь возможность синхронизироваться с операцией, сохраняющей указатель. Это сохранение происходит, когда операция Compare_exchange завершается успешно, поэтому мы должны использовать Release в качестве порядка ее успеха. |
|
Пример: отчет о ходе работы |
После обработки последнего элемента может потребоваться до одной целой секунды, чтобы основной поток узнал об этом, что приводит к ненужной задержке в конце. Чтобы решить эту проблему, мы можем использовать парковку потоков чтобы вывести основной поток из состояния сна всякий раз, когда появляется новая информация, которая может его заинтересовать.
|
|
|
||
В приведенном ниже примере мы продемонстрируем, как порядок «Relaxed» отличается от порядка «Acquire» и «Release» |
Атомные типы являются строительными блоками незакрепленных структур данных и других параллельных типов. При доступе к / модификации атомного типа следует указывать порядок памяти, представляющий силу барьера памяти. Rust обеспечивает 5 примитивов упорядочения памяти: Relaxed (самый слабый), Acquire (для чтения aka load), Release (для записи aka магазинов), AcqRel (эквивалент «Приобретать для загрузки и выпуска для хранения»; полезно, когда оба участвуют в одной операции, такой как compare-and-swap) и SeqCst (самый сильный).
Примечание. Архитектуры x86 имеют сильную модель памяти. Эта статья объясняет это подробно. Также взгляните на страницу Википедии для сравнения архитектур. |
|
Эта библиотека определяет общий тип атомарной оболочки |
||
Атомарные переменные безопасно совместно использовать между потоками (они реализуют Sync), но сами по себе они не предоставляют механизма совместного использования. Самый распространенный способ совместного использования атомарной переменной — это поместить ее в Arc |
||
|
||
|
||