ОС · Конкурентность · 13 мин
Семафоры
Что такое семафор простыми словами
Представь подземную парковку, у которой ровно N мест и один шлагбаум на въезде. Перед шлагбаумом висит электронное табло «свободно: 5». Машина подъезжает, табло уменьшается на единицу, шлагбаум поднимается, машина заезжает. Когда свободных мест 0 — шлагбаум не поднимается, и новые машины стоят в очереди перед въездом, ждут. Как только кто-то выезжает, табло увеличивается на единицу, и первая машина из очереди заезжает.
Вот это табло со шлагбаумом и очередью — и есть семафор. Формально это счётчик с двумя атомарными операциями:
- wait (исторически зовётся P, от голландского proberen — «пробовать»): уменьшить счётчик на 1. Если после уменьшения он стал отрицательным — заблокироваться (встать в очередь и уснуть).
- signal (исторически V, от verhogen — «увеличивать»): увеличить счётчик на 1. Если кто-то спал в очереди — разбудить одного.
«Атомарные» значит неделимые: операция выполняется целиком, без вмешательства других потоков (см. атомарные операции в Go). Никто не сможет заглянуть в момент, когда табло «наполовину обновилось».
Семафор придумал Эдсгер Дейкстра в 1960-х. Слова P и V — его, и их до сих
пор используют в учебниках.
Семафор = парковка с N местами и шлагбаумом
очередь машин шлагбаум парковка (N=3)
┌───┐┌───┐┌───┐ ┌──┐ ┌────┬────┬────┐
│ C ││ B ││ A │ ──────► │▓▓│ ─────► │ ?? │ ?? │ ?? │
└───┘└───┘└───┘ └──┘ └────┴────┴────┘
(ждут, спят) wait: счётчик-- signal: счётчик++
если <0 — стоп разбудить ждущего
табло «свободно»: 3 → 2 → 1 → 0 → (новые встают в очередь)Что нарисовано: машины — это потоки/горутины, парковочные места — единицы
ресурса, табло — значение счётчика, очередь — спящие потоки, которых разбудит
signal.
Значение счётчика: что означает число
Главная интуиция, которую стоит запомнить:
- Положительное значение
Sозначает «естьSсвободных единиц ресурса, столько потоков ещё пройдутwaitбез блокировки». - Ноль — ресурс исчерпан, следующий
waitуснёт. - Отрицательное значение
-Kозначает «Kпотоков прямо сейчас спят в очереди и ждутsignal».
Начальное значение задаёт смысл семафора. От него зависит всё поведение.
| Начальное значение | Что получается | Зачем |
|---|---|---|
| 1 | бинарный семафор = мьютекс | взаимное исключение, 1 поток в критсекции |
| N (N>1) | считающий семафор | пускать максимум N потоков сразу (пул, лимит) |
| 0 | семафор-«ожидание» | один поток ждёт сигнала от другого (синхронизация) |
Бинарный семафор = мьютекс
Если инициализировать семафор значением 1, получится замок. Первый поток
делает wait (счётчик 1→0, проходит), заходит в критическую секцию. Второй
поток делает wait (счётчик 0→-1, блокируется). Когда первый выходит и зовёт
signal (счётчик -1→0), второй просыпается.
Бинарный семафор (init=1) как мьютекс
поток A: wait() ── S:1→0 ──► [критическая секция] ── signal() ─► S:0→1
│
поток B: wait() ── S:0→-1 ─► спит ───┘ просыпается по signal A
▲
(S: -1→0)Что нарисовано: счётчик ходит между 1 и 0 при одиночном доступе; когда заходит
второй, уходит в минус и поток засыпает до signal.
В Go ты обычно пишешь sync.Mutex, а не «бинарный семафор». Это языковой слой
поверх той же идеи. Но есть важная разница в семантике: у мьютекса есть
владелец (кто залочил — тот и должен разлочить), а у семафора владельца нет —
signal может позвать совсем другой поток. Именно поэтому семафор удобен для
сигнализации между потоками, а не только для защиты данных.
Считающий семафор: ограничить число одновременных
Вот где семафор сияет и мьютекс бессилен. Задача: «не больше N запросов к базе
одновременно», «максимум 10 параллельных воркеров», «пул из 5 соединений».
Мьютекс пускает строго одного. Семафор с init=N пускает ровно N.
Лимит параллелизма: семафор init=3, воркеров 6
sem = 3
W1 wait → sem 2 ─► [работает]──signal→ sem растёт
W2 wait → sem 1 ─► [работает]
W3 wait → sem 0 ─► [работает]
W4 wait → sem -1 ─► СПИТ ───┐
W5 wait → sem -2 ─► СПИТ │ ждут, пока кто-то из W1..W3
W6 wait → sem -3 ─► СПИТ ───┘ позовёт signal
как только W1 закончил: signal → sem -2, просыпается W4Что нарисовано: три «слота» заняты сразу, остальные три воркера спят в очереди; каждый завершившийся воркер освобождает слот ровно для одного ждущего.
Producer / consumer через семафоры
Классическая задача: producer кладёт элементы в буфер ограниченного размера, consumer забирает. Нужно: producer ждёт, если буфер полон; consumer ждёт, если буфер пуст. Это решается тремя семафорами.
Ограниченный буфер (capacity = N)
empty = N ← сколько ещё пустых ячеек (для producer)
full = 0 ← сколько заполненных ячеек (для consumer)
mutex = 1 ← защита самого буфера от гонки
PRODUCER: CONSUMER:
wait(empty) // есть место? wait(full) // есть данные?
wait(mutex) // захват wait(mutex) // захват
put(item) item = get()
signal(mutex) signal(mutex)
signal(full) // +1 данных signal(empty)// +1 местаИдея: empty и full — это «зеркальные» счётчики, в сумме всегда дают N.
empty считает свободные места (producer уменьшает, кладя; consumer
увеличивает, забирая), full — наоборот. Третий, бинарный mutex, защищает
сам массив, чтобы два потока не писали в одну ячейку.
Внимание к порядку: сначала wait(empty/full), потом wait(mutex). Если
поменять местами — поток сначала захватит mutex, потом уснёт на wait(empty),
держа замок, и никто не сможет ни положить, ни забрать. Это классический
deadlock (см. Взаимные блокировки): захват ресурсов в
неправильном порядке. Запомни правило: «считающий семафор снаружи, mutex
внутри».
Как семафор устроен под капотом
Семафор не магия — он строится из лока и условной переменной (см. Условные переменные). Условная переменная — это механизм «уснуть до наступления условия и быть разбуженным». Псевдокод:
type Semaphore:
value int
lock Mutex
cond CondVar // очередь спящих
func wait(s):
lock(s.lock)
s.value--
while s.value < 0: // пока единиц нет
cond_wait(s.cond, s.lock) // уснуть, ОТПУСТИВ lock
unlock(s.lock)
func signal(s):
lock(s.lock)
s.value++
cond_signal(s.cond) // разбудить одного ждущего
unlock(s.lock)Почему while, а не if? Проснувшийся поток должен перепроверить условие:
между «разбудили» и «реально захватил lock» мог влезть другой поток и снова
утащить ресурс. Это правило «всегда проверяй условие в цикле» — общее для
условных переменных.
А на самом нижнем уровне, в ядре Linux, эта «очередь спящих» реализована через
futex (fast userspace mutex): пока ресурс свободен, всё происходит в
user-space на атомарных операциях (быстро, без вызова ядра); и только когда
надо реально уснуть/разбудить — делается системный вызов futex(). Об этой
оптимизации «не ходить в ядро без нужды» — глава Цена системного
вызова.
Быстрый путь vs медленный путь (futex)
ресурс свободен: wait/signal = атомарный CAS в user-space ⚡ наносекунды
надо уснуть: syscall futex(WAIT) ─► ядро усыпляет поток 🐢 микросекунды
надо разбудить: syscall futex(WAKE) ─► ядро будит ждущегоЧто нарисовано: пока конкуренции нет, семафор работает на лету без захода в ядро; цена ядра платится только при реальной блокировке.
Семафоры в Go
В Go нет встроенного типа «семафор» как отдельной сущности — он не нужен, потому что буферизированный канал и есть готовый считающий семафор. Ёмкость канала = начальное значение счётчика.
// Семафор на N через канал. Отправка = wait, приём = signal.
sem := make(chan struct{}, N) // N "слотов"
sem <- struct{}{} // wait: занять слот (блокируется, если буфер полон)
// ... работа ...
<-sem // signal: освободить слотstruct{}{} — пустая структура, занимает 0 байт: нам важен сам факт занятости
слота, а не данные. Когда буфер заполнен (N элементов), отправка sem <- ...
блокирует горутину — ровно как wait на исчерпанном семафоре.
Для более богатого API (взвешенные захваты, отмена через context) есть пакет
golang.org/x/sync/semaphore:
sem := semaphore.NewWeighted(int64(N))
if err := sem.Acquire(ctx, 1); err != nil { return err } // wait + context
defer sem.Release(1) // signalПодробнее про семафор как идиому ограничения параллелизма — в главе курса Go
Паттерны конкурентности. А про сами каналы и select —
каналы и select.
| Механизм | Это | Когда |
|---|---|---|
sync.Mutex |
бинарный семафор с владельцем | защита одной критсекции |
chan struct{} ёмкости N |
считающий семафор | лимит параллелизма, пул |
x/sync/semaphore |
взвешенный семафор + context | отмена, разные «веса» задач |
Семафор инициализирован значением 1. Что это даёт?
Что означает значение счётчика семафора, равное -3?
В задаче producer/consumer вы поменяли местами wait(mutex) и wait(empty), захватив mutex ПЕРВЫМ. Что произойдёт?
Как в Go идиоматично ограничить число одновременных воркеров до N?
Сколько семафоров нужно для классического решения producer/consumer с ограниченным буфером?
Из каких примитивов строится семафор под капотом и что используется в ядре Linux?
Что спрашивают на собесе
- Чем семафор отличается от мьютекса? Мьютекс — это частный случай (бинарный
семафор, init=1) и у него есть владелец: разлочить должен тот же поток.
Семафор владельца не имеет,
signalможет позвать кто угодно, и счётчик может быть больше 1 — это позволяет пускать N потоков сразу. - Что означает отрицательное значение счётчика? Сколько потоков сейчас спят
в очереди и ждут
signal. Положительное — сколько ещё свободных единиц. - Как реализовать ограничение «не более N одновременных запросов»? Считающий
семафор с init=N; в Go — буферизированный канал
chan struct{}ёмкости N илиx/sync/semaphore. - Задача producer/consumer на семафорах. Три семафора:
empty=N,full=0,mutex=1. Важен порядок захвата: считающий снаружи, mutex внутри — иначе deadlock. - Почему в реализации семафора проверка условия в while, а не if? Из-за возможного перехвата ресурса между пробуждением и захватом лока; проснувшийся обязан перепроверить условие.
- Как семафор работает под капотом? Лок + условная переменная; в Linux — через futex с быстрым путём в user-space и заходом в ядро только при реальной блокировке.