Сегодня мы поговорим о многопоточности. Не о том многоядерном параллелизме, который должен спасти нас от замедления закона Мура, а о ситуации, когда есть общая память и множество потоков, которые принимают запросы и взаимодействуют с этой памятью. В качестве абстракции общей памяти, как правило, используют потокобезопасные контейнеры. Внутри они используют синхронизацию потоков, чтобы их несогласованные действия не испортили данные. Мы рассмотрим различные способы синхронизации, написав потокобезопасный сет на языке Java.
Статья основана на одной из лекций курса Параллельное программирование в Computer Science Center. Видеозаписи лекций 2016 года есть на youtube.
Мы будем разбираться в теме, используя достаточно простую реализацию абстрактного контейнера сета (множества). Напомню, сет хранит только уникальные элементы и соответствует математическому понятию множества. Он включает три основных метода: добавление и удаление элементов и проверка на наличие элемента в контейнере. Есть много способов реализовать сет (поэтому эта структура и относится к абстрактным). Например, одной из самых популярных реализаций в языке Java является HashSet. Внутри он использует хеш-таблицу, ключами которой являются элементы сета, а значениями — фейковый элемент.
Для того, чтобы разобраться в самых разных способах синхронизации сета, мы будем использовать менее производительную реализацию — на основе односвязного списка. Для поддержки уникальности значений, в сете используется следующий инвариант: каждый элемент списка больше предыдущего. Для простоты наш сет будет работать только для сравниваемых элементов (реализующих интерфейс Comparable). Реализация каждого из методов сета основывается на поиске места в списке, где должен находиться элемент. Ниже представлен код первоначальной версии контейнера, не поддерживающей синхронизацию.
Прежде чем переходить к синхронизации, нужно определиться со способом проверки правильности работы нашего контейнера. Мы не будем писать модульные тесты на проверку того, что сет действительно вставляет и удаляет элементы — в этом вы можете поупражняться самостоятельно. Нам нужен способ для выявления более сложных ситуаций — возникающих в многопоточной среде. Давайте для начала рассмотрим пример, который покажет, что текущая реализация сета некорректна в случае более чем одного потока.
Такие ситуации сложно промоделировать обычными тестами. Тут нам поможет понятие линеаризуемости. Это свойство программы, при котором результат любого параллельного выполнения операций эквивалентен некоторому последовательному выполнению. В реальности получить все возможные перестановки операций в параллельном выполнении кажется слишком затратным и трудно выполнимым, но это можно сделать с последовательным выполнением. Системы тестирования линеаризуемости именно это и проделывают, вычисляя результаты выполения после таких перестановок. Далее запускаются те же операции в параллельной среде и производится проверка, что результат выполнения соответствует одному из полученных в последовательном запуске. Производится значительное число параллельных запусков, чтобы можно было говорить об отсутствии ошибок синхронизации с определенной вероятностью.
Для проверки линеаризуемости нашего сета мы будем использовать библиотеку Lin-chek. Пример подключения ее в Maven:
Напишем тест для нашего исходного сета. Для этого необходимо определить операции, которые будут задействованы в выполнении, как будут генерироваться аргументы для операций (в данном случае это целые числа из промежутка от 1 до 5) и опции запуска. Установим 50 итераций, 3 потока и 5 действий на каждый поток.
Вывод теста выглядит следующим образом:
Как и ожидалось, тест выявил ошибку линеаризации. Это связано с тем, что наша исходная реализация сета не содержит никакой синхронизации потоков. Уже на третьей итерации теста проявилась проблема доступа к одному элементу из разных потоков. Что ж, приступим к изучению различных методов синхронизации и окрасим вывод теста в зеленый цвет.
Начнем с простейшего способа заставить наш сет работать на сколь угодно большом числе потоков. Для этого достаточно добавить в контейнер глобальный мьютекс, который будем захватывать при каждой операции. Такая синхронизация называется грубой.
В качестве мьютекса в Java можно использовать класс ReentrantLock. Приставка reentrant показывает, что данный примитив синхронизации можно захватывать повторно без предварительного освобождения. В нашем случае такой ситуации возникнуть не может, но в большом проекте можно за этим не уследить и при использовании обычного замка получить дедлок даже на одном потоке.
Воспользуемся первоначальной версией сета в реализации структуры с грубой синхронизацией.
Запуск тестов покажет вам, что такая синхронизация работает верно. Действительно, в любой момент времени с контейнером работает только один поток, поэтому линеаризуемость нашего сета очевидна. Тем не менее, в этом то и кроется главная проблема данной реализации. При большом размере структуры и большом числе потоков с запросами, просадка по производительности будет колоссальной. Наша цель: иметь возможность физического параллелизма в разных частях списка. То есть, если один поток хочет добавить элемент в одно место, а другой поток в другое, то они могли бы это делать одновременно.
Прежде чем мы перейдем к другим методам синхронизации, отметим, что и грубую синхронизацию можно несколько оптимизировать. Для этого достаточно использовать не ReentrantLock, а ReentrantReadWriteLock. Такая оптимизация позволит разделить блокировки на запись и чтение. Благодаря этому, можно делать проверки на наличие элемента параллельно, если в текущий момент не происходит изменений в структуре. Заметим также, что в первом случае вместо явного мьютекса можно было использовать блок synchronized.
В грубой синхронизации использовался один мьютекс на весь контейнер. Альтернатива такому способу синхронизации — использование мьютекса в каждом из элементов сета и блокировка константного числа элементов в процессе операции в каждый момент времени. Этот метод называется тонкой синхронизацией. Добавим мьютекс в узел списка:
Приведу пример реализации только одного из методов сета — остальные операции аналогичны. Для нашего контейнера число элементов, которые необходимо держать одновременно заблокированными, равно двум. Это следует из того, что нам достаточно поддерживать инвариант prev.next == curr, чтобы не потерять связь между элементами списка.
В этом методе синхронизации мы платим памятью, добавляя мьютекс в каждый из элементов сета. Но в то же время мы можем значительно выиграть в физическом параллелизме. Действительно, если один поток производит операцию в конце списка, ничего не мешает другим потокам перемещаться по предшествующим позициям. Однако, возможна и ситуация, когда этот поток остановится на одном из первых элементов списка, тем самым не давая другим потокам продвинуться дальше.
В данном методе синхронизации мы хотим избавиться от промежуточных блокировок на пути к искомому элементу. Для этого мы бежим по списку вообще без блокировок, пока не доберемся до нужного места, и только тогда блокируем пару элементов. Однако, до того мгновения, как мы повесили блокировки на найденные элементы, они могли быть физически удалены из сета. В этом то и заключается оптимистичность этого метода. Мы заново пробегаемся по списку (снова без блокировок) и, если достигаем нашей пары элементов, то она по-прежнему в сете и с ней уже ничего не произойдет. Тогда то мы и можем выполнить необходимую операцию. Иначе, повторяем весь процесс заново.
Благодаря такой оптимистичности мы больше не блокируем элементы на нашем пути. Поэтому если первый поток застрянет где-то в начале списка, это не помешает другим потокам пройти дальше и выполнить свою работу. Однако, существуют вероятность того, что перед тем, как блокировки будут установлены, другие потоки удалят найденные элементы из сета, и придется повторять всю работу заново. Этот подход является предпочтительным, только если обход контейнера достаточно производителен, а обход с блокировками работает слишком медленно.
Заметим, что второго прохода по списку можно избежать. Для этого достаточно не удалять физически элементы сета, а помечать их как удаленные. Тогда после того, как блокировки будут установлены, останется проверить соответствующие флаги. Эта идея лежит в основе ленивой синхронизации. Добавим новое поле в узел списка.
В операцию удаления нужно добавить одну строчку с вызовом mark() для соответствующего узла, а добавление не изменилось вовсе. Валидация же значительно упростилась:
Благодаря этой оптимизации, мы подошли к важному классу неблокирующей синхронизации. Алгоритм называется неблокирующим, если в нем не используются традиционные примитивы синхронизации, например, мьютексы. Метод проверки наличия элементов теперь не только не использует блокировки, но и завершает свое выполнение за число шагов, не зависящее от действий других потоков. Это определение класса Wait-free алгоритмов (без ожидания). Этот класс является самым сильным среди алгоритмов неблокирующей синхронизации.
Мы получили выигрыш в производительности, но снова заплатили памятью, так как элементы не удаляются физически из сета, а хранятся вечно. Уменьшить воздействие этой проблемы, можно периодически пробегаясь по списку и удаляя помеченные узлы. Это можно делать, когда нагрузка на контейнер незначительна (например, ночью). Тем не менее, это серьезная оптимизация, которая напрямую подвела нас к ключевому методу синхронизации в контейнерах.
Последним шагом будет избавление от блокировок вовсе. Мы сделаем полностью неблокирующий контейнер, но для этого нам нужно познакомиться с еще одним понятием. Compare And Set (CAS) — атомарная ассемблерная интструкция, которая сравнивает значение в памяти с одним из аргументов и в случае совпадения записывает другой аргумент в память. Важно, что эти две логические операции производятся физически атомарно, то есть между ними не может выполниться никакая другая операция ни одного процесса. Это основной механизм неблокирующей синхронизации в многопоточных контейнерах.
В Java операция CAS представлена в классах Atomic. Они присутствуют для всех примитивных типов (например, AtomicBoolean), для ссылок (AtomicReference<T>), а так же для одновременного хранения ссылки и флага (AtomicMarkableReference<T>) и ссылки и числа (AtomicStampedReference<T>). В нашей задаче мы объединим ссылку на следующий элемент списка и метку о том, что узел удален, в одно Atomic поле. Замена ссылки будет производиться следующим образом: сравнивается первый аргумент с действительной ссылкой и то, что флаг по-прежнему равен false, и в случае успеха ссылка заменяется вторым аргументом. Все это производится атомарно. Код обновленного узла списка:
Теперь перейдем к самому сложному: напишем сет без блокировок, только с использованием CAS-операций. Операции мутации сета используют метод search, который находит нужную пару элементов, как и в прошлых реализациях. Он использует CAS, если встречает помеченный узел, чтобы физически удалить его. В остальном реализация стандартная. Также CAS используется в ситуациях, когда сет изменяется. С его помощью мы пытаемся подменить ссылку или флаг у узла списка. Если это не выходит сделать, значит другой поток нас опередил, и нужно повторить операцию заново.
Контейнер, который мы написали принадлежит классу Lock-free алгоритмов (без блокировок). В этом классе мы не можем гарантировать число шагов выполнения, не зависящее от других потоков (как в Wait-free). Но мы можем быть уверены, что если нам приходится повторять все заново, то какому-то другому потоку удалось выполнить свою задачу. Данный класс алгоритмов гарантирует постоянный прогресс системы.
Мы рассмотрели разные подходы к синхронизации потоков в контейнерах. Каждый из них имеет право на жизнь, у каждого есть свои недостатки. Где-то приходится платить памятью, где-то процессорным временем, а где-то сложностью разработки и трудноуловимыми ошибками.
Written on June 16th, 2019 by Alexey Kalina