Семафор (программирование)


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

Вычислительные семафоры используются для контроля над ограниченными ресурсами. Двоичные семафоры обеспечивают взаимное исключение исполнения критических секций, а их упрощённой реализацией являются мьютексы, которые более ограничены в использовании. Помимо взаимного исключения в общем случае семафоры и мьютексы могут использоваться во множестве других типовых алгоритмов, включая сигнализирование другим задачам, разрешение прохождения определённых контрольных точек только для одной задачи единовременно по аналогии с турникетом, задачу производителя и потребителя, подразумевающую передачу данных от одних задач другим, барьеры, позволяющие синхронизировать группы задач в определённых контрольных точках, условные переменные для оповещения других задач о каких-либо событиях и блокировки чтения и записи, разрешающие одновременное чтение данных, но запрещающих их одновременное изменение.

Типовыми проблемами использования семафоров являются одновременное блокирование двух задач в ожидании друг друга и ресурсное голодание, в результате чего ресурс может быть периодически недоступен для одних задач из-за его использования другими задачами. При использовании в процессах с приоритетом реального времени может возникнуть инверсия приоритетов, которая может привести к неограниченной по времени блокировке процесса с более высоким приоритетом из-за захвата семафора процессом с более низким приоритетом, в то время как процессорное время отдаётся процессу со средним приоритетом, решением чего является наследование приоритетов.

Общие сведения

Понятие семафора было введено в 1965 году нидерландским учёным Эдсгером Дейкстрой, а в 1968 году он предложил использовать два семафора для решения задачи производителя и потребителя.

Семафор представляет собой счётчик, над которым можно выполнять две операции: увеличение на 1 (англ. up) и уменьшение на 1 (англ. down). При попытке уменьшения семафора, значение которого равно нулю, задача, запросившая данное действие, должна блокироваться до тех пор, пока не станет возможным уменьшение значения семафора до неотрицательного значения, то есть пока другой процесс не увеличит значение семафора. Под блокированием задачи понимается изменение состояния процесса или потока планировщиком задач на такое, при котором задача приостановит своё исполнение.

Операции уменьшения и увеличения значения семафора первоначально обозначались буквами P (от нидерл. proberen — пытаться) и V (от нидерл. verhogen — поднимать выше) соответственно. Данные обозначения дал операциям над семафорами Дейкстра, но так как они не понятны для людей, говорящих на других языках, на практике обычно используются другие обозначения. Обозначения up и down впервые начали использоваться в языке Алгол 68.

Операции увеличения и уменьшения значения семафора вместе со всеми проверками должны быть атомарными. Если в момент увеличения значения семафора есть более одного заблокированного по данному семафору процесса, то операционная система выбирает один из них и разрешает ему закончить операцию уменьшения значения семафора.

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

Основным назначением семафора является разрешение или временный запрет на выполнение каких-либо действий, поэтому если значение счётчика семафора больше нуля, то говорят, что он находится в сигнальном состоянии, если же значение равно нулю — в несигнальном состоянии. Уменьшение значения семафора также иногда называют захватом (англ. acquire), а увеличение значения — отпусканием или освобождением (англ. release), что позволяет сделать описание работы семафора более понятным в контексте контроля использования какого-либо ресурса или при использовании в критических секциях.

В общем виде семафор можно представить как объект, состоящий из:

  • переменной-счётчика, хранящей текущее значение семафора;
  • списка заблокированных в ожидании сигнального значения семафора задач;
  • функций атомарного увеличения и уменьшения значения семафора.

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

Виды семафоров

Семафоры могут быть двоичными и вычислительными. Вычислительные семафоры могут принимать целочисленные неотрицательные значения и используются для работы с ресурсами, количество которых ограничено, либо участвуют в синхронизации параллельно исполняемых задач. Двоичные семафоры могут принимать только значения 0 и 1 и используются для взаимного исключения одновременного нахождения двух или более процессов в своих критических секциях.

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

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

Алгоритмы использования

Типовые алгоритмы

Сигнализирование

Сигнализирование, также называемое уведомлением, является базовым назначением семафоров, оно гарантирует исполнение участка кода одной задачи после исполнения участка кода другой задачи. Сигнальное использование семафора обычно предполагает установку его начального значения в 0, чтобы ожидающие сигнального состояния задачи могли блокироваться до наступления события. Сигнализирование выполняется через увеличение значения семафора, а ожидание — через уменьшение значения.

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

Взаимное исключение

В многопоточных приложениях часто требуется, чтобы отдельные участки кода, называемые критическими секциями, не могли работать параллельно, например, при доступе к какому-либо неразделяемому ресурсу или при изменении общих ячеек памяти. Для защиты таких участков можно использовать двоичный семафор или мьютекс. Мьютекс является более безопасным в использовании, поскольку может быть отпущен только тем процессом или потоком, который его захватил. Также использование мьютекса вместо семафора может быть более производительным за счёт оптимизации под два значения на уровне реализации ассемблерного кода.

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

Для разных ресурсов могут быть разные семафоры, отвечающие за критические секции. Таким образом, критические секции, защищённые разными семафорами, могут работать параллельно.

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

Турникет

Частой бывает задача разрешения или запрета для одной или более задач прохождения через определённые контрольные точки. Для решения данной задачи используется алгоритм на основе двух семафоров, который по своей работе напоминает турникет, поскольку позволяет единовременно пропускать только одну задачу. Турникет основывается на семафоре, который в контрольных точках захватывается и сразу же освобождается. Если требуется закрыть турникет, то семафор необходимо захватить, в результате чего все задачи, проходящие через турникет будут блокироваться. Если требуется снова разрешить задачам проходить через турникет, что достаточно отпустить семафор, после чего задачи будут по очереди продолжать исполнение.

У поочерёдного прохождения через турникет есть большой недостаток — на каждое прохождение может происходить лишнее переключение контекста между задачами, в результате чего будет снижаться производительность алгоритма. В некоторых случаях решением может быть использование многоместного турникета, который разблокирует сразу несколько задач, что может осуществляться, например, циклическим отпусканием семафора, если используемая реализация семафора не поддерживает увеличение на произвольное число.

Псевдокод турникета

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

Выключатель

Ещё одним типовым алгоритмом на основе семафоров является реализация выключателя. Задачи могут захватывать выключатель и освобождать его. Первая задача, которая захватывает выключатель, включает его. А последняя задача, которая его освобождает, — выключает. Для данного алгоритма можно провести аналогию с выключателем света в комнате. Первый, кто входит в комнату, — включает свет, а последний, кто выходит, — выключает.

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

Псевдокод алгоритма работы выключателя

Алгоритм выключателя используется в более сложном механизме — блокировках чтения и записи.

Задача производителя и потребителя

Задача производителя потребителя предполагает производство какой-либо информации одной задачей и передачу этой информации другой задаче для обработки. В многопоточных системах одновременное производство и потребление может приводить к состоянию гонки, что требует использования критических секций или других способов синхронизации. Семафор является наиболее простым примитивом синхронизации, с помощью которого можно решать задачу производителя и потребителя.

Передача данных через кольцевой буфер

Кольцевой буфер представляет собой буфер с фиксированным количеством элементов, данные в который заносятся и обрабатываются в порядке очереди (FIFO). В однопоточном варианте исполнения для организации такого буфера достаточно 4-х ячеек памяти:

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

В многозадачной реализации алгоритм усложняется необходимостью синхронизации задач. Для случая двух задач (производитель и потребитель) можно ограничиться двумя ячейками памяти и двумя семафорами:

  • индекс следующего элемента, доступного на чтение,
  • индекс следующего элемента, доступного на запись,
  • семафор, разрешающий чтение очередного элемента,
  • семафор, разрешающий запись очередного свободного элемента буфера.

Начальное значение семафора, отвечающего за чтение, устанавливается в 0, потому что очередь пуста. А значение семафора, отвечающего за запись, выставляется равным общему размеру буфера, то есть весь буфер доступен для заполнения. Перед заполнением очередного элемента в буфере семафор на запись уменьшается на 1, резервируя очередной элемент очереди для записи данных, после чего изменяется индекс на запись, а семафор на чтение увеличивается на 1, разрешая чтение добавленного в очередь элемента. Читающая задача, наоборот, захватывает семафор на чтение, после чего считывает очередной элемент из буфера и изменяет индекс следующего элемента на чтение, а затем отпускает семафор на запись, разрешая запись пишущей задаче в освободившийся элемент.

Псевдокод кольцевого буфера

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

Передача данных через произвольный буфер

Помимо передачи данных через кольцевой буфер, возможна передача и через произвольный, но в таком случае запись и чтение данных требуется защищать мьютексом, а семафор используется для оповещения читающей задачи о наличии очередного элемента в буфере. Пишущая задача добавляет в буфер элемент под защитой мьютекса, а затем сигнализирует о его наличии. Читающая же задача захватывает семафор, а затем, под защитой мьютекса, — получает очередной элемент. Стоит упомянуть, что попытка захвата семафора под защитой мьютекса может приводить к взаимоблокировке в случае попытки чтения из пустого буфера, а отпускание семафора внутри критической секции может слегка снизить производительность. Данный алгоритм, как и в случае с кольцевым буфером, защищённым мьютексом, позволяет писать и читать одновременно нескольким задачам.

В механизмах синхронизации

Барьер

Барьер представляет собой механизм синхронизации критических точек у группы задач. Задачи могут пройти через барьер только все сразу. Перед входом в критические точки задачи группы должны блокироваться, пока входа в критическую точку не достигнет последняя задача из группы. Как только все задачи окажутся перед входом в свои критические точки, они должны продолжить своё исполнение.

Самое простое решение для организации барьера в случае двух задач основывается на двух бинарных семафорах А и Б, инициализируемых нулевым значением. В критической точке первой задачи необходимо перевести в сигнальное состояние семафор Б, а затем захватить семафор А. В критической точке второй задачи необходимо сначала перевести в сигнальное состояние семафор А, а затем — захватить Б. Какая бы задача не дошла до критической точки первой, она просигнализирует другой задаче, разрешив её исполнение. Как только обе задачи достигнут своих критических точек, их семафоры окажутся в сигнальном состоянии, что позволит им продолжить своё исполнение.

Псевдокод простого барьера

Подобная реализация — однопроходная, поскольку барьер не возвращается в исходное состояние, также у неё низкая производительность из-за использования одноместного турникета, что требует переключения контекста для каждой задачи, поэтому на практике данное решение малоприменимо.

Двухфазный барьер

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

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

Псевдокод реентерабельного алгоритма двухфазного барьера

Условная переменная

Условная переменная представляет собой способ оповещения ожидающих задач о возникновении какого-либо события. Механизм условной переменной на прикладном уровне обычно строится на основе фьютекса и предусматривает функции ожидания события и отправки сигнала о его возникновении, но отдельные части этих функций должны защищаться мьютексом или семафором, поскольку помимо фьютекса в механизме условной переменной обычно присутствуют дополнительные разделяемые данные. В простых реализациях фьютекс можно заменить семафором, который при оповещении необходимо будет отпустить столько раз, сколько задач подписалось на условную переменную, однако при большом количестве подписчиков оповещение может стать узким местом.

Механизм условной переменной предполагает наличие трёх операций: ожидание события, сигнализирование о событии одной задаче и оповещение всех задач о событии. Для реализации алгоритма на основе семафоров потребуются: мьютекс или двоичный семафор для защиты, собственно, самой условной переменной, счётчик количества ожидающих задач, мьютекс для защиты счётчика, семафор А для блокировки ожидающих задач и дополнительный семафор Б для своевременного пробуждения очередной ожидающей задачи.

При подписке на события счётчик подписавшихся задач атомарно увеличивается на 1, после чего отпускается предварительно захваченный мьютекс условной переменной. Затем захватывается семафор А для ожидания наступления события. По наступлению события сигнализирующая задача атомарно проверяет счётчик подписавшихся задач и оповещает очередную задачу о наступлении события, отпуская семафор А, а затем блокируется по семафору Б в ожидании подтверждения разблокировки. Получившая оповещение задача отпускает семафор Б и снова захватывает мьютекс условной переменной для возврата в изначальное состояние. Если же делается широковещательное оповещение всех подписанных задач, то семафор заблокированных задач А отпускается в цикле по количеству подписанных задач в счётчике. При этом оповещение происходит атомарно под защитой мьютекса счётчика, чтобы счётчик не мог измениться во время оповещения.

Псевдокод условной переменной

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

Интересным фактом является то, что сам семафор легко реализуется на основе условной переменной и мьютекса, а реализация условной переменной на основе семафоров — намного сложнее.

Блокировки чтения и записи

Одной из классических проблем является синхронизация доступа к ресурсу, доступному одновременно на чтение и на запись. Блокировки чтения и записи призваны решить эту проблему и позволяют организовать раздельную блокировку ресурса на чтение и на запись, разрешая одновременное чтение, но запрещая одновременную запись. Запись также блокирует любое чтение. Эффективный механизм не может быть построен на базе одного лишь фьютекса, счётчик количества читателей может изменяться без разблокирования каких-либо задач. Блокировки чтения и записи могут быть реализованы на основе комбинации мьютексов и семафоров или мьютексов и условной переменной.

Универсальный алгоритм, лишённый проблемы ресурсного голодания пишущих задач, включает в себя выключатель бинарного семафора А для организации критической секции читающих задач и турникет для блокировки новых читающих задач при наличии ожидающих пишущих. При появлении первой читающей задачи она захватывает семафор А с помощью выключателя, запрещая запись. Для пишущих задач семафор А защищает критическую секцию записи, поэтому, если он захвачен читающими задачами, все пишущие задачи блокируются при входе в свою критическую секцию. Однако захват пишущими задачами семафора А с последующей записью защищается семафором турникета. Поэтому, если произошла блокировка пишущей задачи из-за наличия читающих, турникет блокируется вместе с новыми читающими задачами. Как только последняя читающая заканчивает свою работу, семафор выключателя отпускается, и первая в очереди пишущая задача разблокируется. По окончании своей работы она отпускает семафор турникета, снова разрешая работу читающих задач.

Псевдокод универсального алгоритма блокировок чтения-записи

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

В классических задачах

Обедающие философы

Одной из классических задач синхронизации является задача об обедающих философах. Задача включает в себя 5 обедающих за круглым столом философов, 5 тарелок, 5 вилок и общее блюдо с макаронами посреди стола. Перед каждым философом есть тарелка, а справа и слева — по одной вилке, но каждая вилка является общей между двумя соседними философами, а есть макароны можно только двумя вилками одновременно. При этом каждый из философов может или думать, или есть макароны.

Философами представлены взаимодействующие в программе потоки, а решение задачи включает в себя ряд условий:

  • между философами не должно быть взаимоблокировок;
  • ни один философ не должен голодать, ожидая освобождение вилки;
  • должно быть возможным, чтобы одновременно ели хотя бы два философа.

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

Решением взаимоблокировки может быть ограничение количества одновременно обедающих философов до 4-х. В таком случае по крайней мере один философ сможет обедать, пока остальные ожидают. Ограничение можно реализовать через семафор с начальным значением 4. Каждый из философов будет захватывать данный семафор перед тем как взять вилки, а после приёма пищи — отпускать. Также данное решение гарантирует отсутствие голодания у философов, поскольку, если философ ожидает освобождения вилки соседом, тот рано или поздно её отпустит.

Существует и более простое решение. Взаимоблокировка возможна, если одновременно 5 философов держат вилку в одной и той же руке, например, если они все правши и вначале взяли правую вилку. Если же один из философов является левшой и берёт вначале левую вилку, то невозможны ни взаимоблокировка, ни голодание. Таким образом, достаточно, чтобы у одного из философов сначала захватывался семафор левой вилки, а затем — правой, в то время как у остальных философов — наоборот.

Американские горки

Другой классической задачей является задача об американских горках, согласно которой состав вагонеток полностью заполняется пассажирами, затем катает их по кругу и возвращается назад за новыми. По условиям задачи количество желающих пассажиров превышает количество мест в составе, таким образом, очередные пассажиры ожидают своей очереди, пока состав едет по кругу. Если состав имеет М мест, то сначала состав должен ожидать, пока на свои места не сядут М пассажиров, затем он должен прокатить их, подождать, пока они все выйдут и снова ожидать новых пассажиров.

Состав вагонеток вместе с пассажирами можно представить как взаимодействующие задачи. Каждый пассажир должен блокироваться в ожидании своей очереди, а сам состав должен блокироваться на этапах заполнения и освобождения мест. Для загрузки и выгрузки состава можно воспользоваться двумя семафорами с выключателями, защищёнными каждый своим мьютексом, а для блокирования пассажиров на загрузку и на выгрузку можно использовать два семафора, отвечающие за места в вагонетках. Ожидающие пассажиры захватывают семафор на загрузку, а состав семафором на загрузку оповещает M из них о наличии свободных мест. Затем состав блокируется по выключателю, пока последний усаживающийся пассажир не просигнализирует соответствующим семафором, после чего начинается поездка. Перед поездкой пассажиры блокируются по семафору на выгрузку, что не даёт им выйти из состава. После поездки состав оповещает M пассажиров семафором на выгрузку, разрешая им выйти, а затем блокируется по семафору выключателя на выгрузку, ожидая, пока все пассажиры не выйдут. Как только последний пассажир выйдет из состава, он просигнализирует семафором второго выключателя и разрешит составу снова набирать пассажиров.

Проблемы использования

Ограничения семафоров

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

Несмотря на ограничения концепции семафоров, конкретные их реализации могут быть лишены тех или иных ограничений. Например, возможность увеличения значения семафора на произвольное число предусмотрена в реализациях Linux, Windows и System V (POSIX). А семафоры POSIX позволяют определить, будет ли блокировка по захвату семафора.

Сильные и слабые семафоры

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

  • Если есть хотя бы одна задача, готовая к исполнению, она должна исполняться.
  • Если задача готова к исполнению, время до начала её исполнения должно быть конечным.
  • Если происходит сигнализирование семафором, по которому есть заблокированные задачи, то, по крайней мере, одна из них должна перейти в состояние готовности к исполнению.
  • Если задача заблокирована по семафору, то количество других задач, которые будут разблокированы по тому же семафору до заданной, должно быть конечным.
  • Первые два требования необходимы, чтобы любая задача могла получить процессорное время и не находилась бесконечно в состоянии готовности, что уже позволяет писать приложения без ресурсного голодания. Третье требование необходимо для предотвращения ресурсного голодания при взаимном исключении, построенном на семафорах. Если сигнализирование будет лишь увеличивать счётчик семафора, но не будет пробуждать заблокированную по нему задачу, то возможна ситуация, когда одна и та же задача бесконечно отпускает и захватывает семафор, а другие заблокированные задачи не успевают перейти в состояние готовности, либо переходят, но гораздо реже. Однако даже при соблюдении третьего требования в случае большого количества заблокированных задач возможно ресурсное голодание, если каждый раз разблокируются одни и те же задачи. Данную проблему решает четвёртое требование, которое соблюдается, например, если заблокированные по семафору задачи пробуждаются в порядке очереди.

    Соблюдение первых трёх требований позволяет реализовать так называемые слабые семафоры, а соблюдение всех четырёх — сильные.

    Взаимные блокировки

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

    Типовой ошибкой является вызов внутри критической секции подпрограммы, использующей ту же самую критическую секцию.

    Наглядным примером взаимной блокировки могут служить вложенные друг в друга захваты бинарных семафоров А и Б, защищающих разные ресурсы, при условии обратного порядка их захвата в одном из потоков, что может быть обусловлено, например, стилевыми отличиями в написании кода программы. Ошибкой подобной реализации является состояние гонки, из-за которого программа может работать большую часть времени, но в случае параллельного захвата ресурсов высоки шансы на взаимную блокировку.

    Ресурсное голодание

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

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

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

    Инверсия приоритетов

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

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

    Повсеместное наследование приоритетов является крайне сложной в реализации задачей, поэтому поддерживающие её системы могут иметь лишь частичную реализацию. Также наследование приоритетов создаёт другие проблемы, например, к невозможности совмещения кода с наследованием приоритетов с кодом без наследования при использовании одной и той же критической секции.

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

    Прикладное программирование

    Семафоры в POSIX

    Стандарты POSIX на уровне операционных систем предоставляют API языка Си для работы с семафорами как на уровне потоков, так и на уровне процессов через разделяемую память. Стандарты определяют тип данных семафора sem_t и набор функций для работы с ним. Семафоры POSIX доступны в Linux, macOS, FreeBSD и других POSIX-совместимых операционных системах.

    Одним из недостатков семафоров POSIX является способствующая ошибкам спецификация функции sem_timedwait(), которая оперирует часами реального времени (CLOCK_REALTIME) вместо времени непрерывной работы системы (CLOCK_MONOTONIC), что может приводить к сбоям в работе программ при изменении системного времени и может оказаться критичным для встраиваемых устройств, но некоторые операционные системы реального времени предлагают аналоги данной функции, работающие с временем непрерывной работы системы. Другим недостатком является отсутствие поддержки ожидания одновременно нескольких семафоров или семафора и файлового дескриптора.

    В Linux семафоры POSIX реализованы в библиотеке Glibc на основе фьютекса.

    Семафоры System V

    Стандарты POSIX также определяют набор функций из стандарта X/Open System Interfaces (XSI) для межпроцессовой работы с семафорами в рамках операционной системы. В отличие от обычных семафоров семафоры XSI можно увеличивать и уменьшать на произвольное число, они выделяются массивами, и их время жизни распространяется не на процессы, а на операционную систему. Таким образом, если забыть закрыть семафор XSI по завершении всех процессов приложения, то он продолжит существовать в операционной системе, что называется утечкой ресурса. В сравнении с семафорами XSI обычные семафоры POSIX намного проще в использовании, и у них может быть выше быстродействие.

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

    Семафоры в Linux

    Операционные системы семейства Linux поддерживают семафоры POSIX, но также предлагают альтернативу семафорам в виде счётчика, привязанного к файловому дескриптору через системный вызов eventfd() с флагом EFD_SEMAPHORE. При чтении такого счётчика через функцию read() он уменьшается на 1, если его значение было ненулевым. Если же значение было нулевым, то происходит блокировка (если не указан флаг EFD_NONBLOCK), как и в случае с обычными семафорами. Функция write() увеличивает значение счётчика на число, которое записывается по файловому дескриптору. Преимуществом такого семафора является возможность ожидания сигнального состояния семафора наряду с другими событиями с помощью системных вызовов select() или poll().

    Семафоры в Windows

    Ядро Windows также предоставляет API языка Си для работы с семафорами. Потоки, заблокированные по семафору, выстраиваются в очередь FIFO, но могут перейти в конец очереди в случае прерывания потока для обработки других событий.

    Особенностями семафоров под Windows является возможность увеличивать семафор на произвольное число и возможность ожидания его сигнального состояния вместе с блокирующим ожиданием других семафоров или объектов.

    Поддержка в языках программирования

    Семафоры обычно не поддерживаются на уровне языка программирования в явном виде, но часто предоставляются встроенными или сторонними библиотеками. В некоторых языках, таких как Ada и Go, семафоры легко реализуются средствами языка.

    Примеры использования

    Защита критической секции

    Самым простым примером использования семафора может служить взаимное исключение возможности исполнения критических участков кода у потоков или процессов. Для организации взаимного исключения может служить двоичный семафор и две функции: входа в критическую секцию и выхода из неё. Для упрощения в пример не включена возможность запоминания идентификатора захватившего потока и идентификатора процесса, которому поток принадлежит. Также предполагается, что критическая секция имеет конечное не очень большое время исполнения, поэтому прерывания операции захвата семафора (EINTR) игнорируются, а обработку результатов прерывания можно осуществлять после критической секции. Сам семафор проабстрагирован в структуру для повышения читабельности кода.

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

    Пример простой реализации критической секции на основе семафора на языке Си (POSIX) #include <errno.h> #include <pthread.h> #include <semaphore.h> #include <stdbool.h> #include <stdio.h> #include <stdlib.h> #ifndef __STDC_LIB_EXT1__ typedef int errno_t; #endif enum { EOK = 0, }; // Упрощённая реализация мьютекса struct guard_t { sem_t sem_guard; }; typedef struct guard_t guard_t; // Инициализация упрощённого мьютекса errno_t guard_init(guard_t *guard, bool between_processes) { int r; r = sem_init(&guard->sem_guard, between_processes, 1); if (r == -1) { return errno; } return EOK; } // Освобождение упрощённого мьютекса void guard_free(guard_t *guard) { sem_destroy(&guard->sem_guard); } // Вход в критическую секцию errno_t guard_enter(guard_t *guard) { int r; do { r = sem_wait(&guard->sem_guard); } while ((r == -1) && (errno == EINTR)); if (r == -1) { return errno; } return EOK; } // Выход из критической секции errno_t guard_leave(guard_t *guard) { int r; r = sem_post(&guard->sem_guard); if (r == -1) { return errno; } return EOK; } // Счётчик, защищённый упрощённым мьютексом struct safe_counter_t { guard_t lock; int counter; }; enum { // Количество операций уменьшения/увеличения OPERATIONS_COUNT = 100000, }; // Поток, увеличивающий счётчик void *thread_inc_func(void *thread_data) { struct safe_counter_t *safe_counter = thread_data; for (int i = 0; i < OPERATIONS_COUNT; ++i) { guard_enter(&safe_counter->lock); ++safe_counter->counter; guard_leave(&safe_counter->lock); } } // Поток, уменьшающий счётчик void *thread_dec_func(void *thread_data) { struct safe_counter_t *safe_counter = thread_data; for (int i = 0; i < OPERATIONS_COUNT; ++i) { guard_enter(&safe_counter->lock); --safe_counter->counter; guard_leave(&safe_counter->lock); } } // Вывод сообщения об ошибке согласно её коду void print_error(errno_t errnum, const char *error_text) { errno = errnum; perror(error_text); } int main(int argc, char **argv) { errno_t errnum; // Инициализация struct safe_counter_t safe_counter; safe_counter.counter = 0; guard_t lock; errnum = guard_init(&safe_counter.lock, false); if (errnum) { print_error(errnum, "Ошибка инициализации взаимного исключения lock"); exit(EXIT_FAILURE); } // Запуск двух потоков pthread_t thread_inc; errnum = pthread_create(&thread_inc, NULL, thread_inc_func, &safe_counter); if (errnum) { print_error(errnum, "Ошибка создания потока thread_inc"); exit(EXIT_FAILURE); } pthread_t thread_dec; errnum = pthread_create(&thread_dec, NULL, thread_dec_func, &safe_counter); if (errnum) { print_error(errnum, "Ошибка создания потока thread_dec"); exit(EXIT_FAILURE); } // Ожидание, пока потоки закончат исполнение errnum = pthread_join(thread_inc, NULL); if (errnum) { print_error(errnum, "Ошибка ожидания потока thread_inc"); exit(EXIT_FAILURE); } errnum = pthread_join(thread_dec, NULL); if (errnum) { print_error(errnum, "Ошибка ожидания потока thread_dec"); exit(EXIT_FAILURE); } // Освобождение данных guard_free(&lock); // Вывод результата работы потоков, «0» printf("Counter: %d ", safe_counter.counter); return EXIT_SUCCESS; }

    Пример синхронизации кольцевого буфера

    Синхронизация кольцевого буфера выполняется немного сложнее, нежели защита критической секции: семафоров становится уже два и к ним добавляются дополнительные переменные. В примере приведены структура и основные функции, необходимые для синхронизации кольцевого буфера на языке Си, используя интерфейс POSIX. Данная реализация позволяет одному потоку циклически записывать данные в кольцевой буфер, а другому потоку — асинхронно циклически читать из него.

    Пример реализации синхронизирующего примитива для кольцевого буфера с помощью семафоров на языке Си (POSIX) #include <errno.h> #include <semaphore.h> #include <stdio.h> #ifndef __STDC_LIB_EXT1__ typedef int errno_t; #endif enum { EOK = 0, }; struct ring_buffer_t { size_t length; size_t w_index; size_t r_index; sem_t sem_r; sem_t sem_w; }; errno_t ring_buffer_init(struct ring_buffer_t *rbuf, size_t length) { rbuf->length = length; rbuf->r_index = 0; rbuf->w_index = 0; int r; r = sem_init(&rbuf->sem_r, 1, 0); if (r == -1) { return errno; } errno_t errnum; r = sem_init(&rbuf->sem_w, 1, length); if (r == -1) { errnum = errno; goto aborting_sem_r; } return EOK; aborting_sem_r: sem_destroy(&rbuf->sem_r); return errnum; } void ring_buffer_free(struct ring_buffer_t *rbuf) { sem_destroy(&rbuf->sem_w); sem_destroy(&rbuf->sem_r); } errno_t ring_buffer_write_begin(struct ring_buffer_t *rbuf) { int r; do { r = sem_wait(&rbuf->sem_w); } while ((r == -1) && (errno == EINTR)); if (r == -1) { return errno; } return EOK; } errno_t ring_buffer_write_end(struct ring_buffer_t *rbuf) { ++rbuf->w_index; if (rbuf->w_index >= rbuf->length) { rbuf->w_index = 0; } int r; r = sem_post(&rbuf->sem_r); if (r == -1) { return errno; } return EOK; } errno_t ring_buffer_read_begin(struct ring_buffer_t *rbuf) { int r; do { r = sem_wait(&rbuf->sem_r); } while ((r == -1) && (errno == EINTR)); if (r == -1) { return errno; } return EOK; } errno_t ring_buffer_read_end(struct ring_buffer_t *rbuf) { ++rbuf->r_index; if (rbuf->r_index >= rbuf->length) { rbuf->r_index = 0; } int r; r = sem_post(&rbuf->sem_w); if (r == -1) { return errno; } return EOK; }

    Детали реализации

    В операционных системах

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

    Блокировка на уровне операционной системы обычно не предполагает физического ожидания на процессоре, а передаёт управление процессором другой задаче, в то время как ожидающая отпускания семафора — попадает в очередь заблокированных по данному семафору задач. В случае же, если количество готовых к исполнению задач меньше количества процессоров, ядро операционной системы может перевести свободные процессоры в режим экономии энергопотребления до наступления каких-либо событий.

    На уровне процессоров

    В архитектурах x86 и x86_64

    Для синхронизации работы процессоров в многопроцессорных системах существуют специальные инструкции, позволяющие защитить доступ к какой-либо ячейке. В архитектуру x86 компанией Intel для ряда инструкций процессора предусмотрен префикс LOCK, позволяющий выполнять атомарные операции над ячейками памяти. Операции над ячейкой, выполняемые с префиксом LOCK, блокируют доступ остальных процессоров к ячейке, что на примитивном уровне позволяет организовывать легковесные семафоры с активным циклом ожидания.

    Атомарное уменьшение значения семафора на 1 может быть выполнено при помощи инструкции DECL с префиксом LOCK, которая выставляет флаг знака CS в случае, если результирующее значение оказывается меньше нуля. Особенностью такого подхода является то, что значение семафора может оказываться меньше нуля, поэтому после уменьшения счётчика флаг CS может проверяться с помощью инструкции JNS, и, если знак отрицательный, то операционная система может заблокировать текущую задачу.

    Для атомарного увеличения значения семафора на 1 может использоваться инструкция LOCK INCL. Если результирующее значение оказывается отрицательным либо равным нулю, то это означает наличие ожидающих задач, в таком случае операционная система может разблокировать очередную задачу. Для пропуска разблокировки процессов может использоваться инструкция JG, которая осуществляет переход к метке, если флаги нулевого результата операции (ZF) и знака результата (SF) сброшены в 0, то есть если значение больше 0.

    Во время блокировки в случаях отсутствия текущих задач может использоваться инструкция HLT, предназначенная для перевода процессора в режим низкого энергопотребления с ожиданием прерываний, которые необходимо предварительно разрешать с помощью инструкции STI. Однако в современных процессорах более оптимальным может быть использование инструкций MWAIT и MONITOR. Инструкция MWAIT аналогична HLT, но позволяет пробудить процессор по записи в ячейку памяти по адресу, указанному в MONITOR. NWAIT можно использовать для мониторинга изменения ячейки семафора, однако в многозадачных операционных системах эта инструкция используется для мониторинга флага необходимости запустить планировщик задач на заданном ядре.

    Снижение энергопотребления во время активного цикла ожидания может достигаться с помощью инструкции PAUSE.

    В архитектуре ARM

    В архитектуре ARMv7 для синхронизации памяти между процессорами используются так называемые локальный и глобальный эксклюзивные мониторы, представляющие собой автоматы состояний, контролирующие атомарный доступ к ячейкам памяти. Атомарное чтение ячейки памяти может осуществляться с помощью инструкции LDREX, а атомарная запись — через инструкцию STREX, которая также возвращает флаг успеха операции.

    Для уменьшения значения семафора необходимо дождаться, пока его счётчик не станет больше нуля. Ожидание может быть реализовано разными способами:

    • цикл активного ожидания в случае легковесного семафора, при котором периодически проверяется значение счётчика с помощью инструкции LDREX;
    • блокировка с переводом процессора в энергосберегающий режим ожидания с помощью инструкций ожидания прерывания WFI или ожидания события WFE;
    • переключение контекста на исполнение другой задачи вместо блокировки процессора.

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

    Увеличение значения семафора может представлять собой циклическое чтение текущего значения счётчика через инструкцию LDREX с последующим увеличением копии значения и попыткой записи обратно в ячейку счётчика с помощью инструкции STREX. После успешной записи счётчика, если его изначальное значение было нулевым, требуется возобновить исполнение заблокированных задач, что в случае переключения контекста может решаться средствами операционных систем. Если процессор был заблокирован с помощью инструкции WFE, разблокировать его можно через инструкцию SEV, оповещающую о наличии какого-либо события.

    После уменьшения или увеличения значения семафора выполняется инструкция DMB, обеспечивающую гарантию целостности памяти защищаемого семафором ресурса.



    Имя:*
    E-Mail:
    Комментарий: