
- •Национальный исследовательский ядерный университет «мифи»
- •Пояснительная записка к дипломному проекту на тему:
- •Глава 1. Обзор методов и средств многопоточного взаимодействия 7
- •Глава 2. Разработка структуры и алгоритмов взаимодействия 29
- •Глава 3. Реализация и тестирование разработанных структур и алгоритмов взаимодействия 53
- •Введение
- •Глава 1. Обзор методов и средств многопоточного взаимодействия
- •1.1. Блокирующая синхронизация
- •1.2. Неблокирующая синхронизация
- •1.2.1. Общие сведения
- •1.2.2. Принципы неблокирующих алгоритмов Узлы неизменяемого типа
- •Подмена указателей
- •Атомарные операции
- •Специальные методы управления памятью
- •1.2.3. Обзор специальных методов управления памятью Метод использования специальных тегов
- •Метод неблокирующего подсчета ссылок
- •Метод опасных указателей
- •1.2.4. Оценка эффективности методов
- •1.2.5. Типы алгоритмов для неблокирующей синхронизации
- •1.3. Выводы
- •Глава 2. Разработка структуры и алгоритмов взаимодействия
- •2.1. Требования к разрабатываемой структуре данных и обоснование выбранных методов реализации
- •2.2. Обзор существующих неблокирующих структур
- •2.3. Разработка структуры данных
- •2.4. Разработка алгоритмов
- •2.4.1. Алгоритм записи
- •2.4.2. Алгоритм чтения
- •Метод неблокирующего подсчёта ссылок
- •Метод опасных указателей
- •2.4.3. Алгоритм освобождения памяти
- •Метод неблокирующего подсчёта ссылок
- •Метод опасных указателей
- •2.4.4. Алгоритм добавления и удаления опасных указателей
- •Глава 3. Реализация и тестирование разработанных структур и алгоритмов взаимодействия
- •3.1. Особенности программной реализации
- •3.2. Тестирование разработанных алгоритмов
- •3.3. Тестирование разработанной структуры при многопоточном доступе
- •3.4. Сравнение структур по временным характеристикам
- •Заключение
- •Список литературы
1.2.2. Принципы неблокирующих алгоритмов Узлы неизменяемого типа
Как было сказано в самом начале, изменяемость данных автоматически ведет к тому, что в некоторый промежуток времени при их изменении, данные могут быть несогласованными. Блокирующая синхронизация позволяет легко гарантировать, что посторонний процесс не получит доступа к данным во время их изменение. Но неблокирующая синхронизация по определению подразумевает, что доступ к данным любого процесса в любой момент времени должен быть гарантирован!
К счастью, существует парадигма программирования, которая позволяет гарантировать с одной стороны то, что доступ к данным в несогласованном состоянии никогда посторонним процессом получен не будет, и, с другой стороны, данные все же могут быть изменены.
Эта парадигма – использование структур данных неизменяемого (immutable) типа. Неизменяемый тип – это такой тип структуры данных, когда данные, входящие в структуру заносятся в нее (изменяются в ней) только однократно – при ее создании. Изменить данные такой структуры непосредственно нельзя, но можно:
скопировать структуру;
в копии задать необходимые новые значения;
далее вместо исходной структуры использовать копию;
оригинал уничтожить.
Чтобы выполнение третьего пункта было возможным, необходимо применить следующий принцип – подмену указателей.
До того, как рассмотреть этот принцип, необходимо отметить особенность структур данных неизменяемого типа: их использование может дорого обойтись в вычислительном плане и в плане эффективности использования памяти. Действительно, если всегда происходит изменение не самой структуры, а ее копии, то эту копию ведь еще нужно получить. Операция же копирования является довольно дорогой в вычислительном плане, и, кроме того, при работе с типами и структурами большого размера, вообще говоря, можно столкнуться даже с проблемой нехватки памяти.
Подмена указателей
Итак, после получения копии узла структуры данных, который необходимо изменить, и произведения необходимых согласно алгоритму изменений данных узла, необходимо обеспечить подмену оригинального узла его копией. Это действие производится за счет подмены указателя на оригинальный узел на указатель на копию узла. Фактически, задача изменения произвольной динамической структуры данных (сложная) подменяется на задачу изменения лишь одного указателя на эту структуру (простую). Такая подмена задачи позволяет устранить сам промежуток времени, когда изменяемые данные могут быть не согласованы, – за счет использования атомарных операций, поскольку значение указателя – адрес области памяти – это обычное число, которое можно изменить с помощью единственной процессорной инструкции.
Атомарные операции
Атомарность означает неделимость операции. Это значит, что ни один поток не может увидеть промежуточное состояние операции, она либо выполняется, либо нет. Рассмотрим пример простой операции инкрементирования значения, описанный в работе [8]:
1 int x = 0;
2 ++x;
Даже такой простой код нужно синхронизировать в многопоточной среде. Если посмотреть ассемблерный код второй строки, то мы увидим, что она состоит из трех операций:
1 mov eax, dword ptr [x] ; загрузка текущего значения из памяти в регистр eax
2 add eax, 1 ; инкрементирование значения регистра eax
3 mov dword ptr [x], eax ; запись значения регистра eax обратно в память
Модификация встроенных C++ типов не является атомарной, то есть если два потока одновременно попытаются модифицировать переменную x, мы вполне можем получить ситуацию, где значение x станет 1 после двух инкрементов. Пример показан в таблице 1.1.
Таблица 1.1
Пример неверной модификации переменной х двумя потоками
Поток 1 |
Поток 2 |
исходно: x = 0 |
|
прочитать x |
прочитать x |
инкрементировать x |
инкрементировать x |
записать x |
|
|
записать x |
Ситуации подобные этой, в которых финальный результат зависит от очередности выполнения, называется data race. Можно исправить этот код и сделать его потокобезопасным добавив блокировку перед инкрементом и разблокировку после него, тем самым обеспечивая атомарность операции инкрементирования, но существует другой способ.
Некоторые операции, в том числе и операции чтения и записи, являются достаточно простыми и могут быть выполнены процессором атомарно, то есть он может реализовать их в виде специальных инструкций на аппаратном уровне. Поэтому, для того чтобы подменить оригинал узла на его измененную копию, применяется операция подмены указателя, в простейшем случае, используя обычную атомарную операцию записи. Однако неблокирующие алгоритмы более-менее сложных структур требуют наличия и более сложных атомарных операций.
В связи с этим, кроме атомарных операций чтения и записи, в число атомарных примитивов также должна входить такая операция, как сравнение с обменом или compare-and-swap (CAS), также известная, как compare exchange, или пара операций загрузка с пометкой/попытка записи или load linked/store conditional (LL/SC). Суть операции сравнение с обменом заключается в том, что она атомарно сравнивает значение одного объекта с другим и при удачном сравнении заменяет значение объекта.CAS принимает три аргумента: адрес области памяти, ожидаемое значение по этому адресу и вновь записываемое значение. Если и только если область памяти содержит ожидаемое значение, по этому адресу записывается новое значение. Операцией возвращается булево значение, определяющее произошла ли перезапись значения или нет. Иными словами, CAS(address, expected, new) атомарно выполняет следующую операцию (в псевдокоде):
Суть пары операций загрузка с пометкой/попытка записи аналогична сути операции CAS: LL атомарно сравнивает значение одного объекта с другим и при удачном сравнении SCзаменяет значение объекта.LL принимает один аргумент: адрес области памяти и возвращает ее содержимое. SC принимает два аргумента: адрес области памяти и новое значение. Если ни один другой поток не перезаписывал область памяти по адресу после того, как данный поток считал ее значение с помощью LL, только тогда по этому адресу записывается новое значение. Операция возвращает булево значение, определяющее произошла ли перезапись значения. Дополнительная инструкция validate (VL) принимает один аргумент: адрес области памяти, и возвращает булево значение, определяющее, происходила ли перезапись области памяти со стороны других потоков с того момента времени, как данный поток выполнил LL.
Большинство современных широко распространенных процессорных архитектур поддерживает либо CAS, либо пару LL/SC на выровненных однословных операндах. В некоторых 32-разрядных системах эти операции доступны и для двухсловных операндов (то есть доступна поддержка 64-разрядных инструкций), но на 64-битных архитектурах поддержки операций над двухсловными операндами нет (то есть 128-битные инструкции не поддерживаются). Пара LL/SC обычно используется в RISC-архитектурах (DEC Alpha, MIPS, PowerPC, ARM), тогда как на архитектурах x86 используется CAS в различных ее вариациях.
CAS легко выразить через пару LL/SC следующим образом:
Более подробное описание операций можно найти в работах [2,4].