Semaphore — это примитив синхронизации, управляющий доступом к общему ресурсу через счётчик и очередь ожидающих потоков. По данным Wikipedia, 2024, семафор был предложен Эдсгером Дейкстрой в 1965 году для решения задач многопоточного взаимодействия. Инструмент позволяет ограничить число потоков, одновременно работающих с критической секцией.
Главное
Semaphore — это примитив синхронизации, использующий счётчик для управления доступом к разделяемому ресурсу. Концепция была предложена Эдсгером Дейкстрой в 1965 году и стала фундаментом для всех современных механизмов синхронизации в операционных системах.
Семафор представляет собой целочисленную переменную с двумя атомарными операциями: wait (acquire) и signal (release). Операция wait уменьшает счётчик, а signal увеличивает его. Когда счётчик достигает нуля, поток, вызывающий wait, блокируется до выполнения signal другим потоком.
Основное назначение семафора — защита критических секций от одновременного доступа нескольких потоков. В отличие от мьютекса, семафор не требует привязки к потоку-владельцу, что делает его пригодным для более широкого круга задач координации.
Концепция семафора возникла в контексте операционной системы THE, разработанной в Technische Hogeschool Eindhoven. Дейкстра формализовал семафор как математическую абстракцию, доказав его достаточность для реализации любых примитивов синхронизации.
Механизм семафора основан на двух атомарных операциях и внутренней очереди ожидания. При вызове acquire поток проверяет значение счётчика и либо продолжает выполнение, либо блокируется до освобождения ресурса.
При создании семафора задаётся начальное значение счётчика разрешений. Каждый вызов acquire уменьшает счётчик на 1. Если после этого счётчик становится отрицательным, поток блокируется. Операция release увеличивает счётчик и пробуждает один из ожидающих потоков.
import java.util.concurrent.Semaphore
val semaphore = Semaphore(3)
fun accessResource() {
semaphore.acquire()
try {
println("${Thread.currentThread().name} работает")
} finally {
semaphore.release()
}
}
Когда поток вызывает acquire при нулевом счётчике, ОС помещает его в очередь FIFO семафора. Поток переходит в состояние BLOCKED, не потребляя процессорное время. После вызова release первый поток в очереди переводится в состояние RUNNABLE и получает доступ к ресурсу.
В теории синхронизации выделяют два основных типа семафоров: двоичный (binary) и счётный (counting). Выбор типа зависит от конкретной задачи управления доступом к ресурсам.
Двоичный семафор принимает только значения 0 и 1. По поведению он напоминает мьютекс, но без требования владения — любой поток может выполнить release. Такие семафоры удобны для реализации флагов готовности и событий между потоками.
val ready = Semaphore(0)
fun producer() {
Thread.sleep(1000)
ready.release()
}
fun consumer() {
ready.acquire()
println("Данные готовы")
}
Счётный семафор может принимать любое неотрицательное значение. Он используется для управления пулом однотипных ресурсов, где доступно несколько экземпляров. Например, пул из 5 сетевых соединений: каждый acquire занимает одно соединение, release возвращает его в пул.
Счётные семафоры незаменимы при ограничении скорости доступа к внешним сервисам и реализации пулов потоков. Они позволяют точно контролировать степень параллелизма без ручного управления потоками.
| Параметр | Двоичный семафор | Счётный семафор |
|---|---|---|
| Диапазон | 0 или 1 | от 0 до N |
| Потоков одновременно | 1 | до N |
| Применение | сигнализация, флаги | пулы ресурсов, rate limiting |
Разработчики часто путают семафор и мьютекс, хотя между ними есть фундаментальные различия. Понимание этих отличий критически важно для выбора правильного механизма синхронизации в проекте.
Ключевое отличие — концепция владения. Мьютекс всегда знает, какой поток его захватил, и только этот поток может его освободить. Семафор не имеет владельца: любой поток может вызвать release, даже не вызывая acquire. Это делает мьютекс безопаснее для защиты данных, а семафор — гибче для координации.
На практике мьютекс быстрее для простой взаимной блокировки благодаря оптимизациям под типичный сценарий. Семафор требует дополнительных накладных расходов на поддержание счётчика. Однако для ограничения параллелизма или реализации шаблона «производитель-потребитель» семафор незаменим.
| Характеристика | Semaphore | Мьютекс |
|---|---|---|
| Владение | нет владельца | есть владелец |
| Освобождение | любой поток | только поток-владелец |
| Счётчик | от 0 до N | бинарный |
| Use case | ограничение параллелизма и сигнализация | защита критической секции |
| Рекурсия | нет | да (reentrant) |
В разработке мобильных приложений Semaphore используется для управления доступом к ограниченным ресурсам: сетевым соединениям, файлам, базам данных и аппаратным компонентам. Современные платформы предоставляют удобные встроенные реализации.
Один из типичных кейсов — пул HTTP-соединений. Приложение может одновременно отправлять не более 4 запросов к серверу, так как API провайдера ограничивает параллелизм. Семафор с начальным значением 4 гарантирует, что при любой нагрузке количество одновременных запросов не превысит лимит, а остальные потоки будут ждать в очереди.
Без семафора при резком увеличении пользовательской активности серверная инфраструктура может получить внезапную перегрузку, что приводит к таймаутам и ошибкам 429 Too Many Requests. Семафор работает как предохранитель, пропуская строго заданное число одновременных вызовов независимо от количества активных потоков.
Android предоставляет класс Semaphore из пакета java.util.concurrent. Рассмотрим пример ограничения одновременных сетевых запросов до двух потоков для предотвращения перегрузки сервера.
class ApiClient {
private val throttle = Semaphore(2)
suspend fun fetch(url: String): Result {
throttle.acquire()
return try {
httpGet(url)
} finally {
throttle.release()
}
}
}
В iOS DispatchSemaphore из GCD решает ту же задачу. Разработчики используют его для синхронизации доступа к ресурсам в асинхронном коде без блокировки главного потока.
let semaphore = DispatchSemaphore(value: 3)
func processBatch(_ items: [UIImage]) {
for img in items {
semaphore.wait()
DispatchQueue.global().async {
applyFilter(to: img)
semaphore.signal()
}
}
}
Самая частая ошибка — забытый release при исключении. Если поток завершается с ошибкой до вызова release, семафор навсегда остаётся заблокированным для остальных потоков. Используйте try/finally или defer для гарантированного освобождения. Вторая проблема — deadlock при захвате нескольких семафоров в разном порядке разными потоками.
Семафоры применяются не только для защиты данных, но и для координации потоков в сложных многопоточных сценариях. Знание распространённых паттернов ускоряет разработку и снижает вероятность ошибок синхронизации.
Существует несколько проверенных паттернов применения семафоров в реальных проектах. Их знание помогает избежать типичных ошибок и строить надёжные многопоточные системы.
Семафор с начальным значением N и периодическим release через таймер реализует ограничение скорости запросов к API. Например, сервис разрешает 10 запросов в секунду: семафор стартует с 10, каждый запрос уменьшает счётчик, а отдельный TimerTask раз в секунду возвращает счётчик к исходному значению. Это защищает и приложение, и сервер от перегрузки.
В классической задаче производитель-потребитель два семафора управляют буфером: empty (разрешения на запись) и full (разрешения на чтение). Producer вызывает acquire у empty и release у full, Consumer — наоборот. Такая схема гарантирует, что Consumer никогда не прочитает пустой буфер, а Producer не переполнит его.
Эта же схема лежит в основе bounded buffer в операционных системах — кольцевого буфера с фиксированным размером. В мобильных приложениях паттерн применяется для обработки очередей изображений, видеофайлов и аналитических событий.
Семафоры успешно используются для троттлинга сетевых вызовов в фоновых сервисах. Например, приложение аналитики отправляет на сервер пакеты событий. Без ограничения concurrent-потоков при пиковых нагрузках (запуск приложения, синхронизация после офлайна) количество одновременных запросов может превысить лимиты сервера. Семафор с начальным значением 3 гарантирует плавную отправку и предотвращает блокировку на стороне сервера.
Часто задаваемые вопросы
Семафор — это не просто счётчик, а примитив синхронизации с атомарными операциями и очередью ожидания. Обычный счётчик не блокирует поток и не гарантирует атомарность инкремента при конкурентном доступе нескольких потоков.
Да, deadlock возможен при захвате нескольких семафоров в разном порядке разными потоками. Например, поток A захватывает S1, затем S2, а поток B — S2, затем S1. Фиксируйте единый порядок захвата для всех семафоров в проекте.
Поток блокируется и переходит в состояние ожидания. Он не потребляет процессорное время до тех пор, пока другой поток не вызовет release. В Java это состояние BLOCKED, в Swift поток приостанавливается GCD.
Основное отличие — владение. Mutex можно освободить только из потока-владельца. Binary Semaphore освобождается любым потоком, что удобно для сигнализации между потоками, но менее безопасно для защиты целостности данных.
Начальное значение зависит от сценария. Для защиты одного ресурса — 1. Для пула из N соединений — N. Для сигнализации между потоками используйте 0, чтобы поток-потребитель ждал сигнала от производителя.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также