Многопоточность. java.util.concurrent. Механизм Семафор

Многопоточность. java.util.concurrent. Механизм Семафор

Дорогу осилит идущий

Сегодня мы познакомимся с еще одним (и, вероятно, последним в рамках текущего раздела) механизмом синхронизации потоков – семафором (semaphore). Он может чем-то напомнить уже знакомые нам Lock’и, но интересен тем, что позволяет одновременно находиться в критической секции определенному числу потоков. А также изменять это число в процессе выполнения программы.

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

 

Класс Semaphore

В java.util.concurrent семафор представлен единиственным одноименным классом – Semaphore. Он позволяет создавать объекты-семафоры, в которых (с помощью конструктора) можно определить два параметра:

1.    Сколько потоков (условно, к этому мы еще вернемся) могут одновременно занимать ресурс;

2.    Использовать ли справедливое предоставление доступа (уверен, вы по нему скучали).

Полагаю, уже становится понятно, что именно в возможности подсчета числа потоков, входящих в критическую секцию и кроется основное различие семафора и лока.

В данном пункте мы разберем, какие методы предлагает Semaphore. В следующем же – постараемся хотя бы поверхностно понять актуальность семафоров. Полагаю, на первый взгляд этот механизм выглядит странным.

Для начала предлагаю ознакомиться со статьей на metanit:

https://metanit.com/java/tutorial/8.6.php

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

Теперь вкратце разберем методы, предлагаемые Semaphore:

 

acquire()

Имеет две реализации – без параметров и с параметром типа int. Вариант без параметров по смыслу схож с Lock#lockInterruptibly() – занимает семафор, если еще есть свободные «места». Если счетчик семафора равен 0 (нулю) – поток перейдет в состояние ожидания, пока другой поток не освободит семафор и не позволит текущему потоку войти в критическую секцию.

Вариант с параметром интересен тем, что параметр – это число единиц, на которые должен уменьшиться счетчик. Помните, я говорил, что счетчик семафора не равносилен числу потоков, которые могут одновременно занимать ресурс? Особенность именно в этом.

Например, при счетчике семафора равному 3 мы можем повсеместно использовать acquire() с параметров 3, тем самым сведя семафор к бинарному (для гугла: бинарный семафор) – только один поток сможет находиться в критической секции в единицу времени. При таком использовании семафор с параметром счетчика 3 ничем не отличается от семафора со счетчиком 1 и использованием acquire() без параметра. И при таком использовании семафор очень напоминает обычный Lock.

В целом же подобный механизм призван обеспечивать большую гибкость – например, в ряде ситуаций мы можем позволить 2-5-10 потокам находиться в критической секции – например, они производят операции чтения или наша логика предполагает, что они гарантированно работают с разными частями ресурса. Тем временем, какой-то поток производит в критической секции критические (каламбур?:)) изменения ресурса и в это время ресурс не должен быть доступен другим потокам. В таком случае логично создать семафор со счетчиком 10 (например) и N потоков, входящих в критическую секцию с уменьшением счетчика на 1. А также поток (потоки), который при захвате семафора будет занимать счетчик полностью – пытаться захватить все 10 «разрешений» (здесь есть серьезные оговорки, о них ниже). Правда, такой поток не сможет войти в семафор, пока его не освободят все другие потоки. При использовании unfair-доступа подобное ожидание имеет все шансы затянуться.

Если поток был прерван во время ожидания доступа – метод выбросит InterruptedException.

 

acquireUninterruptibly()

Все то же самое, что и с acquire() (включая две реализации), но без исключений, в случае прерывания потока.

Проводя аналогии с локами – похож на Lock#lock().

 

tryAcquire()

Метод, схожий с Lock#tryLock().

Пытается получить доступ, если счетчик позволяет – занимает семафор и возвращает true, если нет – возвращает false и больше не пытается занять семафор.

Имеет 4 реализации:

1.    Без параметров. Похожа на Lock#tryLock() без параметров, если удается занять семафор – уменьшает счетчик семафора на 1;

2.    С параметрами типа long и TimeUnit. Похожа на Lock#tryLock() с параметрами. Пытается занять семафор в течении заданного в параметрах времени, если удалось – уменьшает счетчик на 1;

3.    С параметром типа int. Как реализация без параметров, только в случае успеха счетчик уменьшается на заданное параметром число;

4.    С параметрами int, long и TimeUnit. Полагаю, вы уже догадались: в течении заданного промежутка времени пытается занять семафор, если удалось – уменьшает счетчик на число единиц, переданных в int-параметре.

 

release()

Отчасти, аналог Lock#unlock(). Реализация без параметров увеличивает счетчик семафора на единицу, реализация с параметром – на число, указанное параметром. Полагаю, очевидно, что в общем случае параметр release() должен равняться параметру из acquire().

Полагаю, вы все еще задаетесь вопросом, зачем столько сложностей, когда есть Lock. В конце концов, даже логика acquire()-release() очень похожа на lock()-unlock().

Но есть нюанс: release() не просто разблокирует доступ. Он именно увеличивает значение счетчика. И в ряде ситуаций есть смысл вызывать release() без предварительного вызова acquire(), тем самым увеличивая число потоков, которые могут занять семафор.

Зачем? например, потому что рассматриваемый поток создал набор объектов, которые должны быть обработаны другими потоками. И каждый из этих объектов уже может поступать в обработку.

В такой ситуации могут быть разные решения (некоторые - вообще без использования семафоров). Но в т.ч. возможны с реализации с семафором. К слову, в таких ситуациях могут быть короткоживущие потоки, которые используют acquire(), но не используют release(). В таком случае счетчик семафора является, по сути, счетчиком необработанных объектов.

Помните, что приведенный пример – частный (и довольно узкий) случай.

 

availablePermits()

Возвращает число «разрешений» - единиц счетчика, которые не заняты на данный момент

 

drainPermits()

Метод, который чем-то напоминает acquire() на стероидах. «Обнуляет» счетчик – занимает все доступные «разрешения». Примерно как если бы мы сделали атомарный вызов acquire(availablePermits()).

 

Другие методы

·      isFair(). Как и в локах, возвращает true, если используется fair-стратегия предоставления доступа.

·      hasQueuedThreads(). Возвращает true, если есть потоки, ожидающие доступа. Идентично с одноименным методом ReentrantLock;

·      getQueueLength(). Возвращает число потоков, ожидающих доступа. Идентично с одноименным методом ReentrantLock.

 

Актуальность

Семафор логически более сложный механизм, чем synchronized и Lock. На первый взгляд может даже показаться, что он избыточен – ведь уже не раз упоминалось, что пускать в критическую секцию несколько потоков одновременно – опасно.

Однако существует ряд задач, где семафоры применяются (или могут быть применены). В данном случае рассмотрим упрощенные ситуации, к тому же, опустим применение бинарных семафоров – они вполне могут применяться вместо локов или synchronized-блока, если не требуется логика оповещения через wait()-notify() или Condition.

Итак, в каких ситуациях использование семафоров оправдано?

·      Задача обедающих философов. Она упомянута в статье на метанит (правда, в каком-то странном виде), она же будет основой сегодняшней практики. Более подробное описание также находится в практике;

·      Пулы потоков (Thread pools, тред пулы). Механизм, построенный на поддержании ряда потоков, в которых задачи будут выполняться по мере необходимости. Избавляет от накладных расходов на создание потоков, а также позволяет абстрагироваться от рутинного прямого взаимодействия с Thread. В зависимости от реализации, может использовать семафоры в большей или меньшей степени. В целом, с thread pool’ами мы будем знакомиться в рамках отдельного урока. Возможно, заодно попробуем написать собственный пул потоков, в т.ч. с использованием семафоров;

·      Пул ресурсов. Например, мы имеем группу логически объединенных объектов (ресурсов). С каждым ресурсом в момент времени будет работать один поток. Но в рамках группы потоков может быть несколько. Данный сценарий вполне вероятен при сложной, но эффективно реализованной логики взаимодействия потоков;

·      Издатель-подписчик (Producer-Consumer). Ситуация, когда один поток (или группа потоков) создает(-ют) данные, другой поток (группа потоков) получает(-ют) и обрабатывают их. Таким образом, мы должны иметь возможность эффективно реагировать на объем данных. Семафор же будет отвечать за контроль ресурса – пула данных, в который пишет издатель и из которого читает подписчик. Как и в остальных случаях, семафор не выступает единственным возможным решением. Выбор конечного подхода должен зависеть от конкретной бизнес-проблемы.

В завершении стоит сказать, что в Java семафор по популярности уступает synchronized и Lock (другой вопрос, что он используется внутри некоторых Lock’ов). Однако это один из основных механизмов синхронизации и его стоит знать как минимум в рамках общего теоретического базиса по многопоточности.

 

С теорией на сегодня все!

Переходим к практике:

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

Условие:

Пять безмолвных философов сидят вокруг круглого стола, перед каждым философом стоит тарелка спагетти. Вилки лежат на столе между каждой парой ближайших философов.
Каждый философ может либо есть, либо размышлять. Приём пищи не ограничен количеством оставшихся спагетти — подразумевается бесконечный запас. Тем не менее, философ может есть только тогда, когда держит две вилки — взятую справа и слева.
Каждый философ может взять ближайшую вилку (если она доступна) или положить — если он уже держит её. Взятие каждой вилки и возвращение её на стол являются раздельными действиями, которые должны выполняться одно за другим.
Чтобы наесться, каждый философ должен поесть трижды. Необходимо накормить философов как можно быстрее – ситуация, когда они будут есть строго по одному – недопустима.

 

Задача 1

Решите задачу об обедающих философах, используя посредника – механизм, определяющий, кто из философов может взять вилку.

Подсказка: https://pastebin.com/PHxMKpwq

 

Задача 2

Решите задачу об обедающих философах, не вводя блокировок, связанных с вилками.

Подсказка: https://pastebin.com/QUxS59Qc

 

Задача 3

Решите задачу об обдающих философах, основываясь на порядковых номерах вилок.

Подсказка: https://pastebin.com/q4yQPjSe


Если что-то непонятно или не получается – welcome в комменты к посту или в лс:)

Канал: https://t.me/ViamSupervadetVadens

Мой тг: https://t.me/ironicMotherfucker

 

Дорогу осилит идущий!

Report Page